Skip to content

Commit 0e3bd11

Browse files
committed
refactor: Move deadlock backoff into errback, drop file_worker retry loop
The file_worker retry loop re-ran process_file() on every attempt, so it re-read and re-parsed the file and re-inserted the data each time; its only saving over a requeue was the CollectionFile fetch. Its real value was the randomized backoff that desynchronizes the competing transactions. Centralize that: errback() now sleeps a random 1-5s before requeuing a deadlocked message, and file_worker just lets the OperationalError propagate. This removes the bespoke retry loop and gives every worker (file_worker, wiper, compilers) the same staggered backoff. errback() runs in the consumer thread pool, off the pika I/O loop, so the sleep does not block heartbeats. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CP8dBe7ptjmf25KCzJyHtQ
1 parent 8a30444 commit 0e3bd11

3 files changed

Lines changed: 40 additions & 46 deletions

File tree

process/management/commands/file_worker.py

Lines changed: 29 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import logging
2-
import random
3-
import time
42
from collections import OrderedDict
53
from itertools import islice
64

@@ -46,7 +44,6 @@
4644
Level = CollectionNote.Level
4745

4846
SUPPORTED_FORMATS = {Format.release_package, Format.record_package, Format.compiled_release}
49-
MAX_ATTEMPTS = 5
5047

5148

5249
class Command(BaseCommand):
@@ -116,43 +113,35 @@ def callback(client_state, channel, method, properties, input_message):
116113
nack(client_state, channel, method.delivery_tag, requeue=False)
117114
return
118115

119-
for attempt in range(1, MAX_ATTEMPTS + 1):
120-
try:
121-
with (
122-
deleting_step(
123-
ProcessingStep.Name.LOAD,
124-
collection_file_id=collection_file_id,
125-
finish=finish,
126-
finish_args=(collection_id, collection_file_id),
127-
),
128-
transaction.atomic(),
129-
):
130-
upgraded_collection_file_id = process_file(collection_file)
131-
except OperationalError as e:
132-
# Data that exceeds a PostgreSQL size limit can never be stored, so skip the file.
133-
if isinstance(e.__cause__, ProgramLimitExceeded):
134-
logger.exception("%s is too large to store, skipping", collection_file.filename)
135-
delete_step(ProcessingStep.Name.LOAD, collection_file_id=collection_file_id)
136-
create_note(
137-
collection,
138-
Level.ERROR,
139-
f"{collection_file.filename} is too large to store",
140-
data={"type": type(e).__name__, "message": str(e), **input_message},
141-
)
142-
nack(client_state, channel, method.delivery_tag, requeue=False)
143-
return
144-
145-
# If another transaction in another thread INSERTs the same data, concurrently.
146-
logger.warning("Deadlock on %s %s (%d/%d)\n%s", collection, collection_file, attempt, MAX_ATTEMPTS, e)
147-
if attempt == MAX_ATTEMPTS:
148-
# The transaction is rolled back and the LOAD step preserved, so the errback() function in the
149-
# decorator() function can requeue the message to retry it later, instead of shutting down.
150-
raise
151-
152-
# Make the threads retry at different times, to avoid repeating the deadlock.
153-
time.sleep(random.randint(1, 5)) # noqa: S311 # non-cryptographic
154-
else:
155-
break
116+
try:
117+
with (
118+
deleting_step(
119+
ProcessingStep.Name.LOAD,
120+
collection_file_id=collection_file_id,
121+
finish=finish,
122+
finish_args=(collection_id, collection_file_id),
123+
),
124+
transaction.atomic(),
125+
):
126+
upgraded_collection_file_id = process_file(collection_file)
127+
except OperationalError as e:
128+
# Data that exceeds a PostgreSQL size limit can never be stored, so skip the file.
129+
if isinstance(e.__cause__, ProgramLimitExceeded):
130+
logger.exception("%s is too large to store, skipping", collection_file.filename)
131+
delete_step(ProcessingStep.Name.LOAD, collection_file_id=collection_file_id)
132+
create_note(
133+
collection,
134+
Level.ERROR,
135+
f"{collection_file.filename} is too large to store",
136+
data={"type": type(e).__name__, "message": str(e), **input_message},
137+
)
138+
nack(client_state, channel, method.delivery_tag, requeue=False)
139+
return
140+
141+
# A deadlock can occur when another thread concurrently INSERTs the same deduplicated data. The transaction
142+
# is rolled back and the LOAD step is preserved, so the errback() function in the decorator() function can
143+
# requeue the message to retry it later, instead of shutting down.
144+
raise
156145

157146
message = {"collection_id": collection_id, "collection_file_id": collection_file_id}
158147
publish(client_state, channel, message, routing_key)

process/util.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import io
33
import logging
44
import os
5+
import random
6+
import time
57
from contextlib import contextmanager
68
from textwrap import fill
79

@@ -76,16 +78,17 @@ def errback(exception):
7678
# A foreign-key violation is not a duplicate-message symptom: a package_data or data row is still referenced
7779
# (e.g. by another collection's release), which indicates an error in logic rather than a redelivered message.
7880
#
79-
# A deadlock is transient: it occurs when concurrent transactions INSERT the same deduplicated data into the
80-
# unique index on the data or package_data table in a different order. Requeue the message to retry it later,
81-
# rather than shutting down the worker. The file_worker worker retries in-process first (see its MAX_ATTEMPTS
82-
# loop) and reaches here only if every attempt deadlocks; the compiler workers have no in-process retry and
83-
# rely on this requeue.
81+
# A deadlock is transient: it occurs when concurrent transactions write the same rows in a different order
82+
# (for example, when INSERTing the same deduplicated data, or DELETEing the same shared data). Requeue the
83+
# message to retry it, rather than shutting down the worker. The callback must leave no partial state on a
84+
# rolled-back transaction (see file_worker and wiper). Sleep first, so that the transaction that won the
85+
# deadlock can commit, and so that concurrent threads retry at different times, to avoid repeating it.
8486
if isinstance(exception, IntegrityError) and isinstance(exception.__cause__, errors.ForeignKeyViolation):
8587
logger.exception("Unhandled exception when consuming %r, shutting down gracefully", body)
8688
add_callback_threadsafe(state.connection, state.interrupt)
8789
elif isinstance(exception, OperationalError) and isinstance(exception.__cause__, errors.DeadlockDetected):
8890
logger.exception("Deadlock when consuming %r, requeuing", body)
91+
time.sleep(random.randint(1, 5)) # noqa: S311 # non-cryptographic
8992
nack(state, channel, method.delivery_tag, requeue=True)
9093
elif isinstance(exception, AlreadyExists | InvalidFormError | IntegrityError | Collection.DoesNotExist):
9194
logger.exception("%s maybe caused by duplicate message %r, skipping", type(exception).__name__, body)

tests/test_util.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,17 @@ def test_foreign_key_violation_shuts_down(self, add_callback_threadsafe, nack):
6060
add_callback_threadsafe.assert_called_once_with(state.connection, state.interrupt)
6161
nack.assert_not_called()
6262

63+
@patch("process.util.time.sleep")
6364
@patch("process.util.nack")
6465
@patch("process.util.add_callback_threadsafe")
65-
def test_deadlock_requeues(self, add_callback_threadsafe, nack):
66+
def test_deadlock_requeues(self, add_callback_threadsafe, nack, sleep):
6667
exception = OperationalError("deadlock detected")
6768
exception.__cause__ = errors.DeadlockDetected("deadlock detected")
6869

6970
state, channel, method = self.run_errback(exception)
7071

7172
add_callback_threadsafe.assert_not_called()
73+
sleep.assert_called_once()
7274
nack.assert_called_once_with(state, channel, method.delivery_tag, requeue=True)
7375

7476
@patch("process.util.nack")

0 commit comments

Comments
 (0)