Skip to content

Commit 3e20041

Browse files
committed
feat(jobs): add JOB_LOG_PERSIST_ENABLED flag + local repro docs
Replace the unconditional noop of JobLogHandler.emit from the previous commit with a feature flag (default True, preserves existing behavior). Deployments hitting row-lock contention on jobs_job can set JOB_LOG_PERSIST_ENABLED=false to short-circuit the per-record UPDATE until PR #1259's append-only JobLog table is in place. Validated locally (dev compose, WEB_CONCURRENCY=1, 8 ml fork workers, batched POSTs at 50 results × 10 concurrent): flag=true: blocker_chain=37, 0/10 POSTs complete (120s timeout) flag=false: blocker_chain=1 (trivial), 20/20 POSTs complete, p95=5.5s Also add: - scripts/load_test_result_endpoint.py — standalone batched-POST driver. A single-result-per-POST curl loop does NOT reproduce the contention because it skips the per-iter job.logger.info call inside one ATOMIC_REQUESTS tx. Batching N>1 results per POST is load-bearing. - docs/claude/debugging/row-lock-contention-reproduction.md — full runbook: pathology, prereqs, steps, pg_stat_activity queries, before/after signal table, flag usage.
1 parent 173d1d4 commit 3e20041

4 files changed

Lines changed: 308 additions & 10 deletions

File tree

ami/jobs/models.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pydantic
99
from celery import uuid
1010
from celery.result import AsyncResult
11+
from django.conf import settings
1112
from django.db import models, transaction
1213
from django.utils.text import slugify
1314
from django_pydantic_field import SchemaField
@@ -336,16 +337,41 @@ def emit(self, record: logging.LogRecord):
336337
# Log to the current app logger (container stdout).
337338
logger.log(record.levelno, self.format(record))
338339

339-
# HOTFIX 2026-04-20: Persisting every log line to ``jobs_job.logs`` is
340-
# the dominant remaining source of row-lock contention under concurrent
341-
# async_api load. Every call triggered ``UPDATE jobs_job SET logs = ...``
342-
# on the shared job row; inside ``ATOMIC_REQUESTS`` a single batched
343-
# ``/result`` POST stacked N such UPDATEs in one tx, blocking every ML
344-
# worker on the same row for the duration of the request. Short-circuit
345-
# here until PR #1259 lands an append-only ``JobLog`` child table.
346-
# Container stdout above still captures every line; only the per-job
347-
# UI log view goes blank while this hotfix is active.
348-
return
340+
# Gated by ``JOB_LOG_PERSIST_ENABLED`` (default True). Persisting every
341+
# log line to ``jobs_job.logs`` becomes a row-lock contention point
342+
# under concurrent async_api load — each call triggers
343+
# ``UPDATE jobs_job SET logs = ...`` on the shared job row, and inside
344+
# ``ATOMIC_REQUESTS`` a single batched ``/result`` POST stacks N such
345+
# UPDATEs in one tx, blocking every ML worker on the same row for the
346+
# duration of the request. Deployments hitting that pattern can set the
347+
# flag to False to short-circuit here until PR #1259 lands an
348+
# append-only ``JobLog`` child table. See issue #1256.
349+
if not getattr(settings, "JOB_LOG_PERSIST_ENABLED", True):
350+
return
351+
352+
# Write to the logs field on the job instance.
353+
# Refresh from DB first to reduce the window for concurrent overwrites — each
354+
# worker holds its own stale in-memory copy of `logs`, so without a refresh the
355+
# last writer always wins and earlier entries are silently dropped.
356+
# @TODO consider saving logs to the database periodically rather than on every log
357+
try:
358+
self.job.refresh_from_db(fields=["logs"])
359+
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
360+
msg = f"[{timestamp}] {record.levelname} {self.format(record)}"
361+
if msg not in self.job.logs.stdout:
362+
self.job.logs.stdout.insert(0, msg)
363+
364+
# Write a simpler copy of any errors to the errors field
365+
if record.levelno >= logging.ERROR:
366+
if record.message not in self.job.logs.stderr:
367+
self.job.logs.stderr.insert(0, record.message)
368+
369+
if len(self.job.logs.stdout) > self.max_log_length:
370+
self.job.logs.stdout = self.job.logs.stdout[: self.max_log_length]
371+
372+
self.job.save(update_fields=["logs"], update_progress=False)
373+
except Exception as e:
374+
logger.error(f"Failed to save logs for job #{self.job.pk}: {e}")
349375

350376

351377
@dataclass

config/settings/base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,3 +568,12 @@ def _celery_result_backend_url(redis_url):
568568
# Default taxa filters
569569
DEFAULT_INCLUDE_TAXA = env.list("DEFAULT_INCLUDE_TAXA", default=[]) # type: ignore[no-untyped-call]
570570
DEFAULT_EXCLUDE_TAXA = env.list("DEFAULT_EXCLUDE_TAXA", default=[]) # type: ignore[no-untyped-call]
571+
572+
# When True, ``JobLogHandler.emit`` persists each log line to ``jobs_job.logs``
573+
# (JSONB column) so the per-job log feed in the UI stays populated. When False,
574+
# log lines go to the container stdout logger only — used as an escape hatch
575+
# under concurrent async_api load where the per-record UPDATE on ``jobs_job.logs``
576+
# becomes a row-lock contention point (see issue #1256, PR #1261). Default True
577+
# preserves existing behavior; deployments seeing contention can set to False
578+
# until the append-only ``JobLog`` child table (PR #1259) is in place.
579+
JOB_LOG_PERSIST_ENABLED = env.bool("JOB_LOG_PERSIST_ENABLED", default=True) # type: ignore[no-untyped-call]
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
# Reproducing the `jobs_job` row-lock contention locally
2+
3+
Runbook for reproducing, on a local dev stack, the row-lock contention that
4+
affects concurrent `async_api` ML jobs. Context: issue #1256, PR #1261, and
5+
PR #1259 (complementary `JobLog` table refactor).
6+
7+
**Why this matters.** Naive repro attempts with a `curl` loop that fires one
8+
result per POST (`{"results": [{...}]}`) do NOT trigger the pathology. They
9+
only exercise the worker-side `select_for_update` path, which is fixed once
10+
PR #1261 lands. The dominant remaining bottleneck is per-result logging
11+
inside `ATOMIC_REQUESTS` — to see it locally you need **batched POSTs** that
12+
match the real ADC shape (`AMI_LOCALIZATION_BATCH_SIZE=4`,
13+
`AMI_CLASSIFICATION_BATCH_SIZE=150`).
14+
15+
## The pathology
16+
17+
Two mutating paths UPDATE the `jobs_job` row for every log line written via
18+
`job.logger.info(...)`:
19+
20+
1. **View path** (`ami/jobs/views.py``result` and `tasks` actions): the
21+
per-iteration `job.logger.info("Queued pipeline result: ...")` inside the
22+
POST body loop runs under `ATOMIC_REQUESTS`. A single batched POST with N
23+
results therefore stacks N UPDATEs on `jobs_job.logs` inside one tx that
24+
doesn't commit until the view returns. Every other writer on the same row
25+
(other worker tasks, other POST handlers) blocks behind it.
26+
2. **Worker path** (`ami/jobs/tasks.py``_update_job_progress`): each
27+
`process_nats_pipeline_result` celery task calls `_update_job_progress`,
28+
which emits its own log lines, each triggering another UPDATE on the same
29+
row.
30+
31+
The smoking gun in `pg_stat_activity`:
32+
33+
- Root blocker: a backend `state = idle in transaction`, last query
34+
`UPDATE "jobs_job" SET "logs" = ...`, held for many seconds.
35+
- Waiters: dozens of backends with `wait_event_type = Lock`,
36+
`wait_event = tuple` or `transactionid`, all on the same row.
37+
38+
## Prereqs
39+
40+
- Local antenna stack up via the standard dev compose
41+
(`docker compose up -d`) with postgres, redis, rabbitmq, nats, django,
42+
celeryworker, and celeryworker_ml healthy.
43+
- A job in a running state (any `async_api` job with `status = STARTED` will
44+
do — the view accepts results regardless of whether real tasks exist).
45+
- An auth token for a user with permission to POST to
46+
`/api/v2/jobs/{id}/result/`.
47+
- Python 3.10+ on the host (the load-test script uses stdlib only).
48+
49+
## Scripts
50+
51+
- `scripts/load_test_result_endpoint.py` — fires concurrent batched POSTs.
52+
- `ami/jobs/management/commands/chaos_monkey.py` — adjacent tooling for
53+
`async_api` chaos scenarios; covered in `chaos-scenarios.md`.
54+
55+
## Step-by-step
56+
57+
### 1. Grab an auth token and a target job
58+
59+
From a shell on the host:
60+
61+
```bash
62+
docker compose exec -T django python manage.py shell <<'PY'
63+
from rest_framework.authtoken.models import Token
64+
from ami.users.models import User
65+
from ami.jobs.models import Job
66+
67+
u = User.objects.filter(is_staff=True).first()
68+
t, _ = Token.objects.get_or_create(user=u)
69+
print("TOKEN=", t.key)
70+
71+
j = Job.objects.filter(status="STARTED", dispatch_mode="async_api").first()
72+
if j is None:
73+
# Any running job works — create one if there isn't one.
74+
# Adjust project/collection/pipeline PKs to your local data.
75+
print("No running async_api job found; create one via the UI or shell.")
76+
else:
77+
print("JOB_ID=", j.pk)
78+
PY
79+
```
80+
81+
If no running job exists, create one with whatever project/collection/pipeline
82+
are seeded locally. The view does not need real tasks queued behind the
83+
job — it only needs the job row to accept result POSTs.
84+
85+
### 2. Fire batched POSTs
86+
87+
```bash
88+
python scripts/load_test_result_endpoint.py <JOB_ID> <TOKEN> \
89+
--batch 50 --concurrency 10 --rounds 3
90+
```
91+
92+
`--batch 50` puts 50 `PipelineResultsError` entries in each POST body. Any
93+
batch size >1 will stack UPDATEs; 50 is a comfortable reproduction size
94+
because it makes each POST's tx hold long enough for others to pile up.
95+
`--concurrency 10` fires 10 parallel POSTs per wave. `--rounds 3` fires
96+
three back-to-back waves.
97+
98+
### 3. Monitor Postgres during the test
99+
100+
In a second shell:
101+
102+
```bash
103+
docker exec <postgres-container> psql -U <user> -d <db> <<'SQL'
104+
-- Scalars
105+
SELECT count(*) AS idle_in_tx
106+
FROM pg_stat_activity
107+
WHERE datname = current_database() AND state = 'idle in transaction';
108+
109+
SELECT count(*) AS blocker_chain
110+
FROM pg_stat_activity blocked
111+
JOIN pg_stat_activity blocking
112+
ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
113+
WHERE blocked.wait_event_type = 'Lock'
114+
AND blocked.datname = current_database();
115+
116+
-- Top offenders
117+
SELECT state, wait_event,
118+
substring(query, 1, 80),
119+
EXTRACT(EPOCH FROM now() - xact_start) AS xact_age_s
120+
FROM pg_stat_activity
121+
WHERE datname = current_database()
122+
AND state != 'idle'
123+
AND (state = 'idle in transaction' OR wait_event_type = 'Lock')
124+
ORDER BY xact_start NULLS LAST
125+
LIMIT 20;
126+
SQL
127+
```
128+
129+
### 4. Before/after signatures
130+
131+
Measured on a local dev stack with WEB_CONCURRENCY=1 (gunicorn default) and
132+
8 celery ML-fork workers, batch=50, concurrency=10.
133+
134+
| Signal | PR #1261 only (`JOB_LOG_PERSIST_ENABLED=true`) | PR #1261 + flag off (`JOB_LOG_PERSIST_ENABLED=false`) |
135+
|---|---|---|
136+
| `blocker_chain` count | 30+ | 0–1 (transient) |
137+
| `idle_in_tx` count | 8–10 | 0 |
138+
| Root-blocker query | `UPDATE jobs_job SET logs = ...` held 2–60s | transient `SELECT`s only |
139+
| POST success (10 concurrent × 50-result batch, 120s timeout) | 0/10 (all timeout) | 10/10 |
140+
| p95 POST latency | 120s+ | ~5s |
141+
142+
## The feature flag
143+
144+
Setting `JOB_LOG_PERSIST_ENABLED=false` (env var on the Django container)
145+
causes `JobLogHandler.emit` to write only to the container stdout logger and
146+
skip the per-record UPDATE on `jobs_job.logs`. The per-job UI log feed
147+
stops receiving new entries while the flag is off; container stdout still
148+
captures everything.
149+
150+
Default is `true` — existing deployments keep their current behavior. The
151+
flag is intended as a time-bounded escape hatch until the append-only
152+
`JobLog` child table from PR #1259 is in place.
153+
154+
To test the flag locally, append `JOB_LOG_PERSIST_ENABLED=false` to the
155+
django env file used by your compose (e.g. `.envs/.local/.django`) and
156+
recreate the django container (`docker compose up -d --force-recreate
157+
django`). Verify from a shell:
158+
159+
```bash
160+
docker compose exec -T django python -c \
161+
"from django.conf import settings; print(settings.JOB_LOG_PERSIST_ENABLED)"
162+
```
163+
164+
## Related
165+
166+
- Issue #1256 — full contention analysis with path breakdown.
167+
- PR #1261 — drops `select_for_update` in `_update_job_progress`; adds the
168+
`JOB_LOG_PERSIST_ENABLED` flag; this runbook.
169+
- PR #1259 — append-only `JobLog` child table. When merged, the flag can be
170+
removed in favor of a cutover to the new write path.
171+
- `docs/claude/debugging/chaos-scenarios.md` — adjacent chaos tooling for
172+
NATS redelivery and retry-path validation.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
#!/usr/bin/env python3
2+
"""Fire concurrent batched POSTs against ``POST /api/v2/jobs/{id}/result/``.
3+
4+
Reproduces the row-lock contention pathology described in
5+
``docs/claude/debugging/row-lock-contention-reproduction.md``. Each POST body
6+
contains N fake ``PipelineResultsError`` entries so the per-result
7+
``job.logger.info(...)`` call inside ``ATOMIC_REQUESTS`` stacks N UPDATEs on
8+
``jobs_job.logs`` in a single view transaction — the shape real ADC workers
9+
produce (``AMI_LOCALIZATION_BATCH_SIZE=4``, ``AMI_CLASSIFICATION_BATCH_SIZE=150``).
10+
11+
A single-result-per-POST loop does NOT reproduce the contention. Batching
12+
is load-bearing.
13+
14+
Usage:
15+
16+
python scripts/load_test_result_endpoint.py <job_id> <token> \\
17+
[--batch 50] [--concurrency 10] [--rounds 3] \\
18+
[--host http://localhost:8000]
19+
20+
Dependencies: Python 3.10+, stdlib only.
21+
"""
22+
import argparse
23+
import concurrent.futures
24+
import json
25+
import time
26+
import urllib.error
27+
import urllib.request
28+
import uuid
29+
30+
31+
def make_body(batch_size: int, prefix: str) -> bytes:
32+
results = [
33+
{
34+
"reply_subject": f"{prefix}.r{i}.{uuid.uuid4().hex[:8]}",
35+
"result": {"error": "load-test", "image_id": f"img-{prefix}-{i}"},
36+
}
37+
for i in range(batch_size)
38+
]
39+
return json.dumps({"results": results}).encode()
40+
41+
42+
def fire_one(url: str, token: str, body: bytes, idx: int) -> tuple[int, int, float]:
43+
req = urllib.request.Request(
44+
url,
45+
data=body,
46+
headers={"Authorization": f"Token {token}", "Content-Type": "application/json"},
47+
method="POST",
48+
)
49+
t0 = time.time()
50+
try:
51+
with urllib.request.urlopen(req, timeout=120) as resp:
52+
return (idx, resp.status, time.time() - t0)
53+
except urllib.error.HTTPError as e:
54+
return (idx, e.code, time.time() - t0)
55+
except Exception:
56+
return (idx, -1, time.time() - t0)
57+
58+
59+
def main():
60+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
61+
ap.add_argument("job_id", type=int, help="Target Job.pk (must be in a running state)")
62+
ap.add_argument("token", help="DRF auth Token for a user with result-POST permission")
63+
ap.add_argument("--batch", type=int, default=50, help="results per POST body (default 50)")
64+
ap.add_argument("--concurrency", type=int, default=10, help="parallel POSTs per round (default 10)")
65+
ap.add_argument("--rounds", type=int, default=3, help="how many waves to fire (default 3)")
66+
ap.add_argument("--host", default="http://localhost:8000", help="API host (default localhost:8000)")
67+
args = ap.parse_args()
68+
69+
url = f"{args.host}/api/v2/jobs/{args.job_id}/result/"
70+
print(f"url={url} batch={args.batch} concurrency={args.concurrency} rounds={args.rounds}")
71+
72+
t_start = time.time()
73+
for round_idx in range(args.rounds):
74+
with concurrent.futures.ThreadPoolExecutor(max_workers=args.concurrency) as ex:
75+
futures = [
76+
ex.submit(fire_one, url, args.token, make_body(args.batch, f"r{round_idx}_{i}"), i)
77+
for i in range(args.concurrency)
78+
]
79+
results = [f.result() for f in concurrent.futures.as_completed(futures)]
80+
good = sum(1 for _, s, _ in results if s == 200)
81+
latencies = sorted([lat for _, _, lat in results])
82+
p50 = latencies[len(latencies) // 2]
83+
p95 = latencies[int(len(latencies) * 0.95)]
84+
print(
85+
f"round {round_idx}: ok={good}/{args.concurrency} "
86+
f"p50={p50:.2f}s p95={p95:.2f}s elapsed={time.time() - t_start:.1f}s"
87+
)
88+
89+
90+
if __name__ == "__main__":
91+
main()

0 commit comments

Comments
 (0)