SkipStaleGapsDespiteLiveTransactionsAfter survives detector restarts (evidence-based stall onset) - #5109
Conversation
…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>
|
@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. |
|
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. 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 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 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. |
|
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 Carrying your 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 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. |
…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>
Addresses #5108 (section 3).
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 viaAdvanceHighWaterMarkToLatestAsync().Fix: floor the cap comparison with a durable, evidence-based stall onset computed server-side:
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_updatedalone. 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_skippedpins 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
UseTenantPartitionedEventspath is deliberately not floored (its progressionlast_updatedis 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.timestampis client-written, so evidence age is bounded by client skew (QuickAppend writestransaction_timestamp()).Tests (first direct coverage of the cap)
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_tracking7/7. net9.0 TFM not run locally (no runtime installed) — deferring to CI.The conjoined-tenancy test also pins routing: a
TenancyStyle.Conjoinedstore reportsSupportsTenantPartitioning == falseand takes the store-global detection path.🤖 Generated with Claude Code