Advance filtered AllStreamSubscription checkpoints on server checkpointReached - #554
Conversation
…ents Wire up the checkpointReached callback on SubscriptionFilterOptions so server-reported checkpoint positions flow through the same ordered commit machinery as real events, as payload-less contexts (Message is null, so Handler ignores and acks without touching the consume pipe). Without this, a filtered $all subscription's checkpoint only advanced when a filter-matched event was processed. A long unmatched stretch (sparse filters, quiet servers) left the checkpoint parked at the last matched event: restarts re-scanned everything since then, and any consumer comparing the checkpoint to the $all head saw a phantom, never-closing lag. Adds CheckpointReachedTests, which reproduces the bug with a filter that matches nothing and asserts the checkpoint still advances past written events.
PR Summary by QodoAdvance filtered $all checkpoints using server checkpointReached callbacks
AI Description
Diagram
High-Level Assessment
Files changed (2)
|
Code Review by Qodo
1.
|
…-less contexts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds CheckpointGatedByPendingMatchTests, which reproduces the safety half of the checkpointReached fix: a filter-matched event whose ack is held open must block the stored checkpoint from advancing past it, even while checkpoint markers for unmatched noise keep arriving. The commit handler's "Commit" diagnostic (fired before the sequence gate) proves a marker positioned BEYOND the blocked event reached the commit path during the hold — not assumed from a fixed sleep, and not satisfiable by markers for pre-existing activity below the event — and the blocked handler is always released so a failed assertion can't hang fixture disposal. Adds CheckpointCommitHandlerBackpressureTests, which pins the channel backpressure CheckpointCommitHandler switched to instead of throwing when the commit queue overflows, and that disposing with a writer parked on the full channel releases it (ChannelClosedException) and drains the queue without hanging, force-recommitting the last stored position. The store gate always opens and the handler is always disposed on failure paths, so a caught regression surfaces as an assertion failure rather than a timeout.
…sticCounter Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Test Results 46 files 46 suites 12m 50s ⏱️ Results for commit b5e26cd. ♻️ This comment has been updated with latest results. |
…nsition (#559) (#560) * fix(subscriptions): commit AllStreamSubscription checkpoint at the caught-up transition (#559) The filtered AllStreamSubscription only advanced its checkpoint on received events and server checkpoint messages, which the server sends every CheckpointInterval x max search window scanned events. On stores smaller than that interval, or once the subscription goes live and the server falls quiet, the stored checkpoint parks below the $all head forever - or is never stored at all when the filter matches nothing on a fresh store. Restarts re-scan everything since the parked position, and consumers comparing the checkpoint to the head see a phantom, never-closing lag. The callback-based SubscribeToAllAsync client API silently discards the CaughtUp notification, so the subscription now uses the Messages-based SubscribeToAll API with its own message pump, preserving the confirmation, dispatch, and drop semantics of the callback wrapper. On CaughtUp - which carries no position in the current client - the $all head read just before subscribing is routed through the existing ordered HandleCheckpointReached path from #554: everything at or below it has provably been scanned by the time CaughtUp arrives. The commit is skipped whenever a position at or past the head is already known (stored checkpoint or anything delivered this run), since the commit machinery is sequence-gated, not position-monotonic, and would otherwise regress the stored checkpoint. Also points the ARM64 test container at the 26.1.1-experimental-arm64-10.0-noble image; the 8.0-jammy tag no longer exists on Docker Hub, so every KurrentDB test failed instantly on Apple Silicon. Closes #559 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(subscriptions): refresh caught-up commit candidate on FellBehind, classify drops by source Review follow-ups on #560: - The caught-up commit candidate was captured once before subscribing, so later FellBehind -> CaughtUp cycles could never commit the newly scanned tail. The candidate is now refreshed with a $all head read when FellBehind is processed - safe because every match at or below that read is delivered before the next CaughtUp, unlike a read at CaughtUp time which races in-flight live matches. - Drop reasons are now classified by source (handler failures -> SubscriptionError, transport failures -> ServerError) instead of exception types: DeserializeData rethrows the raw serializer exception, so type matching misattributed malformed payloads to the server. - A clean message-stream completion without cancellation now triggers a ServerError drop so the subscription resubscribes instead of staying silently dead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(subscriptions): use the link's $all position for resolved events, drop FellBehind read to transport errors Review flow follow-ups on #560: - Resolved link events flowed the resolved target's position into the consume context and checkpoint. A link created after the caught-up commit can resolve to an event arbitrarily older than the committed head, and the commit machinery is sequence-gated, not position-monotonic, so acking such a link regressed the stored checkpoint to the target's position. The context position is now the delivered record's own $all position (OriginalPosition, falling back to the original event's record position). Covered by a red-checked integration test that writes a link after the caught-up commit. - The FellBehind head refresh is a server read, but ran inside the per-message try, so its failures were labelled SubscriptionError. It now runs outside that try and reaches the transport (ServerError) path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Problem
AllStreamSubscription.SubscribeconstructsSubscriptionFilterOptions(filter, checkpointInterval)without the thirdcheckpointReachedparameter, so the callback defaults to a no-op. A filtered$allsubscription's checkpoint therefore only advances when a filter-matched event is processed:$allhead (including theeventuous.subscription.gap.countmetric) sees a phantom, never-closing lag.Fix
Wire
checkpointReachedto route the server-reported checkpoint position through the existing ordered commit machinery as a checkpoint-only message: a null-payloadMessageConsumeContext(type$checkpoint-reached) with the position and the subscription'sSequence++, handed toHandleInternal. The null-message arm inEventSubscription.Handleralready Ignores + Acknowledges such contexts, which lands inCheckpointCommitHandler.Commitwith an ordered sequence number — no new commit paths.Why the ordering is safe
eventAppearedandcheckpointReachedsequentially on one delivery loop (each awaited beforeMoveNextAsync), soSequence++never races event contexts and sequences reflect exact delivery order.CheckpointCommitHandler's contiguous-sequence gate (CommitPositionSequence.FirstBeforeGap) holds a gap position back until every lower-sequence event has acked. The checkpoint can never commit past an unprocessed event, so crash/restart semantics are unchanged.Sequence.Test
CheckpointReachedTests.CheckpointAdvancesPastUnmatchedEvents: a filtered subscription whose filter matches nothing, 500 unmatched events appended — asserts the handler saw zero events AND the stored checkpoint advances past the last event's position (bounded 30s poll + 60s timeout). Verified red without the fix, green with it.Notes
CreateContext.SubscriptionActivityfor async contexts when diagnostics are enabled; gap contexts now hit that arm periodically. Harmless inert allocation — happy to short-circuit it here or in a follow-up if preferred.🤖 Generated with Claude Code