The Checkpointing System
To ensure our read models never duplicate work or lose track of where they are, we use Checkpoints:- Every event successfully committed to the database receives a monotonically increasing global
sequencenumber. - The Projection Runner maintains a separate checkpoint table (e.g.,
projection_checkpoints) which stores a single number representing the last globalsequencesuccessfully processed by that specific projection. - Upon startup or loop execution, the runner reads its stored checkpoint, queries the event store for any events with
sequence > checkpoint, processes those new events, and updates its checkpoint.
Asynchronous Projection Pipeline
This architecture completely decouples write transactions from read calculations, enabling horizontal write scaling and instant read-model lookups:Production Reliability Rules
When implementing production-grade projections, you must strictly adhere to these two rules:Rule 1: Projections MUST be Idempotent
If your application server crashes or loses network connectivity mid-transaction, the projection runner will restart and retry applying the last block of events. Your projection code must handle processing the same event multiple times without corrupting your read model data. For example:- SQL Upsert: Use
INSERT INTO ... ON CONFLICT (id) DO UPDATE ...instead of a blindINSERT. - Saturating Arithmetic: Ensure operations like subtraction use defensive helpers (e.g.,
saturating_sub) to prevent arithmetic underflows.
Rule 2: Projections MUST be Sequential
Events must be processed in the exact order they were committed. Processing events out of order can lead to corrupted read states (such as attempting to applyMoneyDeposited before AccountOpened).
Our Projection Runner guarantees sequential processing by executing in a single thread per projection pipeline, reading events ordered strictly by their global database sequence.
Building a Persisted Projection Runner
To run a persisted projection in production, you can use the built-inPersistedProjectionRunner (for synchronous event stores) or AsyncPersistedProjectionRunner (for async event stores), paired with a checkpoint store (such as SqliteCheckpointStore or PostgresCheckpointStore).
Use run_batch(...) for production workers so each loop loads a bounded
backlog slice. The compatibility run(...) methods still exist, but they load
all events after the checkpoint and should be reserved for tests, small local
tools, or one-off maintenance jobs where the backlog size is known.