fix(streaming): advance checkpoints past drained subscriptions - #11249
Conversation
There was a problem hiding this comment.
🟡 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.UpdateDeliveryProgresswith the latest queue-read token. The interface contract defines this parameter as the earliest last-processed token across registered subscriptions and says thatnullmeans 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
GetBatchForConsumerdeliberately returnsSuccesswith no progress when it saw a batch whose position is missing (thesawBatchpath below). This branch only makesIsCaughtUpfalse transiently; after a later refresh returnsNoData, the!HasDeliveryProgressErrorcheck can mark the subscription caught up and let shutdown skip the unknown position. Record a delivery-progress error for any non-NoDataresult 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 recordsLastProcessedToken; it never clears the replay marker. After a successful rewind followed by reattachment, the cursor can drain whileUnconfirmedDeliveryTokenremains set, soIsCaughtUpstays 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.
There was a problem hiding this comment.
🟡 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 reconcilesUnconfirmedDeliveryToken. If the old callback is then rejected by the cursor-version check and the replacement cursor is already drained,RunConsumerCursorleavesIsCaughtUpfalse 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
Initializeresets_lastReadTokenbut leaves_hasUnknownReplayPositionlatched. After a previous read with a missing/incompatible token or failed cache admission, that flag suppresses every laterNotifyDeliveryProgress, 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
There was a problem hiding this comment.
🟡 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
There was a problem hiding this comment.
🟡 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
There was a problem hiding this comment.
🟡 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
There was a problem hiding this comment.
🟡 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
RegisterAsStreamProducercall.PubSubRendezvousGrain.RegisterProducerwrites state, increments producer metrics, and emitsProducerRegisteredon every call even though theHashSetdeduplicates 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
ReadFromQueuefor the entire queue whenever any stream has a non-nullRegistrationTask. 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
|
CI jobs for Functional tests failed across both Ubuntu and Windows on .NET 8.0 and .NET 10.0 with the following test failure: \ This failure appears to be related to the streaming checkpointing changes in this PR affecting stream recovery progress. |
|
Went ahead and simplified. Went down a rabbit hole addressing AI comments which made the scope creep up in size. |
There was a problem hiding this comment.
🟡 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
There was a problem hiding this comment.
🟡 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
There was a problem hiding this comment.
🔵 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.LastProcessedTokencan legitimately be null after an initial handshake which returns aStartToken, before the first batch establishes delivery progress. If the subscription is removed while the queue boundary is known, this condition reachesTryCompareQueueProgressdespite the null-forgiving operator, andComparedereferences 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
StartTokenhandshake leftLastProcessedTokennull because no batch has established progress yet. With a known queue read boundary, this new check then callsTryCompareQueueProgresswith 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
- 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
8e1a248 to
f19b632
Compare
There was a problem hiding this comment.
🔵 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
Code coverage
Report-only conclusion: mixed. The current-main baseline is commit 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 |
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.
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.
This pull request updates the
PersistentStreamPullingAgentso 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