fix(postgres): serialize concurrent row merges before reading clocks - #82
marcobambini wants to merge 1 commit into
Conversation
|
Updated review of the locking strategy at Findings
Correction: a full 64-bit hash does not meet the bounded-lock requirement A larger hash removes practically all accidental collisions, but still allocates roughly one advisory lock per distinct row until transaction end. It therefore reintroduces the same class of exhaustion addressed by 9d0abb3: that fix selects at most 64 stale-fragment candidates before acquiring transaction-level locks. Increasing Alternatives without a new guard table
Conditional writes and retry: proposed direction, not yet a proven complete fix Capture a consistent view of the state used to decide the winner. Within a savepoint, perform the merge and condition the winner-clock write on that observed state still matching. For an initially absent clock, insertion must detect a concurrent creation. If validation fails, roll back the complete attempt, reread committed state, and recompute the decision. For the original stale-winner case: both transactions read clock An added
Conditional writes can still deadlock on ordinary database resources: transaction A updates row X, transaction B updates row Y, then each tries to update the other's row. Successful conditional updates retain write locks, so both can wait before either comparison completes. PostgreSQL resolves this with This is not a reason to reject conditional writes: it removes the pool's artificial conflicts between unrelated rows. With one shared row and consistent resource ordering, the usual outcome is waiting followed by a failed comparison, not a deadlock. Keep base-table/metadata lock acquisition consistent and retain whole-transaction deadlock retry. A stale-comparison retry and a deadlock retry have different rollback requirements. A bounded advisory-lock fallback is possible Use a common advisory merge gate. Normal transactions acquire it shared, then take 64-bit row locks up to a transaction-wide budget. Bulk transactions acquire it exclusively before any merge work and skip per-row advisory locks. All merge entry points must honor the gate. For example, a budget of 128 distinct row keys means at most 129 distinct advisory keys for a normal transaction; a bulk transaction needs only the gate. This bounds this feature's per-transaction contribution, not all lock usage across the server. Capacity must still account for concurrent transactions and other workloads. Choose bulk mode upfront when size is known. Otherwise, exceeding the budget must stop the transaction and require rollback/retry in bulk mode; the extension cannot transparently replay arbitrary earlier caller SQL. Do not upgrade a shared gate while holding row locks: concurrent upgrades can deadlock. A database-wide gate is simplest but bulk work blocks all participating merges; per-table gates reduce interference at the cost of more ordering and budget complexity. Committing smaller batches is another option only when the caller accepts the changed atomicity. Recommendation Prototype conditional winner-clock writes with complete rollback/retry first. This directly addresses the advisory-lock growth and false-contention problems without a new table. The main correctness work is validating lifecycle and block state, not eliminating ordinary PostgreSQL deadlocks. Before replacing the pool, validate the original stale-winner regression, equal-version ties, missing clocks, delete/resurrection, deferred multi-column batches, concurrent block materialization, direct inserts, and rollback/retry behavior. Add disjoint concurrent uploads and a bulk upload alongside a small disjoint upload, checking convergence and advisory-lock usage. These are proposed checks, not completed validation of the new design. If that broader change is unsuitable for this PR, use the bounded advisory-lock/bulk-gate design only with an explicit transaction budget, mode-selection/retry contract, and acceptance of bulk serialization. A full 64-bit hash alone is not a bounded solution. PostgreSQL references: READ COMMITTED conditional-update behavior, locks and deadlocks, lock-pool configuration. |
Problem
Closes #70.
Two PostgreSQL transactions can read the same old row clocks, independently decide that their incoming value wins, and then serialize only at the base-row write. If the transaction carrying
col_version = 2waits behind the one carryingcol_version = 3, it can overwrite the committed value and its clock withlower/2. Applying the same payloads serially converges onhigher/3in either order.Fix
Acquire a transaction-level advisory lock in
merge_insertbefore reading the row's causal length or column clocks. The lock covers the qualified metadata table and encoded primary key, and remains held through deferred writes until the caller commits or rolls back. This protects payload merges and direct inserts intocloudsync_changes, including row deletion/resurrection and block merge paths.Use a fixed pool of 256 lock keys per database to bound shared lock-table usage during large imports. Hash collisions serialize unrelated rows. Transactions touching multiple rows in different orders can deadlock; callers must retry the whole transaction on
40P01.READ COMMITTED: clock reads after waiting see the committed winner.SERIALIZABLE: retain PostgreSQL conflict detection and whole-transaction retry on40001.REPEATABLE READ: reject merges with0A000, since waiting cannot refresh the transaction snapshot. This extends the existing fragment restriction to all row merges.The isolation behavior and retry requirements are documented in
API.md.Reproduce the original bug
The regression builds two monolithic payloads for the same row/column, with distinct origin sites and explicit versions 3 (
higher) and 2 (lower). Both serial orders must yieldhigher/3. With dblink, session x applieshigherinside an open transaction; session y starts applyinglower. The test observeswait_event_type = 'Lock'before committing x, then checks both the value and the stored column version. This avoids timing-dependent overlap.From a checkout of this PR, run the new test against the original source in a separate worktree:
Expected failure before the fix (observed):
Test the fix
From this PR's repository root, with Docker running:
The Docker script builds the extension from source, runs psql with
ON_ERROR_STOP, and removes its container and volumes on exit. It publishes no ports and uses no existing database. Build images remain cached.The regression covers fresh and existing rows, both arrival orders, commit and rollback,
SERIALIZABLEfailure/retry, theREPEATABLE READSQLSTATE, and 10,000 direct row merges with at most 256 advisory locks. It also checks that rollback releases the locks and is included infull_test.sql.Validation completed
lower/2replacinghigher/3.make unitteston macOS: passed, including audit regressions and bootstrap policy tests.bash -n scripts/test-postgres-docker.shandgit diff --check: passed.