Repository navigation
feat(botinfra): aggregate snapshots for EventStore; vahter user streams use them - #518
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Every message loaded the sender's
user:*stream by replaying all its events. Heavy streams (up to 3555 events, almost entirelyUserReactionRecordedfrom 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 seventStore.load,event_count=1690).What
BotInfra.EventStore — opt-in snapshots (
SnapshotPolicy)stream_version > snapshot), fetched in one SQL statement so a concurrent snapshot rewrite can't make the tail skip events.SchemaVersionper aggregate. A snapshot written under a different schema version is ignored and overwritten, so a rollback is safe too.eventstore_snapshot_ops_total{op=write_failed}+ span error) and swallowed.SnapshotEveryevents past the stored one. Frozen streams get snapshotted on their first slow load, so no backfill is needed.SnapshotShape.describerenders a type's structure, so a state's shape can be pinned to its schema version in a test.EVENTSTORE.md(schema, rules, versioning checklist).Vahter
V53__event_snapshot.sql(table + grants).User.SnapshotPolicy = { StateType = "User"; SchemaVersion = 1; SnapshotEvery = 20 }.GetUserByIdgo through snapshots.UserSnapshotTestspin 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
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 intoVahter: Build.User.Foldwithout 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_snapshotis always safe.Review round (independent Opus review → red test → fix → green, one commit each)
Dictionarycache (pre-existing; vahter expires callbacks viaTask.WhenAll)eventStore.snapshotWritespanFoldpin only checked the final state, soUserUnbannedclearingNotSpamUntilpassedReactionCountclamp and a JSON-options change)SnapshotShaperendered generic BCL types with assembly versionsName<args>writtenmetric counted guard-blocked no-opsskippedwhen 0 rows affectedUPDATE/DELETEof events (data-fix migrations) left stale snapshotsevent_snapshot_invalidatetriggers 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;TryAppendwith 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