Опирается на правила: R-SHUT-IDEM-1 и R-SHUT-IDEM-X1 из Graceful Shutdown Style Guide → раздел 7. Идемпотентность in-flight.

Важно знать

  • Операции, которые SIGTERM может прервать, обязаны быть retry-safe.
  • HTTP POSTIdempotency-Key обязателен (AUTH-19).
  • Kafka listenerprocessed_event(event_id) в той же транзакции что side-effect.
  • Outbox-relay — либо двух-фаза pending → publishing → published, либо processed_event-dedup на consumer.
  • Money с @Retry без Idempotency-Key — при SIGTERM в retry дважды списать.
  • Graceful shutdown даёт время на завершение, но не гарантирует отсутствие partial.
  • Идемпотентность — последняя линия защиты, когда graceful не успел.

Graceful shutdown даёт операциям шанс завершиться корректно. Но shutdown — это deadline в 60 секунд. Долгий cascade (HTTP retry × 3 × 30s) может не уложиться, force-shutdown прерывает посередине. Если операция не идемпотентна — partial state → инцидент. UCP формулирует: каждая операция, которую graceful может прервать, обязана быть replay-safe.

Три типа in-flight операций

R-SHUT-IDEM-1: разные защиты для разных контекстов.

1. HTTP POST

Client → POST /payments (Idempotency-Key: abc)
         Server: создаёт idempotency_record, обрабатывает
         [SIGTERM посередине]
         Server force-shutdown, response не отправлен

Client → retry: POST /payments (Idempotency-Key: abc)
         Server (new pod): находит idempotency_record, возвращает сохранённый response
         Дубля нет

Без Idempotency-Key — client retry создаёт второй платёж. Подробнее — Auth → idempotency.

2. Kafka listener

Listener: получил event_id=XYZ
          Начал @Transactional:
          - INSERT processed_event(event_id=XYZ)
          - billing.charge(...) ← side-effect
          - ack.acknowledge() (commit offset)
          [SIGTERM посередине, transaction rollback]

Restart:  Listener получил event_id=XYZ снова (offset не committed)
          Начал @Transactional:
          - INSERT processed_event(event_id=XYZ) — OK (rollback из предыдущего раза)
          - billing.charge(...) ← возможен дубль если HTTP без Idempotency-Key
          - ack

processed_event в той же транзакции — атомарно. Если что-то падает — обе записи откатываются, retry безопасен.

@KafkaListener(topics = "orders.confirmed")
@Transactional
public void onConfirmed(OrderConfirmedEvent event, Acknowledgment ack) {
    if (!processedEventRepository.tryMarkProcessed(event.eventId(), "billing")) {
        ack.acknowledge();
        return;
    }
    billingService.charge(
        event.orderId(),
        event.totalAmount(),
        Map.of("Idempotency-Key", event.eventId().toString())
    );
    ack.acknowledge();
}

Двойная защита: processed_event + Idempotency-Key к payment-provider. Если processed_event пропустил (другой consumer-group), provider сам дедуплицирует.

3. Outbox-relay

Relay: SELECT * FROM outbox_event WHERE published_at IS NULL LIMIT 50
       Для каждого:
         kafkaTemplate.send(...).get()  ← Kafka получил event
         UPDATE outbox_event SET published_at=now() WHERE id=...
         [SIGTERM посередине: send прошёл, UPDATE нет]

Restart: SELECT * FROM outbox_event WHERE published_at IS NULL LIMIT 50
         Тот же event → второй send
         Kafka получает дубль event_id=XYZ

Защита — двух-фаза:

ALTER TABLE outbox_event ADD COLUMN status text NOT NULL DEFAULT 'PENDING';

-- Phase 1: lock + mark PUBLISHING
UPDATE outbox_event
SET status = 'PUBLISHING', locked_at = now()
WHERE id IN (SELECT id FROM outbox_event WHERE status = 'PENDING' LIMIT 50 FOR UPDATE SKIP LOCKED);

-- Phase 2: send
-- Phase 3: mark PUBLISHED
UPDATE outbox_event SET status = 'PUBLISHED', published_at = now() WHERE id = ...;

Если SIGTERM между Phase 1 и Phase 2 — PUBLISHING rows остаются locked. Cleanup job через час → возвращает в PENDING. Retry безопасен только если consumer dedupe-ит через processed_event на receiving side.

Альтернатива (проще) — receiver-side dedup всегда есть (см. Idempotent consumer). Тогда relay может публиковать дубли в Kafka, receiver игнорирует. Дубли в Kafka — небольшая overhead, но операционно проще.

Граничные случаи

R-SHUT-IDEM-1 (детали):

POST без Idempotency-Key

// КАТАСТРОФА — нет Idempotency-Key
@PostMapping("/payments")
public PaymentResponse charge(@RequestBody @Valid ChargeRequest req) {
    return paymentService.charge(req);
}

При shutdown в момент charge:

  1. paymentService.charge() — connection to provider, deduct money.
  2. SIGTERM, force-shutdown.
  3. Client timeout → retry на новый pod.
  4. Charge снова → второй deduct.

Корректно — AUTH-19:

@PostMapping("/payments")
public PaymentResponse charge(
    @RequestHeader("Idempotency-Key") String key,
    @RequestBody @Valid ChargeRequest req
) {
    return paymentService.charge(key, req);
}

Kafka listener offset committed, side-effect не завершён

При ack-mode: RECORD или MANUAL_IMMEDIATE offset commit может произойти до side-effect:

// Сценарий ошибки
@KafkaListener(...)
public void onEvent(SomeEvent event, Acknowledgment ack) {
    sideEffect(event);
    ack.acknowledge();
    [SIGTERM здесь, ack committed, но side-effect внутри ещё не закончил async chain]
}

В UCP - использовать BATCH ack-mode + @Transactional с включённым processed_event. Если @Transactional не commit-нулась — offset тоже не commit-нулся.

Outbox-relay — Kafka send без УПДЕЙТ

См. секцию выше. Решение — receiver-side dedup или двух-фаза.

Что запрещено

Money без Idempotency-Key + @Retry

R-SHUT-IDEM-X1:

// КАТАСТРОФА
@CircuitBreaker(name = "payment")
@Retry(name = "payment")
public Receipt charge(Long orderId, Money amount) {
    return paymentClient.post("/charge", new ChargeRequest(orderId, amount), Receipt.class);
}

Сценарий:

  1. charge() вызван, первый attempt — отправил request, network timeout.
  2. Retry — второй attempt, ещё один POST.
  3. На сервере: оба POST дошли (network timeout != не отправили).
  4. Без Idempotency-Key payment-provider обрабатывает оба → двойной deduct.
  5. На pod SIGTERM посередине — то же самое, ещё хуже.

Корректно — Idempotency-Key обязателен:

@CircuitBreaker(name = "payment")
@Retry(name = "payment")
public Receipt charge(String idempotencyKey, Long orderId, Money amount) {
    return paymentClient.post(
        "/charge",
        new ChargeRequest(orderId, amount),
        Map.of("Idempotency-Key", idempotencyKey),
        Receipt.class
    );
}

idempotencyKey генерируется один раз на бизнес-операцию (например, event.eventId()), используется во всех retry.

Подробнее — Resilience → retry R-RES-RE-X1.

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

АнтипаттернПравилоЧто взамен
Money без Idempotency-Key с @RetryR-SHUT-IDEM-X1header обязателен
Kafka listener offset commit до side-effectR-SHUT-IDEM-1@Transactional с side-effect + processed_event
Outbox-relay без receiver dedupR-SHUT-IDEM-1processed_event на consumer-side
HTTP POST money без Idempotency-Key (AUTH-19)R-SHUT-IDEM-1header обязателен
Non-transactional Kafka handlerR-SHUT-IDEM-1@Transactional обертка
Идемпотентность только на one сервисеR-SHUT-IDEM-1end-to-end (client + server + downstream)
Side-effect (HTTP) внутри @Transactional без try/catchR-SHUT-IDEM-1outbox + worker

Куда дальше

  • Graceful Shutdown → раздел 7. Идемпотентность — нормативные формулировки.
  • Auth → idempotency — Idempotency-Key для money (AUTH-19).
  • Distributed → idempotency — общая теория at-least-once.
  • Kafka → idempotent consumer — processed_event детали.
  • Kafka → outbox publishing — двух-фаза, dedup.
  • Resilience → retry — retry только при идемпотентности.
  • Scheduled / Async / outbox — outbox-relay shutdown.