Skip to content

Commit 2e33361

Browse files
authored
fix(reporting): bound PostgreSQL worker lock waits and retries (#1250)
* fix(reporting): bound PostgreSQL worker lock waits and retries * test(reporting): keep worker timeout outside owned transaction * test(reporting): deliver monitored child input before polling
1 parent 4608ad2 commit 2e33361

9 files changed

Lines changed: 597 additions & 38 deletions

‎docs/reporting-production.md‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -249,11 +249,32 @@ cycle. Timed-out waits roll back to a savepoint; cancellation propagates and the
249249
previous timeout setting is restored. Sampling advances past busy prefixes;
250250
lease eligibility and durable fairness ranks remain transactional. Release
251251
waits for its account and still requires the original worker and expiry fence.
252+
Both shared and admitted producer acquisitions apply a transaction-local
253+
five-second `lock_timeout` before sampling or locking. Account-lock operations
254+
apply the same cap before their advisory lock and subsequent row, checkpoint,
255+
foreign-key and trigger work. A smaller configured timeout remains in force;
256+
commit or rollback restores the session setting. The admitted producer also
257+
uses `FOR UPDATE SKIP LOCKED` after its account try-lock, so a configuration
258+
locked by another transaction cannot hold up the rest of its sample. Neither
259+
path takes a configuration row before the account lock; the unchanged
260+
`reporting_materializer_source_dirty()` trigger re-enters that same lock.
252261
The lock-order regression keeps a frozen copy of the old row-first acquisition
253262
as a negative control, proves a real PostgreSQL deadlock and rollback, then proves
254263
both activation and a subsequent production producer turn complete. Separate
255264
regressions exercise the public shared-store paths without replacing SDK methods.
256-
Neither control adds runtime deadlock retries.
265+
Standalone `ReportingProducer.run_worker()` makes at most three attempts for
266+
PostgreSQL `DeadlockDetected` (`40P01`) or `LockNotAvailable` (`55P03`), waiting
267+
50 ms then 100 ms between attempts. The failed transaction rolls back before
268+
retry. A committed lease is released under its original worker/expiry fence,
269+
and the next attempt reacquires and rechecks eligibility; a competing worker's
270+
new lease cannot be stolen. Lease release has its own three-attempt bound.
271+
Exhausted release retries propagate without starting another turn. Immutable
272+
content conflicts, other errors and cancellation are never retried or replaced
273+
by a cleanup lock error. Calls inside an adopter-owned `store.transaction()`
274+
propagate lock errors for the adopter to roll back; the SDK does not retry that
275+
outer transaction. Source dispatch and authorization checks run afresh on a
276+
retried turn; committed revisions, observations and checkpoints retain their
277+
existing identity and immutability checks.
257278
Lease acquisition, release and recovery are bookkeeping, not new reporting
258279
observations or materializer targets. The production PostgreSQL path retains
259280
the inherited trigger and cancels only its lease-only candidate increment in
@@ -343,11 +364,11 @@ finish under its existing transaction and deadline rules. Drain with `aclose()`,
343364
repair the failed component and construct fresh support to recover; the failed
344365
instance never silently restarts or regains its capability claim. A failing log
345366
sink cannot prevent the stop latch or change the safe public error.
346-
A PostgreSQL deadlock escaping an owned worker follows this same fail-stop
347-
policy after its transaction rolls back; there is no automatic deadlock retry.
348-
Investigate the competing writer's lock order, drain the failed support, and
349-
construct fresh support after correcting it. Retained pending work and fencing
350-
rules continue to govern recovery.
367+
A PostgreSQL lock failure that exhausts the producer's bounded retries follows
368+
this same fail-stop policy. Other owned worker boundaries still propagate their
369+
errors without automatic retries. Investigate the competing writer's lock order,
370+
drain the failed support, and construct fresh support after correcting it.
371+
Retained pending work and fencing rules continue to govern recovery.
351372
The [receipt ingress](reporting-receipt-ingress.md),
352373
[frozen feed](reporting-frozen-feed.md) and original materializer recovery
353374
contracts continue to apply.

‎docs/reporting-release-notes.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,24 @@
66
follows AdCP 3.2.0 final. The release candidate includes AdCP 3.0, 3.1 and
77
3.2.0-rc.7 schema bundles.
88

9+
## PostgreSQL worker contention
10+
11+
Producer account and lease operations now cap lock waits at five seconds per
12+
lock, using transaction-local settings and respecting shorter adopter limits.
13+
The admitted producer skips locked configuration rows after acquiring the
14+
account lock, including when the materializer trigger is installed.
15+
Standalone `ReportingProducer.run_worker()` retries only PostgreSQL deadlock
16+
and lock-timeout failures, with at most three attempts and 50/100 ms backoff.
17+
Every retry follows rollback and fenced lease release, then reacquires a lease;
18+
it does not reuse a cancelled lease or bypass immutable-content validation.
19+
An adopter-owned outer transaction is not retried. Persistent failures still
20+
reach the existing worker failure boundary.
21+
22+
These are internal worker retries, not transport request retries. They do not
23+
change request idempotency keys or make unpinned `sync_reporting_status` calls
24+
share an idempotency-cache entry. See the
25+
[production recovery rules](reporting-production.md#recovery-and-operational-boundaries).
26+
927
## Account-qualified configuration identity
1028

1129
`ReportingConfiguration.generation_key` now returns the frozen

‎src/adcp/reporting/ledger/pg.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,9 +447,31 @@ async def _append_change(
447447
(account_id, kind, record_id, self._clock() if self._clock is not None else None),
448448
)
449449

450+
@staticmethod
451+
async def _bound_lock_waits(connection: Any) -> None:
452+
# set_config(..., true) is SET LOCAL: the cap ends with this transaction
453+
# (or savepoint rollback) and never lengthens an adopter's shorter limit.
454+
# SKIP LOCKED does not skip relation, FK, trigger, or advisory-lock waits.
455+
await connection.execute(
456+
"SELECT set_config('lock_timeout',"
457+
" LEAST(COALESCE(NULLIF(setting::integer,0),5000),5000)::text,true)"
458+
" FROM pg_settings WHERE name='lock_timeout'"
459+
)
460+
461+
def _worker_lock_errors(self) -> tuple[type[Exception], ...]:
462+
from psycopg.errors import DeadlockDetected, LockNotAvailable
463+
464+
bound = _BOUND_CONNECTION.get()
465+
if bound is not None and bound[:2] == (self._pool, asyncio.current_task()):
466+
# The adopter owns the outer rollback and any locks acquired before
467+
# our savepoint. Only standalone worker turns may reacquire a lease.
468+
return ()
469+
return (DeadlockDetected, LockNotAvailable)
470+
450471
@staticmethod
451472
async def _lock_account(connection: Any, account_id: str) -> None:
452473
"""Serialize this account's feed appends so seq order == commit order."""
474+
await PgReportingLedgerStore._bound_lock_waits(connection)
453475
await connection.execute(
454476
"SELECT pg_advisory_xact_lock(hashtext(%s))", (f"adcp.reporting:{account_id}",)
455477
)
@@ -2080,6 +2102,7 @@ async def lease_period_close(
20802102
# account acquisition, including acquisitions that find a stale row.
20812103
can_wait = connection.info.transaction_status == TransactionStatus.IDLE
20822104
async with connection.transaction():
2105+
await self._bound_lock_waits(connection)
20832106
# A materializer installed by any participant adds an AFTER UPDATE
20842107
# trigger taking the account lock. Sample without row locks, then take
20852108
# that account lock before locking a configuration. The base store can

‎src/adcp/reporting/ledger/producer.py‎

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -367,21 +367,71 @@ async def run_worker(self) -> WorkerTurn:
367367
"""Run one leased turn. Safe to call from cron, a loop, or a supervisor.
368368
369369
Returns immediately with an empty turn when nothing is leasable, so a
370-
caller can back off rather than spin.
370+
caller can back off rather than spin. Standalone PostgreSQL turns retry
371+
deadlocks and lock timeouts at most twice, after rollback and fenced
372+
release. Each retry must acquire a new lease; other failures propagate.
371373
"""
372-
with source_turn():
373-
return await self._run_leased_turn()
374+
from adcp.reporting.ledger.pg import PgReportingLedgerStore
375+
376+
retryable = (
377+
self._store._worker_lock_errors()
378+
if isinstance(self._store, PgReportingLedgerStore)
379+
else ()
380+
)
381+
for attempt in range(3):
382+
leased = None
383+
with source_turn():
384+
try:
385+
now = self._clock()
386+
leased = await self._store.lease_period_close(
387+
worker_id=self._worker_id, now=now, lease_seconds=self._lease_seconds
388+
)
389+
turn = WorkerTurn(leased=leased)
390+
if leased is None:
391+
return turn
392+
await self._work_leased_configuration(leased, turn, now=now)
393+
except retryable:
394+
# Store transactions have already exited/rolled back. A
395+
# committed lease is released only under its original fence;
396+
# a competitor may win before the next acquisition.
397+
if leased is not None:
398+
await self._release_worker_lease(leased, retryable)
399+
if attempt == 2:
400+
raise
401+
except BaseException as original:
402+
if leased is not None:
403+
try:
404+
await self._release_worker_lease(leased, retryable)
405+
except retryable:
406+
# Never turn an immutable-content conflict or
407+
# cancellation into a retryable cleanup failure.
408+
raise original from None
409+
raise
410+
else:
411+
# Exhausted release retries escape from here, rather than
412+
# starting another turn or reporting a false empty success.
413+
await self._release_worker_lease(leased, retryable)
414+
return turn
415+
await asyncio.sleep(0.05 * (2**attempt))
416+
raise AssertionError("unreachable worker retry") # pragma: no cover
417+
418+
async def _release_worker_lease(
419+
self, leased: LeasedConfiguration, retryable: tuple[type[Exception], ...]
420+
) -> None:
421+
for attempt in range(3):
422+
try:
423+
await self._store.release_period_close(leased, worker_id=self._worker_id)
424+
return
425+
except retryable:
426+
if attempt == 2:
427+
raise
428+
await asyncio.sleep(0.05 * (2**attempt))
374429

375-
async def _run_leased_turn(self) -> WorkerTurn:
430+
async def _work_leased_configuration(
431+
self, leased: LeasedConfiguration, turn: WorkerTurn, *, now: datetime
432+
) -> None:
376433
from adcp.reporting.production.contracts import _SourceAuthorizationRevokedError
377434

378-
now = self._clock()
379-
leased = await self._store.lease_period_close(
380-
worker_id=self._worker_id, now=now, lease_seconds=self._lease_seconds
381-
)
382-
turn = WorkerTurn(leased=leased)
383-
if leased is None:
384-
return turn
385435
try:
386436
configuration = next(
387437
(
@@ -395,17 +445,14 @@ async def _run_leased_turn(self) -> WorkerTurn:
395445
None,
396446
)
397447
if configuration is None:
398-
return turn
448+
return
399449
require_account_work(configuration.account_id)
400450
await self._close_elapsed_periods(configuration, turn, now=now)
401451
await self._acquire_pending(configuration, turn, now=now)
402452
except _SourceAuthorizationRevokedError:
403453
# Keep pending acquisitions retryable. Authorization may be
404454
# restored on the next turn; it is not a history failure.
405-
return turn
406-
finally:
407-
await self._store.release_period_close(leased, worker_id=self._worker_id)
408-
return turn
455+
return
409456

410457
async def run_configuration(
411458
self,

‎src/adcp/reporting/production/pg.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ async def lease_period_close(
273273
following: _ProducerSample | None = None
274274
result = None
275275
async with self._connection() as connection, connection.transaction():
276+
await self._bound_lock_waits(connection)
276277
# Discover a bounded set without locking configuration rows. The
277278
# inherited configuration trigger takes the account lock, so that
278279
# lock must precede the row lock here, just as it does in activation.
@@ -357,9 +358,12 @@ async def lease_period_close(
357358
acquired = await (
358359
await connection.execute(
359360
"UPDATE reporting_configurations SET lease_worker_id=%s,lease_expires_at=%s"
360-
" WHERE account_id=%s AND delivery_config_id=%s"
361-
" AND delivery_config_version=%s"
362-
" AND (lease_expires_at IS NULL OR lease_expires_at<=%s)"
361+
" WHERE (account_id,delivery_config_id,delivery_config_version) = ("
362+
" SELECT c.account_id,c.delivery_config_id,c.delivery_config_version"
363+
" FROM reporting_configurations c WHERE c.account_id=%s"
364+
" AND c.delivery_config_id=%s AND c.delivery_config_version=%s"
365+
" AND (c.lease_expires_at IS NULL OR c.lease_expires_at<=%s)"
366+
" FOR UPDATE OF c SKIP LOCKED)"
363367
" RETURNING account_id",
364368
(worker_id, expires, *row, moment),
365369
)

‎tests/conformance/reporting/test_reporting_notification_packaging.py‎

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
import subprocess
1212
import sys
1313
import tarfile
14+
import tempfile
1415
import time
1516
import zipfile
17+
from contextlib import nullcontext
1618
from pathlib import Path
1719

1820
import pytest
@@ -76,19 +78,32 @@ def run_step(command, *, label, cwd, value=None, timeout=120, progress=None):
7678
if progress is not None:
7779
environment["PGAPPNAME"] = progress.application
7880
command = progress.command(command)
79-
process = subprocess.Popen(
80-
command,
81-
cwd=cwd,
82-
stdin=subprocess.PIPE,
83-
stdout=subprocess.PIPE,
84-
stderr=subprocess.PIPE,
85-
text=True,
86-
start_new_session=True,
87-
env=environment,
81+
payload = json.dumps(value) if value is not None else None
82+
# Progress polling may time out before communicate finishes writing a large
83+
# input. Give monitored children a complete private input file and EOF so a
84+
# slow reader cannot strand the remaining input between polling calls.
85+
input_context = (
86+
tempfile.TemporaryFile(mode="w+", encoding="utf-8")
87+
if progress is not None and payload is not None
88+
else nullcontext(None)
8889
)
90+
with input_context as input_file:
91+
if input_file is not None and payload is not None:
92+
input_file.write(payload)
93+
input_file.seek(0)
94+
payload = None
95+
process = subprocess.Popen(
96+
command,
97+
cwd=cwd,
98+
stdin=input_file if input_file is not None else subprocess.PIPE,
99+
stdout=subprocess.PIPE,
100+
stderr=subprocess.PIPE,
101+
text=True,
102+
start_new_session=True,
103+
env=environment,
104+
)
89105
print(f"notification_distribution stage={label} pid={process.pid} started", flush=True)
90106
try:
91-
payload = json.dumps(value) if value is not None else None
92107
if progress is None:
93108
stdout, stderr = process.communicate(payload, timeout=timeout)
94109
else:

‎tests/conformance/reporting/test_reporting_production_harness.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Deliberate installed-child stalls retain phase evidence and drain ownership."""
22

3+
import hashlib
34
import json
45
import os
56
import secrets
@@ -84,6 +85,28 @@ def test_installed_progress_preserves_json_protocol_and_real_pytest_phase_timing
8485
assert events[-1]["completed"] == 1
8586

8687

88+
def test_installed_progress_delivers_large_input_after_multiple_polls(tmp_path, capsys):
89+
value = {"manifest": "private-manifest-canary" + "\u03bb" * 65536}
90+
raw = json.dumps(value).encode()
91+
body = (
92+
"import hashlib,json,sys,time;time.sleep(0.25);"
93+
"raw=sys.stdin.buffer.read();"
94+
"print(json.dumps({'bytes':len(raw),'sha256':hashlib.sha256(raw).hexdigest()}))"
95+
)
96+
result = run_step(
97+
[sys.executable, "-I", "-c", body],
98+
label="delayed_large_input",
99+
cwd=tmp_path,
100+
value=value,
101+
timeout=10,
102+
progress=ProgressMonitor(
103+
tmp_path / "progress.json", phase_seconds={"startup": 5}, poll_seconds=0.02
104+
),
105+
)
106+
assert json.loads(result) == {"bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest()}
107+
assert "private-manifest-canary" not in capsys.readouterr().out
108+
109+
87110
@pytest.mark.skipif(not Path("/proc").is_dir(), reason="Linux process ownership diagnostics")
88111
@pytest.mark.parametrize("new_session", [False, True])
89112
def test_installed_phase_deadline_reports_active_case_and_kills_descendant(

‎tests/conformance/reporting/test_reporting_production_lock_order.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ async def test_activation_and_producer_actual_trigger_lock_order(
3131
support, store, item = h.production, h.store, h.item
3232
locked, release, executing = asyncio.Event(), asyncio.Event(), asyncio.Event()
3333
pids = {}
34+
detected_deadlocks = []
3435
original_step = support.projection._activation_step_on
3536
original_execute = psycopg.AsyncConnection.execute
3637
original_lease = store.lease_period_close.__func__
@@ -53,7 +54,11 @@ async def observe(connection, query, *args, **kwargs):
5354
):
5455
pids["producer"] = connection.info.backend_pid
5556
executing.set()
56-
return await original_execute(connection, query, *args, **kwargs)
57+
try:
58+
return await original_execute(connection, query, *args, **kwargs)
59+
except psycopg.errors.DeadlockDetected:
60+
detected_deadlocks.append(connection.info.backend_pid)
61+
raise
5762

5863
async def wait_for_actual_trigger_wait():
5964
await asyncio.wait_for(executing.wait(), 5)
@@ -128,7 +133,10 @@ async def wait_for_actual_trigger_wait():
128133
await asyncio.gather(*tasks, return_exceptions=True)
129134
assert store.lease_period_close.__func__ is original_lease
130135
deadlocks = [r for r in results if isinstance(r, psycopg.errors.DeadlockDetected)]
131-
assert len(deadlocks) == int(wrong_order)
136+
# A producer victim now retries only after its real transaction rolls
137+
# back. Activation still propagates its cancellation to the caller.
138+
# Count the actual server error, including an internally retried victim.
139+
assert len(detected_deadlocks) == int(wrong_order)
132140
assert all(not isinstance(r, BaseException) or r in deadlocks for r in results)
133141

134142
async with h.pool.connection() as c:
@@ -186,7 +194,7 @@ async def wait_for_actual_trigger_wait():
186194
"production_lock_order": "wrong_order_control" if wrong_order else "restored",
187195
"actual_trigger": True,
188196
"observed_trigger_wait": wrong_order,
189-
"deadlocks": len(deadlocks),
197+
"deadlocks": len(detected_deadlocks),
190198
"backend_pids": pids,
191199
"at": datetime.now(timezone.utc).isoformat(),
192200
"rollback_or_commit_verified": True,

0 commit comments

Comments
 (0)