Опирается на правила:
R-SHUT-SCHED-1…R-SHUT-SCHED-3иR-SHUT-SCHED-X1из Graceful Shutdown Style Guide → раздел 5. Scheduled / @Async / outbox.
Важно знать
@Scheduledзавершает текущую итерацию, не начинает новую —TaskScheduler.shutdown().spring.task.scheduling.shutdown.await-termination: true+await-termination-period: 25s.@Asyncс долгим cascade опасен — по умолчанию interrupt() через awaitTermination.ThreadPoolTaskExecutorсsetWaitForTasksToCompleteOnShutdown(true)+setAwaitTerminationSeconds(20).- Outbox-relay на SIGTERM завершает текущий batch (FOR UPDATE SKIP LOCKED атомарно).
setWaitForTasksToCompleteOnShutdown(false)— потеря данных, partial commits.- Total budget 60s — не все timeouts на максимуме одновременно (см. бюджет).
Background tasks (@Scheduled, @Async, outbox-relay) — место, где graceful shutdown часто ломается. Если task убит на середине, transaction откатывается, но side-effect (HTTP-вызов, Kafka send) уже произошёл — это inconsistency. UCP формулирует: дать каждому task завершить текущую итерацию, новую не начинать, force-shutdown только в крайнем случае.
@Scheduled завершает итерацию
R-SHUT-SCHED-1: TaskScheduler.shutdown() с await.
spring:
task:
scheduling:
shutdown:
await-termination: true
await-termination-period: 25s
pool:
size: 4
Что происходит на SIGTERM:
- Spring publishes
ContextClosedEvent. ThreadPoolTaskScheduler.shutdown()вызывается.await-termination: true— ждать завершения running tasks.- Новые scheduled invocations не запускаются (между итерациями).
- После
await-termination-period: 25s—shutdownNow()(interrupt всех).
Пример:
@Component
@Slf4j
@RequiredArgsConstructor
public class OutboxRelay {
private final OutboxRepository outboxRepository;
private final KafkaTemplate<String, Object> kafkaTemplate;
@Scheduled(fixedDelay = 500)
@Transactional
public void publish() {
var batch = outboxRepository.fetchUnpublished(50);
for (var event : batch) {
kafkaTemplate.send(event.topic(), event.partitionKey(), event.payload()).get();
outboxRepository.markPublished(event.id());
}
}
}
При SIGTERM в момент publish():
- Spring ждёт завершения текущего
publish(). - В нём — batch 50 events. Например, processed 30 of 50.
- Если transaction completes (commit) — все 50 marked published.
- Если transaction нет (HTTP timeout, broker not responding) — rollback, 50 остаются unpublished, при следующем старте relay подхватит.
@Async опасен
R-SHUT-SCHED-2: ExecutorService default — interrupt.
@Bean(destroyMethod = "shutdown")
public ThreadPoolTaskExecutor asyncExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(20);
executor.initialize();
return executor;
}
Что важно:
setWaitForTasksToCompleteOnShutdown(true)— ждать running tasks.setAwaitTerminationSeconds(20)— максимум 20 секунд ждать.- После 20s —
shutdownNow(), interrupt всех threads.
Если в @Async методе есть Thread.sleep или Future.get(...) — они бросают InterruptedException при force-shutdown. Код должен это обрабатывать (либо rollback DB, либо markFailed и продолжить).
@Component
@RequiredArgsConstructor
@Slf4j
public class AsyncEmailSender {
private final EmailClient emailClient;
@Async
public CompletableFuture<Void> sendAsync(String to, String subject, String body) {
try {
emailClient.send(to, subject, body);
return CompletableFuture.completedFuture(null);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("Async email send interrupted on shutdown, email may not be sent");
return CompletableFuture.failedFuture(e);
} catch (Exception e) {
log.error("Async email send failed", e);
return CompletableFuture.failedFuture(e);
}
}
}
Лучше — выносить долгие cascades в task-queue через outbox + worker (см. Resilience → async polling). Тогда любой interrupt просто означает «продолжим в следующем worker», результат не теряется.
Outbox-relay
R-SHUT-SCHED-3: типовой паттерн UCP.
@Scheduled(fixedDelay = 500)
@Transactional
public void publish() {
var batch = dsl.selectFrom(OUTBOX_EVENT)
.where(OUTBOX_EVENT.PUBLISHED_AT.isNull())
.orderBy(OUTBOX_EVENT.ID)
.limit(50)
.forUpdate().skipLocked()
.fetch();
for (var row : batch) {
kafkaTemplate.send(row.getTopic(), row.getPartitionKey(), row.getPayload()).get();
row.setPublishedAt(Instant.now());
row.store();
}
}
На SIGTERM:
- Spring TaskScheduler ждёт завершения текущего
publish(). - Текущий batch завершается (либо commit с
published_at, либо rollback). FOR UPDATE SKIP LOCKED— другой pod (или новый pod после deploy) подхватывает unlocked rows.
Главное — не запускать relay-цикл с while(true) без проверки availability:
// КАТАСТРОФА — не контролирует shutdown
@Scheduled(fixedDelay = 100)
public void relay() {
while (true) {
publish();
}
}
Это эффективно один зависший Scheduled task. TaskScheduler.shutdown() не сможет завершить — interrupt только interrupts blocking calls.
Корректно — @Scheduled запускается short периодом (500ms), завершается, следующий tick — новая итерация. Естественный механизм Spring Scheduler.
Что запрещено
setWaitForTasksToCompleteOnShutdown(false)
R-SHUT-SCHED-X1:
// КАТАСТРОФА
@Bean
public ThreadPoolTaskExecutor asyncExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setWaitForTasksToCompleteOnShutdown(false);
return executor;
}
При SIGTERM все запущенные @Async tasks убиты немедленно (interrupt). Сценарий:
@Async public void chargeCustomer(...)запущен.- Внутри:
paymentClient.charge(...)→ 200 OK (deduct money). - Перед
paymentRepository.save(...)— SIGTERM, interrupt. - Deduction в payment-provider произошла, в БД не сохранено.
- Inconsistency: customer списан, но
paymentв БД нет.
Идемпотентность (см. Идемпотентность in-flight) защищает retry, но без setWaitForTasksToCompleteOnShutdown(true) каждый shutdown — riskedпро partial state.
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
setWaitForTasksToCompleteOnShutdown(false) | R-SHUT-SCHED-X1 | true |
await-termination: false | R-SHUT-SCHED-1 | true |
await-termination-period > 30s | R-SHUT-SCHED-1 | ≤ 25s (помещаться в budget) |
while(true) цикл в @Scheduled | R-SHUT-SCHED-3 | короткие итерации, fixed-delay |
@Async без ThreadPoolTaskExecutor bean | R-SHUT-SCHED-2 | явная конфигурация |
Long cascade в @Async без task-queue | R-SHUT-SCHED-2 | outbox + worker |
setAwaitTerminationSeconds(0) | R-SHUT-SCHED-2 | минимум 15-20s |
Custom Executor без destroyMethod | R-SHUT-SCHED-2 | destroyMethod = "shutdown" |
Куда дальше
- Graceful Shutdown → раздел 5. Scheduled / Async / outbox — нормативные формулировки.
- JVM/Spring конфигурация — общий graceful Spring.
- БД и persistence — transaction completion.
- Идемпотентность in-flight — retry-safety на interrupt.
- Бюджеты и observability — total 60s budget.
- Kafka → outbox publishing — outbox-relay details.
- Resilience → async polling — task-queue вместо @Async.