Skip to content

The extended-progression batch write no longer builds a lock convoy (#5167) - #5186

Merged
jeremydmiller merged 1 commit into
masterfrom
fix/5167-per-row-progression-writes
Aug 4, 2026
Merged

The extended-progression batch write no longer builds a lock convoy (#5167)#5186
jeremydmiller merged 1 commit into
masterfrom
fix/5167-per-row-progression-writes

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

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_progressionP a projection batch transaction holding one row (shard_d), H the batched telemetry UPDATE covering all six, Q an unrelated projection committing progress for shard_a:

  pid  | state  | wait_event_type |  wait_event   | blocked_by |  query
 11762 | active | Lock            | transactionid | {11763}    | UPDATE ... WHERE name = 'shard_a'      <- Q
 11763 | active | Lock            | transactionid | {11765}    | UPDATE ... SET heartbeat = t.heartbeat <- H
 11765 | active | Timeout         | PgSleep       | {}         | (the projection batch holding shard_d) <- P

Q — which touches a shard the telemetry statement has no business serializing — blocked and timed out after 4s. It was never contending with P; it was queued behind H, which had already locked shard_a on its way to shard_d and then stalled there.

With H replaced by six single-row autocommit UPDATEs:

H2: starting per-row writes   14:57:25.008
H2: a,b,c committed           14:57:25.009      <-- 1 ms
Q:  updating shard_a          14:57:28.011
Q:  SUCCEEDED                 14:57:28.012      <-- 1 ms, no wait
H2: d finally done            14:57:37.013      <-- only the genuinely contended row waited

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 UPDATE per 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 SET list 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). An is distinct from guard makes the replay an UPDATE 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 #5048 failure_* columns keep their write / clear-on-Started / leave-alone rule.

Verification

DaemonTests green on net9.0 (300 tests). Two new tests, both falsified — each fails against the shape it guards:

test falsified against failure
a_contended_row_does_not_hold_the_locks_of_the_rows_already_written the same loop wrapped in an explicit transaction first row's telemetry never becomes visible while the batch is parked
replaying_identical_telemetry_does_not_rewrite_the_row the is distinct from guard neutralized xmin changes on a byte-identical replay

The 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_timeout rather than queuing behind the batch.

Companion contract change in JasperFx.Events: JasperFx/jasperfx#630.

🤖 Generated with Claude Code

https://claude.ai/code/session_017CTtw2kVRSZKp1p5RTxgAy

…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
@jeremydmiller
jeremydmiller merged commit ef2c117 into master Aug 4, 2026
10 checks passed
@jeremydmiller
jeremydmiller deleted the fix/5167-per-row-progression-writes branch August 4, 2026 15:40
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>
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