Skip to content

fix(streaming): advance checkpoints past drained subscriptions - #11249

Merged
ReubenBond merged 9 commits into
dotnet:mainfrom
fickleEfrit:fix/streaming-safe-checkpoint-progress
Sep 15, 2026
Merged

ReubenBond merged 9 commits into
dotnet:mainfrom
fickleEfrit:fix/streaming-safe-checkpoint-progress

Conversation

@fickleEfrit

@fickleEfrit fickleEfrit commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

This pull request updates the PersistentStreamPullingAgent so a subscription's last acknowledged position caps checkpoint progress only while the subscription is not caught up. If a subscription is confirmed to be caught up, the checkpoint is not stuck at their latest event.

During subscription registrations and handshakes, checkpoint progress does not continue until registration/handshake have finished. This lets the subscriber establish its starting position before the next progress calculation, since that could differ from the latest available event.

Replay requirements in the case of failed reads, registration/delivery errors, and cleanup are preserved. If we get an earlier replay request, we are prevented from advancing beyond it, and if we have unknown subscriber progress, the pulling agent will not report a new delivery-based checkpoint position.

Microsoft Reviewers: Open in CodeFlow

Copilot AI lite review requested due to automatic review settings September 11, 2026 19:59

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

🟡 Changes recommended

Unresolved checkpoint and replay-safety issues remain.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Updates persistent-stream checkpointing so drained subscriptions no longer hold back progress while preserving replay safety.

Changes:

  • Tracks subscription progress, read boundaries, and replay state.
  • Defers checkpoint advancement during registration and handshakes.
  • Adds checkpoint and replay regression tests.
File summaries
File Description
test/Orleans.Streaming.Tests/StreamingTests/PersistentStreamPullingAgentTests.cs Adds checkpoint and replay behavior coverage.
src/Orleans.Streaming/PersistentStreams/QueueStreamDataStructures.cs Adds per-subscription progress state.
src/Orleans.Streaming/PersistentStreams/PersistentStreamPullingAgent.cs Implements checkpoint and replay-progress tracking.
Review details

Suppressed comments (3)

src/Orleans.Streaming/PersistentStreams/PersistentStreamPullingAgent.cs:1056

  • This now seeds the value passed to IQueueCache.UpdateDeliveryProgress with the latest queue-read token. The interface contract defines this parameter as the earliest last-processed token across registered subscriptions and says that null means there are no active subscriptions (src/Orleans.Streaming/QueueAdapters/IQueueCache.cs:101-110); when all consumers are drained or none exist, custom cache implementations can therefore interpret this non-null value as an active-subscription watermark. Please either update that contract and its implementations to explicitly make this a queue checkpoint boundary, or keep the read boundary separate from this API.
            earliest = _lastReadToken;

src/Orleans.Streaming/PersistentStreams/PersistentStreamPullingAgent.cs:1397

  • GetBatchForConsumer deliberately returns Success with no progress when it saw a batch whose position is missing (the sawBatch path below). This branch only makes IsCaughtUp false transiently; after a later refresh returns NoData, the !HasDeliveryProgressError check can mark the subscription caught up and let shutdown skip the unknown position. Record a delivery-progress error for any non-NoData result with no progress so this subscription remains a checkpoint cap.
                            consumerData.IsCaughtUp = nextBatch.CursorResult.Kind == QueueCacheCursorMoveResultKind.NoData
                                && !consumerData.HasDeliveryProgressError
                                && consumerData.UnconfirmedDeliveryToken is null;

src/Orleans.Streaming/PersistentStreams/PersistentStreamPullingAgent.cs:360

  • A reattach handshake can acknowledge a position at or beyond UnconfirmedDeliveryToken, but this path only records LastProcessedToken; it never clears the replay marker. After a successful rewind followed by reattachment, the cursor can drain while UnconfirmedDeliveryToken remains set, so IsCaughtUp stays false and this subscription continues to pin the checkpoint indefinitely. Apply the same acknowledgement/clear rule used after delivery to successful handshake responses (including the implicit-subscription token case where it represents an acknowledgement).
                    RecordDeliveryProgress(data, GetInitialDeliveryProgress(data.LastToken, data.LastProcessedToken));
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Lite

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

Comment thread src/Orleans.Streaming/PersistentStreams/PersistentStreamPullingAgent.cs Outdated
Copilot AI review requested due to automatic review settings September 11, 2026 22:49

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

🟡 Changes recommended

Three unresolved findings remain, including one critical recovery-cursor issue.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

src/Orleans.Streaming/PersistentStreams/PersistentStreamPullingAgent.cs:361

  • A reattachment handshake can acknowledge a position at or beyond an in-flight delivery, but this path only updates LastProcessedToken; it never reconciles UnconfirmedDeliveryToken. If the old callback is then rejected by the cursor-version check and the replacement cursor is already drained, RunConsumerCursor leaves IsCaughtUp false solely because that stale unconfirmed token remains, so the checkpoint stays pinned. Clear the unconfirmed token when the handshake's acknowledged progress reaches it.
                    RecordDeliveryProgress(data, GetInitialDeliveryProgress(data.LastToken, data.LastProcessedToken));

src/Orleans.Streaming/PersistentStreams/PersistentStreamPullingAgent.cs:162

  • Initialize resets _lastReadToken but leaves _hasUnknownReplayPosition latched. After a previous read with a missing/incompatible token or failed cache admission, that flag suppresses every later NotifyDeliveryProgress, so a receiver reinitialized on the same agent can never publish a checkpoint even after successful reads. Reset this per-receiver state here as well.
            _lastReadToken = null;
            pendingConsumerRecoveries.Clear();
  • Files reviewed: 3/4 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread src/Orleans.Streaming/PersistentStreams/PersistentStreamPullingAgent.cs Outdated
Copilot AI review requested due to automatic review settings September 11, 2026 23:34

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

🟡 Changes recommended

Recovery state can permanently block checkpoint advancement after reactivation or cursor failures.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 3/4 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread src/Orleans.Streaming/PersistentStreams/PersistentStreamPullingAgent.cs Outdated
Copilot AI review requested due to automatic review settings September 12, 2026 01:08

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

🟡 Changes recommended

Two unresolved findings remain, including a critical checkpointing issue and a moderate cancellation/recovery issue.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 3/4 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread src/Orleans.Streaming/PersistentStreams/PersistentStreamPullingAgent.cs Outdated
Comment thread src/Orleans.Streaming/PersistentStreams/QueueStreamDataStructures.cs Outdated
Copilot AI review requested due to automatic review settings September 12, 2026 03:15

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

🟡 Changes recommended

An unresolved token-comparison issue can bypass normal recovery handling and must be corrected.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 4/5 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread src/Orleans.Streaming/PersistentStreams/PersistentStreamPullingAgent.cs Outdated
Copilot AI review requested due to automatic review settings September 12, 2026 04:00

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

🟡 Changes recommended

One critical and two moderate unresolved issues remain.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

src/Orleans.Streaming/PersistentStreams/PersistentStreamPullingAgent.cs:1596

  • When an attachment fails after producer registration succeeds, this path leaves the task completed but retries the whole RegisterAsStreamProducer call. PubSubRendezvousGrain.RegisterProducer writes state, increments producer metrics, and emits ProducerRegistered on every call even though the HashSet deduplicates the producer, so each bounded attachment retry causes duplicate persistence work, metrics, and registration events. Avoid re-registering an already registered producer on attachment-only retries (for example, retain the subscriber set or add a read/reattach operation) while still retrying the failed attachments.
                    var subscribers = await RegisterAsStreamProducer(streamId, cancellationToken);

                    if (IsShutdown || cancellationToken.IsCancellationRequested)

src/Orleans.Streaming/PersistentStreams/PersistentStreamPullingAgent.cs:931

  • This guard pauses ReadFromQueue for the entire queue whenever any stream has a non-null RegistrationTask. Registration and handshake work can retry for a long time (producer registration is configured for infinite retries), so one stalled or unavailable subscriber blocks unrelated streams from being read and prevents their checkpoints from advancing; this also contradicts the per-stream background-registration rationale at the call site below. Keep the pending stream pinned and excluded from checkpoint calculation while allowing unrelated stream groups to continue, or otherwise isolate registration stalls.
            if (pubSubCache.Values.Any(static stream => stream.RegistrationTask is not null))
            {
  • Files reviewed: 4/5 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread src/Orleans.Streaming/PersistentStreams/PersistentStreamPullingAgent.cs Outdated
@ReubenBond

Copy link
Copy Markdown
Member

CI jobs for Functional tests failed across both Ubuntu and Windows on .NET 8.0 and .NET 10.0 with the following test failure:

\
UnitTests.StreamingTests.GeneratedImplicitSubscriptionStreamRecoveryTests.Recoverable100EventStreamsWith1NonTransientErrorTest
Assert.Equal() Failure: Values differ
Expected: 4
Actual: 0
at Tester.StreamingTests.ImplicitSubscritionRecoverableStreamTestRunner.CheckCounters(String streamNamespace, Int32 streamCount, Int32 eventsInStream, Boolean assertIsTrue, CancellationToken cancellationToken)
at Tester.StreamingTests.ImplicitSubscritionRecoverableStreamTestRunner.Recoverable100EventStreamsWith1NonTransientError(...)
\\

This failure appears to be related to the streaming checkpointing changes in this PR affecting stream recovery progress.

Copilot AI review requested due to automatic review settings September 12, 2026 17:49
@fickleEfrit

Copy link
Copy Markdown
Contributor Author

Went ahead and simplified. Went down a rabbit hole addressing AI comments which made the scope creep up in size.

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

🟡 Changes recommended

A critical handshake/replay issue can allow shutdown to persist a checkpoint past an earlier replay point.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings September 12, 2026 18:25

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

🟡 Changes recommended

Pending-handshake streams must remain protected from removal and checkpoint advancement.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings September 12, 2026 19:25

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

🔵 Needs a closer look

Two moderate null-token cleanup issues remain unresolved.

Review details

Suppressed comments (2)

src/Orleans.Streaming/PersistentStreams/PersistentStreamPullingAgent.cs:785

  • consumer.LastProcessedToken can legitimately be null after an initial handshake which returns a StartToken, before the first batch establishes delivery progress. If the subscription is removed while the queue boundary is known, this condition reaches TryCompareQueueProgress despite the null-forgiving operator, and Compare dereferences the null token instead of removing the subscription. Guard the token before comparing and fall back to legacy progress.
                    && (!HasCaughtUpDeliveryProgress(consumer, streamData.LastReadToken)
                        || _lastReadToken is null
                        || !TryCompareQueueProgress(consumer.LastProcessedToken!, _lastReadToken, out _))))

src/Orleans.Streaming/PersistentStreams/PersistentStreamPullingAgent.cs:1057

  • Cleanup can encounter a subscription whose initial StartToken handshake left LastProcessedToken null because no batch has established progress yet. With a known queue read boundary, this new check then calls TryCompareQueueProgress with null and throws from the queue-read cleanup path, preventing subsequent reads; add the same null guard before comparing.
                            !HasCaughtUpDeliveryProgress(consumer, streamData.LastReadToken)
                            || _lastReadToken is null
                            || !TryCompareQueueProgress(consumer.LastProcessedToken!, _lastReadToken, out _))))
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

fickleEfrit and others added 9 commits September 13, 2026 07:57
- Track accounted read boundaries and explicitly drained consumers.
- Preserve replay floors across registration, delivery, and cursor errors.
- Honor earlier and unknown replay requests before repositioning cursors.
- Cover shutdown progress, pending deliveries, and recovery regressions.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 8d1da79c-4b16-4f03-be66-dfa8d0c71bf6
- Preserve replay constraints through handshake and cursor fallbacks.
- Resume progress only after acknowledged, gap-free recovery.
- Retry pending registrations and consumers without losing state.
- Reject stale delivery callbacks and unproven inclusive replays.
- Cover recovery, cleanup, batching, and checkpoint safety regressions.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 8d1da79c-4b16-4f03-be66-dfa8d0c71bf6
- Skip refresh for tracked recovery cursors to preserve replay anchors
  and cache-miss detection without changing normal idle cursor wake-up.
- Cover empty-cache recovery followed by new reads for inclusive and
  exclusive tokens, missing/restored replay, and grouped delivery.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 8d1da79c-4b16-4f03-be66-dfa8d0c71bf6
- Bound replay recovery and outer registration restarts with provider
  backoff, a shared delivery deadline, and a finite attempt limit.
- Release exhausted recovery resources while preserving checkpoint
  constraints and normal delivery of new traffic.
- Reject detached handshake continuations and resume messages received
  during terminal failure notifications.
- Reset unknown progress per receiver lifetime and recover null cursors.
- Cover retry limits, failure policy, cleanup, and lifecycle races.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 8d1da79c-4b16-4f03-be66-dfa8d0c71bf6
- Retain proven progress when local subscription records are removed.
- Do not advance to later reads solely because no subscribers remain.
- Keep unsubscribe progress bookkeeping constant-time.
- Handle canceled backoff without resetting bounded recovery budgets.
- Clarify checkpoint semantics and cover lifecycle and scaling cases.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 8d1da79c-4b16-4f03-be66-dfa8d0c71bf6
- Compare outstanding tokens before accepting checkpoint progress.
- Preserve unresolved delivery state and the existing recovery budget.
- Cover filtered acknowledgments and bounded recovery across token types.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 8d1da79c-4b16-4f03-be66-dfa8d0c71bf6
Remove PR-added replay recovery, retry budgets, and retained gaps.
Keep proven drained-subscription advancement bounded by queue reads.
Use original checkpoint calculation when the optimization is uncertain.
Restore original delivery retries and skip-and-continue failure handling.
Preserve baseline coverage and focused checkpoint fallback regressions.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 8d1da79c-4b16-4f03-be66-dfa8d0c71bf6
- Reject delivery progress while any subscriber handshake is pending.
- Cover pending and completed re-handshakes in both checkpoint modes.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 8d1da79c-4b16-4f03-be66-dfa8d0c71bf6
- Keep pending-handshake subscriptions out of idle cleanup.
- Use legacy checkpointing when delivery overlaps handshake completion.
- Cover cleanup and delivery interleavings with deterministic regressions.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 8d1da79c-4b16-4f03-be66-dfa8d0c71bf6
@ReubenBond
ReubenBond force-pushed the fix/streaming-safe-checkpoint-progress branch from 8e1a248 to f19b632 Compare September 13, 2026 14:57
Copilot AI review requested due to automatic review settings September 13, 2026 14:57

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

🔵 Needs a closer look

The changes affect checkpointing and lifecycle behavior across several streaming components and warrant final human validation.

Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@github-actions

Copy link
Copy Markdown
Contributor

Code coverage

Metric Pull request Current main Variance
Lines 81.61% (109,970 / 134,756) 81.59% (109,822 / 134,600) +0.0154 pp
Branches 70.49% (31,281 / 44,378) 70.49% (31,209 / 44,272) -0.0061 pp

Report-only conclusion: mixed.

The current-main baseline is commit e69d956b23 and uses the same reviewed coverage matrix.

Coverage combines every CI test matrix job, including providers, CodeGen, .NET 8/10, Linux, Windows, and macOS, using canonical physical source and branch identities.

The comparison remains report-only while normal line and branch variance is calibrated.

Coverage details

@ReubenBond
ReubenBond merged commit f64f1f1 into dotnet:main Sep 15, 2026
73 checks passed
ReubenBond added a commit to ReubenBond/orleans that referenced this pull request Sep 18, 2026
Reapply the checkpoint-advancement portion of dotnet#11249 after its dedicated revert (4bf635d). The following commits in dotnet#11269 replace lifetime fallback with certified recovery and preserve the reviewed final implementation. Land the revert first, then rebase this re-attempt onto its actual main merge commit.
ReubenBond added a commit to ReubenBond/orleans that referenced this pull request Sep 18, 2026
Reapply the checkpoint-advancement portion of dotnet#11249 after its dedicated revert (4bf635d). The following commits in dotnet#11269 replace lifetime fallback with certified recovery and preserve the reviewed final implementation. Land the revert first, then rebase this re-attempt onto its actual main merge commit.
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.

3 participants