The extended-progression batch write no longer builds a lock convoy (#5167) - #5186
Merged
Merged
Conversation
…5167) The batched extended-progression write was one `UPDATE ... FROM unnest(...)` covering every shard in the flush. A multi-row statement takes a row lock on EVERY row it matches and holds all of them until it commits, so one slow projection batch sitting on one progression row stalled the telemetry write of every OTHER shard on that database -- and, transitively, whatever those shards had queued behind it. Reproduced against PostgreSQL: an unrelated projection committing progress for a row the slow batch never touched timed out after 4s, queued behind the telemetry statement, which had locked that row on its way to the genuinely contended one and then stalled there. Rewritten as one single-row statement per shard, each its own implicit transaction, the same collision clears in ~1ms and only the genuinely contended row waits. Blast radius goes from "every shard on the database" to "the shard this write is about". The batch is still one rented connection -- that is what #553 was about, and N single-row statements on one connection cost one rent. What it is no longer is one transaction. The writes go in shard-name order so two writers racing over the same rows cannot take their locks in opposite orders. Also adds an `is distinct from` guard so replaying unchanged telemetry is an UPDATE 0 instead of a fresh tuple version. The SET list was unconditional, so every flush rewrote every matched row whether anything had changed or not, on a small hot table; each avoided rewrite is also an avoided row lock. Both new tests fail against the shape they guard: the lock test against the same loop wrapped in an explicit transaction, the replay test against the neutralized guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017CTtw2kVRSZKp1p5RTxgAy
Merged
jeremydmiller
added a commit
that referenced
this pull request
Aug 4, 2026
#630 (marten#5167), the companion to #5186. Two changes: The IEventDatabase batched extended-progression contract now REQUIRES one row per transaction rather than asking for "as few round-trips as the store can manage". The old wording said nothing about transaction scope, which is what made Marten's single `UPDATE ... FROM unnest(...)` a reasonable reading of it -- and that statement holds a row lock on every shard in the batch until it commits. Polecat needs the corrected contract before it implements the batched overload. ExtendedProgressionWriter now flushes each batch ordered by shard name. With one row per transaction a single writer never holds more than one row lock, but the tracker is per-database and shared and BuildProjectionDaemonAsync is not cached, so two writers can race over the same rows; the pending dictionary's enumeration order is an implementation detail rather than an agreement between them. Marten's own fix (#5186) does not depend on this bump -- the convoy was in Marten's SQL. What this adds is the cross-writer deadlock hazard being closed and the contract being stated for the next implementer. DaemonTests 300/300 and CoreTests jasper_fx_mechanics 18/18 green on net9.0. Claude-Session: https://claude.ai/code/session_017CTtw2kVRSZKp1p5RTxgAy Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
jeremydmiller
added a commit
that referenced
this pull request
Aug 4, 2026
#631 — a status transition published while the shard has NO progression row to decorate landed nowhere, because every store's extended-progression write is update-only. That describes every fresh shard's Started: the agent starts before its first batch commits. With the periodic beat off by default since #622 there was no later write to correct it, so agent_status and heartbeat stayed NULL for the entire life of a healthy agent. The shard is now remembered and written again on the first publication carrying a committed sequence, which is proof the row exists. The #630 follow-up: a duplicate ExtendedProgressionWriter on one database now logs a warning. It used to announce itself as lock contention -- two writers issuing multi-row UPDATEs over the same rows in plan-dependent order is a deadlock hazard. The one-row-per-transaction rewrite in #5186 made a duplicate harmless to correctness and therefore SILENT, while it still means two projection daemons are started for one database. Reported and not refused: the duplicate writer is the symptom, not the bug. Version bumped to 9.22.4 for the release. DaemonTests 301/301 green on net9.0. Claude-Session: https://claude.ai/code/session_017CTtw2kVRSZKp1p5RTxgAy Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the lock-wait half of #5167. #622 (adopted in #5176, documented in #5183) removed the steady-state cost by turning the periodic beat off; this removes the blast radius of the writes that remain.
The finding
The production report that this statement increases database lock waits is correct, and the mechanism is not write volume — it is that a single statement takes row locks on every shard in the batch at once and holds them until it completes.
Three sessions against a stand-in for
mt_event_progression— P a projection batch transaction holding one row (shard_d), H the batched telemetry UPDATE covering all six, Q an unrelated projection committing progress forshard_a:Q— which touches a shard the telemetry statement has no business serializing — blocked and timed out after 4s. It was never contending withP; it was queued behindH, which had already lockedshard_aon its way toshard_dand then stalled there.With
Hreplaced by six single-row autocommit UPDATEs:This also explains the observation in #5167 that the reporter could not account for — "the blocker and the blocked statement were both this heartbeat UPDATE, within the same database".
pg_blocking_pids()reports direct blockers only. The heartbeat UPDATE genuinely is the blocker; the projection transaction that is the real root is one hop further down the chain and invisible unless you walk it recursively. No second concurrent writer is required to produce that picture.Why #622 alone was not enough. It made this rarer without making it milder. The transitions that still reach the database are precisely the correlated ones — node start, node stop, rollout, agent rebalance — when many shards on a database transition together, which is when a batch is at its widest and when projection batches are being cancelled and re-established on those same rows.
What changed
One row per transaction. The flush is now one single-row
UPDATEper shard on the same rented connection, each in its own implicit transaction. That is the load-bearing property — six single-row statements inside one transaction would reproduce the convoy exactly, which is why they must stay separate round trips rather than one batched Npgsql command (several statements in one command share an implicit transaction). What a batch amortizes is the connection, which is all #553 ever needed: N single-row statements on one rented connection still cost one rent. Writes go in shard-name order so two writers racing over the same rows cannot take their locks in opposite orders.A change guard. The
SETlist was unconditional, so every flush gave every matched row a new tuple version whether anything had changed or not — on a small, hot table (two byte-identical replays of a 6-row batch left 31 dead tuples). Anis distinct fromguard makes the replay anUPDATE 0. This is secondary to the convoy, but each avoided rewrite is also an avoided row lock.Semantics are otherwise unchanged: update-only, never INSERT, never touches
last_seq_id/last_updated, missing rows skipped silently, and the #5048failure_*columns keep their write / clear-on-Started/ leave-alone rule.Verification
DaemonTestsgreen on net9.0 (300 tests). Two new tests, both falsified — each fails against the shape it guards:a_contended_row_does_not_hold_the_locks_of_the_rows_already_writtenreplaying_identical_telemetry_does_not_rewrite_the_rowis distinct fromguard neutralizedxminchanges on a byte-identical replayThe lock test holds a real row lock from a second connection, then asserts the earlier row's write is visible from a third connection while the batch is still parked — and that an unrelated writer touching that row completes under a 2s
lock_timeoutrather than queuing behind the batch.Companion contract change in JasperFx.Events: JasperFx/jasperfx#630.
🤖 Generated with Claude Code
https://claude.ai/code/session_017CTtw2kVRSZKp1p5RTxgAy