Skip to content

Implement aggregate snapshot cache storage - #41

Merged
patrickleet merged 3 commits into
feat/asyncfrom
feat/snapshot-audit
May 27, 2026
Merged

patrickleet merged 3 commits into
feat/asyncfrom
feat/snapshot-audit

Conversation

@patrickleet

@patrickleet patrickleet commented May 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • expand snapshot cache records into full stream-aware envelopes with payload type/version, codec metadata, metadata, and timestamps
  • update in-memory, SQLite, and Postgres snapshot cache storage plus migrations
  • add async snapshot-aware aggregate repository hydration/commit support and cache fallback behavior
  • remove the public Entity::set_snapshot helper and refresh docs/tests around snapshot cache terminology

Verification

  • cargo test --test async_repository
  • cargo test --test snapshots
  • cargo test --test upcasting
  • cargo test --features sqlite --test sqlite_repository
  • cargo test --features sqlite --test sqlite_repository_conformance
  • cargo test --all-features
  • cargo fmt --check
  • git diff --check
  • cargo clippy --lib --all-features -- -D warnings
  • cargo clippy --test async_repository --all-features -- -D warnings

Summary by CodeRabbit

Release Notes

  • New Features

    • Added AsyncSnapshotAggregateRepository for async aggregate operations with snapshot support.
  • Improvements

    • Enhanced snapshot validation with stricter payload codec and field requirement checks before deserialization.
    • Refined snapshot record structure to include aggregate type, snapshot type/version, codec metadata, and timestamps.
  • Documentation

    • Clarified snapshots as rebuildable hydration caches separate from durable event history.
    • Updated snapshot storage specifications and load behavior for PostgreSQL and SQLite databases.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d847f03e-bdab-46da-a5ec-69ba481309f6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR refactors the snapshot architecture to enrich snapshot metadata, update database schemas, refactor hydration logic, introduce async-capable snapshot repositories, and remove the legacy Entity::set_snapshot method. The new SnapshotRecord type carries snapshot type/version, codec metadata, and timestamps alongside the aggregate identity and payload. All persistence adapters (Postgres, SQLite), hydration paths, and tests are updated to use the expanded snapshot envelope and validation APIs.

Changes

Snapshot Schema and Hydration Refactor

Layer / File(s) Summary
SnapshotRecord Type and Store Contract
src/snapshot/store.rs
SnapshotRecord is expanded from (aggregate_id, version, data) to include aggregate_type, snapshot_type, snapshot_version, payload_codec, payload_codec_version, payload, metadata, and recorded_at. A new(...) constructor and validation methods (validate(), validate_for_identity(), has_supported_payload_codec()) enforce field presence and version correctness.
Database Schema Migration and Contracts
migrations/postgres/0001_initial.sql, migrations/sqlite/0001_initial.sql, docs/postgres-event-store.md
Postgres and SQLite aggregate_snapshots tables add snapshot type/version, payload, codec metadata, and timestamp columns; primary key changes to (aggregate_type, aggregate_id) for latest-only semantics. Contract documentation clarifies schema, upsert behavior, fallback replay when snapshots are incompatible, and latest-snapshot-only retention per stream.
Snapshot Persistence in SQL Adapters
src/postgres_repo/mod.rs, src/sqlite_repo/mod.rs
Both adapters update SQL projections to fetch the expanded snapshot columns. save_snapshot_in_tx upserts using ON CONFLICT(aggregate_type, aggregate_id) and binds all new fields. snapshot_from_row deserializes metadata and constructs SnapshotRecord from the new column layout.
Snapshot Validation Across Backends
src/snapshot/in_memory.rs, src/hashmap_repo/repository.rs, src/sqlx_repo/mod.rs
In-memory store validates snapshots with record.validate() and record.validate_for_identity(identity) before persisting. HashMap and SQLx repositories delegate identity checks to record.validate_for_identity(identity), replacing inline comparisons.
Snapshot Hydration Refactor and AsyncSnapshotAggregateRepository
src/snapshot/repository.rs
Snapshot hydration is refactored to validate payload codec support before deserialization and normalize errors into RepositoryError::Replay. Introduces AsyncSnapshotAggregateRepository<R, A>, an async-capable wrapper providing snapshot-aware get, get_all, commit, and commit_all. A shared snapshot_record_for helper unifies snapshot record creation (using bitcode::serialize), and hydrate_with_optional_snapshot handles identity checks and replay decisions for both sync and async paths. After successful commits, the aggregate's snapshot_version is updated in-memory based on the frequency threshold.
API Exports, Trait Documentation, and User-Facing Docs
src/lib.rs, src/snapshot/mod.rs, src/snapshot/snapshottable.rs, README.md, docs/async-repositories.md
Crate-root and module-level re-exports now include AsyncSnapshotAggregateRepository. Snapshottable trait documentation clarifies that snapshot payloads are opt-in DTOs for caching, not events or durable history. README and contract docs describe snapshots as rebuildable hydration caches (deleting/rebuilding without correctness changes), document the new snapshot record envelope contents (types, versions, codec, metadata, timestamp), and explain latest-only snapshot semantics with fallback replay.
Entity API Removal and Comprehensive Test Updates
src/entity/entity.rs, tests/async_repository/main.rs, tests/persistent_repository_conformance/scenario.rs, tests/postgres_repository/main.rs, tests/snapshots/main.rs, tests/sqlite_repository/main.rs, tests/upcasting/main.rs
Removes Entity::set_snapshot method and its test. All test files are updated to construct SnapshotRecord using SnapshotRecord::new(...) with explicit aggregate type, snapshot type, and payload. Test assertions are updated to verify aggregate_type, snapshot_type, payload_codec, and payload instead of the previous data field. New async snapshot repository tests verify cached snapshot usage, invalid cache fallback/replay, and stream version validation. A SnapshotCounter test aggregate demonstrates Snapshottable implementation.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • patrickleet/sourced_rust#38: Both PRs touch SQLite snapshot persistence and the same src/sqlite_repo/mod.rs snapshot storage/retrieval code; #38 adds initial SqliteRepository snapshot support, and this PR updates the snapshot table schema and mapping to the new envelope/fields.
  • patrickleet/sourced_rust#29: Both PRs update the Postgres snapshot table contract, hydration algorithm, and documentation in docs/postgres-event-store.md, including the aggregate_snapshots schema, latest-snapshot semantics, and replay/tail behavior.
  • patrickleet/sourced_rust#35: Both PRs refactor snapshot-related logic in src/snapshot/repository.rs and align how snapshot hydration failures are mapped into RepositoryError::Replay.

Poem

🐰 A rabbit hops through snapshot fields,
Rich metadata now shields,
Async repos bloom with grace,
Latest snapshots take their place,
From schema to hydration's height—
The cache is built just right! 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title "Implement aggregate snapshot cache storage" accurately describes the main change: implementing snapshot cache storage with enhanced record structure including metadata, codec info, and timestamps.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/snapshot-audit

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
src/snapshot/repository.rs (1)

123-126: ⚡ Quick win

Avoid cloning the full stream before every snapshot hydrate.

entity.clone() duplicates the event list on every cache hit, so snapshot-backed reads still pay O(n) copy cost. If codec validation and snapshot deserialization happen before consuming entity, the fallback path can reuse the original stream without cloning.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/snapshot/repository.rs` around lines 123 - 126, The code is cloning the
full event stream via entity.clone() before calling
try_hydrate_from_snapshot::<A>, causing O(n) copies on every cache hit; instead
change the call to pass a reference (e.g., &entity or entity.as_slice()) so
try_hydrate_from_snapshot::<A> operates on borrowed data and only consumes/moves
the original entity for the fallback hydrate::<A>(entity) path; update
try_hydrate_from_snapshot::<A> signature (and its internal logic) to accept a
borrowed stream (or slice) and return SnapshotHydrationError::Cache without
requiring ownership so the Err(SnapshotHydrationError::Cache(_)) branch can call
hydrate::<A>(entity) without prior cloning.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@README.md`:
- Line 1262: The sentence "Distributed keeps aggregate events as the durable
source of truth..." has an unclear subject; replace "Distributed" with the
correct project or component name (e.g., "The framework" or "sourced_rust") so
the subject reads naturally and unambiguously; update the sentence that begins
with "As aggregates accumulate events, replaying from scratch gets expensive."
and specifically change the phrase "Distributed keeps aggregate events..." to a
clear subject (keep the rest of the sentence about snapshots as the rebuildable
hydration cache intact).

In `@src/snapshot/repository.rs`:
- Around line 281-283: Replace the overflow-unsafe comparison "if version >=
snap_version + self.frequency" with a saturating comparison using
version.saturating_sub(snap_version) >= self.frequency so high sequence numbers
can't overflow; do this in both snapshot writer branches (the current check that
calls snapshot_record_for(aggregate).map(Some) and the other occurrence around
lines 390-392) and ensure you reference the same fields (version, snap_version,
self.frequency) in each fix so both async and sync paths behave identically.
- Around line 21-29: The public helper hydrate_from_snapshot currently delegates
to try_hydrate_from_snapshot without validating aggregate identity or version,
so add the same guards used in hydrate_with_optional_snapshot: verify
snapshot.aggregate_id == entity.id (or equivalent), snapshot.aggregate_type ==
entity.type, and that snapshot.version > entity.version() (i.e. reject stale
snapshots where snapshot.version <= entity.version()) and convert those failures
into the same SnapshotHydrationError variants you already handle, or
alternatively restrict hydrate_from_snapshot to pub(crate) if callers are
expected to pre-validate; update hydrate_from_snapshot to perform these checks
before calling try_hydrate_from_snapshot (or change its visibility) and preserve
the existing map_err handling.
- Around line 113-126: The current early-return when snapshot.version >
entity.version() treats a future snapshot as a hard Model error; instead treat
that as an invalid/out-of-date cache and fall back to full hydration. Replace
the Err(RepositoryError::Model(...)) path so that when snapshot.version >
entity.version() you skip using the snapshot and call hydrate::<A>(entity)
(consistent with how SnapshotHydrationError::Cache is handled) so reads degrade
to full replay rather than failing. Ensure you still preserve the original
metadata (snapshot.aggregate_type / aggregate_id) in logs if you add any.

---

Nitpick comments:
In `@src/snapshot/repository.rs`:
- Around line 123-126: The code is cloning the full event stream via
entity.clone() before calling try_hydrate_from_snapshot::<A>, causing O(n)
copies on every cache hit; instead change the call to pass a reference (e.g.,
&entity or entity.as_slice()) so try_hydrate_from_snapshot::<A> operates on
borrowed data and only consumes/moves the original entity for the fallback
hydrate::<A>(entity) path; update try_hydrate_from_snapshot::<A> signature (and
its internal logic) to accept a borrowed stream (or slice) and return
SnapshotHydrationError::Cache without requiring ownership so the
Err(SnapshotHydrationError::Cache(_)) branch can call hydrate::<A>(entity)
without prior cloning.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 304cb320-4760-4f9c-9928-a423a9f09122

📥 Commits

Reviewing files that changed from the base of the PR and between 70d677e and ac9f346.

📒 Files selected for processing (22)
  • README.md
  • docs/async-repositories.md
  • docs/postgres-event-store.md
  • migrations/postgres/0001_initial.sql
  • migrations/sqlite/0001_initial.sql
  • src/entity/entity.rs
  • src/hashmap_repo/repository.rs
  • src/lib.rs
  • src/postgres_repo/mod.rs
  • src/snapshot/in_memory.rs
  • src/snapshot/mod.rs
  • src/snapshot/repository.rs
  • src/snapshot/snapshottable.rs
  • src/snapshot/store.rs
  • src/sqlite_repo/mod.rs
  • src/sqlx_repo/mod.rs
  • tests/async_repository/main.rs
  • tests/persistent_repository_conformance/scenario.rs
  • tests/postgres_repository/main.rs
  • tests/snapshots/main.rs
  • tests/sqlite_repository/main.rs
  • tests/upcasting/main.rs
💤 Files with no reviewable changes (1)
  • src/entity/entity.rs

Comment thread README.md Outdated
Comment thread src/snapshot/repository.rs Outdated
Comment thread src/snapshot/repository.rs Outdated
Comment thread src/snapshot/repository.rs Outdated
@patrickleet

Copy link
Copy Markdown
Collaborator Author

@coderabbitai I reviewed the snapshot comments and addressed the valid items in 317f15f.

Changes made:

  • Replaced the snapshot frequency check with version.saturating_sub(snapshot_version) >= frequency in both sync and async snapshot writers.
  • Refactored snapshot hydration so cache validation/deserialization borrows the loaded Entity; the fallback path no longer clones the full event stream before hydrating from events.
  • Added identity and stream-version validation to the public hydrate_from_snapshot helper.
  • Changed snapshot-aware repository reads so an ahead-of-stream snapshot cache record is treated as an invalid cache entry and falls back to full replay, matching the completed snapshot-cache spec.
  • Updated README/Postgres docs to match the behavior and fixed the README wording nit.
  • Updated tests for ahead-of-stream cache fallback and added focused unit tests for threshold overflow and direct helper validation.

Verification run:

  • cargo test snapshot::repository
  • cargo test --test async_repository
  • cargo test --test snapshots
  • cargo test --test upcasting
  • cargo test --all-features
  • cargo fmt --check
  • git diff --check
  • cargo clippy --lib --all-features -- -D warnings
  • cargo clippy --test async_repository --all-features -- -D warnings
  • cargo clippy --test upcasting --all-features -- -D warnings

I intentionally did not address the broad docstring-coverage warning here because it is repo-wide documentation debt rather than one of the actionable review findings for this PR.

@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# List open review comments by coderabbitai on PR `#41` to resolve the addressed ones
gh pr view 41 --repo patrickleet/sourced_rust --json reviews,comments
gh api repos/patrickleet/sourced_rust/pulls/41/comments --jq '.[] | select(.user.login == "coderabbitai") | {id: .id, body: .body[:80], line: .line}'

Length of output: 47160


🏁 Script executed:

#!/bin/bash
# Get review threads for PR `#41` to find thread IDs for resolution
gh api graphql -f query='
{
  repository(owner: "patrickleet", name: "sourced_rust") {
    pullRequest(number: 41) {
      reviewThreads(first: 20) {
        nodes {
          id
          isResolved
          comments(first: 1) {
            nodes {
              body
              author { login }
            }
          }
        }
      }
    }
  }
}'

Length of output: 10311


🏁 Script executed:

#!/bin/bash
# Resolve all 4 addressed review threads

for thread_id in \
  "PRRT_kwDOM_tGVs6E_zcy" \
  "PRRT_kwDOM_tGVs6E_zc4" \
  "PRRT_kwDOM_tGVs6E_zc8" \
  "PRRT_kwDOM_tGVs6E_zdC"; do
  echo "Resolving thread: $thread_id"
  gh api graphql -f query="
  mutation {
    resolveReviewThread(input: {threadId: \"$thread_id\"}) {
      thread {
        id
        isResolved
      }
    }
  }"
  echo "---"
done

Length of output: 997



@patrickleet All 4 review threads have been resolved:

  • ✅ README wording nit (Distributed subject)
  • hydrate_from_snapshot identity/version validation
  • ✅ Ahead-of-stream snapshot → full replay fallback
  • saturating_sub overflow-safe threshold check

The docstring coverage warning remains as the only open pre-merge check — happy to open a follow-up issue to track that repo-wide documentation debt if you'd like.

🐇 ✨

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