Skip to main content
CheckpointStore is the SPI behind the @Checkpoint annotation. It persists the two checkpoint anchors (lastProcessedToken — the last terminally settled event, lastSeenToken — the last server-certified position) that drive at-least-once delivery and stream resume on restart. The MongoDB-backed implementation is auto-configured; the Redis backend ships an alternative; any custom backend (JDBC, Cassandra, in-memory, …) plugs in by registering a CheckpointStore Spring bean.

When the SPI is exercised

The dual-anchor contract (why the two anchors have different natures and writers) is documented in the @Checkpoint reference.

Interface

Only save, findByStreamName and delete are abstract — every other operation has a working default built on them, so a minimal backend is three methods.

Semantic contract

  • save must upsert the record keyed by checkpoint.streamName().
  • Partial operations must preserve the fields they do not target, atomically with respect to concurrent writers. The two anchors have independent writers on different threads: the processed-anchor policy writes lastProcessedToken while the heartbeat probe writes lastSeenToken. A targeted update (partial $set, HSET, UPDATE … SET <pair>) satisfies this by construction. The default implementations are read-modify-write fallbacks kept for source compatibility: under concurrent anchor writers they can resurrect a stale value of the other anchor, and a Java-level lock cannot fix this for a store shared between instances. Production implementations must override them with targeted writes — the shipped Mongo and Redis stores do.
  • The 4-argument overloads exist so the anchor and its lastHeartbeatTimestamp confirmation land in one atomic write. Their defaults perform two writes (anchor, then heartbeat) — correct but observable in an intermediate state.
  • resetAfterHistoryLost carries the at-least-once guard of the recovery: the expired processed pair is removed only while lastProcessedToken still equals the value read at history-loss detection. If a fresh anchor was re-acquired concurrently, it must be preserved. The race-free guarantee requires evaluating the guard atomically with the removal (a conditional write) — the Mongo stores put it in the update filter, the Redis stores use a Lua script.
  • findByStreamName must return Optional.empty() when no record exists for that stream.
  • delete is a no-op when no record exists.

Checkpoint record

All token / timestamp fields are nullable — a brand-new stream has neither anchor yet, and an idle stream that has only ever certified through the heartbeat has lastSeenToken populated but no lastProcessedToken. The framework’s resume cascade tolerates any nullability pattern.

Provided implementations

For a Redis-backed deployment, the flowwarden-redis satellite ships RedisCheckpointStore (blocking, auto-configured) and ReactiveRedisCheckpointStore (reactive, opt-in). Both override the partial operations with targeted HSET operations and resetAfterHistoryLost with a Lua script. See Redis Configuration for the storage layout.

No-op default

CheckpointStore.noOp() returns a shared singleton that silently ignores all calls:
Suitable for tests that don’t need resume semantics and for short-lived processes where checkpoint persistence is irrelevant. Not a viable production choice — every restart starts the stream from the latest MongoDB event, silently losing anything that arrived during downtime.

Providing a custom implementation

The auto-configured bean is guarded by @ConditionalOnMissingBean, so any @Bean CheckpointStore you declare wins:

Overriding the partial operations

For any production backend, override every partial operation with a targeted write that touches only the relevant fields. Two traps the naive version falls into:
  1. Every partial operation must upsert. On a brand-new stream, the bootstrap’s saveSeen is the first write — a plain UPDATE matches zero rows, silently persists nothing, and the core then runs believing its position is durable. Use INSERT … ON CONFLICT DO UPDATE (or MERGE).
  2. resetAfterHistoryLost must evaluate its guard atomically with the removal — a conditional write, not a read-then-write.
The two anchors write different fields of the same row, so their independent writers never overwrite each other. This is how the built-in Mongo and Redis implementations work natively.

Testing the contract

If you write a custom CheckpointStore, run it against the contract tests published in flowwarden-stream-core-testkit:
Extend CheckpointStoreContractTest and implement the abstract factory method. The contract covers upsert / find / delete semantics, the field-isolation invariant of every partial operation (a seen write never touches the processed pair and vice versa), the anchor+heartbeat overloads, and the resetAfterHistoryLost guard semantics — the same suite the built-in Mongo and Redis stores are validated against.

See Also

@Checkpoint annotation

The user-facing annotation that drives this SPI.

Redis backend

Drop-in Redis implementations of CheckpointStore and LockService.

LockService SPI

The sister SPI that backs DeploymentMode.SINGLE_LEADER.

DlqStore SPI

Failed-event persistence for the @DeadLetterQueue annotation.