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

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

Conversation

@alexey-troshkin-xpress

@alexey-troshkin-xpress alexey-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-projects Bot 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 (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

qodo-free-for-open-source-projects Bot 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-actions Bot 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-xpress alexey-troshkin-xpress changed the title Snapshot support (as an event in the stream) Snapshot support (as an event) Dec 3, 2025

@nmummau nmummau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I’ve tested this implementation locally against commit a06a726 using Testcontainers with PostgreSQL, Redis, MongoDB, SQL Server, and KurrentDB, plus focused source-generator tests.

The regression tests, reproduction commands, and captured test reports are available in this commit. They run against the unchanged snapshot implementation at a06a726.

The tests reproduce several correctness issues: backward pagination repeats pages, Redis can combine one snapshot’s revision with another’s payload, snapshot persistence failures report command failure after domain events commit, and generic snapshot attributes emit no registration.

I also reproduced incorrect revision handling for snapshot-only commands and explicitly returned snapshots inside a command batch. I’ll add the specific reproductions inline so they can be addressed separately.

This looks worth moving forward. My main concern is ensuring snapshot loading reproduces both the state and stream revision of full replay.

Test-by-test outcomes and captured failures: RESULTS.md. Reproduction commands: README.md. The raw final report is archived in the same commit.

var database = _getDatabase();
var key = GetSnapshotKey(streamName);

var revisionValue = await database.HashGetAsync(key, Revision).NoContext();

@nmummau nmummau Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These separate reads can combine the revision from one snapshot with the payload from a newer snapshot if a write occurs between them.

I reproduced this against real Redis by replacing the snapshot after the revision read completes. The loader receives revision 9 with state already covering revision 10, then replays event 10 again. Expected: 11 applied events, balance 5.00. Actual: 12 applied events, balance 6.25.

Could revision, event type, and payload be fetched together using a single multi-field HashGetAsync call?

Regression test · Passing no-replacement control · Recorded results


if (events.Length < pageSize) break;

position = new(position.Value - events.Length);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reproduced against KurrentDB: when the snapshot is behind at least 500 tail events, this repeatedly reads the same page. A stream containing exactly 500 events and no snapshot also reproduces it. The second cursor is long.MaxValue - 500, rather than the revision before the oldest returned event. Could the next cursor derive from the returned revision, with an explicit beginning-of-stream check?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Regression tests: snapshot beyond the first page and no snapshot at page boundaries. All six parameterized cases fail against the reviewed implementation by exceeding the bounded backward-read budget.


// Handle snapshots based on strategy
if (snapshotEvents.Length > 0 && storageStrategy != SnapshotStorageStrategy.SameStream) {
await HandleSnapshots(streamName, snapshotEvents, storeResult.NextExpectedVersion, storageStrategy, resolvedWriter, cancellationToken).NoContext();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When a command produces a snapshot but no domain events, the empty append returns revision -1. Both command-service styles then use that as the snapshot revision. With an existing stream at revision 2, PostgreSQL and SQL Server reject the write, while Redis and MongoDB persist -1. Could this path preserve the loaded stream revision when no domain events are appended?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Regression test: snapshot-only command preserves the existing revision. It covers both command-service styles and all four external stores; all eight cases fail against the reviewed implementation.

var changes = result.Select(x => Change.FromEvent(x, _typeMap));

// Handle snapshots based on strategy
if (snapshotEvents.Length > 0 && storageStrategy != SnapshotStorageStrategy.SameStream) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This new snapshot operation runs after the domain events have committed, but inside the same exception handler. I reproduced that a snapshot-write failure makes both command-service styles report command failure despite the durable domain event. Retrying a deposit of 10 then produces balance 20. Could snapshot persistence failure be distinguished from domain-command failure so callers don’t retry an already committed operation?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reproduction test: snapshot failure after commit and duplicate retry. Both service-style cases pass because the test explicitly asserts the undesirable outcome: a reported failure, a durable event, and a duplicate business effect on retry.

};
}

static HashSet<string> GetTypesFromSnapshotsAttribute(AttributeData attributeData) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Compiling declarations using the generic snapshot attribute produces no expected snapshot registration. Equivalent non-generic declarations pass, including multiple snapshot contracts. This extraction inspects constructor and named arguments but does not extract AttributeClass.TypeArguments. The generic declaration shape used by the banking sample needs coverage here.

// Separate snapshots from regular events based on storage strategy
var snapshotTypes = SnapshotTypeMap.GetSnapshotTypes<TState>();
var storageStrategy = SnapshotTypeMap.GetStorageStrategy<TState>();
var (regularEvents, snapshotEvents) = SeparateSnapshots(newEvents, snapshotTypes, storageStrategy);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If a command returns a snapshot between two domain events, this separation loses the snapshot’s position in the batch. For example, add 10 → snapshot balance 10 → add 5 should reload as 15. Instead, the snapshot is saved with the revision of the second addition, so loading skips that addition and returns 10. Could snapshots be required to come last, or retain the revision they actually represent?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Regression test: explicit snapshot inside a batch. The test handler deliberately returns those three objects from one command. The test fails because the reloaded balance is 10 instead of 15. This is the manually returned snapshot path; automatic snapshot production appends its snapshot last.

@nmummau

nmummau commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

@alexey-troshkin-xpress could you rebase or merge current dev into the PR?

Its common ancestor with dev is from February 16, and the comparison currently shows 173 upstream commits missing from this branch.

One concrete consequence: our tests reproduced PostgreSQL concurrent appends reporting success while dropping an event, but that is an existing provider bug already fixed in merged PR #582, commit f887c96e. The snapshot PR's PostgreSQL append/check-stream scripts are identical to its base; this PR did not introduce that problem.

Updating the branch would bring in that fix and let us distinguish current snapshot defects from old provider behavior. Our existing evidence remains pinned to a06a726 so those original results remain reproducible.

Upstream comparison


public string ReadSnapshot => $"SELECT revision, event_type, json_data FROM {schema}.snapshots WHERE stream_name = @stream_name";
public string WriteSnapshot
=> $"INSERT INTO {schema}.snapshots (stream_name, revision, event_type, json_data) VALUES (@stream_name, @revision, @event_type, @json_data) ON CONFLICT (stream_name) DO UPDATE SET revision = @revision, event_type = @event_type, json_data = @json_data, created = now() at time zone 'utc'";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should ISnapshotStore guarantee that writes cannot move the represented revision backwards? I reproduced a delayed revision-100 write replacing an already stored revision-110 snapshot in PostgreSQL, Redis, MongoDB, and SQL Server. Each payload can be individually valid, but the older write becomes the one returned by Read.

With complete event history retained, this increases replay work rather than inherently corrupting state. If applications truncate history based on a newer snapshot, accepting an older snapshot would no longer be safe. Could the adapters atomically ignore older revisions, with an explicit policy for equal-revision writes? Alternatively, the contract should document last-completed-write behavior and the requirement to retain replayable history.

Regression test covering all four stores · Recorded results

/// <summary>
/// Store snapshots in the same stream as other aggregate events
/// </summary>
SameStream,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With SameStream, snapshots will also be delivered to subscriptions. Typed handlers can ignore unhandled types, but catch-all handlers, gateways, and independent consumers may need explicit filtering.

Should this PR provide a way for subscriptions to identify and ignore snapshot events—perhaps through metadata, allowing filtering before deserialization—while still advancing checkpoints correctly?

This also seems relevant to SeparateStream when subscribing to all streams. Some consumers may intentionally want snapshots, so making this configurable seems useful.


var snapshotAppend = new ProposedAppend(
snapshotStreamName,
ExpectedStreamVersion.Any,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could a delayed snapshot write move the represented domain revision backwards here? For example, writer A commits domain revision 100 and pauses; writer B commits through 110 and saves its snapshot; then A appends snapshot(100) using Any.

The loader appears to select the last appended snapshot, so it would choose snapshot(100). The subsequent truncation could also remove snapshot(110), since it occupies an earlier snapshot-stream position.

With complete domain history this means extra replay, but it could become unsafe if an application truncates domain history based on the newer snapshot.

Should we compare represented domain revisions and use the snapshot stream’s expected version together to prevent this? The aggregate command service appears to have the same path. This is based on reading the code; I haven’t reproduced the SeparateStream interleaving yet.

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.

4 participants