Skip to content

feat(botinfra): aggregate snapshots for EventStore; vahter user streams use them - #518

Merged
Szer merged 10 commits into
mainfrom
eventstore-snapshots
Sep 25, 2026
Merged

Szer merged 10 commits into
mainfrom
eventstore-snapshots

Conversation

@Szer

@Szer Szer commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Why

Every message loaded the sender's user:* stream by replaying all its events. Heavy streams (up to 3555 events, almost entirely UserReactionRecorded from 2026-05-11 → 2026-07-10) took 0.3–4 s per load when their pages were cold. That's ~1 heap page per event: 695 page reads → 400 ms for 1088 rows; warm is 30 ms for 3555. Example trace: f6ddbd351e577041f911e4024f585f66 (2.4 s eventStore.load, event_count=1690).

What

BotInfra.EventStore — opt-in snapshots (SnapshotPolicy)

  • Load = stored snapshot + tail (stream_version > snapshot), fetched in one SQL statement so a concurrent snapshot rewrite can't make the tail skip events.
  • Versioning: SchemaVersion per aggregate. A snapshot written under a different schema version is ignored and overwritten, so a rollback is safe too.
  • Stale detection: a snapshot that is ahead of its log, or won't deserialize, is deleted and the stream is replayed.
  • It's a cache, never the source of truth:
    • Snapshots only move forward within a schema version (guarded upsert).
    • Write failures are recorded (eventstore_snapshot_ops_total{op=write_failed} + span error) and swallowed.
    • On the append path the snapshot write runs after the commit, so it can never roll back events.
  • Write cadence: a snapshot is written when a load or an append leaves ≥ SnapshotEvery events past the stored one. Frozen streams get snapshotted on their first slow load, so no backfill is needed.
  • Request cache: holds snapshot-loaded states next to raw streams. Any committed append, through any API, drops both cached views of that stream.
  • SnapshotShape.describe renders a type's structure, so a state's shape can be pinned to its schema version in a test.
  • Documented in EVENTSTORE.md (schema, rules, versioning checklist).

Vahter

  • V53__event_snapshot.sql (table + grants).
  • User.SnapshotPolicy = { StateType = "User"; SchemaVersion = 1; SnapshotEvery = 20 }.
  • User appends and GetUserById go through snapshots.
  • UserSnapshotTests pin the User shape and Fold behaviour to the schema version, so changing either fails until the version is bumped. There are also JSON round-trip tests for every intermediate state and a check that snapshot + tail equals full replay at every split point.

Tests

  • New tests/BotInfra.Tests (Testcontainers Postgres, 18 tests): replay then snapshot, threshold, a valid snapshot is actually used, snapshot + tail equals replay across boundaries, schema bump, ahead and unreadable discards, per-state-type rows, a failing snapshot write doesn't fail loads or appends, concurrent transacts lose no events, request-cache behaviour in both directions, policy validation. Wired into Vahter: Build.
  • Local runs: BotInfra.Tests 18/18, VahterBanBot.Unit.Tests 133/133, VahterBanBot.Tests 302/302.
  • Mutation check: changing User.Fold without bumping the version fails the fold pin, and adding a field fails compilation of the pin.

Rollout

No flags. Right after deploy, each heavy user's first load is still a full replay, which then writes the snapshot. After that, loads fetch only the tail. Truncating event_snapshot is always safe.

Review round (independent Opus review → red test → fix → green, one commit each)

Finding Red test Fix
Parallel appends in one request scope corrupted the plain Dictionary cache (pre-existing; vahter expires callbacks via Task.WhenAll) 200 streams appended in parallel in one scope failed in 2 of 3 runs Thread-safe request cache
A stale in-scope load could overwrite a fresher cached state or make the raw and snapshot views disagree Parked-fold interleavings (2 tests) Per-stream generation counter: invalidation bumps it, and a load only stores its result if the generation is unchanged
Fire-and-forget work kept using a disposed scope's cache Background load after dispose Disposed scope stops caching
A swallowed snapshot-write failure on append marked the caller's span as Error Caller-span status assertion Dedicated eventStore.snapshotWrite span
A discard's DELETE could fail the load, and a bad "ahead" row was protected forever by the monotonic guard Snapshot table whose DELETE always raises Discard force-overwrites the row; the empty-stream delete is recorded and swallowed
The Fold pin only checked the final state, so UserUnbanned clearing NotSpamUntil passed Mutation: the old pin passes, the new pin fails Transcript pin: every intermediate state + each event on Zero and on a fully-populated state, as snapshot JSON, with edge cases (also catches a ReactionCount clamp and a JSON-options change)
SnapshotShape rendered generic BCL types with assembly versions Map/Set/KeyValuePair rendering Recursive Name<args>
The written metric counted guard-blocked no-ops Slow writer vs newer snapshot (also proves no regression) skipped when 0 rows affected
In-place UPDATE/DELETE of events (data-fix migrations) left stale snapshots Rewrite/delete an event, then load event_snapshot_invalidate triggers in V53 and EVENTSTORE.md (the bot role never mutates events, so they only fire in migrations)

Coverage added without a bug behind it: snapshot-vs-log invariant under concurrent loads+appends, raw and snapshot writers racing (= old pods during this rollout), stale scoped state → conflict → retry, two schema versions racing. Documented instead of fixed: replays while two SchemaVersions coexist during a rollout; TryAppend with a too-high expected version can create gaps.

Local runs: BotInfra.Tests 32/32, VahterBanBot.Unit.Tests 133/133, SerializationCompat 51/51, VahterBanBot.Tests 302/302.

🤖 Generated with Claude Code

Szer and others added 10 commits September 25, 2026 10:58
…ms use them

Loading a user:* stream replayed every event on each message. Heavy streams
(up to 3555 events, mostly frozen reaction history) cost 0.3-4s per load
on cold heap pages (~1 page read per event).

BotInfra.EventStore gains opt-in snapshotting (SnapshotPolicy):
- state = stored snapshot + tail (`stream_version > snapshot`) in ONE statement
- schema_version per aggregate; mismatching snapshots are ignored/overwritten
- snapshots ahead of the log or undeserializable are deleted + replayed
- monotonic upsert; write failures are recorded (metric + span) and swallowed,
  and on append run after commit, so the cache can never fail a write
- request-scope cache holds snapshot-loaded state; any committed append
  invalidates both cached views of the stream
- SnapshotShape.describe for pinning a state's shape to its schema version

Vahter: V53 event_snapshot table, User.SnapshotPolicy (every 20 events),
user appends + GetUserById go through snapshots; pin tests for User shape and
Fold behaviour force a SchemaVersion bump on change. New tests/BotInfra.Tests
(Testcontainers Postgres) wired into Vahter: Build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…appends; add concurrency tests

A handler can append to several streams in parallel inside one request scope
(vahter expires callbacks via Task.WhenAll), which raced the plain Dictionary
(InvalidOperationException / corrupted state). Switch both cache maps to
ConcurrentDictionary.

New tests: snapshot-vs-log invariant under concurrent loads+appends, raw and
snapshot writers racing on one stream, stale scoped state -> conflict ->
re-read -> append, parallel appends to 200 streams inside one scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ached state

A load that read the DB before a parallel append committed could put its
pre-append result back into the request cache after the append invalidated it
(and leave the raw and snapshot views disagreeing). The request cache now
serializes access and keeps a per-stream generation: invalidation bumps it and
a put only lands if the generation is unchanged since the caller's read. An
appender re-populates under the generation its own commit produced.

Disposing a scope also disables its cache, so fire-and-forget work that
captured it falls through to the DB instead of serving request-era state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n span

The post-commit snapshot write ran under the caller's span, so a deliberately
swallowed failure marked the business span as Error. It now runs under a
dedicated eventStore.snapshotWrite span.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tuck

Deleting a known-bad snapshot (ahead of its log / unreadable) could throw and
fail the load, and if it didn't run the monotonic upsert guard kept the bad
row forever. The discard path now force-overwrites the row with the replayed
state; only an empty stream deletes, and that delete is recorded + swallowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…final state

The old pin only compared the final state of one sequence, so a Fold change
whose effect is overwritten later (e.g. UserUnbanned clearing NotSpamUntil)
passed. The pin is now a transcript of every intermediate state plus each
event applied to Zero and to a fully-populated state, serialized with the
snapshot JSON options; the sequence gains no-grant/not-banned/negative edge
cases. Catches that mutation, a ReactionCount clamp, and a JSON-options change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Generic BCL/FSharp.Core types fell back to FullName, which embeds
assembly-qualified type arguments with versions — a runtime upgrade would
fail shape pins with no real change. Render them as Name<args> recursively.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…itten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hots

A data-fix migration that UPDATEs/DELETEs event rows left existing snapshots
looking valid (same schema version, not ahead of the log) while disagreeing
with the rewritten history. event_snapshot_invalidate triggers (row-level on
UPDATE/DELETE, statement-level on TRUNCATE) now drop affected snapshots; the
bot role never mutates events, so they only fire in migrations. Documented
along with the known costs (two-schema rollout replays, TryAppend gaps).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Szer
Szer merged commit c2fc69c into main Sep 25, 2026
9 checks passed
@Szer
Szer deleted the eventstore-snapshots branch September 25, 2026 10:53
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.

1 participant