Skip to content

JournaledGrain snapshotting: replay only post-snapshot events (#144) - #166

Merged
thnak merged 10 commits into
mainfrom
feature/journaledgrain-snapshotting
Jul 10, 2026
Merged

JournaledGrain snapshotting: replay only post-snapshot events (#144)#166
thnak merged 10 commits into
mainfrom
feature/journaledgrain-snapshotting

Conversation

@thnak

@thnak thnak commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Summary

Adds an optional snapshot mechanism to JournaledGrain<TState,TEvent> so activation replays only the events after the latest snapshot instead of the entire event log from version 0. Previously replay cost was unbounded and grew linearly with a grain's whole history, forever.

The event log remains the sole source of truth — a snapshot is only a replay-shortcut and can never change the replayed result.

What's included

  • ISnapshotStore abstraction + SnapshotEnvelope<TState> + CorruptSnapshotException (Quark.Persistence.Abstractions.Journaling) — a dedicated snapshot store, separate from ILogStorage/IGrainStorage.
  • InMemorySnapshotStore + AddInMemorySnapshotStore() — deep-copies state on write and read (via ICopierProvider) to isolate the stored snapshot from the grain's live, still-mutating state, matching InMemoryGrainStorage.
  • JournaledGrain write path — auto-snapshots every N confirmed events (protected virtual int SnapshotInterval => 100, 0 disables) plus a manual WriteSnapshotAsync() hook. New optional ISnapshotStore? ctor param (backward-compatible: unregistered → snapshotting off).
  • JournaledGrain activation path — seeds state from the snapshot and replays only the tail. Uses a Version-1 boundary probe to confirm the log contains the snapshot's events without adding any length API to ILogStorage (relies on the existing version == index contiguity guarantee).
  • Fail-fast + recovery — a missing snapshot full-replays from 0 (unchanged); a present-but-broken snapshot (undeserializable, or version ahead of the log) throws CorruptSnapshotException, recoverable via ClearSnapshotAsync.
  • Bank sample — the event-sourced ledger now snapshots every 5 events (LedgerState gains [GenerateSerializer]; silo registers AddInMemorySnapshotStore()).

Design & plan

  • Spec: docs/superpowers/specs/2026-07-10-journaledgrain-snapshotting-design.md
  • Plan: docs/superpowers/plans/2026-07-10-journaledgrain-snapshotting.md

Scope

InMemory provider + the abstraction only. A RedisSnapshotStore and a durable Redis ILogStorage are explicit, documented follow-ups (spec §9).

Testing

  • 12 new snapshot unit tests (auto-cadence at N/2N/3N, tail-only replay, missing→full-replay, ahead-of-log→throw, store-corruption propagation, ClearSnapshotAsync recovery, deep-copy isolation on both write and read, and a reactivate-twice read-isolation regression guard against the real store).
  • Existing JournaledGrainTests unchanged and green (backward compatibility).
  • Full solution build: 0 warnings / 0 errors. AOT publish smoke (Quark.Runtime, -r linux-x64 -p:PublishAot=true): clean.
  • Each task passed an independent per-task spec+quality review; the whole branch passed a final review with no Critical/Important findings.

Fixes #144

🤖 Generated with Claude Code

thnak and others added 10 commits July 10, 2026 03:06
Replaces the GrainScopeInitializer/IGrainScopeInitializerRegistry/
AddGrainScopeInitializer family with a single compile-time-discovered
IGrainUserServiceProviderFactory, addressing per-call DI resolution
overhead found via benchmarking.
Replaces the "generator emits a duplicate satellite collection" idea
with a marker-capture mechanism (AddQuarkOwnedScoped) that reuses the
existing deferred-registration idiom with far less generator surface.
Also documents a real correctness bug the naive design would have hit:
BehaviorResolver captured its own ambient IServiceProvider, which
would silently starve user-owned constructor parameters when resolved
from the Quark-only scope — fixed by passing the construction provider
explicitly. Narrows v1 scope to exclude persistence patterns that need
cross-package services (IStorage<T> etc.), confirmed with the user.
10 TDD tasks covering: new interface/composite-provider/registry types,
the BehaviorResolver ambient-scope-capture fix, RuntimeServiceCollection-
Extensions wiring, SiloHostedService satellite-provider construction,
GrainActivation branch, end-to-end tests, generator changes, and docs.
Design spec and task-by-task implementation plan for #144 — adds an optional
ISnapshotStore so JournaledGrain replays only post-snapshot events instead of
the entire log on every activation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduces ISnapshotStore, SnapshotEnvelope<TState>, and
CorruptSnapshotException in Quark.Persistence.Abstractions.Journaling as
the foundation for JournaledGrain log snapshotting (#144).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deep-copies state on write and read (via ICopierProvider) to isolate the
stored snapshot from the grain's live state, matching InMemoryGrainStorage
isolation. Adds AddInMemorySnapshotStore() DI helper. (#144)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an optional ISnapshotStore ctor dependency, a per-type SnapshotInterval
(default 100, 0 disables), and a protected WriteSnapshotAsync() hook.
ConfirmEventsAsync writes a snapshot once ConfirmedVersion advances a full
interval past the last snapshot. (#144)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On activation, reads the latest snapshot and replays only events after its
version. A missing snapshot full-replays from 0 (unchanged). A snapshot whose
version is ahead of the log throws CorruptSnapshotException (fail-fast);
recovery is via ISnapshotStore.ClearSnapshotAsync. (#144)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Makes LedgerState [GenerateSerializer], registers its deep copier, forwards
ISnapshotStore into LedgerBehavior with SnapshotInterval=5, and wires
AddInMemorySnapshotStore() in the silo. (#144)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface the read/write isolation requirement on ISnapshotStore's XML docs
(ReadSnapshotAsync must return an isolated copy since the caller mutates it
in place during tail replay; WriteSnapshotAsync must store an isolated copy
since the caller keeps mutating its own state after the call returns), move
the throwaway `new TState()` in ReloadFromLogAsync so it only allocates on
the no-snapshot full-replay path, and add two tests: one exercising 2N/3N
auto-snapshot cadence and one exercising read-isolation across repeated
reactivations from the same snapshot via the real InMemorySnapshotStore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thnak
thnak merged commit af53a84 into main Jul 10, 2026
0 of 2 checks passed
@thnak
thnak deleted the feature/journaledgrain-snapshotting branch July 10, 2026 04:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JournaledGrain replays the entire event log on every activation — no snapshotting

1 participant