Skip to content

Snapshot support (as an event) - #479

Open
alexey-troshkin-xpress wants to merge 27 commits into
Eventuous:devfrom
alexey-troshkin-xpress:feature/snapshots
Open

Snapshot support (as an event)#479
alexey-troshkin-xpress wants to merge 27 commits into
Eventuous:devfrom
alexey-troshkin-xpress:feature/snapshots

Conversation

@alexey-troshkin-xpress

@alexey-troshkin-xpressalexey-troshkin-xpress commented Nov 29, 2025

Copy link
Copy Markdown
Contributor

User description

Added snapshot support according to the description in #468, except that the attribute is called Snapshots instead of HasRollingSnapshots, registration is done through source generators, and also added a project with an example of usage


PR Type

Enhancement


Description

  • Added rolling snapshot support with Snapshots attribute for state types

  • Implemented source generator to auto-register snapshot type mappings

  • Added ReadStreamAfterSnapshot method to read events from latest snapshot

  • Created banking sample demonstrating snapshot usage with deposit/withdraw operations


Diagram Walkthrough

flowchart LR
A["State with Snapshots Attribute"] -->|Source Generator| B["SnapshotTypeMappings.g.cs"]
B -->|Registers| C["SnapshotTypeMap"]
D["LoadState/LoadAggregate"] -->|Queries| C
C -->|Returns Snapshot Types| E["ReadStreamAfterSnapshot"]
E -->|Reads Backwards| F["Event Stream"]
F -->|Returns Events After Snapshot| G["Folded State"]
Loading

File Walkthrough

Relevant files
Enhancement
7 files
SnapshotTypeMap.cs
Core snapshot type registration and attribute definition
+33/-0
SnapshotMappingsGenerator.cs
Source generator for automatic snapshot type mapping
+197/-0
Helpers.cs
Helper utility for global type name formatting
+8/-0
TypeMappingsGenerator.cs
Refactored to use shared MakeGlobal helper method
+1/-2
StoreFunctions.cs
Implemented ReadStreamAfterSnapshot for backward event reading
+48/-0
StateStoreFunctions.cs
Integrated snapshot support in LoadState method
+10/-2
AggregatePersistenceExtensions.cs
Integrated snapshot support in LoadAggregate method
+9/-1
Configuration changes
9 files
Constants.cs
Added snapshot attribute constants for generators
+3/-0
Banking.Domain.csproj
Domain project file with core dependencies
+6/-0
Banking.Api.csproj
API project file with KurrentDB and generator references
+21/-0
Banking.AppHost.csproj
AppHost project file with Aspire configuration
+19/-0
appsettings.json
API application settings configuration
+9/-0
appsettings.Development.json
API development logging configuration
+8/-0
appsettings.json
AppHost application settings configuration
+9/-0
appsettings.Development.json
AppHost development logging configuration
+8/-0
Eventuous.slnx
Added banking sample projects to solution
+5/-0
Tests
5 files
AccountState.cs
State with Snapshots attribute and snapshot event handler
+26/-0
AccountEvents.cs
Domain events including snapshot event definition
+16/-0
AccountService.cs
Command service with snapshot creation logic
+56/-0
Program.cs
API setup with KurrentDB and command service registration
+29/-0
AppHost.cs
Aspire AppHost configuration for banking sample
+13/-0
Documentation
1 files
README.md
Documentation for snapshot feature usage
+13/-0
Dependencies
1 files
Directory.Packages.props
Added KurrentDB Aspire package versions
+2/-0

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projectsBot commented Nov 29, 2025

Copy link
Copy Markdown
Contributor

PR Compliance Guide 🔍

Below is a summary of compliance checks for this PR:

Security Compliance
🟢
No security concerns identified No security vulnerabilities detected by AI analysis. Human verification advised for critical code.
Ticket Compliance
🟡
🎫 #468
🟢Support for rolling snapshots stored as events in the stream alongside domain events
Ability to distinguish snapshot events from regular domain events
Attribute-based mechanism to mark snapshot event types on State classes
ReadStream method should read backwards from end until finding a snapshot event
ReadStream should not return events that come before the snapshot event
Model should be able to determine when to apply snapshots based on available data (event
count, time elapsed, etc.)
Implementation should work with both aggregates (using Original field) and models without
aggregates (using event list in decider function)
Codebase Duplication Compliance
🟢
No codebase code duplication found New Components Detected (Top 5):
- ApplySnapshot
- AccountEvents
- V1
- SnapshotMappingsGenerator
- GetTypesFromSnapshotsAttribute
Custom Compliance
🟢
Generic: Comprehensive Audit Trails

Objective: To create a detailed and reliable record of critical system actions for security analysis
and compliance.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Error Handling

Objective: To prevent the leakage of sensitive system information through error messages while
providing sufficient detail for internal debugging.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Logging Practices

Objective: To ensure logs are useful for debugging and auditing without exposing sensitive
information like PII, PHI, or cardholder data.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

🔴
Generic: Meaningful Naming and Self-Documenting Code

Objective: Ensure all identifiers clearly express their purpose and intent, making code
self-documenting

Status:
Generic variable names: Variables ra, ns, nt, cns, sb, ch, and attr use abbreviations that reduce code readability

Referred Code
foreach(varraincompilation.SourceModule.ReferencedAssemblySymbols){ProcessNamespace(ra.GlobalNamespace);}returnbuilder.ToImmutable();voidProcessType(INamedTypeSymboltype){varattr=GetSnapshotsAttribute(type);if(attris not null){varmap=newMap{SnapshotTypes=GetTypesFromSnapshotsAttribute(attr),StateType=MakeGlobal(type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))};builder.Add(map);}foreach(varntintype.GetTypeMembers()){ProcessType(nt);}}
...(clipped 11lines)

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Robust Error Handling and Edge Case Management

Objective: Ensure comprehensive error handling that provides meaningful context and graceful
degradation

Status:
Generic exception thrown: Throwing InvalidOperationException without a descriptive message provides no context about
why the withdrawal failed

Referred Code
thrownewInvalidOperationException();}

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Security-First Input Validation and Data Handling

Objective: Ensure all data inputs are validated, sanitized, and handled securely to prevent
vulnerabilities

Status:
Missing input validation: Route parameters id and amount are not validated before being passed to the service,
allowing potentially invalid or malicious input

Referred Code
app.MapGet("/accounts/{id}/deposit/{amount}",async([FromRoute]stringid,[FromRoute]decimalamount,[FromServices]AccountServiceaccountService)=>{varcmd=newAccountService.Deposit(id,amount);varres=awaitaccountService.Handle(cmd,default);returnres.Match<object>(ok =>ok, err =>err);});app.MapGet("/accounts/{id}/withdraw/{amount}",async([FromRoute]stringid,[FromRoute]decimalamount,[FromServices]AccountServiceaccountService)=>{varcmd=newAccountService.Withdraw(id,amount);varres=awaitaccountService.Handle(cmd,default);returnres.Match<object>(ok =>ok, err =>err);

Learn more about managing compliance generic rules or creating your own custom rules

  • Update
Compliance status legend 🟢 - Fully Compliant
🟡 - Partial Compliant
🔴 - Not Compliant
⚪ - Requires Further Human Verification
🏷️ - Compliance label

@alexey-troshkin-xpressalexey-troshkin-xpress changed the title feature/snapshotsSnapshot support (as an event in the stream)Nov 29, 2025
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projectsBot commented Nov 29, 2025

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion Impact
Possible issue
Correct pagination logic for reading events

Fix the backward pagination logic in ReadStreamAfterSnapshot by updating the
read position using the revision of the last event in the batch, preventing a
potential infinite loop.

src/Core/src/Eventuous.Persistence/EventStore/StoreFunctions.cs [97-143]

 public static async Task<StreamEvent[]> ReadStreamAfterSnapshot(
this IEventReader eventReader,
StreamName streamName,
HashSet<Type> snapshotTypes,
bool failIfNotFound = true,
CancellationToken cancellationToken = default
) {
const int pageSize = 500;
var streamEvents = new List<StreamEvent>();
var position = StreamReadPosition.End;
try {
while (true) {
var events = await eventReader.ReadEventsBackwards(streamName, position, pageSize, failIfNotFound, cancellationToken).NoContext();
+ if (events.Length == 0) break;+
var snapshotIndex = (int?) null;
for (var i = 0; i < events.Length; i++) {
var payload = events[i].Payload;
if (payload is not null && snapshotTypes.Contains(payload.GetType())) {
snapshotIndex = i;
break;
}
}
if (snapshotIndex.HasValue) {
streamEvents.AddRange(events[..(snapshotIndex.Value + 1)]);
break;
- } else {- streamEvents.AddRange(events);- }+ } ++ streamEvents.AddRange(events);
if (events.Length < pageSize) break;
- position = new(position.Value - events.Length);+ position = new(events[^1].Revision - 1);
}
} catch (StreamNotFound) when (!failIfNotFound) {
return [];
}
streamEvents.Reverse();
return [.. streamEvents];
}

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 9

__

Why: The suggestion correctly identifies a critical bug in the pagination logic of ReadStreamAfterSnapshot that would cause an infinite loop when reading streams with more events than the page size, and it provides the correct fix.

High
General
Prevent duplicate snapshot map registrations

Prevent duplicate snapshot map registrations in the source generator by
filtering out duplicate state types before generating the output code.

src/Core/gen/Eventuous.Shared.Generators/SnapshotMappingsGenerator.cs [164-168]

 var mergedMaps = maps
.Combine(mapsFromReferencedAssmeblies)
- .Select(static (pair, _) => pair.Left.AddRange((IEnumerable<Map>)pair.Right));+ .Select(static (pair, _) => pair.Left.AddRange(pair.Right))+ .Select(static (maps, _) => maps.GroupBy(x => x.StateType).Select(x => x.First()).ToImmutableArray());
context.RegisterSourceOutput(mergedMaps, Output!);
  • Apply / Chat
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies a potential issue with duplicate state type registrations in the source generator and proposes a robust solution to prevent it, improving the generator's reliability.

Medium
  • Update

@github-actions

github-actionsBot commented Nov 29, 2025

Copy link
Copy Markdown

Test Results

 54 files + 35 54 suites +35 27m 45s ⏱️ + 16m 37s
290 tests + 7 288 ✅ + 5 0 💤 ±0 2 ❌ +2 
749 runs +455 743 ✅ +449 0 💤 ±0 6 ❌ +6 

For more details on these failures, see this check.

Results for commit a06a726. ± Comparison against base commit 2bcac37.

This pull request removes 5 and adds 12 tests. Note that renamed tests count towards both.
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(2/16/2026 9:53:05 PM +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(2/16/2026 9:53:05 PM)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(4c68ec1c-1745-48f3-8d51-ceff19e57ad6)
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-02-16T21:53:05.4432860+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-02-16T21:53:05.4432860+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-02-16T21:53:05.4432860+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-02-16T21:53:05.4432860+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-02-16T21:53:05.4432860+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-02-16T21:53:05.4432860+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-02-16T21:53:05.4432860+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-02-16T21:53:05.4432860+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-02-16T21:53:05.4432860+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-02-16T21:53:05.4432860+00:00 })
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(2/17/2026 5:52:10 PM +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(2/17/2026 5:52:10 PM)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(2/17/2026 5:52:14 PM +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(2/17/2026 5:52:14 PM)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(24122bac-f5c6-4ae9-bdd5-e4faaf9fdab2)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(8f6ec133-4aac-41ef-b8bb-64becb6e0e19)
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-02-17T17:52:10.3538143+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-02-17T17:52:10.3538143+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-02-17T17:52:10.3538143+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-02-17T17:52:10.3538143+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-02-17T17:52:10.3538143+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-02-17T17:52:10.3538143+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-02-17T17:52:10.3538143+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-02-17T17:52:10.3538143+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-02-17T17:52:10.3538143+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-02-17T17:52:10.3538143+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-02-17T17:52:14.2849776+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-02-17T17:52:14.2849776+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-02-17T17:52:14.2849776+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-02-17T17:52:14.2849776+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-02-17T17:52:14.2849776+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-02-17T17:52:14.2849776+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-02-17T17:52:14.2849776+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-02-17T17:52:14.2849776+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-02-17T17:52:14.2849776+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-02-17T17:52:14.2849776+00:00 })
…

♻️ This comment has been updated with latest results.

@alexey-troshkin-xpressalexey-troshkin-xpress changed the title Snapshot support (as an event in the stream)Snapshot support (as an event)Dec 3, 2025
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@alexey-troshkin-xpress@alexeytroshkin@alexeyzimarev