Skip to content

fix(subscriptions): release relational gaps once the skip timeout expires - #588

Merged
alexeyzimarev merged 3 commits into
devfrom
fix/222-gap-skip-timeout
Aug 27, 2026
Merged

fix(subscriptions): release relational gaps once the skip timeout expires#588
alexeyzimarev merged 3 commits into
devfrom
fix/222-gap-skip-timeout

Conversation

@alexeyzimarev

@alexeyzimarev alexeyzimarev commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Follows up on #424, refs #222.

The problem

Gap detection holds an all-stream subscription when it sees a hole in the global position sequence, which is what stops concurrent appends from being skipped — transaction (a) takes position 5, (b) takes 6 and commits first, and the subscription waits for 5 rather than running past it. That part works and is covered by SubscriptionGapsDetection.Postgres_ShouldNotSkipEvents.

Some positions are never filled, though: a rolled back append still consumes the identity value. Releasing such a gap was broken. GapSkipTimeoutMs never fired — DetectGap rebuilt the gap with FirstSeen = DateTime.UtcNow on every poll, restarting the timer before it could elapse — so release fell to GapAgeThresholdMs, default one hour and measured against the Created timestamp of the event after the gap. A subscription at the head stalled for up to an hour on a permanent gap.

This lives in the shared relational base, so Postgres, SQL Server and SQLite are all affected.

The fix

Preserve a held gap's FirstSeen so the skip timeout can expire, and match held gaps by position so an unrelated gap's timer doesn't survive the subscription moving on.

Evaluate GapAgeThresholdMs on every poll, ahead of the held-gap branch. It previously sat below that branch, which returns without reaching it, so it only ran on the poll that first observed a gap. Age release deliberately precedes remediation: the option exists so replays don't recreate tombstones that were deliberately deleted.

Let remediation resolve a gap before anything abandons it. The two escape hatches are not equally safe. try_insert_tombstone inserts at the gap position with overriding system value and on conflict do nothing against the unique global_position: a committed row makes it a no-op, an in-flight row makes it block until that transaction resolves. It cannot displace a real event. GapSkipTimeoutMs has no such interlock — it advances on elapsed time alone, so an append slower than the timeout has its event skipped.

Previously the release cleared gap before the remediation check in PollOnce, which is gated on a non-null gap, so setting GapHandlingTimeoutMs at or above GapSkipTimeoutMs silently produced no tombstones and skipped a position it could have resolved. Remediation is now tracked on DetectedGap.RemediationAttempted and takes precedence.

GapSkipTimeoutMs now defaults to null, making time-based skipping opt-in. Note this is only safe together with the age-threshold reordering above — without it, a null skip timeout returns unconditionally at the held-gap branch and stalls indefinitely.

The XML docs described behaviour the code did not have, and have been corrected.

Tests

  • GapSkipTimeoutTest — asserts the hold at a quarter of the timeout, then the release. Before the fix: stalled the full 10s deadline with the checkpoint parked at Position = 2.
  • GapRemediationBeforeSkipTest — both timeouts equal, so remediation only runs if it takes precedence. Before the fix: Expected to be 1 but found 0 tombstones.
  • GapAgeReleaseWhileRunningTest — a gap detected while the following event is young, ageing out while subscribed, both timeouts null. Before the reordering: Expected to be 5 but found 2. GapIgnoreTest did not cover this, as it only tests a gap already old at startup.

Each was verified to fail before its fix and pass after. Locally on net10.0: Postgres 52/52, SQLite 43/43, 0 warnings. SQL Server is excluded on macOS and runs in CI.

Follow-ups, not in this PR

  • ExecutePollCycle does Task.Delay(InitialDelayMs * retryCount++) — the first retry waits 0ms and the delay then grows unbounded. Eventuous.Sql.Base has no test project of its own to cover it.
  • SQL Server and SQLite inherit this gap detection but override neither HandleGapTimeout nor ShouldSkipEvent, so they have no safe resolution available — only the opt-in time-based skip.

🤖 Generated with Claude Code

…ires

A gap in the global position sequence holds an all-stream subscription
until the missing position becomes visible, which is what keeps
concurrent appends from being skipped. Some positions never arrive
though: a rolled back append still consumes the sequence value.

GapSkipTimeoutMs exists to bound that wait, but DetectGap rebuilt the
gap with FirstSeen = UtcNow on every poll, so the timer restarted before
it could ever expire. The only escape left was GapAgeThresholdMs,
default one hour, measured against the event after the gap. A live
subscription therefore stalled for up to an hour on a permanent gap.

Keep the original FirstSeen when the gap is one we are already holding,
and match it by position so an unrelated gap's timer no longer survives
the subscription moving on.

Refs #222

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Fix relational subscription gap skip timeout

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Preserve a held gap’s first-seen timestamp so skip timeouts can expire.
• Reset gap timing when the expected missing global position changes.
• Verify permanent PostgreSQL gaps release without age thresholds or tombstones.
Diagram

graph TD
    A["SQL Poller"] --> B["Read Event"] --> C{"Position gap?"}
    C -- No --> G["Process Event"]
    C -- Yes --> D{"Same gap?"}
    D -- No --> E["Hold Subscription"]
    D -- Yes --> F{"Skip expired?"}
    F -- No --> E
    F -- Yes --> G
Loading
High-Level Assessment

Preserving FirstSeen for the same expected position is the narrowest correct fix: it restores the configured timeout while preventing stale timing state from carrying across unrelated gaps. A broader timer abstraction would add complexity without improving this targeted behavior.

Files changed (2) +57 / -11

Bug fix (1) +12 / -11
SqlSubscriptionBase.csPreserve gap timing until the configured skip timeout +12/-11

Preserve gap timing until the configured skip timeout

• Updates shared relational gap detection to retain FirstSeen only when the same expected global position remains missing. Once GapSkipTimeoutMs expires, the gap is released; a different gap receives a fresh timer.

src/Relational/src/Eventuous.Sql.Base/Subscriptions/SqlSubscriptionBase.cs

Tests (1) +45 / -0
GapSkipTimeoutTest.csCover skip-timeout release of a permanent PostgreSQL gap +45/-0

Cover skip-timeout release of a permanent PostgreSQL gap

• Adds an integration test that burns a global position through a rolled-back append, disables age-based release and tombstone handling, and verifies all five real events are consumed after the 500 ms skip timeout. It also confirms no tombstone was inserted.

src/Postgres/test/Eventuous.Tests.Postgres/Subscriptions/GapSkipTimeoutTest.cs

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

qodo-free-for-open-source-projects Bot commented Aug 27, 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. DetectGap skips live transactions ✓ Resolved 📎 Requirement gap ≡ Correctness
Description
After GapSkipTimeoutMs elapses, DetectGap returns null solely based on elapsed time, so the
poll processes the later event and advances its checkpoint even if the missing position belongs to a
still-running transaction. If that transaction commits afterward, its event remains behind the
checkpoint and is skipped, violating the PostgreSQL subscription requirement.
Code

src/Relational/src/Eventuous.Sql.Base/Subscriptions/SqlSubscriptionBase.cs[191]

+            return DateTime.UtcNow - previousGap.FirstSeen < TimeSpan.FromMilliseconds(Options.GapSkipTimeoutMs.Value) ? previousGap : null;
Evidence
PR Compliance ID 1 requires every event covered by a PostgreSQL checkpoint to have been handled. The
changed timeout branch at line 191 drops the held gap based only on elapsed wall-clock time; the
polling loop then handles the visible later event and moves start, while PostgreSQL's existing
provider-specific tombstone mechanism demonstrates that a database operation is available to resolve
or conflict with the missing position safely.

Prevent PostgreSQL subscriptions from skipping events
src/Relational/src/Eventuous.Sql.Base/Subscriptions/SqlSubscriptionBase.cs[188-191]
src/Relational/src/Eventuous.Sql.Base/Subscriptions/SqlSubscriptionBase.cs[107-123]
src/Postgres/src/Eventuous.Postgresql/Subscriptions/PostgresSubscriptionBase.cs[51-62]

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

## Issue description
Do not release a relational gap solely because `GapSkipTimeoutMs` elapsed; a long-running transaction can still commit the missing event after the checkpoint advances.
## Issue Context
The polling loop advances `start` after `DetectGap` returns `null`. The timeout must trigger a provider-safe resolution that distinguishes a permanently consumed sequence value from an in-flight row, such as a conflicting tombstone/verification operation, before allowing later events through.
## Fix Focus Areas
- src/Relational/src/Eventuous.Sql.Base/Subscriptions/SqlSubscriptionBase.cs[188-191]
- src/Relational/src/Eventuous.Sql.Base/Subscriptions/SqlSubscriptionBase.cs[107-123]
- src/Postgres/src/Eventuous.Postgresql/Subscriptions/PostgresSubscriptionBase.cs[51-62]

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


Grey Divider

Tip of the day
💡 Did you know, you can ask Qodo to dismiss a finding you disagree with, with your reason on record

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

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

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

if (Options.GapAgeThresholdMs == null || newGapAge.TotalMilliseconds < Options.GapAgeThresholdMs.Value) {
return new(expectedNext, DateTime.UtcNow);
}
return DateTime.UtcNow - previousGap.FirstSeen < TimeSpan.FromMilliseconds(Options.GapSkipTimeoutMs.Value) ? previousGap : null;

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 Run gap remediation before clearing an expired gap

When PostgreSQL sets GapHandlingTimeoutMs equal to the default GapSkipTimeoutMs (or sufficiently close that one poll crosses both thresholds), this returns null as soon as the skip timeout expires. PollOnce subsequently calls HandleGapTimeout only when gap != null, so the configured tombstone is never created even though the gap persisted for the handling duration; later subscriptions must each wait for and skip the same permanent gap. Perform the timeout remediation before clearing the expired gap, or otherwise preserve enough state to invoke it.

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, and reproduced: with GapHandlingTimeoutMs = GapSkipTimeoutMs = 500, the release cleared gap on the same poll that crossed both thresholds, so the remediation block — gated on gap != null — never ran. No tombstone was created, and start then advanced past the position so it was never retried. TombstonesCreationTest uses 500 against the 5000 default, which is why it stayed green.

Fixed in 81d7a80 by tracking remediation on the gap and giving it precedence over the skip:

if (DateTime.UtcNow - previousGap.FirstSeen < TimeSpan.FromMilliseconds(Options.GapSkipTimeoutMs.Value)) return previousGap;

// Remediation resolves the position safely, unlike skipping it, so it gets its chance first.
return Options.GapHandlingTimeoutMs != null && !previousGap.RemediationAttempted ? previousGap : null;

GapRemediationBeforeSkipTest covers it — it failed with Expected to be 1 but found 0 tombstones before the change.

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

Test Results

   45 files  ± 0     45 suites  ±0   14m 14s ⏱️ +22s
  595 tests +12    595 ✅ +12  0 💤 ±0  0 ❌ ±0 
1 172 runs  +20  1 172 ✅ +20  0 💤 ±0  0 ❌ ±0 

Results for commit 457919f. ± Comparison against base commit a978e32.

This pull request removes 9 and adds 21 tests. Note that renamed tests count towards both.
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/26/2026 13:58:02 +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/26/2026 13:58:02)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(d25c31ec-257c-4295-9fe1-c0a07b4ba197)
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-26T13:53:38.6902296+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:53:38.6902296+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-08-26T13:53:38.6902296+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-26T13:53:38.6902296+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:53:38.6902296+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-26T13:53:38.6902296+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:53:38.6902296+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-26T13:53:38.6902296+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-08-26T13:53:38.6902296+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:53:38.6902296+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-26T13:53:45.6579460+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:53:45.6579460+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-08-26T13:53:45.6579460+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-26T13:53:45.6579460+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:53:45.6579460+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-26T13:53:45.6579460+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:53:45.6579460+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-26T13:53:45.6579460+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-08-26T13:53:45.6579460+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:53:45.6579460+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-26T13:54:08.7430352+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:54:08.7430352+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-08-26T13:54:08.7430352+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-26T13:54:08.7430352+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:54:08.7430352+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-26T13:54:08.7430352+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:54:08.7430352+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-26T13:54:08.7430352+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-08-26T13:54:08.7430352+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-26T13:54:08.7430352+00:00 })
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/27/2026 14:59:14 +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/27/2026 14:59:14)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(f8dd4a36-208f-4b55-8372-8a2112cd434c)
Eventuous.Tests.KurrentDB.Metrics.SubscriptionGapMetricsTests ‑ ShouldReportZeroGapWhenCaughtUp_Esdb
Eventuous.Tests.Postgres.Metrics.SubscriptionGapMetricsTests ‑ ShouldReportZeroGapWhenCaughtUp_Postgres
Eventuous.Tests.Postgres.Subscriptions.GapAgeReleaseWhileRunningTest ‑ ShouldReleaseGapThatAgesOutWhileSubscribed
Eventuous.Tests.Postgres.Subscriptions.GapRemediationBeforeSkipTest ‑ ShouldCreateTombstoneBeforeSkippingGap
Eventuous.Tests.Postgres.Subscriptions.GapSkipTimeoutTest ‑ ShouldSkipGapOnlyAfterSkipTimeout
Eventuous.Tests.Postgres.Subscriptions.StreamSubscriptionMeasure ‑ Postgres_ShouldMeasureEndOfSubscribedStream
Eventuous.Tests.SqlServer.Metrics.SubscriptionGapMetricsTests ‑ ShouldReportZeroGapWhenCaughtUp_SqlServer
…

♻️ This comment has been updated with latest results.

alexeyzimarev and others added 2 commits August 27, 2026 16:00
Two escape hatches exist for a gap in the global position sequence, and
they are not equally safe. GapHandlingTimeoutMs inserts a tombstone,
which conflicts with a committed row and blocks on an in-flight one, so
it can never displace a real event. GapSkipTimeoutMs advances past the
position on elapsed time alone, so an append slower than the timeout has
its event skipped.

Releasing the gap cleared it before the remediation check in PollOnce,
which is gated on a non-null gap, so configuring GapHandlingTimeoutMs at
or above GapSkipTimeoutMs silently produced no tombstones at all and the
subscription skipped a position it could have resolved. Track whether
remediation ran and let it take precedence over the skip.

Default GapSkipTimeoutMs to null. It never took effect before, so every
deployment already behaves this way; leaving it at 5 sec would instead
turn a visible stall into silently skipped events on upgrade. Skipping
on time alone is now opt-in, and the docs say what the options do.

Refs #222

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The age threshold sat below the held-gap branch, which returns without
reaching it, so it only ever ran on the poll that first observed a gap.
With GapSkipTimeoutMs defaulting to null that branch returns
unconditionally, and a gap first seen while the event after it was still
young was never released at all — an indefinite stall where the previous
behaviour bounded it at GapAgeThresholdMs. Evaluate the threshold ahead
of the held-gap branch and on every poll.

GapIgnoreTest missed this because it only covers a gap that was already
older than the threshold before the subscription started, which is
released on first detection.

The two timeout tests asserted eventual release and remediation but not
that either waited for its configured timeout, so an implementation that
acted immediately would also pass. Both now assert the hold before the
release, and the shared arrange and wait steps move to the fixture.

Refs #222

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alexeyzimarev
alexeyzimarev merged commit e1facb9 into dev Aug 27, 2026
17 checks passed
@alexeyzimarev
alexeyzimarev deleted the fix/222-gap-skip-timeout branch August 27, 2026 15:05
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.

1 participant