Skip to content

feat: add multi-stream atomic append to IEventWriter#510

Merged
alexeyzimarev merged 11 commits intodevfrom
feat/multi-stream-append
Mar 9, 2026
Merged

feat: add multi-stream atomic append to IEventWriter#510
alexeyzimarev merged 11 commits intodevfrom
feat/multi-stream-append

Conversation

@alexeyzimarev
Copy link
Contributor

Summary

  • Add NewStreamAppend type and AppendEvents(IReadOnlyCollection<NewStreamAppend>, CancellationToken) overload to IEventWriter with a default sequential fail-fast fallback
  • KurrentDB: native atomic via MultiStreamAppendAsync (requires KurrentDB 25.1+)
  • PostgreSQL / SQL Server: atomic via single DB transaction in SqlEventStoreBase
  • SQLite: atomic via single DB transaction (refactored to extract shared AppendToStream method)
  • InMemoryEventStore: explicit implementation for testing
  • Decorators (TracedEventWriter, TracedEventStore, TieredEventStore) delegate to underlying store
  • Application layer Store extension overloads for ProposedAppend[] and raw event collections
  • Documentation updated with atomicity guarantees per store

Test plan

  • Base test class StoreAppendTests — 3 new tests inherited by all stores
  • SQLite: 27/27 passed
  • PostgreSQL: 35/35 passed
  • SQL Server: 39/39 passed
  • KurrentDB: 53/53 passed (including native multi-stream)
  • Core tests: 26/26 passed
  • Full solution build: 0 errors

🤖 Generated with Claude Code

alexeyzimarev and others added 9 commits March 9, 2026 15:06
…tWriter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… TieredEventStore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@qodo-free-for-open-source-projects
Copy link
Contributor

Review Summary by Qodo

Add atomic multi-stream append capability to event store implementations

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add NewStreamAppend type and multi-stream AppendEvents overload to IEventWriter
• Implement atomic multi-stream append across all event store implementations
• Provide default sequential fail-fast fallback for stores without native support
• Add extension methods and comprehensive test coverage for multi-stream operations
Diagram
flowchart LR
  A["IEventWriter<br/>Interface"] -->|"adds multi-stream<br/>AppendEvents"| B["NewStreamAppend<br/>Type"]
  B -->|"implemented by"| C["Event Store<br/>Implementations"]
  C -->|"KurrentDB"| D["Native Atomic<br/>MultiStreamAppendAsync"]
  C -->|"SQL Stores"| E["Transaction-based<br/>Atomic Append"]
  C -->|"InMemory"| F["Sequential<br/>Append"]
  A -->|"default impl"| G["Sequential<br/>Fail-fast"]
  H["Store Extensions<br/>WriterExtensions"] -->|"wraps"| A
  I["Decorators<br/>Traced/Tiered"] -->|"delegate to"| A
Loading

Grey Divider

File Changes

1. src/Core/src/Eventuous.Persistence/EventStore/IEventWriter.cs ✨ Enhancement +26/-0

Add multi-stream AppendEvents interface with default implementation

src/Core/src/Eventuous.Persistence/EventStore/IEventWriter.cs


2. src/Core/src/Eventuous.Persistence/StreamEvent.cs ✨ Enhancement +7/-0

Define NewStreamAppend struct for multi-stream operations

src/Core/src/Eventuous.Persistence/StreamEvent.cs


3. src/Core/src/Eventuous.Application/Persistence/WriterExtensions.cs ✨ Enhancement +31/-0

Add Store extension for ProposedAppend multi-stream append

src/Core/src/Eventuous.Application/Persistence/WriterExtensions.cs


View more (11)
4. src/Core/src/Eventuous.Persistence/EventStore/StoreFunctions.cs ✨ Enhancement +31/-0

Add Store extension for raw event collection multi-stream append

src/Core/src/Eventuous.Persistence/EventStore/StoreFunctions.cs


5. src/Core/src/Eventuous.Persistence/Diagnostics/Tracing/TracedEventWriter.cs ✨ Enhancement +32/-0

Implement multi-stream append with activity tracing support

src/Core/src/Eventuous.Persistence/Diagnostics/Tracing/TracedEventWriter.cs


6. src/Core/src/Eventuous.Persistence/Diagnostics/Tracing/TracedEventStore.cs ✨ Enhancement +8/-0

Delegate multi-stream append to underlying writer

src/Core/src/Eventuous.Persistence/Diagnostics/Tracing/TracedEventStore.cs


7. src/Core/src/Eventuous.Persistence/EventStore/TieredEventStore.cs ✨ Enhancement +7/-0

Delegate multi-stream append to hot store

src/Core/src/Eventuous.Persistence/EventStore/TieredEventStore.cs


8. src/Testing/src/Eventuous.Testing/InMemoryEventStore.cs ✨ Enhancement +23/-0

Implement multi-stream append for in-memory testing

src/Testing/src/Eventuous.Testing/InMemoryEventStore.cs


9. src/Relational/src/Eventuous.Sql.Base/SqlEventStoreBase.cs ✨ Enhancement +55/-0

Implement atomic multi-stream append via database transaction

src/Relational/src/Eventuous.Sql.Base/SqlEventStoreBase.cs


10. src/Sqlite/src/Eventuous.Sqlite/SqliteStore.cs ✨ Enhancement +134/-76

Refactor single-stream append and add atomic multi-stream support

src/Sqlite/src/Eventuous.Sqlite/SqliteStore.cs


11. src/KurrentDB/src/Eventuous.KurrentDB/KurrentDBEventStore.cs ✨ Enhancement +69/-0

Implement native atomic multi-stream append via KurrentDB API

src/KurrentDB/src/Eventuous.KurrentDB/KurrentDBEventStore.cs


12. src/Core/test/Eventuous.Tests.Persistence.Base/Fixtures/Helpers.cs 🧪 Tests +3/-0

Add test helper for multi-stream append operations

src/Core/test/Eventuous.Tests.Persistence.Base/Fixtures/Helpers.cs


13. src/Core/test/Eventuous.Tests.Persistence.Base/Store/Append.cs 🧪 Tests +48/-0

Add three comprehensive multi-stream append test cases

src/Core/test/Eventuous.Tests.Persistence.Base/Store/Append.cs


14. docs/src/content/docs/persistence/event-store.md 📝 Documentation +27/-0

Document multi-stream append with atomicity guarantees per store

docs/src/content/docs/persistence/event-store.md


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Contributor

qodo-free-for-open-source-projects bot commented Mar 9, 2026

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (2) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Missing .NoContext() in IEventWriter📘 Rule violation ⛯ Reliability
Description
The new default multi-stream IEventWriter.AppendEvents implementation awaits an async call without
.NoContext(), which can capture synchronization context in library code. This violates the
requirement to consistently apply .NoContext() on awaited I/O tasks.
Code

src/Core/src/Eventuous.Persistence/EventStore/IEventWriter.cs[R42-46]

+        foreach (var append in appends) {
+            results[i++] = append.Events.Count == 0
+                ? AppendEventsResult.NoOp
+                : await AppendEvents(append.StreamName, append.ExpectedVersion, append.Events, cancellationToken);
+        }
Evidence
Compliance requires awaited async operations in library code to use .NoContext(). The newly added
default implementation awaits AppendEvents(...) without .NoContext() on the awaited task.

CLAUDE.md
src/Core/src/Eventuous.Persistence/EventStore/IEventWriter.cs[42-46]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new default interface implementation for multi-stream append awaits `AppendEvents(...)` without `.NoContext()`, which can capture synchronization context in library code.
## Issue Context
The repository requires consistent use of `.NoContext()` (ConfigureAwait(false)) for awaited async work.
## Fix Focus Areas
- src/Core/src/Eventuous.Persistence/EventStore/IEventWriter.cs[42-46]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. StreamEvent.cs missing final newline📘 Rule violation ✓ Correctness
Description
src/Core/src/Eventuous.Persistence/StreamEvent.cs is missing a final newline, which conflicts with
the repository formatting configuration. This can cause formatting noise and lint/formatter
failures.
Code

src/Core/src/Eventuous.Persistence/StreamEvent.cs[R18-19]

[StructLayout(LayoutKind.Auto)]
public record struct StreamEvent(Guid Id, object? Payload, Metadata Metadata, string ContentType, long Revision, bool FromArchive = false);
Evidence
.editorconfig requires a final newline in C# files, but the PR diff indicates StreamEvent.cs has
no newline at end of file.

CLAUDE.md
.editorconfig[159-162]
src/Core/src/Eventuous.Persistence/StreamEvent.cs[18-19]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The file `StreamEvent.cs` is missing the final newline required by the repository formatting rules.
## Issue Context
`.editorconfig` sets `resharper_csharp_insert_final_newline = true`, and the PR diff indicates `StreamEvent.cs` currently has no newline at EOF.
## Fix Focus Areas
- src/Core/src/Eventuous.Persistence/StreamEvent.cs[18-19]
- .editorconfig[159-162]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. No-op RPC to KurrentDB 🐞 Bug ➹ Performance
Description
KurrentDBEventStore.AppendEvents(appends) sends AppendStreamRequest entries even when a stream
append has zero events, despite later treating those as NoOp results. This adds avoidable
network/server work for no-op items and diverges from the default interface behavior that skips
store calls for empty appends.
Code

src/KurrentDB/src/Eventuous.KurrentDB/KurrentDBEventStore.cs[R169-188]

+                var requests = appends.Select(a => new AppendStreamRequest(
+                    a.StreamName,
+                    ToStreamState(a.ExpectedVersion),
+                    a.Events.Select(ToEventData)
+                ));
+
+                var result = await _client.MultiStreamAppendAsync(
+                    ToAsyncEnumerable(requests),
+                    cancellationToken
+                ).AsTask().NoContext();
+
+                var responseMap = new Dictionary<string, long>();
+
+                foreach (var resp in result.Responses ?? []) {
+                    responseMap[resp.Stream] = resp.StreamRevision;
+                }
+
+                return appends.Select(a => {
+                    if (a.Events.Count == 0) return AppendEventsResult.NoOp;
+
Evidence
The default multi-stream implementation in IEventWriter explicitly returns NoOp without calling the
underlying store when Events.Count == 0. The KurrentDB override constructs and sends requests for
all appends (including empty ones), then only later maps empty ones to NoOp in the returned array,
meaning the RPC work still happens for those no-op entries.

src/Core/src/Eventuous.Persistence/EventStore/IEventWriter.cs[35-46]
src/KurrentDB/src/Eventuous.KurrentDB/KurrentDBEventStore.cs[167-178]
src/KurrentDB/src/Eventuous.KurrentDB/KurrentDBEventStore.cs[186-188]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`KurrentDBEventStore.AppendEvents(IReadOnlyCollection&amp;amp;amp;lt;NewStreamAppend&amp;amp;amp;gt;)` sends `AppendStreamRequest` entries for appends with `Events.Count == 0`, even though the method later treats those entries as `AppendEventsResult.NoOp`. This causes unnecessary RPC/server work for no-op entries.
### Issue Context
The default `IEventWriter` multi-stream implementation skips calling the underlying store when an append has zero events and returns `AppendEventsResult.NoOp`.
### Fix Focus Areas
- src/KurrentDB/src/Eventuous.KurrentDB/KurrentDBEventStore.cs[169-195]
### Notes
A straightforward approach is:
- Precompute the indices of non-empty appends.
- Send only those non-empty appends to `MultiStreamAppendAsync`.
- Build the final `AppendEventsResult[]` in original input order, returning `NoOp` for the empty appends.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Null Events cause NRE🐞 Bug ⛯ Reliability
Description
WriterExtensions.Store(IReadOnlyCollection<ProposedAppend>, ...) iterates a.Events without
validating it is non-null, so a default-initialized ProposedAppend (Events == null) will throw
NullReferenceException during the Select/ToArray projection. This differs from the existing
single-stream overload which defensively checks append.Events for null.
Code

src/Core/src/Eventuous.Application/Persistence/WriterExtensions.cs[R43-49]

+        if (appends.Count == 0) return [];
+
+        var streamAppends = appends.Select(a => new NewStreamAppend(
+            a.StreamName,
+            a.ExpectedVersion,
+            a.Events.Select(evt => ToStreamEvent(evt, amendEvent)).ToArray()
+        )).ToArray();
Evidence
ProposedAppend is a record struct with an array field Events; a default struct instance will have
Events == null. The existing single-stream Store overload already guards against this with
Ensure.NotNull(append.Events), but the new multi-stream overload does not and directly enumerates
a.Events.

src/Core/src/Eventuous.Application/Persistence/ProposedAppend.cs[8-13]
src/Core/src/Eventuous.Application/Persistence/WriterExtensions.cs[9-12]
src/Core/src/Eventuous.Application/Persistence/WriterExtensions.cs[43-49]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new `WriterExtensions.Store(IReadOnlyCollection&amp;amp;amp;lt;ProposedAppend&amp;amp;amp;gt; ...)` overload projects `a.Events.Select(...)` without checking `a.Events` for null. Because `ProposedAppend` is a `record struct`, callers can pass default-initialized values where `Events` is null, causing a `NullReferenceException`.
### Issue Context
The existing single-stream overload already performs `Ensure.NotNull(append.Events)`, indicating the library expects to defensively guard against null even though the type is non-nullable.
### Fix Focus Areas
- src/Core/src/Eventuous.Application/Persistence/WriterExtensions.cs[35-59]
- src/Core/src/Eventuous.Application/Persistence/ProposedAppend.cs[8-13]
### Notes
Possible fixes:
- Validate each `ProposedAppend` before projection (e.g., loop + `Ensure.NotNull(a.Events)`), or
- Treat null as empty (`a.Events ?? []`) if that matches intended semantics.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Null Changes cause NRE🐞 Bug ⛯ Reliability
Description
StoreFunctions.Store(multi-stream tuple overload) enumerates s.Changes without validating it is
non-null, so callers passing a tuple with a null Changes collection will throw
NullReferenceException during the projection. This differs from the existing single-stream Store
overload which defensively checks changes for null.
Code

src/Core/src/Eventuous.Persistence/EventStore/StoreFunctions.cs[R64-70]

+        if (streams.Count == 0) return [];
+
+        var appends = streams.Select(s => new NewStreamAppend(
+            s.StreamName,
+            s.ExpectedVersion,
+            s.Changes.Select(evt => ToStreamEvent(evt, amendEvent)).ToArray()
+        )).ToArray();
Evidence
The single-stream Store overload already calls Ensure.NotNull(changes), but the new multi-stream
overload does not validate the tuple’s Changes field before calling s.Changes.Select(...). Since the
tuple contains a reference-type IReadOnlyCollection, it can be null at runtime.

src/Core/src/Eventuous.Persistence/EventStore/StoreFunctions.cs[29-33]
src/Core/src/Eventuous.Persistence/EventStore/StoreFunctions.cs[64-70]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`StoreFunctions.Store(this IEventWriter, IReadOnlyCollection&amp;amp;amp;lt;(StreamName, ExpectedStreamVersion, IReadOnlyCollection&amp;amp;amp;lt;object&amp;amp;amp;gt; Changes)&amp;amp;amp;gt; ...)` projects `s.Changes.Select(...)` without checking `s.Changes` for null. Callers can pass a tuple with `Changes == null`, which will throw a `NullReferenceException`.
### Issue Context
The single-stream overload already uses `Ensure.NotNull(changes)` before accessing `changes.Count`, so the library has an established defensive pattern for this.
### Fix Focus Areas
- src/Core/src/Eventuous.Persistence/EventStore/StoreFunctions.cs[56-85]
### Notes
Consider validating within the projection (e.g., a loop that builds `NewStreamAppend[]` and checks each `Changes`), or normalize null to an empty collection if that’s desired.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

@cloudflare-workers-and-pages
Copy link

cloudflare-workers-and-pages bot commented Mar 9, 2026

Deploying eventuous-main with  Cloudflare Pages  Cloudflare Pages

Latest commit: 93eabf7
Status: ✅  Deploy successful!
Preview URL: https://0e5ca858.eventuous-main.pages.dev
Branch Preview URL: https://feat-multi-stream-append.eventuous-main.pages.dev

View logs

@github-actions
Copy link

github-actions bot commented Mar 9, 2026

Test Results

   60 files  + 40     60 suites  +40   38m 1s ⏱️ + 26m 0s
  340 tests + 22    340 ✅ + 22  0 💤 ±0  0 ❌ ±0 
1 023 runs  +694  1 023 ✅ +694  0 💤 ±0  0 ❌ ±0 

Results for commit ce0fc07. ± Comparison against base commit 52b3249.

♻️ This comment has been updated with latest results.

alexeyzimarev and others added 2 commits March 9, 2026 15:59
…Context()

- Add .NoContext() to IEventWriter default implementation await call
- Throw InvalidOperationException for empty Events in NewStreamAppend (can't verify expected version)
- Add Ensure.NotNull checks for Events/Changes in WriterExtensions and StoreFunctions
- Remove redundant empty-events NoOp handling from all store implementations
  (KurrentDB, SqlEventStoreBase, SqliteStore, InMemoryEventStore)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@alexeyzimarev alexeyzimarev merged commit 843d9ba into dev Mar 9, 2026
6 checks passed
@alexeyzimarev alexeyzimarev deleted the feat/multi-stream-append branch March 9, 2026 15:24
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