Опирается на правила: R-SHUT-SCHED-1R-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:

  1. Spring publishes ContextClosedEvent.
  2. ThreadPoolTaskScheduler.shutdown() вызывается.
  3. await-termination: true — ждать завершения running tasks.
  4. Новые scheduled invocations не запускаются (между итерациями).
  5. После await-termination-period: 25sshutdownNow() (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:

  1. Spring TaskScheduler ждёт завершения текущего publish().
  2. Текущий batch завершается (либо commit с published_at, либо rollback).
  3. 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). Сценарий:

  1. @Async public void chargeCustomer(...) запущен.
  2. Внутри: paymentClient.charge(...) → 200 OK (deduct money).
  3. Перед paymentRepository.save(...) — SIGTERM, interrupt.
  4. Deduction в payment-provider произошла, в БД не сохранено.
  5. Inconsistency: customer списан, но payment в БД нет.

Идемпотентность (см. Идемпотентность in-flight) защищает retry, но без setWaitForTasksToCompleteOnShutdown(true) каждый shutdown — riskedпро partial state.

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
setWaitForTasksToCompleteOnShutdown(false)R-SHUT-SCHED-X1true
await-termination: falseR-SHUT-SCHED-1true
await-termination-period > 30sR-SHUT-SCHED-1≤ 25s (помещаться в budget)
while(true) цикл в @ScheduledR-SHUT-SCHED-3короткие итерации, fixed-delay
@Async без ThreadPoolTaskExecutor beanR-SHUT-SCHED-2явная конфигурация
Long cascade в @Async без task-queueR-SHUT-SCHED-2outbox + worker
setAwaitTerminationSeconds(0)R-SHUT-SCHED-2минимум 15-20s
Custom Executor без destroyMethodR-SHUT-SCHED-2destroyMethod = "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.