Deadlines & timeouts

Deadlines let a saga act when nothing happens: schedule a timeout when a step begins and cancel it if the expected confirmation arrives before it fires. If it fires instead, a dedicated @SagaDeadlineHandler method runs on the same saga instance.

For the saga writing fundamentals, see Writing a saga. For a conceptual overview of the delivery model, see Overview.

Scheduling and cancelling deadlines

Inject SagaDeadlines as a handler parameter in any @SagaEventHandler or @SagaDeadlineHandler method to schedule or cancel named deadlines on the current saga instance:

@SagaEventHandler(associationProperty = "transferId")
@SagaStart
fun on(event: TransferRequested, commands: SagaCommands, deadlines: SagaDeadlines) { (1)
    this.transferId = event.transferId
    this.sourceAccountId = event.sourceAccountId
    this.targetAccountId = event.targetAccountId
    this.amount = event.amount
    commands.send(DebitAccount(accountId = event.sourceAccountId, amount = event.amount))
    deadlines.schedule("payment-timeout", Duration.ofMinutes(30)) (2)
}
1 SagaDeadlines is injected per invocation by the framework.
2 Schedules a deadline named "payment-timeout" to fire in 30 minutes. Calling schedule again with the same name reschedules the deadline (delete-then-insert).

The full SagaDeadlines API:

Method Description

schedule(name, after: Duration)

Schedules (or reschedules) deadline name to fire after from now.

schedule(name, at: Instant)

Schedules (or reschedules) deadline name to fire at an absolute instant.

schedule(name, after: Duration, payload: Any)

Like the first form, but stores payload for delivery at fire time.

schedule(name, at: Instant, payload: Any)

Like the second form, but stores payload for delivery at fire time.

cancel(name)

Cancels deadline name if it is scheduled; no-op otherwise.

Atomic buffering

Calls to SagaDeadlines are buffered in memory during the handler and flushed atomically in the same transaction that saves the saga state (BufferedSagaDeadlines). A deadline is therefore never persisted without its triggering state change, and a failed save never leaves a stale deadline behind. The flush commits to a durable table, so once the handler’s transaction commits the deadline is safe across an application restart — see Surviving restarts.

When a saga completes (via @SagaEnd or SagaLifecycle.end()), all remaining deadlines are automatically cancelled. No manual cleanup is needed in terminal handlers.

Handling a fired deadline

Declare a @SagaDeadlineHandler(name = "…") method on the saga class to handle a deadline when it fires. There must be exactly one handler per (sagaType, name) pair. The handler may carry @SagaEnd to complete the saga declaratively:

@SagaDeadlineHandler(name = "payment-timeout") (1)
@SagaEnd (2)
fun onPaymentTimeout(commands: SagaCommands) {
    commands.send(ReverseDebit(accountId = sourceAccountId!!, amount = amount))
}
1 Fires when the "payment-timeout" deadline becomes due on this saga instance.
2 Adds @SagaEnd to complete the saga after the compensation command is sent. All remaining deadlines are cancelled automatically.

Cancelling a deadline on success

Cancel the deadline in the handler that signals a successful outcome:

@SagaEventHandler(associationProperty = "transferId")
fun on(event: DebitConfirmed, commands: SagaCommands, deadlines: SagaDeadlines) {
    debitConfirmed = true
    commands.send(CreditAccount(accountId = targetAccountId!!, amount = amount))
    deadlines.cancel("payment-timeout") (1)
}
1 If the debit is confirmed before the deadline fires, cancel it immediately. The framework flushes the cancellation atomically with the state save.

Handler parameters for @SagaDeadlineHandler

A @SagaDeadlineHandler method may declare any of the following parameters, in any order (they are resolved by declared type):

Parameter type What you get

Declared payload type (any)

The object passed as payload when the deadline was scheduled; omit if none was supplied.

SagaLifecycle

Per-invocation handle to call end() for conditional completion.

SagaDeadlines

Per-invocation handle to schedule or cancel other deadlines.

@Value("…​")

A value resolved from Spring configuration — properties or SpEL — exactly like Spring’s @Value elsewhere.

SagaCommands

Handle for emitting commands from the saga. See Emitting commands.

Any other bean type

Resolved from the Spring application context at invocation time.

Durability and delivery guarantees

Durable store and single-active poller

Scheduled deadlines are persisted to a dedicated table (SAGA_DEADLINE by default). A single-active SmartLifecycle poller acquires a lease from a lock table (SAGA_DEADLINE_LOCK) before sweeping. Only one poller runs at a time even in a clustered deployment — other nodes wait for the lease to expire before taking over.

Each sweep fetches up to polling.batch-size due deadlines, then dispatches each one on the same per-saga keyed executor used for event handlers, in a REQUIRES_NEW transaction.

Surviving restarts

A scheduled deadline that has not fired yet is not lost when the application restarts. It lives in the SAGA_DEADLINE table, committed in the same transaction as the handler that scheduled it (see Atomic buffering), so it is durable the moment that handler commits.

When the application starts again, the poller resumes and fires any deadline whose time has arrived — including one whose due time passed while the application was down. Such a deadline simply fires on the first sweep after restart: a little late, not lost. A deadline is only removed by an explicit cancel, by the saga completing, or by the deadline firing.

Deadline dispatch is at-least-once. If the application stops after persisting a deadline but before the handler completes, the poller will redispatch it on the next sweep. Design @SagaDeadlineHandler methods to be idempotent.

Deadlines carry no message metadata

A deadline is a timer, not a message: it has no event payload and no message metadata. A @SagaDeadlineHandler therefore cannot inject @MetadataValue parameters, and message interceptors do not run for deadline dispatch.

If a deadline handler needs context that was available when the deadline was scheduled — a tenant id, the originating actor — store it in the saga’s own state (a var field), which is durable and always available to the handler. See Message metadata for how metadata works on event handlers.

Worked example: TransferSaga with payment timeout

The following extends the TransferSaga from Writing a saga with a "payment-timeout" deadline that compensates if neither confirmation arrives in time:

@Saga(type = "transfer-saga")
class TransferSaga {

    var transferId: String? = null
    var sourceAccountId: String? = null
    var targetAccountId: String? = null
    var amount: BigDecimal = BigDecimal.ZERO
    var debitConfirmed: Boolean = false
    var creditConfirmed: Boolean = false

    @SagaEventHandler(associationProperty = "transferId")
    @SagaStart
    fun on(event: TransferRequested, commands: SagaCommands, deadlines: SagaDeadlines) {
        this.transferId = event.transferId
        this.sourceAccountId = event.sourceAccountId
        this.targetAccountId = event.targetAccountId
        this.amount = event.amount
        commands.send(DebitAccount(accountId = event.sourceAccountId, amount = event.amount))
        deadlines.schedule("payment-timeout", Duration.ofMinutes(30)) (1)
    }

    @SagaEventHandler(associationProperty = "transferId")
    fun on(event: DebitConfirmed, commands: SagaCommands, deadlines: SagaDeadlines) {
        debitConfirmed = true
        commands.send(CreditAccount(accountId = targetAccountId!!, amount = amount))
        deadlines.cancel("payment-timeout") (2)
    }

    @SagaEventHandler(associationProperty = "transferId")
    fun on(event: CreditConfirmed, lifecycle: SagaLifecycle) {
        creditConfirmed = true
        if (debitConfirmed && creditConfirmed) {
            lifecycle.end() (3)
        }
    }

    @SagaDeadlineHandler(name = "payment-timeout") (4)
    @SagaEnd
    fun onPaymentTimeout(commands: SagaCommands) {
        commands.send(ReverseDebit(accountId = sourceAccountId!!, amount = amount))
    }
}
1 Schedule the timeout as soon as the transfer starts.
2 Cancel it the moment the debit is confirmed — the timeout is no longer needed.
3 Normal completion: both legs confirmed, no deadline running.
4 Timeout path: compensate and complete the saga. @SagaEnd tombstones the instance; the framework cancels any other open deadlines automatically.

Key configuration properties

The following properties control deadline behaviour. Provide them under the spring.ddd.saga.deadline.* prefix:

Property Default Description

enabled

true

Whether the deadline poller starts. Set to false to disable deadline support entirely.

polling.interval

30s

How often the single-active poller sweeps for due deadlines.

lease-ttl

2m

How long an acquired sweep lease is held. Must exceed polling.interval; another node takes over after expiry.

polling.batch-size

100

Maximum number of due deadlines fetched per sweep.

schema.auto

true

Whether Spring DDD creates the SAGA_DEADLINE and SAGA_DEADLINE_LOCK tables on startup.

For the full list of spring.ddd.saga.* properties including table names and lock-table names, see Configuration properties.