Skip to content

fix: true streaming reads in KurrentDB store and paged read-to-end API - #568

Open
alexeyzimarev wants to merge 1 commit into
devfrom
fix/streaming-reads-567
Open

fix: true streaming reads in KurrentDB store and paged read-to-end API#568
alexeyzimarev wants to merge 1 commit into
devfrom
fix/streaming-reads-567

Conversation

@alexeyzimarev

Copy link
Copy Markdown
Contributor

Closes #567

Summary

  • True streaming reads in KurrentDBEventStore: ReadEvents and ReadEventsBackwards now yield each event as it arrives from the server instead of materializing the whole requested range into two stream-sized arrays before the first yield. Exception mapping (StreamNotFound, ReadFromStreamException) and logging are preserved by wrapping each advance of the source enumerator; OperationCanceledException now propagates instead of being wrapped.
  • First-class read-to-end: new IEventReader.ReadStreamToEnd extension returns IAsyncEnumerable<StreamEvent>, reading in pages (default 500, tunable) and advancing from the last yielded event's revision — bounded memory on every provider, so count: int.MaxValue stops being the idiom. ReadStream now delegates to it, which also fixes its page advancement for truncated streams.
  • Documented memory semantics on IEventReader.ReadEvents/ReadEventsBackwards: implementations either stream or buffer up to count, and whole-stream reads should use ReadStreamToEnd.

New shared contract tests exposed two pre-existing provider bugs, fixed here:

  • Sqlite never threw StreamNotFound when reading a missing stream (a plain SELECT can't tell a missing stream from a read past the end). SqlEventStoreBase now checks StreamExists when a read returns no rows.
  • Postgres and SqlServer crashed reading backwards from StreamReadPosition.End: long.MaxValue was marshalled into an INT parameter (arithmetic overflow). The parameter is now clamped to the 32-bit position range, which is lossless since both schemas store positions as INT and both procedures already trim the position to the stream head.

Test plan

  • New StreamingReadTests (KurrentDB) prove streaming with a counting serializer: exactly 1 event deserialized at first yield, previously the full range
  • New shared StoreReadTests cases (inherited by KurrentDB, Postgres, SqlServer, Sqlite): read-to-end across page boundaries, exact page multiples, from a position, missing-stream throw/empty behavior, and missing-stream contract for plain reads
  • Full affected suites green on net10.0: KurrentDB 68/68, Postgres 44/44, SqlServer 48/48, Sqlite 34/34, core Eventuous.Tests 26/26

🤖 Generated with Claude Code

KurrentDB ReadEvents/ReadEventsBackwards materialized the entire requested
range (raw ResolvedEvent[] plus deserialized StreamEvent[]) before the first
yield, so IAsyncEnumerable consumers got O(stream) memory instead of
streaming. Rewrite both as true streaming iterators that map exceptions per
enumerator advance and hold at most one deserialized event at a time.

Add IEventReader.ReadStreamToEnd extension that reads a stream to the end in
pages, so count: int.MaxValue stops being the read-to-end idiom, and make
ReadStream delegate to it, fixing page advancement for truncated streams.
Document the memory semantics on IEventReader.

New contract tests exposed two pre-existing provider bugs, also fixed:
- Sqlite reads never threw StreamNotFound for a missing stream; empty read
  results are now verified with StreamExists in SqlEventStoreBase
- Postgres and SqlServer overflowed reading backwards from
  StreamReadPosition.End (long.MaxValue into an INT parameter); the client
  parameter is now clamped to the 32-bit position range

Closes #567

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

True streaming reads for KurrentDB and paged ReadStreamToEnd API

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Make KurrentDB reads yield events as received, avoiding full-range buffering.
• Add paged ReadStreamToEnd to read whole streams with bounded memory.
• Strengthen cross-provider missing-stream/backwards-read contracts and fix relational edge cases.
Diagram

graph TD
  A["Async consumer"] --> B["StoreFunctions"] --> C["IEventReader"]
  C --> D["KurrentDBEventStore"] --> E["KurrentDB client"]
  C --> F["SqlEventStoreBase"] --> G["Postgres/SqlServer stores"]
  H["Contract & streaming tests"] --> C
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Add ReadStreamToEnd to IEventReader interface
  • ➕ Makes read-to-end capability explicit and discoverable in all implementations
  • ➕ Allows providers to optimize read-to-end behavior beyond generic paging
  • ➖ Breaking change for all implementers of IEventReader
  • ➖ Harder to ship as a patch-level fix compared to an extension method
2. Provider-native read-to-end APIs (no generic paging loop)
  • ➕ Potentially fewer round-trips and better server-side continuation support
  • ➕ Can better handle truncation/head-movement semantics per store
  • ➖ More duplicated logic across providers
  • ➖ Harder to enforce consistent missing-stream semantics and paging behavior
3. Keep ReadStream(count: int.MaxValue) as the idiom, document only
  • ➕ No new API surface
  • ➕ Lowest code change footprint
  • ➖ Still encourages unbounded buffering in non-streaming implementations
  • ➖ Hard to reason about memory usage and paging correctness across providers

Recommendation: The chosen approach (ReadStreamToEnd as an extension over ReadEvents with explicit pageSize) is the best trade-off for a patch release: it standardizes bounded-memory whole-stream reads without breaking IEventReader implementers, and it enables shared contract tests to enforce consistent semantics. If a future major version is planned, consider promoting ReadStreamToEnd into IEventReader to make the capability first-class and allow provider-specific optimizations.

Files changed (8) +287 / -57

Enhancement (1) +57 / -15
StoreFunctions.csAdd paged ReadStreamToEnd and delegate ReadStream to it +57/-15

Add paged ReadStreamToEnd and delegate ReadStream to it

• Introduces ReadStreamToEnd as an IAsyncEnumerable that reads pages via ReadEvents, yields events as they arrive, and advances by last yielded revision. Refactors ReadStream (array materialization) to enumerate ReadStreamToEnd, fixing page advancement for truncated streams and avoiding the int.MaxValue idiom.

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

Bug fix (4) +57 / -42
KurrentDBEventStore.csRewrite KurrentDB reads as true streaming async iterators +47/-40

Rewrite KurrentDB reads as true streaming async iterators

• Replaces array-materializing reads with an iterator that advances the underlying enumerator and yields one deserialized StreamEvent at a time. Preserves StreamNotFound mapping and logging while ensuring OperationCanceledException propagates without wrapping.

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

PostgresStore.csClamp backwards read start position to 32-bit range +2/-1

Clamp backwards read start position to 32-bit range

• Fixes overflow when reading backwards from StreamReadPosition.End by clamping the start position to int.MaxValue before binding to an integer parameter. Keeps semantics lossless since schema/procs store and trim positions as INT.

src/Postgres/src/Eventuous.Postgresql/PostgresStore.cs

SqlEventStoreBase.csThrow StreamNotFound when empty reads come from missing streams +6/-0

Throw StreamNotFound when empty reads come from missing streams

• Adds a StreamExists check when forward/backward reads return zero rows, distinguishing missing streams from reads past the end. Ensures relational providers conform to the missing-stream contract tests.

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

SqlServerStore.csClamp backwards read start position to 32-bit range +2/-1

Clamp backwards read start position to 32-bit range

• Fixes arithmetic overflow when binding StreamReadPosition.End (long.MaxValue) to an INT stored procedure parameter by clamping to int.MaxValue. Matches existing schema/procedure behavior that trims to the stream head.

src/SqlServer/src/Eventuous.SqlServer/SqlServerStore.cs

Tests (2) +167 / -0
Read.csExtend store contract tests for missing-stream and read-to-end behavior +96/-0

Extend store contract tests for missing-stream and read-to-end behavior

• Adds shared tests asserting StreamNotFound behavior for missing streams (forward/backward) and validates ReadStreamToEnd paging across boundaries, exact page multiples, and start positions. Also verifies optional failIfNotFound behavior for read-to-end.

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

StreamingReadTests.csAdd tests proving KurrentDB reads don’t buffer entire ranges +71/-0

Add tests proving KurrentDB reads don’t buffer entire ranges

• Introduces StreamingReadTests using a counting serializer to assert exactly one event is deserialized at first yield for forward and backward reads. Ensures total deserialization count matches event count, validating true streaming behavior.

src/KurrentDB/test/Eventuous.Tests.KurrentDB/Store/StreamingReadTests.cs

Documentation (1) +6 / -0
IEventReader.csDocument memory semantics of streaming vs buffering reads +6/-0

Document memory semantics of streaming vs buffering reads

• Expands XML docs for ReadEvents/ReadEventsBackwards to clarify that implementations may stream or buffer up to count. Directs whole-stream reads to the new ReadStreamToEnd helper instead of using int.MaxValue.

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

Comment on lines +262 to +270
} catch (Exception ex) {
var (message, args) = getError();
// ReSharper disable once TemplateIsNotCompileTimeConstantProblem
#pragma warning disable CA2254
_logger.LogWarning(ex, message, args);
#pragma warning restore CA2254

throw new ReadFromStreamException(stream, ex);
}

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 12c0020b87

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

public async IAsyncEnumerable<StreamEvent> ReadStreamToEnd(
StreamName streamName,
StreamReadPosition start,
int pageSize = 500,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject non-positive page sizes

When pageSize is zero, readers such as SqlEventStoreBase return an empty page, but yielded < pageSize is false, so the outer loop repeats indefinitely and continuously queries the store until cancellation. Negative values can behave similarly for readers that treat non-positive counts as empty. Validate that this public argument is greater than zero before entering the paging loop.

Useful? React with 👍 / 👎.

yield return evt;
}

if (yielded < pageSize) yield break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Track source-page exhaustion instead of yielded events

When a KurrentDB page contains an event that EnumerateStream suppresses, such as an unresolved $> link event whose deserialization fails, the enumerable yields fewer than pageSize items even though the underlying raw page was full and later pages exist. Treating the number of yielded user events as proof that the source reached its end therefore makes ReadStreamToEnd silently omit the remaining events; page exhaustion must be tracked independently of filtered events.

Useful? React with 👍 / 👎.

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

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Infinite loop on pageSize 🐞 Bug ☼ Reliability
Description
StoreFunctions.ReadStreamToEnd can loop forever when pageSize <= 0 because it only exits when
yielded < pageSize, which is false for yielded == 0 and pageSize <= 0, so it keeps advancing
position and issuing empty reads.
Code

src/Core/src/Eventuous.Persistence/EventStore/StoreFunctions.cs[R200-203]

+                if (yielded < pageSize) yield break;
+
+                position = new(lastRevision + 1);
+            }
Evidence
ReadStreamToEnd terminates only when yielded < pageSize; for pageSize <= 0, a page that yields
0 events will not satisfy this exit condition, and the code will compute a new position and repeat.
Some store implementations (e.g., SQL base) explicitly yield-break for count <= 0, making
yielded remain 0 forever under invalid pageSize values.

src/Core/src/Eventuous.Persistence/EventStore/StoreFunctions.cs[167-204]
src/Relational/src/Eventuous.Sql.Base/SqlEventStoreBase.cs[100-109]

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

### Issue description
`ReadStreamToEnd` does not validate `pageSize`. When `pageSize <= 0`, the paging loop can become non-terminating (especially for implementations that yield no events for `count <= 0`), repeatedly performing empty reads.

### Issue Context
This method is a new public read-to-end API intended for safe, bounded-memory paging. Invalid `pageSize` values should fail fast (or be normalized) to prevent hangs.

### Fix Focus Areas
- src/Core/src/Eventuous.Persistence/EventStore/StoreFunctions.cs[167-204]

### Suggested fix
- Add an argument guard at the start of `ReadStreamToEnd`, e.g.:
 - `if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize), "pageSize must be > 0");`
- (Optional) Add a regression test ensuring `pageSize: 0` (and negative) throws `ArgumentOutOfRangeException`.

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


Grey Divider

Context

Grey Divider

Tip of the day
💡 Did you know, you can enable the Remediation agent and Qodo fixes findings in a dedicated fix PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +200 to +203
if (yielded < pageSize) yield break;

position = new(lastRevision + 1);
}

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.

Action required

1. Infinite loop on pagesize 🐞 Bug ☼ Reliability

StoreFunctions.ReadStreamToEnd can loop forever when pageSize <= 0 because it only exits when
yielded < pageSize, which is false for yielded == 0 and pageSize <= 0, so it keeps advancing
position and issuing empty reads.
Agent Prompt
### Issue description
`ReadStreamToEnd` does not validate `pageSize`. When `pageSize <= 0`, the paging loop can become non-terminating (especially for implementations that yield no events for `count <= 0`), repeatedly performing empty reads.

### Issue Context
This method is a new public read-to-end API intended for safe, bounded-memory paging. Invalid `pageSize` values should fail fast (or be normalized) to prevent hangs.

### Fix Focus Areas
- src/Core/src/Eventuous.Persistence/EventStore/StoreFunctions.cs[167-204]

### Suggested fix
- Add an argument guard at the start of `ReadStreamToEnd`, e.g.:
  - `if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize), "pageSize must be > 0");`
- (Optional) Add a regression test ensuring `pageSize: 0` (and negative) throws `ArgumentOutOfRangeException`.

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

@github-actions

Copy link
Copy Markdown

Test Results

 46 files  + 24   46 suites  +24   12m 2s ⏱️ -29s
403 tests + 34  403 ✅ + 34  0 💤 ±0  0 ❌ ±0 
744 runs  +364  744 ✅ +364  0 💤 ±0  0 ❌ ±0 

Results for commit 12c0020. ± Comparison against base commit 3cb68c2.

This pull request removes 5 and adds 39 tests. Note that renamed tests count towards both.
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/03/2026 14:31:31 +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/03/2026 14:31:31)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(d6ea070b-8d6b-46d6-9e04-bb9b4acedc59)
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-03T14:31:52.5505416+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T14:31:52.5505416+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-08-03T14:31:52.5505416+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-03T14:31:52.5505416+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T14:31:52.5505416+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-03T14:31:52.5505416+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T14:31:52.5505416+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-03T14:31:52.5505416+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-08-03T14:31:52.5505416+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T14:31:52.5505416+00:00 })
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/12/2026 16:53:53 +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/12/2026 16:53:53)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(44ea7e57-7478-457f-8885-ee0e58b64a25)
Eventuous.Tests.KurrentDB.Store.Read ‑ ShouldReadStreamToEnd
Eventuous.Tests.KurrentDB.Store.Read ‑ ShouldReadStreamToEndFromPosition
Eventuous.Tests.KurrentDB.Store.Read ‑ ShouldReadStreamToEndWithExactPageMultiple
Eventuous.Tests.KurrentDB.Store.Read ‑ ShouldReturnNothingWhenReadingMissingStreamToEnd
Eventuous.Tests.KurrentDB.Store.Read ‑ ShouldThrowWhenReadingMissingStream
Eventuous.Tests.KurrentDB.Store.Read ‑ ShouldThrowWhenReadingMissingStreamBackwards
Eventuous.Tests.KurrentDB.Store.Read ‑ ShouldThrowWhenReadingMissingStreamToEnd
…

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.

KurrentDB ReadEvents buffers the entire requested range before yielding — IAsyncEnumerable is not actually streaming

1 participant