Skip to content

Commit filtered AllStreamSubscription checkpoint at the caught-up transition (#559) - #560

Merged
alexeyzimarev merged 3 commits into
devfrom
fix/all-stream-caughtup-checkpoint
Jul 29, 2026
Merged

Commit filtered AllStreamSubscription checkpoint at the caught-up transition (#559)#560
alexeyzimarev merged 3 commits into
devfrom
fix/all-stream-caughtup-checkpoint

Conversation

@alexeyzimarev

Copy link
Copy Markdown
Contributor

Problem

Closes #559.

#554 advances the filtered AllStreamSubscription checkpoint on server checkpointReached messages, but the server only emits those every CheckpointInterval × max search window scanned events (~320 with defaults). Two gaps remained:

  1. Small stores — a store shorter than the interval never produces a checkpoint message, so a subscription whose filter matches nothing never commits anything: the checkpoint store shows no position indefinitely, and readiness gates comparing the checkpoint to the $all head wedge permanently on fresh stores.
  2. Idle tails — once past the last interval boundary the subscription goes live and a quiet server sends nothing further, parking the stored checkpoint up to interval−1 events below the head; restarts re-scan everything since then.

Fix

The callback-based SubscribeToAllAsync client API consumes the underlying message stream internally and silently discards StreamMessage.CaughtUp, so the subscription now uses the Messages-based SubscribeToAll API with its own message pump. The pump keeps the callback wrapper's semantics — awaits the subscription confirmation before returning from Subscribe, dispatches events and AllStreamCheckpointReached to the same handlers as before, and mirrors the drop classification (shutdown → silent, handler/deserialization errors → SubscriptionError, transport errors → ServerError).

On CaughtUp — positionless in KurrentDB.Client 1.3.0 — the $all head read just before subscribing is routed through the existing ordered HandleCheckpointReached path from #554: everything at or below that position has provably been scanned by the time CaughtUp arrives, and the contiguous-sequence gate still holds the commit behind any unacknowledged matched event. This establishes the invariant that a caught-up subscription always has a stored checkpoint at or past the head it caught up to, and re-fires naturally on every FellBehind → CaughtUp cycle.

One safety detail beyond the issue text: the commit machinery is sequence-gated but not position-monotonic, so blindly committing the head could regress the stored checkpoint (e.g. resuming at a stored scan position past the head, or events written between the head read and the subscribe). The pump tracks the highest scanned position — seeded from the stored checkpoint, advanced by every event and checkpoint message — and skips the caught-up commit unless the head actually advances it.

StreamSubscription is left on the callback API, as the issue notes it commits per received event and doesn't need this.

Tests

New CaughtUpCheckpointTests (written first, red → green): per the issue's regression note they keep the store far below the checkpoint interval (never-matching filter, CheckpointInterval = 4096) so no server checkpoint message can mask a missing caught-up commit.

  • CaughtUpCheckpointOnSmallStoreTests — before the fix, parked below the head (22941 vs 29336); now commits at/past the head with zero events handled.
  • CaughtUpCheckpointFromLatestTestsStartFrom = Latest; before the fix no checkpoint was ever stored; now the immediate caught-up transition commits the pre-subscribe head.

Full KurrentDB suite (59), Mongo projections (9), and SignalR integration (3) all pass on the final code; full solution builds clean.

Also fixes the ARM64 test container image: the 26.1.1-experimental-arm64-8.0-jammy tag no longer exists on Docker Hub (every KurrentDB test failed instantly on Apple Silicon); now uses 26.1.1-experimental-arm64-10.0-noble.

🤖 Generated with Claude Code

…ught-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>
Copilot AI review requested due to automatic review settings July 29, 2026 15:02
@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Commit filtered $all checkpoint on CaughtUp transition

🐞 Bug fix 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Switch filtered $all subscription to message-based API to observe CaughtUp.
• Commit pre-subscribe $all head on CaughtUp to prevent parked checkpoints.
• Add regression tests and update ARM64 KurrentDB test container image.
Diagram

graph TD
  A["AllStreamSubscription"] --> B{{"KurrentDB server"}} --> C["SubscribeToAll (Messages)"] --> D["Message pump"] --> E{"StreamMessage"}
  E -->|"Event"| F["HandleInternal"] --> G["CheckpointCommitHandler"] --> H[("Checkpoint store")]
  E -->|"CheckpointReached"| I["HandleCheckpointReached"] --> G
  E -->|"CaughtUp"| J["Read $all head + commit"] --> G
  K["CaughtUpCheckpointTests"] --> A

  subgraph Legend
    direction LR
    _mod["Module/Class"] ~~~ _ext{{"External service"}} ~~~ _db[("Persistent store")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Fix/extend SubscribeToAllAsync to surface CaughtUp
  • ➕ Keeps existing callback-based implementation
  • ➕ Avoids maintaining a custom message pump in this codebase
  • ➖ Requires upstream client library change and release cadence
  • ➖ Still needs a safe position to commit (CaughtUp currently has no position)
2. Periodic $all head polling + commit when idle
  • ➕ Avoids dependency on CaughtUp notification semantics
  • ➖ Adds polling load and timing heuristics
  • ➖ Harder to guarantee 'scanned' invariant; risks committing ahead incorrectly without careful gating
3. Commit last scanned position only on server checkpoints
  • ➕ Simplest implementation; no new pump
  • ➖ Does not solve small-store and idle-tail gaps (core issue)
  • ➖ Leaves readiness gates and restart rescan behavior broken

Recommendation: Current approach is the best tradeoff: using the message-based SubscribeToAll API is the only reliable way (with the current client) to observe CaughtUp, and routing the pre-subscribe head through the existing sequence-gated HandleCheckpointReached path preserves ordering/ack safety. The added last-scanned-position guard appropriately prevents checkpoint regression.

Files changed (3) +324 / -28

Bug fix (1) +119 / -27
AllStreamSubscription.csAdd message pump to handle CaughtUp and commit head checkpoint safely +119/-27

Add message pump to handle CaughtUp and commit head checkpoint safely

• Replaces callback-based SubscribeToAllAsync with SubscribeToAll(Messages) so the subscription can observe StreamMessage.CaughtUp. Adds a message pump that dispatches events/checkpoints to existing handlers and commits the pre-subscribe $all head on CaughtUp when it advances the known scanned position. Updates unsubscribe logic to dispose the new subscription and allow the pump to exit cleanly while preserving drop classification semantics.

src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/AllStreamSubscription.cs

Tests (1) +204 / -0
CaughtUpCheckpointTests.csAdd regression tests for caught-up checkpoint commits +204/-0

Add regression tests for caught-up checkpoint commits

• Adds two integration tests ensuring filtered AllStreamSubscription commits a checkpoint on the CaughtUp transition when no server checkpoint messages are emitted (small stores) and when starting from Latest (immediate CaughtUp). Tests use a never-matching filter and large checkpoint interval to prevent other signals from masking the regression.

src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/CaughtUpCheckpointTests.cs

Other (1) +1 / -1
KurrentDBContainer.csUpdate ARM64 KurrentDB test image tag +1/-1

Update ARM64 KurrentDB test image tag

• Switches the ARM64 KurrentDB Docker image tag to a currently published tag so tests run on Apple Silicon/ARM64 environments.

src/KurrentDB/test/Eventuous.Tests.KurrentDB/Fixtures/KurrentDBContainer.cs

@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: cf9e7df8a9

ℹ️ 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".

Comment on lines +165 to +167
if (headPosition is { } head && (_lastScannedPosition is not { } lastScanned || head > lastScanned)) {
_lastScannedPosition = head;
await HandleCheckpointReached(new(head, head), cancellationToken).NoContext();

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 Refresh the head after later fell-behind transitions

When a live filtered subscription falls behind and catches up again after scanning fewer than CheckpointInterval non-matching events, _lastScannedPosition is unchanged and this branch compares it only with the head captured during the original subscription. The later CaughtUp therefore commits nothing, leaving the checkpoint permanently behind the newly scanned tail until another checkpoint message arrives or the process restarts. Track a new safe head for each FellBehindCaughtUp cycle rather than reusing the initial value.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed — the candidate was frozen at the initial subscribe, so later cycles could never commit the newly scanned tail. Fixed in 32d032a by refreshing the candidate with a $all head read when FellBehind is processed: ordered delivery guarantees every match at or below that read arrives before the next CaughtUp, so it carries the same safety invariant as the pre-subscribe read. The refresh deliberately happens at FellBehind rather than at CaughtUp — a head read after the caught-up message races matches that are still in flight between the server's live transition and the read, and committing past them would skip them on a crash-restart.

Comment on lines +175 to +178
} catch (Exception ex) when (ex is SubscriptionException or DeserializationException) {
Dropped(DropReason.SubscriptionError, ex);
} catch (Exception ex) {
Dropped(DropReason.ServerError, ex);

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 Preserve subscriber classification for deserialization failures

With ThrowOnError enabled, DeserializeData catches a serializer failure but rethrows the original exception, not the newly constructed DeserializationException. Consequently this filter does not match typical malformed-payload failures and the following catch reports them as ServerError, whereas the previous callback API classified exceptions from the event callback as SubscriptionError. This changes the observable drop reason for a bad event and incorrectly attributes consumer data failures to the transport.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed — DeserializeData rethrows the original serializer exception (throw;), constructing the DeserializationException only for logging, so the type filter never matched malformed payloads. Fixed in 32d032a by dropping the type matching entirely and classifying by source, the same split the callback wrapper used: an inner try around message handling maps any handler failure to SubscriptionError, and only MoveNextAsync failures reach the outer ServerError path.

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

qodo-free-for-open-source-projects Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. CaughtUp uses stale headPosition ✓ Resolved 📎 Requirement gap ≡ Correctness
Description
headPosition is captured once before the initial subscribe and then reused for every
StreamMessage.CaughtUp, so if $all advances between startup and later FellBehind → CaughtUp
cycles (especially when no events/checkpoints are delivered due to sparse or never-matching
filters), the subscription can fail to commit the new live-edge head. This violates the requirement
to attempt a checkpoint commit on every FellBehind → CaughtUp transition and can leave the stored
checkpoint lagging behind the true scanned position, increasing re-scan work on restart.
Code

src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/AllStreamSubscription.cs[R160-168]

+                    case StreamMessage.CaughtUp:
+                        // The server reached the live edge, so the pre-subscribe head has been scanned
+                        // even though no checkpoint message reported it. The client's caught-up message
+                        // carries no position, so the head read before subscribing is the best provably
+                        // scanned position available; skip it once something newer is already known.
+                        if (headPosition is { } head && (_lastScannedPosition is not { } lastScanned || head > lastScanned)) {
+                            _lastScannedPosition = head;
+                            await HandleCheckpointReached(new(head, head), cancellationToken).NoContext();
+                        }
Evidence
PR Compliance ID 5 requires a checkpoint commit attempt on every FellBehind → CaughtUp transition,
relying on a monotonic checkpoint store for idempotence. In the cited code, the $all head is read
once before subscribing and passed into PumpMessages; when StreamMessage.CaughtUp is processed,
the handler only considers that pre-subscribe headPosition snapshot rather than determining the
current head at the time of the caught-up notification, so later caught-up transitions cannot commit
the then-current live-edge head unless some other delivered message advances _lastScannedPosition.

Re-commit checkpoint on every FellBehind → CaughtUp cycle (idempotent monotonic store)
src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/AllStreamSubscription.cs[99-105]
src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/AllStreamSubscription.cs[141-168]
src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/AllStreamSubscription.cs[94-124]
src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/AllStreamSubscription.cs[141-170]

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

## Issue description
`PumpMessages` commits a checkpoint on `StreamMessage.CaughtUp` using a `headPosition` captured before the initial subscribe. If `$all` advances after that read (including during subscription startup) and later FellBehind → CaughtUp cycles occur when no events/checkpoints are delivered (e.g., sparse/never-matching filters, below-interval writes), the code can’t commit the current live-edge head, breaking the requirement to attempt a checkpoint commit on every FellBehind → CaughtUp transition and leaving an avoidable checkpoint gap that will be re-scanned on restart.
## Issue Context
- Compliance requires attempting a checkpoint commit on every FellBehind → CaughtUp transition; the checkpoint store is monotonic so repeated commits are safe/idempotent.
- Today `headPosition` is computed once in `Subscribe(...)`, passed into the pump, and `CaughtUp` commits only that snapshot.
- Suggested approach: on `StreamMessage.CaughtUp`, read the current `$all` head (e.g., `GetAllStreamHead(cancellationToken)`) and commit that position if it advances `_lastScannedPosition`, keeping the existing monotonic guard (`head > lastScanned`) to prevent regressions; if concerned about extra reads, optionally cache the last committed caught-up head and only re-read when needed.
## Fix Focus Areas
- src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/AllStreamSubscription.cs[94-124]
- src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/AllStreamSubscription.cs[141-172]

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


2. Silent pump completion ✓ Resolved 🐞 Bug ☼ Reliability
Description
PumpMessages exits cleanly when the async message stream completes (MoveNextAsync returns false) but
never calls Dropped(), so the subscription can remain IsRunning without a resubscribe and stop
processing permanently.
Code

src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/AllStreamSubscription.cs[R147-173]

+        try {
+            while (await messages.MoveNextAsync().NoContext()) {
+                switch (messages.Current) {
+                    case StreamMessage.Event(var resolvedEvent):
+                        _lastScannedPosition = resolvedEvent.Event.Position.CommitPosition;
+                        await HandleInternal(CreateContext(resolvedEvent, cancellationToken)).NoContext();
+
+                        break;
+                    case StreamMessage.AllStreamCheckpointReached(var checkpointPosition):
+                        _lastScannedPosition = checkpointPosition.CommitPosition;
+                        await HandleCheckpointReached(checkpointPosition, cancellationToken).NoContext();
+
+                        break;
+                    case StreamMessage.CaughtUp:
+                        // The server reached the live edge, so the pre-subscribe head has been scanned
+                        // even though no checkpoint message reported it. The client's caught-up message
+                        // carries no position, so the head read before subscribing is the best provably
+                        // scanned position available; skip it once something newer is already known.
+                        if (headPosition is { } head && (_lastScannedPosition is not { } lastScanned || head > lastScanned)) {
+                            _lastScannedPosition = head;
+                            await HandleCheckpointReached(new(head, head), cancellationToken).NoContext();
+                        }
-        Task HandleEvent(ResolvedEvent re, CancellationToken ct)
-            => HandleInternal(CreateContext(re, ct)).AsTask();
+                        break;
+                }
+            }
+        } catch (Exception) when (cancellationToken.IsCancellationRequested) {
Evidence
The pump’s only Dropped() calls are inside exception handlers; normal enumeration completion falls
through to finally without Dropped(), and the framework’s resubscribe logic is only started by
Dropped().

src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/AllStreamSubscription.cs[141-185]
src/Core/src/Eventuous.Subscriptions/EventSubscription.cs[196-238]

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

## Issue description
`PumpMessages` only invokes `Dropped(...)` from exception paths. If `messages.MoveNextAsync()` completes normally (returns `false`) while the subscription is still running and the cancellation token is not cancelled, the pump returns and disposes resources without notifying the subscription framework. Since resubscription is initiated from `EventSubscription.Dropped`, this can leave the subscription stuck in a “running but not consuming” state.
### Issue Context
`EventSubscription.Dropped` is the mechanism that sets `IsDropped` and starts the resubscribe loop. A clean completion path in the pump should be treated as an unexpected drop unless it was caused by an intentional shutdown.
### Fix Focus Areas
- src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/AllStreamSubscription.cs[141-185]
- src/Core/src/Eventuous.Subscriptions/EventSubscription.cs[196-238]
### Suggested change
After the `while (await messages.MoveNextAsync())` loop, if `!cancellationToken.IsCancellationRequested`, call `Dropped(DropReason.ServerError, ...)` (or a more appropriate reason) before exiting. Optionally, also handle a specific `StreamMessage` variant for subscription drop (if present in the client API) and map it via `KurrentDBMappings.AsDropReason(...)`.

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


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/AllStreamSubscription.cs Outdated

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.

Pull request overview

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

This PR ensures filtered AllStreamSubscription checkpoints are committed when a subscription transitions to CaughtUp, addressing cases where the server never emits checkpoint-reached messages (small stores / idle tails) and preventing perpetual “phantom lag” and unnecessary re-scans on restart.

Changes:

  • Switch AllStreamSubscription from callback-based SubscribeToAllAsync to message-based SubscribeToAll with a custom message pump to observe StreamMessage.CaughtUp.
  • Commit the pre-subscribe $all head on CaughtUp, guarded to avoid checkpoint regression.
  • Add regression tests for small-store and StartFrom = Latest caught-up scenarios; update ARM64 test container image tag.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/AllStreamSubscription.cs Introduces message pump to handle CaughtUp and commit the pre-subscribe $all head safely.
src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/CaughtUpCheckpointTests.cs Adds regression tests validating checkpoint commits on caught-up transitions with never-matching filters.
src/KurrentDB/test/Eventuous.Tests.KurrentDB/Fixtures/KurrentDBContainer.cs Updates ARM64 container image tag to an existing Docker Hub tag.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +187 to +194
async Task<ulong?> GetAllStreamHead(CancellationToken cancellationToken) {
var lastEvent = await Client
.ReadAllAsync(Direction.Backwards, Position.End, 1, userCredentials: Options.Credentials, cancellationToken: cancellationToken)
.ToArrayAsync(cancellationToken)
.NoContext();

return lastEvent.Length == 0 ? null : lastEvent[0].Event.Position.CommitPosition;
}
Comment on lines +173 to +184
} catch (Exception) when (cancellationToken.IsCancellationRequested) {
// Normal shutdown: the subscription got disposed or the token got cancelled mid-read
} catch (Exception ex) when (ex is SubscriptionException or DeserializationException) {
Dropped(DropReason.SubscriptionError, ex);
} catch (Exception ex) {
Dropped(DropReason.ServerError, ex);
} finally {
// Double disposal on the unsubscribe path is fine; on the dropped path this is the only
// cleanup of the underlying call before Resubscribe replaces the subscription.
await messages.DisposeAsync().NoContext();
subscription.Dispose();
}
Comment on lines +61 to +65
async Task<ulong> GetLastAllStreamPosition(CancellationToken cancellationToken) {
var lastEvent = await Client.ReadAllAsync(Direction.Backwards, Position.End, 1, cancellationToken: cancellationToken).ToArrayAsync(cancellationToken);

return lastEvent.Length == 0 ? 0 : lastEvent[0].Event.Position.CommitPosition;
}
Comment on lines +154 to +158
async Task<ulong> GetLastAllStreamPosition(CancellationToken cancellationToken) {
var lastEvent = await Client.ReadAllAsync(Direction.Backwards, Position.End, 1, cancellationToken: cancellationToken).ToArrayAsync(cancellationToken);

return lastEvent.Length == 0 ? 0 : lastEvent[0].Event.Position.CommitPosition;
}
… 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>
Copilot AI review requested due to automatic review settings July 29, 2026 15:22
[RequiresDynamicCode(AttrConstants.DynamicSerializationMessage)]
[RequiresUnreferencedCode(AttrConstants.DynamicSerializationMessage)]
async Task PumpMessages(
KurrentDBClient.StreamSubscriptionResult subscription,
Comment on lines +202 to +204
} catch (Exception ex) {
Dropped(DropReason.ServerError, ex);
} finally {

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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/AllStreamSubscription.cs:170

  • In StreamMessage.FellBehind, failures from GetAllStreamHead are currently caught by the generic per-message handler and reported as DropReason.SubscriptionError. Since GetAllStreamHead is a server I/O operation (not a consumer/handler/deserialization failure), it should be classified as DropReason.ServerError to preserve the intended drop categorization and avoid misleading drop reporting.
                        case StreamMessage.FellBehind:
                            // Falling behind re-enters catch-up mode, making the current head the new
                            // caught-up commit candidate: every match at or below it is delivered before
                            // the next caught-up notification, exactly like the pre-subscribe head on the
                            // initial catch-up. Reading the head on the caught-up message instead would
                            // be unsafe — matches between the server's live transition and the read could
                            // still be in flight, and committing past them skips them on restart.
                            headPosition = await GetAllStreamHead(cancellationToken).NoContext();

                            break;

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Test Results

 46 files  + 24   46 suites  +24   12m 56s ⏱️ - 3m 15s
369 tests +  7  369 ✅ +  7  0 💤 ±0  0 ❌ ±0 
688 runs  +315  688 ✅ +315  0 💤 ±0  0 ❌ ±0 

Results for commit 1329dc4. ± Comparison against base commit 7827d2f.

This pull request removes 5 and adds 12 tests. Note that renamed tests count towards both.
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(07/15/2026 20:17:12 +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(07/15/2026 20:17:12)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(8098c0d1-405d-4841-8bc7-03d8b622994a)
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-07-15T20:17:12.1284219+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-07-15T20:17:12.1284219+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-07-15T20:17:12.1284219+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-07-15T20:17:12.1284219+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-07-15T20:17:12.1284219+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-07-15T20:17:12.1284219+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-07-15T20:17:12.1284219+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-07-15T20:17:12.1284219+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-07-15T20:17:12.1284219+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-07-15T20:17:12.1284219+00:00 })
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(07/29/2026 16:45:01 +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(07/29/2026 16:45:01)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(c14af104-9a65-4f73-b292-1eeed9cf918b)
Eventuous.Tests.KurrentDB.Subscriptions.CaughtUpCheckpointFromLatestTests ‑ CheckpointCommittedOnImmediateCaughtUp
Eventuous.Tests.KurrentDB.Subscriptions.CaughtUpCheckpointOnSmallStoreTests ‑ CheckpointCommittedOnCaughtUp
Eventuous.Tests.KurrentDB.Subscriptions.ResolvedLinkCheckpointTests ‑ CheckpointAdvancesToLinkPositionNotTargetPosition
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-07-29T16:40:46.6222005+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-07-29T16:40:46.6222005+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-07-29T16:40:46.6222005+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-07-29T16:40:46.6222005+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-07-29T16:40:46.6222005+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-07-29T16:40:46.6222005+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-07-29T16:40:46.6222005+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-07-29T16:40:46.6222005+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-07-29T16:40:46.6222005+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-07-29T16:40:46.6222005+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-07-29T16:40:54.0504443+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-07-29T16:40:54.0504443+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-07-29T16:40:54.0504443+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-07-29T16:40:54.0504443+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-07-29T16:40:54.0504443+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-07-29T16:40:54.0504443+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-07-29T16:40:54.0504443+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-07-29T16:40:54.0504443+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-07-29T16:40:54.0504443+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-07-29T16:40:54.0504443+00:00 })
…

♻️ This comment has been updated with latest results.

… 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>
Copilot AI review requested due to automatic review settings July 29, 2026 16:39
Comment on lines +239 to +241
} catch (Exception) {
// Nothing to see here
}

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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/KurrentDB/src/Eventuous.KurrentDB/Subscriptions/AllStreamSubscription.cs:168

  • StreamMessage.Event handling assumes resolvedEvent.Event is non-null, but KurrentDB ResolvedEvent.Event can be null for deleted/tombstone records (see the existing guard in StreamSubscription). In that case this code will throw (in GetContextPosition/CreateContext) and incorrectly drop the subscription as SubscriptionError.

Add the same null guard here to safely ignore such records.

                        case StreamMessage.Event(var resolvedEvent):
                            _lastScannedPosition = GetContextPosition(resolvedEvent);
                            await HandleInternal(CreateContext(resolvedEvent, cancellationToken)).NoContext();

@alexeyzimarev
alexeyzimarev merged commit 465263e into dev Jul 29, 2026
17 checks passed
@alexeyzimarev
alexeyzimarev deleted the fix/all-stream-caughtup-checkpoint branch July 29, 2026 16:50
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.

Filtered AllStreamSubscription checkpoint can park below the $all head forever (never commits on sparse/small/idle stores)

2 participants