0021. Snapshot Policy/Scheduler Composition¶
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 replaces the
SnapshotStrategy protocol and AggregateSnapshotManager class from ADR
0017 with four smaller collaborators — SnapshotPolicy, SnapshotScheduler,
take_snapshot(), and read_valid_snapshot() — composed directly by
AggregateRepository, and explains why the manager was removed rather than
carried forward into the application ring.
Status¶
Accepted. Supersedes ADR 0017.
Amended by ADR 0049 — EveryNEvents
fires on crossing a multiple of n, not on landing exactly on one. The
composition this ADR decided is unchanged; only the predicate is.
Implemented in src/eventsource/application/aggregates/snapshotting.py
(SnapshotPolicy, EveryNEvents, Never, SnapshotScheduler,
ImmediateScheduler, BackgroundScheduler, take_snapshot(),
read_valid_snapshot()) and
src/eventsource/application/aggregates/repository.py
(AggregateRepository.__init__ composition and wiring).
Context¶
ADR 0017 split when to snapshot (SnapshotStrategy.should_snapshot) from
how to execute the write (SnapshotStrategy.execute_snapshot), but left
both questions on the same protocol and introduced a fourth object,
AggregateSnapshotManager, to hold the store, the strategy, and the
load-path validation logic. Moving that design into the new ring layout
surfaced four problems ADR 0017 itself had already named or was structurally
prone to:
The manager mixed four responsibilities behind one interface¶
AggregateSnapshotManager owned: reading and validating a snapshot on load
(load_valid_snapshot), deciding whether to write one automatically
(maybe_create_snapshot, which just forwarded to the strategy), writing one
on demand (create_snapshot, which duplicated the strategy's serialization
logic rather than reusing it), and reporting on background work
(pending_count / await_pending, forwarded to the strategy when there was
one). None of those four are the same job, and three of them already lived
on the strategy or could be free functions — the manager's only irreducible
job was owning the store reference, which the repository already holds.
Construction was duplicated between the manager and the strategy¶
ADR 0017's own Consequences section flagged this: "two spellings of snapshot
creation (strategy and manager) must stay in sync." BaseSnapshotStrategy._create_snapshot()
and AggregateSnapshotManager.create_snapshot() built the same Snapshot
object from the same fields, in two places, with only the manager's copy
carrying a tracing span. A field added to one and not the other was a latent
bug with no test able to catch it structurally.
The manager isinstance-sniffed a concrete strategy (LSP)¶
pending_count and await_pending() on AggregateSnapshotManager did
isinstance(self._strategy, BackgroundSnapshotStrategy) and returned 0 /
a no-op otherwise. A collaborator that inspects the concrete type of another
collaborator to decide whether a method call is meaningful violates
Liskov substitution — code holding a SnapshotStrategy could not treat all
three implementations interchangeably without the manager's type check
standing in for the difference the Protocol was supposed to abstract away.
NoSnapshotStrategy implemented an unrunnable method (ISP)¶
Every SnapshotStrategy had to declare async execute_snapshot(...), but
NoSnapshotStrategy never needed to be called — its should_snapshot()
always returns False, so nothing in the manager ever reaches its
execute_snapshot(). The manual mode was forced to carry a method its
contract can never legitimately trigger, because when and how were
merged into one protocol. Splitting them lets manual mode be expressed by a
policy alone (Never), with no scheduler-shaped no-op required.
The library is unreleased, so there is nothing to shim¶
ADR 0017 preserved the snapshot_mode/snapshot_threshold mode-string API
as a stable public surface across a factory-function indirection
(create_snapshot_strategy). eventsource has not yet shipped a release
with that surface as its own committed contract independent of this
refactor — the mode/threshold knobs are kept here because they are still a
good ergonomic default, not because a deprecation shim is owed to existing
callers. There is no 0.8.0-style compatibility layer for SnapshotStrategy,
AggregateSnapshotManager, or create_snapshot_strategy; they are deleted
outright.
Decision¶
SnapshotPolicy — pure predicate for when¶
SnapshotPolicy is a @runtime_checkable Protocol with one method:
should_snapshot(aggregate, events_since_snapshot) -> bool, synchronous and
side-effect free. Two implementations ship:
EveryNEvents(n)— the ADR 0017 default rule, unchanged:aggregate.version > 0 and aggregate.version % n == 0. Keyed off the aggregate's absolute version, notevents_since_snapshot, so two processes saving the same aggregate agree on where boundaries fall. The straddle caveat carries over verbatim: a save that jumps the version across a multiple ofnwithout landing on it takes no snapshot until the next boundary — acceptable, because snapshots are an optimization, not a guarantee.
Amended by ADR 0049. The straddle
caveat was wrong to accept: for a constant save stride it is not "no
snapshot until the next boundary" but no snapshot ever, since a stride may
never land on a multiple at all. The predicate is now a crossing test. The
agreement property this paragraph is defending is preserved.
- Never — should_snapshot always returns False. This is manual mode,
expressed as a policy with nothing to schedule, rather than as a strategy
with an unrunnable execution method.
SnapshotScheduler — uniform surface for how¶
SnapshotScheduler is a @runtime_checkable Protocol with schedule(write, *, aggregate_type, aggregate_id) -> Snapshot | None,
plus pending_count (property) and await_pending() (coroutine). Every
implementation carries the full surface — pending_count and
await_pending() return 0 for a scheduler with nothing ever in flight —
so no caller, including the repository, ever needs to sniff the concrete
type to decide whether asking about pending work is meaningful. This is the
fix for the ADR 0017 LSP violation: the isinstance check moves from a
consumer of the protocol into "every implementation answers the same
questions."
ImmediateScheduler— awaitswriteinline; on failure, logs aWARNINGwithexc_info=Trueand returnsNone. Equivalent to ADR 0017'sThresholdSnapshotStrategyexecution half.BackgroundScheduler— handswriteto aBackgroundTaskManager(_internal/background_tasks.py) and returnsNoneimmediately;await_pending()is the join point for tests and graceful shutdown. Equivalent toBackgroundSnapshotStrategy's execution half, with the task bookkeeping delegated to the same collaborator the event bus already uses rather than reimplemented.
take_snapshot() — the single construction path, strict¶
A free async function: take_snapshot(aggregate, aggregate_type, store) -> Snapshot.
It reads schema_version off the aggregate class (defaulting to 1),
serializes state, builds the Snapshot, saves it, and returns it. There is
exactly one place a Snapshot object gets built from a live aggregate — the
two-spellings problem from ADR 0017 does not exist here because there is
only one spelling. take_snapshot() does not catch anything: errors from
_serialize_state() or store.save_snapshot() propagate to the caller.
Degradation is entirely the scheduler's job — ImmediateScheduler and
BackgroundScheduler both wrap their call to take_snapshot() in the
catch-log-swallow logic ADR 0017 established, so the automatic path still
degrades exactly as before. The manual path
(AggregateRepository.create_snapshot()) calls take_snapshot() directly,
with no scheduler in between, so it stays strict by construction rather than
by a separate manager method that happened to skip the try/except.
read_valid_snapshot() — the single load-path validation function¶
A free async function: read_valid_snapshot(store, aggregate_id, aggregate_type, aggregate_factory) -> Snapshot | None.
Store error, missing snapshot, and schema_version mismatch all collapse to
None, exactly as AggregateSnapshotManager.load_valid_snapshot() did:
a store exception is caught and logged at WARNING; a missing snapshot
returns None silently; a schema_version mismatch is logged at INFO.
AggregateRepository.load() calls this directly.
The repository composes all four¶
AggregateRepository.__init__ builds a SnapshotPolicy and a
SnapshotScheduler and holds them as plain attributes — there is no
intervening manager object. save() asks self._snapshot_policy.should_snapshot(...)
and, if true, opens a span and calls
self._snapshot_scheduler.schedule(take_snapshot(...), ...). load() calls
read_valid_snapshot(...) directly. create_snapshot() calls
take_snapshot(...) directly. Every one of the manager's four
responsibilities now lives with the collaborator that actually owns it:
policy, scheduler, or the repository's own load/save methods.
Mode-string knobs preserved, mapped in __init__¶
The public constructor keeps snapshot_mode: Literal["sync", "background", "manual"]
and snapshot_threshold: int | None, mapped inside __init__ rather than
through a factory function:
snapshot_mode != "manual"andsnapshot_threshold is not None→EveryNEvents(snapshot_threshold); otherwiseNever().snapshot_mode == "background"→BackgroundScheduler(); otherwiseImmediateScheduler().
New escape hatches: snapshot_policy= / snapshot_scheduler=¶
Two new constructor parameters accept a SnapshotPolicy or
SnapshotScheduler directly, for custom policies (elapsed time, event type,
state size — ADR 0017's own suggested extension point) without subclassing
anything the library ships. They are mutually exclusive with
snapshot_mode/snapshot_threshold: passing either alongside a
non-default mode or an explicit threshold raises ValueError at
construction, so there is exactly one way to end up with a given
policy/scheduler pair, never a silent knob that was ignored.
Carried-forward rationale¶
Snapshots remain disposable optimizations, never the source of truth: the
event stream is authoritative, and every automatic failure path degrades
to full replay rather than raising, for the same reasons ADR 0017 gives —
events are already durable by the time a snapshot is attempted, so failing
the operation that succeeded would invert the value proposition. The manual
path (create_snapshot() / take_snapshot()) stays strict for the same
reason ADR 0017's AggregateSnapshotManager.create_snapshot() was strict:
an explicit request should tell the caller whether it succeeded. See ADR
0017's Rationale section for the full argument; it is not restated here.
Consequences¶
Positive: one spelling of construction¶
take_snapshot() is the only place a Snapshot is built from a live
aggregate. The manual and automatic paths call the same function; there is
no second copy to keep in sync.
Positive: no type-sniffing¶
Every SnapshotScheduler answers pending_count / await_pending()
uniformly, so AggregateRepository.pending_snapshot_count and
await_pending_snapshots() forward directly with no isinstance check
anywhere in the call chain.
Positive: policies and schedulers are unit-testable in isolation¶
EveryNEvents, Never, ImmediateScheduler, and BackgroundScheduler are
each testable with a stub aggregate and a fake store, with no event store,
repository, or manager object in play — the same isolation ADR 0017 claimed
for strategies, now split across two smaller surfaces instead of one.
Positive: custom policies and schedulers without library changes¶
A new when rule is a new SnapshotPolicy implementation; a new how
(bounded background queue, batched writes) is a new SnapshotScheduler
implementation. Neither requires touching AggregateRepository or any
existing collaborator, and each can be supplied independently — a custom
policy with the built-in ImmediateScheduler, or the built-in
EveryNEvents with a custom scheduler.
Negative: BackgroundScheduler still has no backpressure bound¶
Carried forward from ADR 0017's BackgroundSnapshotStrategy: the
BackgroundTaskManager it delegates to tracks in-flight tasks but does not
cap them. Under a burst where snapshot writes are slower than saves crossing
policy boundaries, pending work grows unbounded. This ADR does not change
that; it is still a known gap.
Observable change: tracing spans moved¶
eventsource.snapshot_manager.* spans (load_valid_snapshot,
maybe_create_snapshot, create_snapshot) no longer exist — there is no
manager object to open them. The repository's own spans remain:
eventsource.repository.load, .save, .create_snapshot, and a new
eventsource.repository.snapshot span opened around the scheduler call,
only when a snapshot is actually scheduled (i.e. only when
should_snapshot() returned True), mirroring the "no span for the common
non-boundary case" behavior ADR 0017's manager already had.
Alternatives Considered¶
Keep AggregateSnapshotManager, move it into application/aggregates/¶
Simplest migration: rename the module, update imports, done. Rejected
because it would carry the isinstance-sniffing and the duplicated
construction logic forward unchanged into a ring redesign that is
explicitly supposed to be an opportunity to fix known issues, not just
relocate them. The manager's four responsibilities were already candidates
for splitting under ADR 0017's own "one responsibility per module"
principle (see docs/development/code-structure.md); this ADR is that
split.
Merge SnapshotPolicy and SnapshotScheduler back into one protocol¶
Rejected for the same ISP reason the original SnapshotStrategy was split
in spirit if not in code: manual mode has a when (never) with no how to
speak of, and a merged protocol forces every implementation to declare an
execution method even when it is unreachable. Two protocols let Never
exist with a single method.
Bound BackgroundScheduler's pending-task list in this ADR¶
Considered and rejected as out of scope: the backpressure gap is inherited from ADR 0017 unchanged, and fixing it is an independent decision (a queue depth, a rejection policy) that deserves its own ADR rather than being folded into a decomposition refactor.
References¶
docs/superpowers/specs/2026-07-30-aggregates-application-ring-design.md— the design spec this decision implements.src/eventsource/application/aggregates/snapshotting.py—SnapshotPolicy,EveryNEvents,Never,SnapshotScheduler,ImmediateScheduler,BackgroundScheduler,take_snapshot(),read_valid_snapshot().src/eventsource/application/aggregates/repository.py— composition and thesnapshot_mode/snapshot_threshold→ policy/scheduler mapping.src/eventsource/ports/snapshots.py—Snapshot,SnapshotStore.src/eventsource/exceptions.py—SnapshotErrorhierarchy (moved fromeventsource.snapshots.exceptions).tests/unit/application/aggregates/test_snapshotting_properties.py,tests/unit/domain/test_aggregate_memento_properties.py— property-based coverage of the policy boundary predicate and the snapshot/replay equivalence.- ADR 0017 — the superseded design; read for the full rationale on graceful degradation, which this ADR carries forward without restating.