Async daemon can advance projection progress past committed events during concurrent appends #4953
|
I found this while working on a bulk import feature for my application. During the import, I noticed that an async projection could report as caught up even though some committed events had not been projected. I used an agent to help isolate the behavior into a smaller reproduction. My current understanding is that this can happen when concurrent appends reserve event sequence numbers ahead of rows becoming visible in The regression test is intended to model that race by delaying one event insert while allowing later events to commit, then checking that the daemon/page loader does not advance past the missing committed event. I split the work into two commits in my fork at https://github.com/gschuager/marten/tree/projection-skip:
I do not fully understand all the implications of the proposed solution, but I validated it against my application’s bulk import scenario and it did resolve the projection skip I was seeing. I’m sharing it as a starting point and would be happy to help however I can with further investigation or refinement. Expected BehaviorIf projection progress advances to sequence Actual BehaviorUnder concurrent append/import load, the daemon can advance projection progress past a matching committed event that was not included in the loaded page, causing that event to be missed by the async projection. BTW, I also worked on a similar issue in https://github.com/Eventuous/eventuous and ended up adding some gap handling timeouts there Eventuous/eventuous#424 |
Replies: 7 comments 9 replies
|
@gschuager Before I even think about doing anything about this, have you:
And I wouldn't think that Marten's model is similar enough to Eventuous + KurrentDb to really apply, but I could be wrong. And lastly, what version of Marten? |
|
Yeah, use this: https://martendb.io/events/bulk-appending.html#bulk-appending-events. Not willing to look at this until you try our official event importing:) |
|
Opened #4964 to track the page-loader / observability half of this separately. Confirmed deterministically (advisory-lock + Notably this skip is silent — it records nothing in |
Root cause: how a gap-crossing high-water mark reaches the loader silentlyThe page loader (
-- (1) first interior gap at/after Start
select seq_id
from (select seq_id, lead(seq_id) over (order by seq_id) as no
from mt_events where seq_id >= :start) ct
where no is not null and no - seq_id > 1
LIMIT 1;
-- (2) fallback when (1) finds nothing
select max(seq_id) from mt_events where seq_id >= :start;Query (1) only compares consecutive visible rows, and Case A — start event present, interior gap (the reproduction's data): committed
Case B — start lands in/just below a gap, first visible rows internally contiguous (the silent skip): committed above start =
How
This matches the fix on Takeaways for where to fix:
|
Follow-up: full audit of every path that can cross an outstanding sequence — 4 confirmed mechanisms on 9.16.0@gschuager you're right, and thank you for pushing back with the 9.16.0 re-test. I went back through the whole high-water detection surface hunting specifically for ways the mark can advance over a sequence number that is reserved by a still-running transaction ("outstanding" — will commit later, unlike a rolled-back hole). There are four distinct mechanisms, and I reproduced all four deterministically against 9.16.0 (open-transaction holds + statement-boundary widening, no load-dependent flakiness). 1. 2. 3. The stale fallback teleports into pure reservations. When the mark is stale and nothing above it is committed yet, detection jumps to 4. The deliberate stale skip can't tell "slow" from "rolled back". The 3s-default threshold skip is wall-clock only; at cold start the mark pins at 0 on the first in-flight event, and the skip then lands past every outstanding sequence below the first visible gap edge. Your trigger repro's daemon-level test races exactly this (2s hold vs. the 3s threshold). The interesting part: Postgres can prove a gap dead — no wall-clock guessing neededAn in-flight append holds a granted PlanReproductions first — the four scenarios above are being turned into permanent Your instinct in the fork was right — the |
Fixes are up: PR #4977 + JasperFx.Events 2.29.1Following up on the four mechanisms above — the full fix set is now in review as #4977, with the daemon-coordination half already published in JasperFx.Events 2.29.1. What changed, mechanism by mechanism:
For your import scenario specifically: the trigger-style reproduction (first event of the store held in-flight while later events commit) is now a permanent regression test in the daemon suite, both as a detector-level test and end-to-end — daemon running, rebuild during the in-flight append — and both require every committed event to be projected with zero skips. Two knobs, should you ever want different trade-offs: This will ship in the next Marten release once #4977 lands. I'd appreciate you re-running both your repro branch and the real bulk-import flow against it when it's out. |
|
Marten 9.16.1 is out with the full fix set described above: https://github.com/JasperFx/marten/releases/tag/V9.16.1 (on nuget.org now, alongside JasperFx.Events 2.29.1). @gschuager when you get a chance, please re-run both your |
Marten 9.16.1 is out with the full fix set described above: https://github.com/JasperFx/marten/releases/tag/V9.16.1 (on nuget.org now, alongside JasperFx.Events 2.29.1).
@gschuager when you get a chance, please re-run both your
projection-skiprepro branch (the trigger technique) and your real bulk-import flow against 9.16.1 — with the transaction-evidence gating on by default, the daemon should now hold for your in-flight appends rather than skipping them, and any skip that ever does happen (genuinely rolled-back appends only) will be logged at Warning with its exact sequence range. If you still see a missing event after this, that's something new and I want to know about it here.