Skip to content

Commit dbddeb5

Browse files
jensensclaude
andauthored
fix: sort zoids in CacheWarmer._flush to prevent PK-index deadlock (#61)
Concurrent Waitress workers with overlapping pending sets deadlocked on cache_warm_stats PK index when their INSERT ... ON CONFLICT DO UPDATE acquired row locks in opposing orders. Set iteration order is not stable across processes, so two flushes could see the same zoids in different orders. Sort zoids before the upsert for a deterministic row-lock acquisition order. Added regression test verifying the INSERT parameter is sorted. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b816c30 commit dbddeb5

3 files changed

Lines changed: 44 additions & 1 deletion

File tree

CHANGES.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## 1.11.1
4+
5+
- Fix PK-index deadlock in `CacheWarmer._flush` between concurrent
6+
Waitress workers. Two workers with overlapping pending sets used to
7+
deadlock on the `cache_warm_stats` primary-key index when their
8+
`INSERT ... ON CONFLICT DO UPDATE` acquired row locks in opposing
9+
orders (set iteration order is not stable across processes). Zoids
10+
are now sorted before the upsert, giving a deterministic lock
11+
acquisition order.
12+
313
## 1.11.0
414

515
- Implement `IStorage.afterCompletion()` on `PGJsonbStorageInstance`

src/zodb_pgjsonb/cache_warmer.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,13 @@ def _flush(self, decay=False):
8484
8585
Wrapped in an explicit transaction so the decay UPDATE, score
8686
UPSERT, and low-score DELETE are atomic.
87+
88+
Zoids are sorted to give a deterministic row-lock acquisition
89+
order across concurrent flushes. Without this, two workers with
90+
overlapping pending sets deadlock on the PK index when they
91+
upsert rows in opposing orders.
8792
"""
88-
zoids = list(self._pending)
93+
zoids = sorted(self._pending)
8994
if not zoids:
9095
return
9196
self._pending.clear()

tests/test_cache_warmer.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,34 @@ def test_flush_exception_triggers_rollback(self):
297297
assert "BEGIN" in calls
298298
assert "ROLLBACK" in calls
299299

300+
def test_flush_sorts_zoids_for_deterministic_locking(self):
301+
"""Prevent PK-index deadlock between concurrent workers.
302+
303+
The INSERT must receive zoids in sorted order so that two
304+
workers with overlapping pending sets acquire row locks in the
305+
same order and never deadlock.
306+
"""
307+
from zodb_pgjsonb.cache_warmer import CacheWarmer
308+
309+
conn = mock.MagicMock()
310+
cursor = conn.cursor.return_value.__enter__.return_value
311+
312+
w = CacheWarmer(conn=conn, target_count=100)
313+
w._pending = {42, 7, 99, 3, 58}
314+
w._flush(decay=False)
315+
316+
insert_calls = [
317+
call
318+
for call in cursor.execute.call_args_list
319+
if "INSERT INTO cache_warm_stats" in call.args[0]
320+
]
321+
assert len(insert_calls) == 1
322+
zoids_param = insert_calls[0].args[1]["z"]
323+
assert zoids_param == sorted(zoids_param), (
324+
f"zoids must be sorted for deterministic lock order, got {zoids_param}"
325+
)
326+
assert zoids_param == [3, 7, 42, 58, 99]
327+
300328

301329
class TestWarmLoadMultiple:
302330
"""Integration test for PGJsonbStorage._warm_load_multiple()."""

0 commit comments

Comments
 (0)