Event delivery

A saga reads its events from topics, as a projection does: the event store topic, which carries the events of your @EventSourced aggregates, and the capture topic, which carries the captured @DomainEvent`s. Spring DDD does not hand a saga an event any other way. A saga handler whose event type no topic carries — a plain application event, a `@NotCaptured event, or one excluded from capture — fails the startup.

One consumer per saga type

Each saga type has a topic consumer of its own, of type saga and named after the saga type. It shows in the consumer actuator like any other consumer; see Observability.

  • Delivery is at-least-once: a handler may see the same event again after a failure or a restart, so write it to tolerate that.

  • One saga instance applies one message at a time, in position order within a topic.

  • Across topics a saga sees its events in any order. A saga fed by several topics must mark every event that can arrive first @SagaStart. A follow-up event that arrives before its saga instance exists is held back when it comes from another topic than the saga’s start events: it waits up to the start timeout for a start event to create the saga, and is then skipped with one WARN, never through the error handler. While an event is held, later events of the same saga instance from that topic wait behind it. A held event shows among the consumer’s undelivered messages as failed until it applies or its start timeout passes. An event from a topic that carries one of the saga’s start events is skipped at once when no saga exists, so mark every event that can arrive first @SagaStart. A start timeout of zero turns the waiting off, so every such follow-up is skipped at once. Set it with spring.ddd.saga.consumer.start-timeout; it is 1m by default. Raise it when the topic with the start events lags further behind than that.

The saga state, the deadlines the handler scheduled and the consumer’s checkpoint are committed together. A command a handler sends goes out during the apply, before the saga’s state and checkpoint commit, so an apply that is retried sends it again: your command handlers must tolerate duplicates. The startup fails when the consumer state is not committed in the saga’s transaction, for example when the checkpoint store runs on another transaction manager or DataSource than the saga store.

An apply holds one database connection for its transaction, and each command it sends runs in a transaction of its own and borrows a second one. Size the connection pool for at least twice the number of saga messages applied at once, which spring.ddd.messaging.consumer.executor.virtual.concurrency-limit (default 4; consumer.executor.platform.max-pool-size with platform threads) bounds, plus what the rest of the application needs. With a smaller pool the applies wait for a connection, time out and are retried, which sends their commands again.

Scaling

The application instances share a saga type’s partitions, as they share a projection’s: each partition is applied by one instance at a time, so more instances apply more saga messages at once, and every instance stands by for the others. A message’s partition is the hash of its saga’s association value, so all events of one saga instance are applied by the same instance, in order within a topic. When an instance joins or leaves, its partitions move to another one; see Running on multiple instances. With distribution: single-owner, one instance applies the saga type as a whole, one message at a time, and the others stand by.

Deadlines are delivered apart from the events, by one instance at a time; see Durable store, one instance at a time. A deadline that fires for a saga instance at the same moment as a message for it on another application instance makes one of them conflict on the saga’s version; that one is retried.

Errors

A message a saga handler cannot apply is retried, while the later messages of the same saga instance wait behind it; the other saga instances go on. A message the saga cannot read, or whose association value is missing, belongs to no saga instance, so it holds back its whole partition; see Errors and halts. When retrying does not help, the saga type’s error handler decides, as for every consumer; see Errors and halts:

  • HaltErrorHandler, the default, halts the saga type’s consumer on every instance; other saga types and consumers keep running.

  • DeadLetterKeyErrorHandler holds back only the saga instance the message belongs to, and keeps applying the others.

  • SkipErrorHandler drops the message for that saga instance, which then never sees that event: use it only for a saga that can still complete without it, for example through a deadline.

Pause, resume and retry a saga type, and list its undelivered messages, under /actuator/consumers/saga/<saga-type>, as for any consumer; see Getting a consumer going again. /actuator/sagas shows each saga type’s state, lag and undelivered messages at a glance; see Observability.

Settings

spring.ddd.saga.consumer:
  start: latest          # latest | earliest
  polling:
    interval: 1s
  distribution: partitioned   # partitioned | single-owner
  error-handler: de.dwittkoetter.ddd.messaging.consumer.HaltErrorHandler
start

Where a saga type reads from when it first starts. With latest, the default, a new saga type reacts only to events that happen after that; with earliest it reads every event the topics still hold.

polling.interval

How long a caught-up saga consumer waits before it reads its topics again.

distribution

How application instances share a saga type: partitioned, the default, or single-owner. every-instance is rejected, because every instance would then apply each event to the same saga.

error-handler

The ErrorHandler class that decides a message the saga cannot apply. The outcomes are the same as for a projection; see Configuring the error handler and Errors and halts.

start-timeout

How long a follow-up waits for its saga’s start from another topic; see above.

Settings of one saga type

Every key above can be set for one saga type under spring.ddd.sagas.<saga-type>.consumer, keyed by its @Saga(type) id. A key left out falls back to spring.ddd.saga.consumer.

spring.ddd.sagas:
  payment-saga:
    consumer:
      start: earliest
      start-timeout: 5m
  "[transfer.saga]":              # a saga type with a dot is written in brackets
    consumer:
      polling:
        interval: 200ms
      distribution: single-owner

A key that names no saga type fails the startup. An environment variable cannot spell a saga type’s letter case or its dots, so SPRING_DDD_SAGAS_GUARDEDSAGA_…​ binds to the type guardedsaga, which then names no saga type. Set the keys of a saga type in a properties or YAML file, or in SPRING_APPLICATION_JSON.

For every property, see Configuration properties.