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 |
|---|---|
|
Schedules (or reschedules) deadline |
|
Schedules (or reschedules) deadline |
|
Like the first form, but stores |
|
Like the second form, but stores |
|
Cancels deadline |
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 |
|
Per-invocation handle to call |
|
Per-invocation handle to schedule or cancel other deadlines. |
|
A value resolved from Spring configuration — properties or SpEL — exactly like Spring’s |
|
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 |
|---|---|---|
|
|
Whether the deadline poller starts. Set to |
|
|
How often the single-active poller sweeps for due deadlines. |
|
|
How long an acquired sweep lease is held.
Must exceed |
|
|
Maximum number of due deadlines fetched per sweep. |
|
|
Whether Spring DDD creates the |
For the full list of spring.ddd.saga.* properties including table names and lock-table names,
see Configuration properties.
Related pages
-
Writing a saga — declaring saga classes, event handlers, lifecycle.
-
Overview — concept, delivery model, starters.
-
Storage & tables —
@SagaTable, JSON persistence, schema creation. -
Ordered event delivery — opt-in ordered inbound from an event-sourced store.