Иерархия заданий
Неограниченная вложенность родительских и дочерних заданий, последовательно или параллельно. Дочерние идемпотентны по построению: тот же метод и те же аргументы → выполнится один раз, результат закеширован.
Apache 2.0 · Java и Kotlin · Spring Boot 2/3/4 · PostgreSQL 15+
JES надёжно выполняет процессы (workflow) и задания: они сохраняются при сбоях, перезапусках и перемещении между машинами, не теряя своего состояния. Отдельный сервер не нужен. Если у вас уже есть Spring Boot и PostgreSQL, у вас уже есть все необходимое.
Одна зависимость. Три таблицы. Ноль инфраструктуры.
@Service
public class OrderWorkflow {
// Запускаем workflow обработки заказа
public void start(String orderId) {
JobOptions options =
JobOptions.jobWithExternalId(orderId);
jobSystem.startJob(options,
this::processOrder, String.class, orderId);
}
// Сам переживёт сбои и повторные запуски
public String processOrder(String orderId) {
String receipt = jobSystem.execute(
paymentService::chargePayment,
String.class, orderId);
try {
jobSystem.execute(
deliveryService::deliverGoods, orderId);
} catch (JobFailure e) {
jobSystem.execute(
paymentService::rollbackPayment,
orderId, receipt);
throw e;
}
return receipt;
}
}
@Service
class OrderWorkflow(
private val jobSystem: JobSystem,
private val paymentService: PaymentService,
private val deliveryService: DeliveryService,
) {
// Запускаем workflow обработки заказа
fun start(orderId: String) {
val options =
JobOptions.jobWithExternalId(orderId)
jobSystem.startJob(options,
this::processOrder, String::class.java, orderId)
}
// Сам переживёт сбои и повторные запуски
fun processOrder(orderId: String): String {
val receipt = jobSystem.execute(
paymentService::chargePayment,
String::class.java, orderId)
try {
jobSystem.execute(
deliveryService::deliverGoods, orderId)
} catch (e: JobFailure) {
jobSystem.execute(
paymentService::rollbackPayment,
orderId, receipt)
throw e
}
return receipt
}
}
Неограниченная вложенность родительских и дочерних заданий, последовательно или параллельно. Дочерние идемпотентны по построению: тот же метод и те же аргументы → выполнится один раз, результат закеширован.
Выполнение как минимум один раз, бесконечные повторы, автоматическое восстановление после сбоев - вплоть до полной потери всех инстансов приложения.
Именованные типизированные слоты состояния переживают перезапуски задания и читаются извне. Типизированный контекст передаётся между заданиями - с поддержкой полиморфизма.
Отправляйте произвольные сообщения работающему заданию, отменяйте его извне, прерывайте при необходимости. Сигналы передаются вниз по иерархии.
Лимиты на общее число заданий, на тип задания и на ресурс. Равномерное распределение по инстансам приложения - из коробки.
Метрики Micrometer, распространение trace_id/span_id по иерархии, HealthIndicator для Actuator, встроенный HTTP API и поиск по истории заданий.
Лёгкий TestJobSystem выполняет настоящие задания в юнит-тестах - без PostgreSQL, с пропуском задержек и ограничением перезапусков.
Задокументированные стратегии эволюции заданий на работающем приложении: версионирование методов, ветвление по времени первого запуска, drain-затем-update.
JES опубликована на Maven Central. Spring Boot 2.7+, Java 17+, PostgreSQL 15+.
<dependency>
<groupId>dev.jesproject</groupId>
<artifactId>job-execution-system</artifactId>
<version>2.0.0</version>
</dependency>
implementation("dev.jesproject:job-execution-system:2.0.0")
Три таблицы через Liquibase: скопируйте db/job_execution_system из репозитория в свои миграции и подключите changelog.
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog">
<!-- Таблицы JES идут в комплекте с библиотекой:
скопируйте db/job_execution_system в свои миграции -->
<include file="job_execution_system/init/v2.0.xml"
relativeToChangelogFile="true"/>
</databaseChangeLog>
databaseChangeLog:
# Таблицы JES идут в комплекте с библиотекой:
# скопируйте db/job_execution_system в свои миграции
include:
file: job_execution_system/init/v2.0.xml
relativeToChangelogFile: true
Одна аннотация на главном классе приложения.
@SpringBootApplication
@EnableJobExecutionSystem
public class ExampleApp {
public static void main(String[] args) {
SpringApplication.run(ExampleApp.class, args);
}
}
@SpringBootApplication
@EnableJobExecutionSystem
class ExampleApp
fun main(args: Array<String>) {
SpringApplication.run(ExampleApp::class.java, *args)
}
Любой публичный метод Spring-бина становится устойчивым заданием - повторяется до успеха.
@Service
public class HelloService {
private final JobSystem jobSystem;
public void runHelloJobs() {
jobSystem.startJob(this::hello, "World");
}
public void hello(String name) {
// выполнится как минимум один раз, переживёт сбой
log.info("Hello, {}!", name);
}
}
@Service
class HelloService(
private val jobSystem: JobSystem,
) {
fun runHelloJobs() {
jobSystem.startJob(this::hello, "World")
}
fun hello(name: String) {
// выполнится как минимум один раз, переживёт сбой
log.info("Hello, {}!", name)
}
}
Задание - это просто строка в таблице PostgreSQL: метод, аргументы, родитель, статус, расписание. Никакого скрытого состояния - сделайте SELECT из таблиц jobs, job_signals и job_data и увидите всё.
Экземпляры приложения опрашивают таблицы и забирают следующее задание короткой транзакцией с FOR UPDATE SKIP LOCKED - поэтому десятки инстансов масштабируются горизонтально, не мешая друг другу, а работа сама течёт к свободным.
Каждый исполнитель подтверждает активность heartbeat'ом. Если инстанс умер, другой забирает его задания и выполняет заново - хотя бы раз, с сохранённым состоянием. То же верно и для падения всего кластера.
Задание-workflow порождает дочерние задания - шаги бизнес-процесса, последовательные или параллельные. Дочерние задания детерминированы по построению: тот же метод + те же аргументы → результат уже в базе. Поэтому при перезапуске родителя где угодно выполненные шаги не выполняются повторно - workflow просто продолжается с места остановки.
Небольшие фрагменты - каждый демонстрирует гарантию, которую вы получаете без написания инфраструктурного кода.
Одно дочернее задание на заказ - выполняются параллельно, повторяются независимо, результаты собираются после завершения всех.
// По дочернему заданию на заказ: параллельно, независимые повторы
public List<Report> processAll(List<Order> orders) {
List<Job> jobs = jobSystem.startJobs(
JobDefinition.ofList(this::processOrder, orders));
// ждём все дочерние и собираем результаты
return Job.getAll(jobs, Report.class).toList();
}
public Report processOrder(Order order) {
// те же аргументы → выполнится один раз, результат закеширован
return warehouse.reserve(order);
}
// По дочернему заданию на заказ: параллельно, независимые повторы
fun processAll(orders: List<Order>): List<Report> {
val jobs = jobSystem.startJobs(
JobDefinition.ofList(this::processOrder, orders))
// ждём все дочерние и собираем результаты
return Job.getAll(jobs, Report::class.java).toList()
}
fun processOrder(order: Order): Report {
// те же аргументы → выполнится один раз, результат закеширован
return warehouse.reserve(order)
}
Дубликатная доставка из Kafka не создаст второе задание: id события - ключ идемпотентности, а уже существующее задание просто переиспользуется.
// Kafka-листенер: дубликатная доставка не должна
// запустить второе задание - id события ключ идемпотентности
@KafkaListener(topics = "orders")
public void onOrderCreated(
String eventId, String orderId, String userId) {
try {
jobSystem.startJob(
JobOptions.jobWithExternalId(eventId),
this::processOrder, orderId, userId);
} catch (JobAlreadyExistsException e) {
// дубликат: задание уже существует
log.info("Event {} already has a job", eventId);
}
}
public void processOrder(String orderId, String userId) {
// повторяется до успеха - события не теряются
}
// Kafka-листенер: дубликатная доставка не должна
// запустить второе задание - id события ключ идемпотентности
@KafkaListener(topics = "orders")
fun onOrderCreated(
eventId: String, orderId: String, userId: String
) {
try {
jobSystem.startJob(
JobOptions.jobWithExternalId(eventId),
this::processOrder, orderId, userId)
} catch (e: JobAlreadyExistsException) {
// дубликат: задание уже существует
log.info("Event {} already has a job", eventId)
}
}
fun processOrder(orderId: String, userId: String) {
// повторяется до успеха - события не теряются
}
Отправляйте произвольные сообщения заданию прямо во время его работы - само задание решает, когда и как их обрабатывать.
// Задание получает команды во время выполнения
public void exportReport(ExportRequest request) {
Signal<ExportCommand> command = Signal.of();
for (String page : request.pages()) {
ExportCommand cmd = command.waitAndPop();
render(page, cmd);
}
}
// Из любого места - например, из REST-контроллера:
jobSystem.signal(job, new ExportCommand("change-scope"));
// Задание получает команды во время выполнения
fun exportReport(request: ExportRequest) {
val command: Signal<ExportCommand> = Signal.of()
for (page in request.pages()) {
val cmd = command.waitAndPop()
render(page, cmd)
}
}
// Из любого места - например, из REST-контроллера:
jobSystem.signal(job, ExportCommand("change-scope"))
Расписание само является устойчивым заданием: после падения приложения пропущенный запуск будет восстановлен и продолжен.
@Service
public class ReportScheduler {
private final RecurringJobScheduler recurring;
public void register() {
// каждую ночь в 02:00 - само задание устойчиво
recurring.scheduleAtCron(
"nightly-report", "0 0 2 * * *",
this::generateReport);
}
public void generateReport() {
// автоматически восстанавливается после сбоев
}
}
@Service
class ReportScheduler(
private val recurring: RecurringJobScheduler,
) {
fun register() {
// каждую ночь в 02:00 - само задание устойчиво
recurring.scheduleAtCron(
"nightly-report", "0 0 2 * * *",
this::generateReport)
}
fun generateReport() {
// автоматически восстанавливается после сбоев
}
}
Чанки запрашиваются из БД начиная с сохранённого индекса; после перезапуска обработка продолжается с места остановки - готовые чанки не запрашиваются повторно.
// Чанки из БД: сохранённый индекс позволяет продолжить
// после перезапуска без повторной обработки готовых чанков
public void processBacklog() {
// возвращает сохранённый индекс или 0 при первом запуске
Integer index = Job.computeState("index",
Integer.class, current ->
current == null ? 0 : current);
// запрашиваем чанки начиная с сохранённого индекса
List<Chunk> chunks =
chunkRepository.findFrom(index);
for (int i = 0; i < chunks.size(); i++) {
Job.throwIfInterruptedOrCancelled();
chunkService.handle(chunks.get(i));
Job.setState("index", index + i + 1);
}
}// Чанки из БД: сохранённый индекс позволяет продолжить
// после перезапуска без повторной обработки готовых чанков
fun processBacklog() {
// возвращает сохранённый индекс или 0 при первом запуске
val index = Job.computeState("index",
Integer::class.java) { it ?: 0 }
// запрашиваем чанки начиная с сохранённого индекса
val chunks =
chunkRepository.findFrom(index)
chunks.forEachIndexed { i, chunk ->
Job.throwIfInterruptedOrCancelled()
chunkService.handle(chunk)
Job.setState("index", index + i + 1)
}
}
Каждый шаг регистрирует свою компенсацию. Если следующий шаг падает, завершённые откатываются в обратном порядке - и каждое задание повторяется до успеха.
// Saga: каждый шаг регистрирует компенсацию.
// При сбое завершённые шаги откатываются в обратном порядке.
public String placeOrder(String orderId) {
Deque<Runnable> compensate = new ArrayDeque<>();
try {
String paymentId = jobSystem.execute(
paymentService::charge, String.class, orderId);
compensate.push(() -> jobSystem.execute(
paymentService::refund, orderId, paymentId));
String shipmentId = jobSystem.execute(
deliveryService::ship, String.class, orderId);
compensate.push(() -> jobSystem.execute(
deliveryService::cancelShipment, shipmentId));
jobSystem.execute(loyaltyService::addPoints, orderId);
return paymentId;
} catch (JobFailure e) {
compensate.forEach(Runnable::run); // обратный порядок
throw e;
}
}
// Saga: каждый шаг регистрирует компенсацию.
// При сбое завершённые шаги откатываются в обратном порядке.
fun placeOrder(orderId: String): String {
val compensate = ArrayDeque<() -> Unit>()
try {
val paymentId = jobSystem.execute(
paymentService::charge, String::class.java, orderId)
compensate.addFirst {
jobSystem.execute(
paymentService::refund, orderId, paymentId)
}
val shipmentId = jobSystem.execute(
deliveryService::ship, String::class.java, orderId)
compensate.addFirst {
jobSystem.execute(
deliveryService::cancelShipment, shipmentId)
}
jobSystem.execute(loyaltyService::addPoints, orderId)
return paymentId
} catch (e: JobFailure) {
compensate.forEach { it() } // обратный порядок
throw e
}
}
Отмените задание извне в любой момент: сигнал каскадно передаётся дочерним заданиям, а само задание останавливается между шагами.
// Задание проверяет отмену между шагами
public void exportReport(ExportRequest request) {
for (String page : request.pages()) {
Job.throwIfInterruptedOrCancelled();
render(page);
}
}
// Из любого места: отмена каскадно передаётся дочерним заданиям
jobSystem.cancel(job);
// Задание проверяет отмену между шагами
fun exportReport(request: ExportRequest) {
for (page in request.pages()) {
Job.throwIfInterruptedOrCancelled()
render(page)
}
}
// Из любого места: отмена каскадно передаётся дочерним заданиям
jobSystem.cancel(job)
Из коробки JES работает с настройками по умолчанию - конфигурировать ничего не нужно. Но каждая ручка доступна: пул потоков, задержки повторов, heartbeat, автоочистка и встроенный API управления.
job-execution-system:
max-runner-count: 100 # потоки под задания
jobs:
new-check-interval: 200ms # старт новых заданий
heartbeat-rate: 850ms # обнаружение сбоев
heartbeat-timeout: 5000ms # порог failover
delay-on-error: 0ms # пауза перед повтором
signals:
new-check-interval: 1000ms # доставка сигналов
cleanup:
enabled: false # автоочистка старых заданий
expiration-period: P1Y8M5D
cron: "0 0 */20 * * *" # расписание очистки
api:
enabled: false # HTTP API управления
Все значения выше необязательны - без этого блока JES работает с теми же дефолтами.
Количество потоков, предварительная выборка и сигналы подтверждения активности устанавливаются для каждого экземпляра приложения; балансировка работы экземпляров осуществляется автоматически.
Частоты опроса и размер пула Hikari настраиваются - число соединений строго ограничено.
Все настройки необязательны - в примере показаны значения по умолчанию.
Полный справочник в документации →