0017. Snapshot Strategy Pattern¶
Snapshots make loading a long-lived aggregate cheap: instead of replaying every
event from version 0, the repository restores serialized state at version N and
replays only what came after. This ADR explains why the when and how of
snapshot creation live behind a SnapshotStrategy protocol rather than inside
AggregateRepository, and why nearly every snapshot failure path degrades
silently instead of raising.
Status¶
Superseded by ADR 0021. Historical record of the strategy-pattern design it replaced.
Accepted. Implemented in src/eventsource/snapshots/strategies.py
(SnapshotStrategy, BaseSnapshotStrategy, ThresholdSnapshotStrategy,
BackgroundSnapshotStrategy, NoSnapshotStrategy, create_snapshot_strategy)
and src/eventsource/aggregates/snapshot_manager.py
(AggregateSnapshotManager), wired up by AggregateRepository.__init__ in
src/eventsource/aggregates/repository.py.
Context¶
Snapshot creation logic lived inside AggregateRepository¶
AggregateRepository already carries load, save, optimistic-locking, event
publication and tracing responsibilities. Snapshot creation added a further
concern that had nothing to do with the repository's core job: deciding whether
version 300 is a snapshot boundary, serializing state, writing to the snapshot
store, and swallowing the resulting errors.
Adding a snapshot mode meant editing the repository (Open/Closed violation)¶
The public knob is a mode string — snapshot_mode="sync" | "background" |
"manual" on the repository constructor. When that string was interpreted by
branching inside the save path, a fourth mode (say, time-based or size-based
snapshotting) could not be added without editing repository code that was
already correct for the existing three.
Three distinct timing behaviors were needed: sync, background, manual¶
The three modes differ only in timing, not in what a snapshot is:
- sync — write the snapshot before
save()returns; predictable, but the snapshot write is on the caller's latency path. - background — schedule the write and return immediately; keeps save latency flat under load.
- manual — never write automatically; the application decides when a snapshot is worth taking (a business milestone, an end-of-day close).
Decision¶
Extract a SnapshotStrategy Protocol (snapshots/strategies.py)¶
SnapshotStrategy is a @runtime_checkable Protocol with exactly two
members. Structural typing means an application can supply its own strategy
without importing or subclassing anything from the library.
should_snapshot() — the when¶
should_snapshot(aggregate, events_since_snapshot) -> bool is synchronous and
side-effect free. It is asked once per save, after the events are durably
written.
execute_snapshot() — the how¶
execute_snapshot(aggregate, snapshot_store, aggregate_type) -> Snapshot | None
is async and performs (or schedules) the write. Its None return is
overloaded on purpose: it means "skipped", "deferred to a background task", or
"attempted and failed" — the caller is not expected to distinguish.
BaseSnapshotStrategy ABC holds the shared _create_snapshot() body¶
Every strategy that writes a snapshot does the same four things: read
schema_version off the aggregate class (defaulting to 1), call
aggregate._serialize_state(), build a Snapshot stamped with
datetime.now(UTC), and await snapshot_store.save_snapshot(...).
BaseSnapshotStrategy implements that once as _create_snapshot() and
declares execute_snapshot() abstract. It also supplies the default
threshold-boundary should_snapshot() and a read-only threshold property.
Three concrete strategies: Threshold (sync), Background, No-op (manual)¶
ThresholdSnapshotStrategyawaits_create_snapshot()directly insideexecute_snapshot().BackgroundSnapshotStrategywraps_create_snapshot()in_create_snapshot_background(), spawns it withasyncio.create_task, appends the task to_pending_tasks, prunes completed tasks, and returnsNone.NoSnapshotStrategyoverridesshould_snapshot()to returnFalseunconditionally andexecute_snapshot()to returnNone.
Version-boundary predicate: version > 0 and version % threshold == 0¶
The default rule keys off the aggregate's version, not the
events_since_snapshot argument. With threshold=100, snapshots land at
versions 100, 200, 300. This makes snapshot points deterministic and
repository-independent: two processes saving the same aggregate agree on where
the boundaries are. A threshold of None disables the predicate entirely
(returns False). Note the consequence: a save that jumps the aggregate from
version 98 to 102 straddles the boundary without landing on it, and no snapshot
is taken until version 200 — acceptable because snapshots are an optimization,
not a guarantee.
create_snapshot_strategy(mode, threshold) factory preserves the mode-string API¶
The factory maps "sync" → ThresholdSnapshotStrategy, "background" →
BackgroundSnapshotStrategy, "manual" → NoSnapshotStrategy (constructed
without a threshold), and raises ValueError for anything else. This is the
one place that understands mode strings; AggregateRepository.__init__ calls it
once when a snapshot_store is supplied and keeps only the resulting object.
The ergonomic snapshot_mode= constructor argument survives, but the branching
does not.
AggregateSnapshotManager owns load/validate; the strategy owns create¶
AggregateSnapshotManager sits between the repository and the strategy. It
holds the store, the aggregate type name, an optional strategy, and a tracer,
and exposes:
load_valid_snapshot()— fetch and validate on the read path (no strategy involvement).maybe_create_snapshot()— short-circuit if there is no strategy orshould_snapshot()says no, otherwise open a span and delegate toexecute_snapshot().create_snapshot()— unconditional, for manual mode.pending_count/await_pending()— forwarded to the strategy when it is aBackgroundSnapshotStrategy, otherwise0.
Rationale¶
Snapshots are disposable optimizations, not the source of truth¶
Events remain authoritative; snapshots are regenerable cache entries¶
The event stream is the system of record. A snapshot is a memoized fold over a prefix of that stream. Deleting every snapshot in the database changes load latency and nothing else.
Consequence: every snapshot failure path degrades rather than raises¶
Because the correct answer is always recoverable from events, a failed snapshot write or an unreadable snapshot should never surface as an application error. The worst outcome of any snapshot failure is a slower load.
Failures are swallowed by design¶
Sync strategy catches and logs, returns None¶
ThresholdSnapshotStrategy.execute_snapshot() wraps _create_snapshot() in a
bare except Exception, emits a logger.warning with exc_info=True, and
returns None. A snapshot-store outage therefore cannot fail a save() whose
events were already committed.
Background strategy is fire-and-forget via asyncio.create_task¶
The task body _create_snapshot_background() has the same catch-log-continue
shape, so a failing background snapshot never becomes an unretrieved-exception
warning from the event loop.
await_pending() exists so tests can join the fire-and-forget tasks¶
Fire-and-forget is untestable without a join point. await_pending() gathers
_pending_tasks with return_exceptions=True, logs anything unexpected at
error, clears the list, and returns how many tasks it awaited. It is also
useful for graceful shutdown. pending_count prunes completed tasks before
reporting.
Schema-version mismatch degrades to full replay, not an error¶
load_valid_snapshot() returns None on mismatch, store error, or missing snapshot¶
All three conditions collapse to the same signal. A store exception is caught
and logged at warning; a missing snapshot returns None silently; a
snapshot.schema_version != aggregate_factory.schema_version mismatch is logged
at info and returns None. The repository's fallback is identical in every
case: replay from version 0.
Why SnapshotSchemaVersionError exists but is not raised on the load path¶
SnapshotSchemaVersionError (and its siblings SnapshotDeserializationError,
SnapshotNotFoundError, under the SnapshotError base) is defined and
exported from eventsource, but nothing in the library's load path raises it —
load_valid_snapshot() logs and returns None instead. The type exists so
that a custom SnapshotStore or a caller with stricter requirements can signal
the condition precisely, and so applications can catch the whole family with a
single except SnapshotError.
Bulk invalidation via delete_snapshots_by_type(schema_version_below=...)¶
When an aggregate's state model changes and its schema_version is bumped,
existing snapshots are dead weight — they will be read, rejected, and ignored on
every load. SnapshotStore.delete_snapshots_by_type(aggregate_type,
schema_version_below=N) clears them in one operation, turning a slow-but-correct
state into a clean one.
Consequences¶
Positive: new modes are additive; strategies are unit-testable in isolation¶
A new timing behavior is a new BaseSnapshotStrategy subclass plus one entry in
the factory table; no existing strategy, the manager, or the repository changes.
Each strategy can be exercised with a fake store and a stub aggregate, with no
event store, session factory, or repository in play.
Negative: silent failure means snapshot loss is only visible in logs/metrics¶
If the snapshot store is misconfigured, nothing breaks and nothing complains
loudly. Loads get progressively slower as streams grow, and the only in-band
evidence is warning-level log lines from eventsource.snapshots.strategies.
Negative: background mode has no backpressure bound on _pending_tasks¶
BackgroundSnapshotStrategy appends to _pending_tasks without a cap. Pruning
only removes tasks that are already done(). Under a burst where snapshot
writes are slower than saves crossing thresholds, the list grows unbounded.
Negative: two spellings of snapshot creation (strategy and manager) must stay in sync¶
BaseSnapshotStrategy._create_snapshot() and
AggregateSnapshotManager.create_snapshot() build the Snapshot object with
the same fields and the same schema_version lookup, in two places. The
manager's copy adds a tracing span and returns a non-optional Snapshot; the
strategy's copy is the one wrapped in failure handling. A change to snapshot
construction has to be applied to both.
Operational guidance: monitor snapshot-failure log lines; snapshot absence is not an outage¶
Alert on the Failed to create snapshot, Background snapshot creation failed,
and Error loading snapshot warnings rather than on aggregate load errors —
the latter will never fire for snapshot reasons. Treat a sustained rise in
those lines as a latency problem to schedule, not a correctness incident to
page on. After bumping an aggregate's schema_version, expect the
schema version mismatch info lines until snapshots are regenerated, and use
delete_snapshots_by_type(..., schema_version_below=...) to cut the noise.
Alternatives Considered¶
Keep snapshot_mode branching in AggregateRepository¶
Simplest, and honest about there being only three modes. Rejected because the
repository was already the largest-responsibility class in aggregates/, and
each mode's error handling differs enough (synchronous try/except vs. task
lifecycle management) that inlining all three obscured the save path. The mode
string was kept as the public API precisely so this refactor cost callers
nothing.
Raise on snapshot failure instead of degrading¶
Rejected: it inverts the value proposition. Events are already committed by the
time a snapshot is attempted, so raising would fail an operation that
succeeded, and would make an optional performance feature a new source of
availability risk. Callers who need strictness can use manual mode and call
AggregateSnapshotManager.create_snapshot(), which does propagate store errors.
Auto-migrate snapshot state across schema versions instead of replaying¶
Rejected: an upcast function per schema transition is real code that must be written, tested and kept forever, and a bug in it silently corrupts loaded state. Replay is free, always correct, and self-healing — the next threshold boundary writes a snapshot at the new schema version.
References¶
src/eventsource/snapshots/strategies.py— protocol, base class, three strategies, factorysrc/eventsource/aggregates/snapshot_manager.py—AggregateSnapshotManagersrc/eventsource/aggregates/repository.py—snapshot_mode/snapshot_thresholdwiringsrc/eventsource/snapshots/interface.py—Snapshot,SnapshotStore,delete_snapshots_by_typesrc/eventsource/snapshots/exceptions.py—SnapshotErrorhierarchytests/unit/aggregates/test_repository_snapshot.py,tests/unit/snapshots/— behavioral coverage