Skip to content

fix(insert): make the batch insert function give way instead of deadlocking (0.6) - #700

Merged
hardbyte merged 11 commits into
release/0.6.6from
brian/batch-claim-deadlock-0.6
Nov 9, 2026
Merged

hardbyte merged 11 commits into
release/0.6.6from
brian/batch-claim-deadlock-0.6

Conversation

@hardbyte

@hardbyte hardbyte commented Nov 9, 2026 •

Copy link
Copy Markdown
Owner

Summary

Since 0.6.15, awa.insert_jobs_compat_batch (used by insert_many_copy and insert_many_skip_unique_conflicts) takes all of a batch's unique claims before its lane locks, while per-row inserts — generic insert_many, single-job inserts and every 0.6.14-and-earlier binary — take each job's claim and lane together. A per-row transaction holding a lane the batch needs, inserting a key the batch has claimed but not committed, deadlocked with it (40P01). The per-row side can't change, so the batch now gives way where it can, and is never worse than the 0.6.15 function.

Design

The batch takes its claims, lane registrations and lane reservations inside one subtransaction (an attempt).

  • Bounded phase (first 2 s by default). Each lock wait in an attempt is limited to what remains of a budget measured from the attempt's start: 500 ms, or half of the batch session's deadlock_timeout if lower, and never longer than the caller's lock_timeout. The cap exists because the per-row producer's deadlock check runs on its own session's setting, which the batch can't see. Claims are first inserted in one statement that gives way to any in-flight claim of the same key (each lock wait capped at 10 ms, or the budget if shorter), then key by key on later attempts. Each lane registration statement and each reservation gets the remaining budget. A wait that runs out rolls the attempt back, releasing every claim, lane lock and registration row it took, and the batch retries after a 1–100 ms jittered pause.
  • Final attempt. After four budgets of retries the batch makes one attempt with the caller's lock_timeout and ordinary waits, as the 0.6.15 function did. Every call therefore ends in success, 55P03 or 40P01; it never retries forever, and contention that outlasts the retries is served in lock order.
  • Caller's lock_timeout. It applies per wait, as in 0.6.15: only a wait that the caller's lock_timeout ended raises 55P03. The batch's own work and its retries do not count against it.
  • Claims stay in key order and lanes in canonical lane order, so batches cannot deadlock with each other. Result rows are built once before any lock and the skipped rows filtered on return, so building them does not use up an attempt's budget. The ready rows and segments are written after the attempt, under the lane locks it holds, so the lane lock is held only for the reservation, the ready-row writes and the commit, as in 0.6.15.
  • The patch probe matches the new body (v_bounded_until), so 0.6.15 databases see insert_jobs_compat_batch as pending; the 0.6.15 body is kept as a test fixture. A released 0.6.15 awa migrate still treats the new body as applied, so rolling binaries back keeps it.

What it covers, and what it doesn't

Covered: the two-party cycle between a batch and a per-row producer (shared key, or lanes enqueued in separate statements in the opposite order), when the batch call is the first thing its transaction locks, the per-row session's deadlock_timeout is above 500 ms (the default is 1 s), the batch inserts its claims within the budget, and the per-row transaction closes the cycle within the bounded phase. The batch gives way within its budget and the per-row transaction proceeds before its deadlock check runs.

Not covered, and documented as such:

  • A per-row transaction that holds a lane for longer than the bounded phase (about 2.5 s) before inserting one of the batch's keys meets the ordinary final attempt, and one side gets 40P01 as in 0.6.15 (pinned by a test).
  • A cycle through a lock the batch's own transaction took in an earlier statement. Rolling back the attempt can't release it, so the cycle ends with 40P01 after the bounded phase: after about 3–4 s instead of 0.6.15's 1–2 s, and usually by aborting the batch's (caller's) transaction rather than the per-row one.
  • A batch can give way only while inserting its unique claims fits the 500 ms budget: about 20 µs per key with ON CONFLICT DO NOTHING, so roughly 10,000–20,000 keys on a fast server and fewer on a slow or busy one. Larger batches behave as in 0.6.15. Cycles of three or more transactions can also fall back to 0.6.15 behaviour.
  • The budget is per wait, not per statement: the set-based claim statement can wait up to 10 ms on each in-flight key it meets, and a lane reservation can wait for the lane's provisioning lock and then the lane lock, each up to the remaining budget.
  • A per-row session with deadlock_timeout at or below 500 ms can still be aborted by its own earlier deadlock check. A deadlock_timeout below about 3 ms on the batch session leaves no margin.
  • Each call opens one subtransaction, so more than 64 batch calls in one transaction overflow the per-backend subtransaction cache. The result array has the same 1 GB limit as the input arrays.

Rejected alternatives: rolling back only the current lane (earlier claims stay held, livelock); taking lanes before claims (per-row transactions wait both ways across rows, and it lengthens #690's lane hold); catching 40P01 in the function (the per-row side is usually the victim); retrying without a final ordinary attempt (spins forever on a cycle through the caller's earlier locks); remembering lost lanes and pre-locking them (reset the budget, held lanes across claims, and starved batches with two busy lanes).

main's v055 has the 0.6.15 body and gets this in the 0.6.16 forward-port.

Testing

New tests in insert_many_batch_test, all on the skip and COPY paths where relevant:

  • the two-connection per-row/batch claim cycle, the same with the batch session at a 20 s deadlock_timeout and the per-row side at the default, a multi-statement producer in opposite lane order, and an 8×12 mixed-producer stress test asserting zero 40P01, exact row accounting and each key landing once. These fail on the 0.6.15 body (the stress test saw 17 deadlocks) and pass now; the long-deadlock_timeout test fails without the 500 ms cap.
  • a batch inside a transaction that already holds a lane: on an earlier body of this PR it retried forever; now exactly one side gets 40P01.
  • a caller lock_timeout shorter than the batch's own work before its lane wait: fails with 55P03 on the previous body, succeeds now as on 0.6.15.
  • a per-row transaction holding the lane past the bounded phase (the documented limit: exactly one side gets 40P01), two lanes held past the bounded phase, the caller's lock_timeout on a lane held throughout, and a large-batch cycle that must give way when an equivalent claim insert takes under half the budget (5,000 keys take 90–100 ms here) and must otherwise end as in 0.6.15.

insert_many_schema_fallback_test, enqueue_lane_lock_order_test, copy_test, integration_test and migrate_patch_cli_test pass on PG17, as does patch_upgrade_rehearsal_test against released 0.6.15 and 0.6.9, now expecting claim_batch_job_index, insert_jobs_compat_batch and prune_controls pending on 0.6.15.

Lane-contention benchmark, 16 producers on one lane, A/B on fresh databases with asynchronous commit (three runs each):

Body jobs/s p99 batch latency
0.6.15 37.1k / 39.3k / 41.8k 93 / 86 / 156 ms
this PR 37.2k / 36.5k / 40.6k 163 / 173 / 137 ms

Single-producer p50 is 12.0 ms vs 11.6 ms. Throughput is within run-to-run noise. The p99 tail is not confirmed as retries: counting them on a busy machine, 0.2–0.3% of calls retried because the claim probe, then capped at 1 ms, tripped on a wait other than a shared key (the benchmark's keys never overlap), and lane retries appeared only when lane waits passed 500 ms under machine load. Under that load the 0.6.15 body's p99 rose as well (86 → 171 ms vs 116 → 220 ms). The probe now waits up to 10 ms per lock to avoid those spurious retries; the benchmark has not been re-run since.

…ocking

`awa.insert_jobs_compat_batch` takes all of a batch's unique claims before any lane lock, while per-row inserts (generic `insert_many`, single-job inserts, released binaries) take each job's claim and its lane together. A per-row transaction holding a lane the batch needed, then inserting a key the batch had claimed, waited on the batch while the batch waited on it, and PostgreSQL aborted one with 40P01. The same happened with a transaction that enqueued lanes in separate statements in the opposite order to a batch.

The function now takes its claims and lanes inside a subtransaction (an attempt). After the attempt's first lock, every lock wait ends within half of `deadlock_timeout` of it: claims are first inserted in one statement with a 1 ms lock_timeout, then key by key with the remaining budget, and each lane's registration and reservation get the remaining budget. A wait that runs out rolls the attempt back, releasing every claim and lane it took, and the batch retries after a short randomised pause. A per-row producer waiting on the batch is therefore released before its own deadlock check fires, and the batch never waits long enough to run one, so neither is the victim of a two-party cycle. Batches still order claims by key and lanes by lane, so they do not make each other retry.

A lane whose wait ran out is awaited first on the next attempt, while the attempt holds nothing and with no bound beyond the caller's, so a lane kept busy by long per-row transactions cannot starve the batch. The caller's lock_timeout, when set, bounds the whole call. The ready rows and segments are written after the attempt, under the lane locks it took, and the result rows are built before the lock window as before, so the lane lock is held no longer than in 0.6.15.

The patch probe now matches the wait budget, so `awa migrate` replaces the 0.6.15 body; a fixture of that body keeps the probe honest. New tests reproduce both deadlocks deterministically (they fail with 40P01 on the 0.6.15 body), cover the busy-lane fallback and the caller's lock_timeout, and race batch and per-row producers on shared keys and lanes asserting no 40P01 and that every key lands exactly once.
Adds the Unreleased changelog entries, a 0.6.15 to 0.6.16 upgrade guide and its README row, and updates the enqueue deadlock section of troubleshooting: batches through the function now give way to per-row producers and to multi-statement enqueue transactions, and the 0.6.15 body is replaced by `awa migrate`.
…tch-claim-deadlock-0.6

# Conflicts:
#	CHANGELOG.md
#	docs/upgrade-0.6.15-to-0.6.16.md
@hardbyte hardbyte added the full-ci Run the full CI matrix (Python build+test, E2E) on this PR label Nov 9, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-09T00:14:17.456426Z 8459e6d PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: b0444291-2a3e-4bfa-b756-cfe3dfc4c90a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8459e6db5b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

-- waits long enough to run one. The caller's lock_timeout, when set,
-- bounds the whole insert.
v_caller_lock_timeout := current_setting('lock_timeout');
v_wait_budget := current_setting('deadlock_timeout')::interval / 2;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cap waits below every participant's deadlock timeout

When producers use different session-level deadlock_timeout values—for example, a batch connection configured to 20s and a per-row connection using the 1s default—this gives the batch a 10s wait budget while it still holds claims or lanes. In the documented claim/lane cycle, the per-row session therefore runs deadlock detection after 1s, before the batch releases its locks, so one transaction can still fail with 40P01. The retry budget cannot safely be derived solely from the batch session's setting; it must be below the possible peer detector delay or avoid waiting while holding these locks.

Useful? React with 👍 / 👎.

The wait budget was half of the batch session's own `deadlock_timeout`, but a per-row producer runs its deadlock check on its own session's setting, which the batch cannot see. A batch session at 20 s therefore held claims and lanes for 10 s while a per-row producer at the 1 s default was aborted with 40P01. The budget is now 500 ms, or half of the batch session's `deadlock_timeout` when that is lower, so per-row sessions at PostgreSQL's default or above are covered.

The probe marker follows the new budget expression. The lane-window test no longer relies on a long batch budget: a probe that lands after the first attempt's budget is repeated rather than judged. A new test runs the claim cycle with the batch session at a 20 s `deadlock_timeout` and the per-row producer at the default; it fails with 40P01 without the cap.

The patch upgrade rehearsal now expects a released 0.6.15 database to report both `insert_jobs_compat_batch` and `prune_controls` as pending, and the candidate migrate to apply both.
…tch-claim-deadlock-0.6

# Conflicts:
#	CHANGELOG.md
Retrying a batch attempt releases only what the attempt took. When the caller's transaction already held a lock from an earlier statement and a per-row producer waited for it while holding one of the batch's keys, the batch retried forever: every wait stayed under `deadlock_timeout`, so the batch never ran a deadlock check, and the per-row producer's single check found the cycle only if it happened to land during one of the batch's waits. Under sustained contention, alternating lost lanes could also keep the batch retrying for many seconds.

The batch now retries with bounded waits for four wait budgets (2 s by default), then makes one final attempt with the caller's lock_timeout and ordinary waits, as the 0.6.15 function did. Every call therefore ends in success, a lock timeout or `deadlock detected`, and contention that outlasts the retries is served in lock order. This replaces the remembered first lane, which held a lane across the claims of later attempts, reset the wait budget after an unbounded wait, and tracked only one lane.

The result rows are built once before any lock, and skipped rows are filtered out on return, so their construction no longer uses up an attempt's wait budget on large batches. Each lane registration statement now gets its own remaining budget.

New tests cover a batch inside a transaction that already holds a lane, which looped without end before and now ends with one side aborted by `deadlock detected`; two lanes held past the bounded phase; and a 20,000-row batch that still gives way to a per-row producer. The probe marker follows the new body.
The first attempt inserts its claims in one statement and gave way after a 1 ms lock wait. Waits other than a claim of the same key in flight, such as relation extension, could pass 1 ms under load, so about 0.2-0.3% of calls in the lane contention benchmark retried although no keys overlapped. The probe now waits up to 10 ms per lock, or the remaining wait budget if shorter, and the patch probe matches the new body.
A batch gives way only while inserting its unique claims takes less than the 500 ms wait budget; past that every bounded attempt runs out before it reaches its lanes, and the ordinary final attempt behaves as in 0.6.15. On a slow CI runner the 20,000-row test batch was past that point and was correctly aborted with 40P01. The test now uses 5,000 rows, times an equivalent claim insert first, and requires the batch to give way only when that takes under half the budget; otherwise it requires the cycle to end as in 0.6.15. The docs state the size limit.
…tch-claim-deadlock-0.6

# Conflicts:
#	CHANGELOG.md
#	awa-model/src/insert.rs
#	awa/tests/patch_upgrade_rehearsal_test.rs
#	docs/troubleshooting.md
#	docs/upgrade-0.6.15-to-0.6.16.md
The batch function measured the caller's lock_timeout from entry and compared it cumulatively, so its own work, retries and successive waits all counted against it. Once spent, every bounded wait dropped to 1 ms and any timeout raised 55P03, where 0.6.15, which applied lock_timeout to each wait, succeeded. Each bounded wait is now the shorter of the remaining budget and the caller's lock_timeout, only a wait that the caller's value ended raises 55P03, and the final attempt applies the caller's lock_timeout per wait as 0.6.15 did.

New tests cover a caller lock_timeout shorter than the batch's own work before its lane wait, which fails with 55P03 on the previous body, and pin the documented limit that a per-row transaction holding a lane past the bounded phase still deadlocks with the batch. The docs now state that limit, that a cycle through the caller's earlier locks ends after about 3 to 4 s and usually aborts the caller's transaction, and that the budget applies per wait rather than per statement.
@hardbyte
hardbyte merged commit 605b73a into release/0.6.6 Nov 9, 2026
34 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

full-ci Run the full CI matrix (Python build+test, E2E) on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant