Skip to content

Conversation

@alexey-troshkin-xpress
Copy link
Contributor

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

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
Copy link
Contributor

qodo-free-for-open-source-projects bot commented Nov 29, 2025

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 (var ra in compilation.SourceModule.ReferencedAssemblySymbols) {
    ProcessNamespace(ra.GlobalNamespace);
}

return builder.ToImmutable();

void ProcessType(INamedTypeSymbol type) {
    var attr = GetSnapshotsAttribute(type);
    if (attr is not null) {
        var map = new Map {
            SnapshotTypes = GetTypesFromSnapshotsAttribute(attr),
            StateType = MakeGlobal(type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))
        };

        builder.Add(map);
    }

    foreach (var nt in type.GetTypeMembers()) {
        ProcessType(nt);
    }
}


 ... (clipped 11 lines)

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
    throw new InvalidOperationException();
}

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] string id, [FromRoute] decimal amount, [FromServices] AccountService accountService) => {
    var cmd = new AccountService.Deposit(id, amount);
    var res = await accountService.Handle(cmd, default);

    return res.Match<object>(ok => ok, err => err);
});

app.MapGet("/accounts/{id}/withdraw/{amount}", async ([FromRoute] string id, [FromRoute] decimal amount, [FromServices] AccountService accountService) => {
    var cmd = new AccountService.Withdraw(id, amount);
    var res = await accountService.Handle(cmd, default);

    return res.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-xpress alexey-troshkin-xpress changed the title feature/snapshots Snapshot support (as an event in the stream) Nov 29, 2025
@qodo-free-for-open-source-projects
Copy link
Contributor

qodo-free-for-open-source-projects bot commented Nov 29, 2025

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
Copy link

github-actions bot commented Nov 29, 2025

Test Results

 51 files  + 34   51 suites  +34   41m 54s ⏱️ + 31m 5s
282 tests + 10  282 ✅ + 10  0 💤 ±0  0 ❌ ±0 
849 runs  +566  849 ✅ +566  0 💤 ±0  0 ❌ ±0 

Results for commit cdac775. ± Comparison against base commit 2f9d974.

This pull request removes 5 and adds 15 tests. Note that renamed tests count towards both.
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(12/3/2025 7:25:25 AM +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(12/3/2025 7:25:25 AM)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(81afcab6-084f-4b49-92a9-7564158e5743)
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2025-12-03T07:25:25.5801568+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2025-12-03T07:25:25.5801568+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2025-12-03T07:25:25.5801568+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2025-12-03T07:25:25.5801568+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2025-12-03T07:25:25.5801568+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2025-12-03T07:25:25.5801568+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2025-12-03T07:25:25.5801568+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2025-12-03T07:25:25.5801568+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2025-12-03T07:25:25.5801568+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2025-12-03T07:25:25.5801568+00:00 })
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(12/9/2025 2:44:24 PM +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(12/9/2025 2:44:24 PM)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(12/9/2025 2:44:34 PM +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(12/9/2025 2:44:34 PM)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(12/9/2025 2:44:59 PM +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(12/9/2025 2:44:59 PM)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(2139f58f-ea9c-43eb-95da-04552cc50117)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(28381c08-9649-4c6c-8d71-ca8332669dff)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(cf1a17ae-bdb1-46f2-918b-70c6196b6ab4)
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2025-12-09T14:44:24.0438340+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2025-12-09T14:44:24.0438340+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2025-12-09T14:44:24.0438340+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2025-12-09T14:44:24.0438340+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2025-12-09T14:44:24.0438340+00:00 })
…

♻️ This comment has been updated with latest results.

@alexey-troshkin-xpress alexey-troshkin-xpress changed the title Snapshot support (as an event in the stream) Snapshot support (as an event) Dec 3, 2025
Sign up for free to 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.

2 participants