CheckpointStore is the SPI behind the @Checkpoint annotation. It persists the two resume tokens (lastSeenToken, lastProcessedToken) 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
| Path | Calls into the SPI |
|---|---|
| Handler returns successfully | saveProcessed(streamName, token, timestamp) — only lastProcessedToken advances. |
Heartbeat timer ticks (saveIntervalSeconds) | saveSeen(streamName, token, timestamp) — only lastSeenToken advances, even when the event was rejected by @Filter or no event arrived. |
| Stream starts | findByStreamName(streamName) — feeds the resume cascade. |
Stream is deleted from a @ChangeStream definition | delete(streamName). |
| Bulk / migration paths | save(checkpoint) — full-record upsert keyed by streamName. |
Interface
Semantic contract
savemust upsert the record keyed bycheckpoint.streamName(). Concurrentsavecalls for the same stream may interleave; the framework guarantees that for a given stream at most one writer is active at a time, so implementations don’t need to serialize internally.saveSeenmust update only thelastSeenTokenandlastSeenTimestampfields.lastProcessedToken/lastProcessedTimestampmust remain untouched — they advance on a different schedule (handler success) and overwriting them would break at-least-once delivery.saveProcessedmust update only thelastProcessedTokenandlastProcessedTimestampfields. Same invariant in mirror.findByStreamNamemust returnOptional.empty()when no record exists for that stream — never a half-constructedCheckpointwith nulls in unrelated fields.deleteis a no-op when no record exists.
saveSeen and saveProcessed fall back to findByStreamName + save, which preserves the dual-token contract through a read-modify-write — at the cost of one extra read per call. Custom implementations should override both methods with a native targeted write (see Overriding saveSeen / saveProcessed below).
Checkpoint record
lastSeenToken populated but no lastProcessedToken. The framework’s resume cascade tolerates any nullability pattern.
Provided implementations
| Implementation | When active | Storage |
|---|---|---|
MongoCheckpointStore | Imperative mode (flowwarden.default-mode=IMPERATIVE) — default | The _fw_checkpoints collection. saveSeen / saveProcessed use a native MongoDB upsert with a partial $set that targets only the relevant pair of fields, avoiding a hot-path read. |
ReactiveMongoCheckpointStore | Reactive mode (flowwarden.default-mode=REACTIVE) | Same _fw_checkpoints collection, same document layout. Switching between modes is transparent on the persisted state. |
flowwarden-redis satellite ships RedisCheckpointStore (blocking, auto-configured) and ReactiveRedisCheckpointStore (reactive, opt-in). Both override saveSeen / saveProcessed with targeted HSET operations. See Redis Configuration for the storage layout.
No-op default
CheckpointStore.noOp() returns a shared singleton that silently ignores all calls:
Providing a custom implementation
The auto-configured bean is guarded by@ConditionalOnMissingBean, so any @Bean CheckpointStore you declare wins:
Overriding saveSeen / saveProcessed
The default implementations work correctly for any backend, but each call costs an extra read. For write-heavy stores (high-throughput streams with low saveEveryN and short saveIntervalSeconds), override both methods with a targeted write that touches only the relevant pair of fields:
last_seen_token while handler success advances only last_processed_token. This is how the built-in Mongo and Redis implementations work natively.
Overriding only one of the two methods is supported but unusual. The framework relies on both being kept consistent with
save semantics (same record, same dual-token contract) — if you override saveSeen, you almost certainly want to override saveProcessed too.Testing the contract
If you write a customCheckpointStore, run it against the contract tests published in flowwarden-stream-core-testkit:
CheckpointStoreContractTest and implement the abstract factory method. The contract covers upsert / find / delete semantics, the dual-token invariant (saveSeen doesn’t touch lastProcessedToken and vice versa), and the findByStreamName + save fallback for default implementations — 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.