Skip to content

SkipStaleGapsDespiteLiveTransactionsAfter survives detector restarts (evidence-based stall onset) - #5109

Closed
uniquelau wants to merge 2 commits into
JasperFx:masterfrom
uniquelau:high-water-cap-fenceless-detector
Closed

SkipStaleGapsDespiteLiveTransactionsAfter survives detector restarts (evidence-based stall onset)#5109
uniquelau wants to merge 2 commits into
JasperFx:masterfrom
uniquelau:high-water-cap-fenceless-detector

Conversation

@uniquelau

Copy link
Copy Markdown
Contributor

Addresses #5108 (section 3).

⚠️ Please review with a healthy dose of skepticism. This comes from consumers of the async daemon, not maintainers: we reproduced the behavior, traced the detector/probe/fence code, and validated against the daemon test suites — but we don't have deep knowledge of these internals or the design history behind #4977/#5057, so take the approach with a pinch of salt. If a different mechanism fits better, we're glad to rework this, or for it to serve purely as an executable problem statement.

What

The cap's stuck-gap clock (_stuckGap.Since) is detector-scoped, so daemon churn — managed-distribution rebalancing, pause/resume cycles, restarts — resets it each time; with detector lifetimes shorter than threshold+cap, the documented "bounded override" never fires and a genuinely dead gap (rolled-back optimistic-concurrency loser) holds the high-water mark indefinitely, recoverable only via AdvanceHighWaterMarkToLatestAsync().

Fix: floor the cap comparison with a durable, evidence-based stall onset computed server-side:

transaction_timestamp() − greatest(min(timestamp of committed events above the pinned mark), time of the mark's last advance)

An event committed above the mark drew its sequence strictly after the gap's numbers — proof the gap has existed at least that long. When nothing is committed above the mark (an idle store's first live append), there is no durable floor and the in-memory clock governs, which holds.

Deliberately rejected design, pinned by a guard test: flooring with last_updated alone. It measures time-since-advance, conflating idle time with stall time — an overnight-idle store's first live append would have its in-flight gap cap-skipped instantly, committing events behind the mark (permanently unprojected). idle_store_first_live_append_is_not_cap_skipped pins against that shape.

Scope guarantees: null-cap default path byte-identical (#4977's evidence gating untouched); liveness probe consulted unchanged; the evidence query runs only when a cap is configured and the probe reports live; the UseTenantPartitionedEvents path is deliberately not floored (its progression last_updated is a per-poll heartbeat — any onset clamps to ~now; why-comment in code, follow-up conversation welcome). Rich-append caveat documented at the handler: mt_events.timestamp is client-written, so evidence age is bounded by client skew (QuickAppend writes transaction_timestamp()).

Tests (first direct coverage of the cap)

Test master this branch
fenceless cap skip, single detector pass pass
detector churn (300ms cycles, 600ms cap) fails — never skips pass (cycle-0 skip)
conjoined-tenancy churn + store-global routing pin fails — never skips pass
daemon-level resume pass pass
guard: idle-store first live append holds pass pass
guard: young live reserver holds until cap genuinely elapses pass pass

Runs (net10.0, dedicated database): new suite 6/6 (timing-sensitive trio stable across 3 consecutive runs); targeted store-global set 32/32; per-tenant suites 16/16; advanced_async_tracking 7/7. net9.0 TFM not run locally (no runtime installed) — deferring to CI.

The conjoined-tenancy test also pins routing: a TenancyStyle.Conjoined store reports SupportsTenantPartitioning == false and takes the store-global detection path.

🤖 Generated with Claude Code

uniquelau and others added 2 commits August 1, 2026 17:35
…s detector restarts

The JasperFx#5057 cap is documented as the bounded override for the transaction-
evidence hold: once a gap has been stuck past the cap, the skip proceeds
even though a candidate reserver still appears alive. But the clock it
was measured from — the detector-scoped _stuckGap observation — resets
with every fresh HighWaterDetector, and a daemon resumed after the gap
formed also has no allocation history to fence the probe with, so
long-lived idle-in-transaction sessions (Wolverine's advisory-lock
listeners) hold the gate on every cycle. Under daemon restarts or
managed-distribution agent churn shorter than the cap, the override
never fires and a dead gap pins the high water mark indefinitely — the
exact unbounded stall the cap exists to rule out.

Fix: floor the cap comparison with durable evidence of the stall's
onset. mt_event_progression.last_updated alone is NOT that evidence — it
records the last ADVANCE and ages just as much on a caught-up idle
store, where it would fire the cap against a seconds-old live append.
The onset is instead the timestamp of the earliest committed event ABOVE
the pinned mark (its sequence number was allocated strictly after the
gap's, so its append postdates the gap's birth), clamped no earlier than
the mark's last advance, computed in one server-side statement. When
nothing is committed above the mark there is no durable evidence and the
in-memory clock governs alone, which correctly holds for a store whose
first append after an idle stretch is still in flight. The stale
threshold stays on the in-memory clock, so a fresh detector still gives
a just-appeared gap a full settle window, and a null cap (the default)
is untouched — no change to the never-knowingly-skip posture of JasperFx#4977.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bal path

Conjoined tenancy shares one event sequence and one HighWaterMark row —
the vectorized per-tenant detection path is gated on
UseTenantPartitionedEvents, not on TenancyStyle — so a conjoined store's
daemon takes the same store-global DetectInSafeZone path as a
single-tenant store, durable cap floor included. The new test pins that
routing (SupportsTenantPartitioning false, the per-tenant API collapsing
to the store-global reading) and re-runs the detector-churn scenario on a
conjoined store: it fails without the durable floor and passes with it.

The per-tenant cap check keeps its detector-scoped clock; the comment
there records why the durable floor is inert on that path (the
TenantedHighWaterCoordinator re-marks each tenant's progression row on
every vectorized poll, so its last_updated clamps any evidence onset to
roughly now).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jeremydmiller

Copy link
Copy Markdown
Member

@uniquelau Hey Laurence, I'm going to take this just a little bit different way. You can't trust IEvent.Timestamp enough to use it in this test.

@jeremydmiller

Copy link
Copy Markdown
Member

Thanks for this — you asked for a steer, so here it is, along with what we found chasing it down. #5163 is the result, and it supersedes this PR.

Your diagnosis is correct. _stuckGap.Since really is detector-scoped, the cap really can be postponed indefinitely by churn shorter than threshold+cap, and the "bounded override" really is unbounded in that shape. That part isn't in question, and your churn test fails on master exactly as you describe.

Where we landed differently is on which mechanism to repair.

Every earlier change in this lineage (#4977#5057#5091) attacked the evidence — "is a transaction that could fill this gap actually alive?". Your PR is the first to concede that question and attack the clock instead. That's a legitimate reading, but it means the fix can only engage for people who have opted into SkipStaleGapsDespiteLiveTransactionsAfter, which is lossy by design. §1 of your own issue — defaults, permanent stall — is untouched by it, and against #5125's shape the evidence query is inert, because nothing is committed above the mark for it to date the gap from.

So we went back to the fence. It was already the sound mechanism; it just wasn't durable. Persisting it is cheaper than it looks — the lookup wants the newest observation whose allocated high is at or below the mark, and both values only move forward, so a single incrementally-maintained row is exactly equivalent to the in-memory history. No new table, no migration, engages on every existing database at upgrade.

Two findings from building it that are worth passing back:

First, the durable fence alone did not fix the restart case. Our end-to-end test still stalled, and the reason is that the replacement daemon's own leadership lock is a new session that postdates the fence — so it's never quiescent and holds the gap by itself. That's #5125's mechanism, and it turns out to dominate #5108 §1 too. It needed a second, structural fix: those connections exist only to hold the lock and never append, so they're ruled out by identity rather than by timing. We wouldn't have found that ordering without a test that drove real HotCold daemons, which is a point in favour of how you framed the problem.

Second, on the mt_events.timestamp arithmetic specifically — we'd have had to ask you to change that regardless. Reviewing it turned up #5136: now() at time zone 'utc' is written into a timestamptz column in three places, which double-converts and skews the stored instant by the server's UTC offset on any database whose TimeZone isn't UTC. On a UTC+2 server every event timestamp reads two hours old, which would have quietly collapsed a configured 5-minute cap into "skip as soon as the stale threshold passes". Your PR comment flagged client skew as the soft spot and you were right to — it was just worse than either of us thought. Genuinely useful thing to have surfaced.

What we'd like to keep from this PR: your detector-churn test. It's the clearest executable statement of the problem in the whole thread, and even though the mechanism changed, the property it pins — a fresh detector must not reset the clock on a gap that's already old — is still worth guarding. If you're happy for us to carry it over into #5163's suite (credited to you), say the word; equally happy for you to push it as its own PR.

We'll close this one as superseded rather than merged, but the analysis in it did real work. Thanks for taking the trouble to trace it properly and for saying plainly which parts you weren't sure about — that made it much faster to review.

@jeremydmiller

Copy link
Copy Markdown
Member

Superseded by #5163, now merged. The reasoning is in my earlier comment; the short version is that we repaired the allocation fence (the sound mechanism) rather than the cap clock (the lossy one), so both #5108 §1 and #5125 recover at default settings without anyone opting into SkipStaleGapsDespiteLiveTransactionsAfter. #5164 has also merged since, stopping WaitForNonStaleProjectionDataAsync from treating high-water bookkeeping as a projection shard.

Carrying your detector_churn_does_not_postpone_the_cap_forever test forward now, with co-authorship on the commit.

To be straight about what that involves: the defect it pins — the cap clock being detector-scoped — is real and is still present on master, because #5163 did not touch it. So the test does not pass today, and salvaging it properly means fixing the clock rather than just filing the test. I plan to do that with a durable record of when a gap was first observed, keyed on the pinned mark. That needs no mt_events.timestamp arithmetic, so it avoids the skew in #5136 that the evidence query in your PR would have inherited.

If that lands, your test lands with it, unchanged in substance. Thanks again — the churn scenario was not on our radar and would not have been without your report.

jeremydmiller added a commit that referenced this pull request Aug 3, 2026
…5165)

SkipStaleGapsDespiteLiveTransactionsAfter is documented as the bounded
override for the transaction-evidence hold: once a gap has been stuck past
the cap, the skip proceeds even though a candidate reserver still looks
alive. But it measured from _stuckGap.Since, an in-memory observation that
dies with the detector instance. Under managed-distribution agent churn or
restart cycles shorter than the cap, the clock reset every cycle and the
documented bound never fired — the exact unbounded stall the cap exists to
rule out.

Record the first sighting durably instead: a reserved mt_event_progression
row holding the pinned mark and the moment it was first seen pinned. The
mark only ever advances, so while it has not moved that timestamp stays a
sound LOWER bound on how long the gap has been stuck, which is the
direction a cap needs. Keying the row on the mark makes it self-
invalidating — a different mark is a different gap, and the clock restarts.

The stale threshold deliberately stays on the in-memory clock, so a fresh
detector still gives a just-appeared gap a full settle window, and a null
cap (the default) is untouched.

Server time only, and that is the point. The obvious alternative — dating
the stall from the earliest committed event above the mark, as the original
PR did — reads mt_events.timestamp, which is client-written in Rich append
mode (the default) and skewed by the server's UTC offset in Quick mode
(#5136). A behind-clock client or a non-UTC server could then age a gap
past the cap and drop a live append's events.

Tests are Laurence Gillian's from #5109, carried over unchanged in
substance. Four of the six already passed once #5163 landed; the two
detector-churn cases are what this commit fixes.

DaemonTests 280/280 net10.0.

Co-authored-by: Laurence Gillian <lau@eddlondon.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants