Skip to content

Add ToStreamPageAsync and execute end cursors in EF paging - #4

Merged
michaelstaib merged 5 commits into
mst/stream-v2from
mst/ef-paging-streampage
Sep 28, 2026
Merged

michaelstaib merged 5 commits into
mst/stream-v2from
mst/ef-paging-streampage

Conversation

@michaelstaib

Copy link
Copy Markdown
Owner

This adds ToStreamPageAsync, which runs a paged EF Core query and hands back a StreamPage<T> as soon as the first row arrives. The remaining rows are yielded as the database returns them. Backward pages (last) never over-fetch: the query takes exactly N rows in reverse and the database re-sorts them into ascending order. ToPageAsync and ToBatchPageAsync now execute end cursors: an end cursor always forces a fresh total count, and one that points before the first page returns an empty page. To keep all three methods in sync, the argument validation and slicing move into a shared internal query composer; existing SQL snapshots are unchanged. Reviewers should check the backward-flag and lifetime-disposal paths in ToStreamPageAsync, where a few edge cases are still open with the planner.

await using var page = await context.Products
    .OrderBy(p => p.Name).ThenBy(p => p.Id)
    .ToStreamPageAsync(new PagingArguments { First = 10 }, lifetime: scope);

await foreach (var product in page) { }   // rows arrive as the reader produces them

Closed tickets: hc-fork-1-o47.1 (968fcd9), hc-fork-1-o47.2 (f8b1293, 7f7e25d, d0fa596), hc-fork-1-o47.3 (e021e28), hc-fork-1-o47.5 (no code change; EXPLAIN shows the key filter already sits inside the ROW_NUMBER window input).

Stack: sits on #3 (batch pump edge cases, mst/primitives-pump-fixes).

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings September 27, 2026 01:33

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Unresolved lifetime-disposal paths and end-cursor validation ordering require correction.

Review effort: Lite
Findings: 2 Medium severity · 2 Low severity

Open (4)
What changed in this PR

Adds EF Core streaming pagination, backward-page handling, and end-cursor support across paging APIs.

Changes:

  • Introduces ToStreamPageAsync with forward and backward streaming.
  • Centralizes paging composition and end-cursor slicing.
  • Adds comprehensive paging tests and snapshots.
File Description
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​StreamPagingHelperTests.cs Streaming pagination tests.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​RelativeCursorTests.cs End-cursor and batch tests.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​StreamPagingHelperTests.Fetch_Rows_Only_NET9_0.snap NET9 streaming snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​StreamPagingHelperTests.Fetch_Rows_Only_NET11_0.snap NET11 streaming snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​StreamPagingHelperTests.Fetch_Rows_Only_NET10_0.snap NET10 streaming snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​StreamPagingHelperTests.Fetch_Forward_First_After_NET9_0.snap NET9 forward-page snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​StreamPagingHelperTests.Fetch_Forward_First_After_NET11_0.snap NET11 forward-page snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​StreamPagingHelperTests.Fetch_Forward_First_After_NET10_0.snap NET10 forward-page snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​StreamPagingHelperTests.Fetch_End_Cursor_25_10_NET9_0.snap NET9 end-cursor snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​StreamPagingHelperTests.Fetch_End_Cursor_25_10_NET11_0.snap NET11 end-cursor snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​StreamPagingHelperTests.Fetch_End_Cursor_25_10_NET10_0.snap NET10 end-cursor snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​StreamPagingHelperTests.Fetch_Empty_Result_With_Count_NET9_0.snap NET9 empty-result snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​StreamPagingHelperTests.Fetch_Empty_Result_With_Count_NET11_0.snap NET11 empty-result snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​StreamPagingHelperTests.Fetch_Empty_Result_With_Count_NET10_0.snap NET10 empty-result snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​StreamPagingHelperTests.Fetch_Count_Only_NET9_0.snap NET9 count-only snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​StreamPagingHelperTests.Fetch_Count_Only_NET11_0.snap NET11 count-only snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​StreamPagingHelperTests.Fetch_Count_Only_NET10_0.snap NET10 count-only snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​StreamPagingHelperTests.Fetch_Backward_Last_Before_NET9_0.snap NET9 backward-page snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​StreamPagingHelperTests.Fetch_Backward_Last_Before_NET11_0.snap NET11 backward-page snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​StreamPagingHelperTests.Fetch_Backward_Last_Before_NET10_0.snap NET10 backward-page snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​RelativeCursorTests.Fetch_End_Cursor_Two_Pages_Before_Last.snap End-cursor page snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​RelativeCursorTests.Fetch_End_Cursor_One_Page_Before_Last.snap End-cursor page snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​RelativeCursorTests.Fetch_End_Cursor_One_Page_Before_Last_Forces_Fresh_Count.snap Fresh-count snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​RelativeCursorTests.Fetch_End_Cursor_Offset_Before_First_Page_Returns_Empty_Page.snap Empty out-of-range snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​RelativeCursorTests.Fetch_End_Cursor_Last_Page_Smaller_Than_RequestedSize.snap Short final-page snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​RelativeCursorTests.Fetch_End_Cursor_Last_Page_Not_Evenly_Divisible.snap Uneven final-page snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​RelativeCursorTests.Fetch_End_Cursor_Last_Page_Evenly_Divisible.snap Even final-page snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​RelativeCursorTests.Fetch_End_Cursor_Last_Page_Empty_Dataset.snap Empty-dataset snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​RelativeCursorTests.BatchFetch_End_Cursor_Trims_Per_Key_Total.snap Batch totals snapshot.
src/​GreenDonut/​test/​GreenDonut.Data.EntityFramework.Tests/​__snapshots__/​RelativeCursorTests.BatchFetch_End_Cursor_Offset_Before_First_Page_Returns_Empty_Pages.snap Batch empty-page snapshot.
src/​GreenDonut/​src/​GreenDonut.Data.EntityFramework/​Internal/​PagingQueryComposer.cs Shared paging composition logic.
src/​GreenDonut/​src/​GreenDonut.Data.EntityFramework/​Extensions/​StreamPagingQueryableExtensions.cs Streaming page implementation.
src/​GreenDonut/​src/​GreenDonut.Data.EntityFramework/​Extensions/​PagingQueryableExtensions.cs End-cursor support.
src/​GreenDonut/​src/​GreenDonut.Data.EntityFramework/​Expressions/​OriginalOrderReapplier.cs Restores backward-page ordering.
src/​GreenDonut/​src/​GreenDonut.Data.EntityFramework/​Expressions/​ExpressionHelpers.cs Batch end-cursor slicing and validation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@michaelstaib
michaelstaib added this pull request to stack #7 September 27, 2026 10:23
@michaelstaib
michaelstaib force-pushed the mst/ef-paging-streampage branch from e021e28 to 34528a6 Compare September 27, 2026 20:58
@michaelstaib
michaelstaib force-pushed the mst/ef-paging-streampage branch from 34528a6 to 5e14e55 Compare September 27, 2026 21:40
Base automatically changed from mst/primitives-pump-fixes to mst/stream-v2 September 27, 2026 22:02
@michaelstaib
michaelstaib force-pushed the mst/ef-paging-streampage branch from 5e14e55 to 2e14ead Compare September 27, 2026 22:02
@michaelstaib
michaelstaib requested a lite review from Copilot September 27, 2026 22:03

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Unresolved critical and moderate end-cursor issues affect batch slicing, range handling, metadata, and offset overflow.

Review effort: Lite
Findings: 3 High severity

Open (3)
Resolved since last review (4)

Comment on lines +544 to +552
var cachedTotal = cursor!.TotalCount!.Value;
var pagesBeforeLast = -cursor.Offset!.Value;

if (pagesBeforeLast > 0)
{
var remainder = cachedTotal % requestedCount == 0
? requestedCount
: cachedTotal % requestedCount;
var skip = remainder + (pagesBeforeLast - 1) * requestedCount;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Agree. Superseding the uniform-cached-total rule: batch end cursors with a negative offset align PER KEY — the group expression fetches 2N rows from the end after Skip((k−1)×N) (a window that always contains the key's page), then each key is trimmed by its own freshly counted total. Follow-up PR (task hc-fork-1-o47.13).

Comment on lines +486 to +494
if (pagesBeforeLast > 0
&& (int)Math.Ceiling(endCursor.TotalCount!.Value / (double)arguments.Last.Value) - pagesBeforeLast < 1)
{
foreach (var (key, count) in counts!)
{
map.Add(key, CreateEndCursorPage<TValue>([], keys, false, false, 1, arguments.Last.Value, count));
}

return map;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Agree. The out-of-range test moves per key: index_k = ceil(total_k / N) − k, and only a key whose index falls below 1 gets an empty page; the other keys return their pages. Same follow-up as the per-key skip (task hc-fork-1-o47.13).

Comment on lines +154 to +156
offset = cursor.Offset!.Value;
var cachedTotal = cursor.TotalCount!.Value;
var pagesBeforeLast = -offset;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Agree. Offsets are validated so that negation and the skip arithmetic cannot overflow (int.MinValue and any offset whose skip does not fit an int are rejected with ArgumentException; the parser rejects int.MinValue). Follow-up PR (task hc-fork-1-o47.10).

@michaelstaib
michaelstaib force-pushed the mst/ef-paging-streampage branch from 2e14ead to e770f85 Compare September 28, 2026 08:08
@michaelstaib
michaelstaib merged commit 7ed8ae1 into mst/stream-v2 Sep 28, 2026
1 check passed
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