Skip to content

Commit 8580069

Browse files
carlosgjscarlos-irreverentlabsmihowclaudeCopilot
authored
Avoid redis based locking by using atomic redis primitives (#1150)
* fix: PSv2 follow-up fixes from integration tests (#1135) * fix: prevent NATS connection flooding and stale job task fetching - Add connect_timeout=5, allow_reconnect=False to NATS connections to prevent leaked reconnection loops from blocking Django's event loop - Guard /tasks endpoint against terminal-status jobs (return empty tasks instead of attempting NATS reserve) - IncompleteJobFilter now excludes jobs by top-level status in addition to progress JSON stages - Add stale worker cleanup to integration test script Found during PSv2 integration testing where stale ADC workers with default DataLoader parallelism overwhelmed the single uvicorn worker thread by flooding /tasks with concurrent NATS reserve requests. Co-Authored-By: Claude <noreply@anthropic.com> * docs: PSv2 integration test session notes and NATS flooding findings Session notes from 2026-02-16 integration test including root cause analysis of stale worker task competition and NATS connection issues. Findings doc tracks applied fixes and remaining TODOs with priorities. Co-Authored-By: Claude <noreply@anthropic.com> * docs: update session notes with successful test run #3 PSv2 integration test passed end-to-end (job 1380, 20/20 images). Identified ack_wait=300s as cause of ~5min idle time when GPU processes race for NATS tasks. Co-Authored-By: Claude <noreply@anthropic.com> * fix: batch NATS task fetch to prevent HTTP timeouts Replace N×1 reserve_task() calls with single reserve_tasks() batch fetch. The previous implementation created a new pull subscription per message (320 NATS round trips for batch=64), causing the /tasks endpoint to exceed HTTP client timeouts. The new approach uses one psub.fetch() call for the entire batch. Co-Authored-By: Claude <noreply@anthropic.com> * docs: add next session prompt * feat: add pipeline__slug__in filter for multi-pipeline job queries Workers that handle multiple pipelines can now fetch jobs for all of them in a single request: ?pipeline__slug__in=slug1,slug2 Co-Authored-By: Claude <noreply@anthropic.com> * chore: remove local-only docs and scripts from branch These files are session notes, planning docs, and test scripts that should stay local rather than be part of the PR. Co-Authored-By: Claude <noreply@anthropic.com> * feat: set job dispatch_mode at creation time based on project feature flags ML jobs with a pipeline now get dispatch_mode set during setup() instead of waiting until run() is called by the Celery worker. This lets the UI show the correct mode immediately after job creation. Co-Authored-By: Claude <noreply@anthropic.com> * fix: add timeouts to all JetStream operations and restore reconnect policy Add NATS_JETSTREAM_TIMEOUT (10s) to all JetStream metadata operations via asyncio.wait_for() so a hung NATS connection fails fast instead of blocking the caller's thread indefinitely. Also restore the intended reconnect policy (2 attempts, 1s wait) that was lost in a prior force push. Co-Authored-By: Claude <noreply@anthropic.com> * fix: propagate NATS timeouts as 503 instead of swallowing them asyncio.TimeoutError from _ensure_stream() and _ensure_consumer() was caught by the broad `except Exception` in reserve_tasks(), silently returning [] and making NATS outages indistinguishable from empty queues. Workers would then poll immediately, recreating the flooding problem. - Add explicit `except asyncio.TimeoutError: raise` in reserve_tasks() - Catch TimeoutError and OSError in the /tasks view, return 503 - Restore allow_reconnect=False (fail-fast on connection issues) - Add return type annotation to get_connection() Co-Authored-By: Claude <noreply@anthropic.com> * fix: address review comments (log level, fetch timeout, docstring) - Downgrade reserve_tasks log to DEBUG when zero tasks reserved (avoid log spam from frequent polling) - Pass timeout=0.5 from /tasks endpoint to avoid blocking the worker for 5s on empty queues - Fix docstring examples using string 'job123' for int-typed job_id Co-Authored-By: Claude <noreply@anthropic.com> * fix: catch nats.errors.Error in /tasks endpoint for proper 503 responses NoServersError, ConnectionClosedError, and other NATS exceptions inherit from nats.errors.Error (not OSError), so they escaped the handler and returned 500 instead of 503. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> * PSv2: Improve task fetching & web worker concurrency configuration (#1142) * feat: configurable NATS tuning and gunicorn worker management Rebase onto main after #1135 merge. Keep only the additions unique to this branch: - Make TASK_TTR configurable via NATS_TASK_TTR Django setting (default 30s) - Make max_ack_pending configurable via NATS_MAX_ACK_PENDING setting (default 100) - Local dev: switch to gunicorn+UvicornWorker by default for production parity, with USE_UVICORN=1 escape hatch for raw uvicorn - Production: auto-detect WEB_CONCURRENCY from CPU cores (capped at 8) when not explicitly set in the environment Co-Authored-By: Claude <noreply@anthropic.com> * fix: address PR review comments - Fix max_ack_pending falsy-zero guard (use `is not None` instead of `or`) - Update TaskQueueManager docstring with Args section - Simplify production WEB_CONCURRENCY fallback (just use nproc) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Michael Bunsen <notbot@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> * fix: include pipeline_slug in MinimalJobSerializer (#1148) * fix: include pipeline_slug in MinimalJobSerializer (ids_only response) The ADC worker fetches jobs with ids_only=1 and expects pipeline_slug in the response to know which pipeline to run. Without it, Pydantic validation fails and the worker skips the job. Co-Authored-By: Claude <noreply@anthropic.com> * Update ami/jobs/serializers.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Avoid redis based locking by using atomic updates * Test concurrency * Increase max ack pending * update comment * CR feedback * CR feedback * CR 2 * fix: OrderedEnum comparisons now override str MRO in subclasses JobState(str, OrderedEnum) was using str's lexicographic __gt__ instead of OrderedEnum's definition-order __gt__, because str comes first in the MRO. This caused max(FAILURE, SUCCESS) to return SUCCESS, silently discarding failure state in concurrent job progress updates. Fix: __init_subclass__ injects comparison methods directly onto each subclass so they take MRO priority over data-type mixins. Also preserve FAILURE status through the progress ternary when progress < 1.0, so early failure detection isn't overwritten. Co-Authored-By: Claude <noreply@anthropic.com> * fix: correct misleading error log about NATS redelivery The NATS message is ACK'd at line 145, before update_state() and _update_job_progress(). If either of those raises, the except block was logging "NATS will redeliver" when it won't. Co-Authored-By: Claude <noreply@anthropic.com> * Use job.logger --------- Co-authored-by: Carlos Garcia Jurado Suarez <carlos@irreverentlabs.com> Co-authored-by: Michael Bunsen <notbot@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent b01f01b commit 8580069

7 files changed

Lines changed: 255 additions & 199 deletions

File tree

‎ami/jobs/tasks.py‎

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,13 @@ def process_nats_pipeline_result(self, job_id: int, result_data: dict, reply_sub
8484

8585
state_manager = AsyncJobStateManager(job_id)
8686

87-
progress_info = state_manager.update_state(
88-
processed_image_ids, stage="process", request_id=self.request.id, failed_image_ids=failed_image_ids
89-
)
87+
progress_info = state_manager.update_state(processed_image_ids, stage="process", failed_image_ids=failed_image_ids)
9088
if not progress_info:
91-
logger.warning(
92-
f"Another task is already processing results for job {job_id}. "
93-
f"Retrying task {self.request.id} in 5 seconds..."
94-
)
95-
raise self.retry(countdown=5, max_retries=10)
89+
logger.error(f"Redis state missing for job {job_id} — job may have been cleaned up prematurely.")
90+
# Acknowledge the task to prevent retries, since we don't know the state
91+
_ack_task_via_nats(reply_subject, logger)
92+
# TODO: cancel the job to fail fast once PR #1144 is merged
93+
return
9694

9795
try:
9896
complete_state = JobState.SUCCESS
@@ -126,6 +124,7 @@ def process_nats_pipeline_result(self, job_id: int, result_data: dict, reply_sub
126124
_ack_task_via_nats(reply_subject, logger)
127125
return
128126

127+
acked = False
129128
try:
130129
# Save to database (this is the slow operation)
131130
detections_count, classifications_count, captures_count = 0, 0, 0
@@ -145,20 +144,18 @@ def process_nats_pipeline_result(self, job_id: int, result_data: dict, reply_sub
145144
captures_count = len(pipeline_result.source_images)
146145

147146
_ack_task_via_nats(reply_subject, job.logger)
147+
acked = True
148148
# Update job stage with calculated progress
149149

150150
progress_info = state_manager.update_state(
151151
processed_image_ids,
152152
stage="results",
153-
request_id=self.request.id,
154153
)
155154

156155
if not progress_info:
157-
logger.warning(
158-
f"Another task is already processing results for job {job_id}. "
159-
f"Retrying task {self.request.id} in 5 seconds..."
160-
)
161-
raise self.retry(countdown=5, max_retries=10)
156+
job.logger.error(f"Redis state missing for job {job_id} — job may have been cleaned up prematurely.")
157+
# TODO: cancel the job to fail fast once PR #1144 is merged
158+
return
162159

163160
# update complete state based on latest progress info after saving results
164161
complete_state = JobState.SUCCESS
@@ -176,9 +173,11 @@ def process_nats_pipeline_result(self, job_id: int, result_data: dict, reply_sub
176173
)
177174

178175
except Exception as e:
179-
job.logger.error(
180-
f"Failed to process pipeline result for job {job_id}: {e}. NATS will redeliver the task message."
181-
)
176+
error = f"Error processing pipeline result for job {job_id}: {e}"
177+
if not acked:
178+
error += ". NATS will re-deliver the task message."
179+
180+
job.logger.error(error)
182181

183182

184183
def _ack_task_via_nats(reply_subject: str, job_logger: logging.Logger) -> None:
@@ -256,9 +255,33 @@ def _update_job_progress(
256255
state_params["classifications"] = current_classifications + new_classifications
257256
state_params["captures"] = current_captures + new_captures
258257

258+
# Don't overwrite a stage with a stale progress value.
259+
# This guards against the race where a slower worker calls _update_job_progress
260+
# after a faster worker has already marked further progress.
261+
try:
262+
existing_stage = job.progress.get_stage(stage)
263+
progress_percentage = max(existing_stage.progress, progress_percentage)
264+
# Explicitly preserve FAILURE: once a stage is marked FAILURE it should
265+
# never regress to a non-failure state, regardless of enum ordering.
266+
if existing_stage.status == JobState.FAILURE:
267+
complete_state = JobState.FAILURE
268+
except (ValueError, AttributeError):
269+
pass # Stage doesn't exist yet; proceed normally
270+
271+
# Determine the status to write:
272+
# - Stage complete (100%): use complete_state (SUCCESS or FAILURE)
273+
# - Stage incomplete but FAILURE already determined: keep FAILURE visible
274+
# - Stage incomplete, no failure: mark as in-progress (STARTED)
275+
if progress_percentage >= 1.0:
276+
status = complete_state
277+
elif complete_state == JobState.FAILURE:
278+
status = JobState.FAILURE
279+
else:
280+
status = JobState.STARTED
281+
259282
job.progress.update_stage(
260283
stage,
261-
status=complete_state if progress_percentage >= 1.0 else JobState.STARTED,
284+
status=status,
262285
progress=progress_percentage,
263286
**state_params,
264287
)

‎ami/jobs/test_tasks.py‎

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,26 @@
66
"""
77

88
import logging
9+
from concurrent.futures import ThreadPoolExecutor
910
from unittest.mock import AsyncMock, MagicMock, patch
1011

1112
from django.core.cache import cache
12-
from django.test import TestCase
13+
from django.test import TransactionTestCase
1314
from rest_framework.test import APITestCase
1415

1516
from ami.base.serializers import reverse_with_params
1617
from ami.jobs.models import Job, JobDispatchMode, JobState, MLJob
1718
from ami.jobs.tasks import process_nats_pipeline_result
1819
from ami.main.models import Detection, Project, SourceImage, SourceImageCollection
1920
from ami.ml.models import Pipeline
20-
from ami.ml.orchestration.async_job_state import AsyncJobStateManager, _lock_key
21+
from ami.ml.orchestration.async_job_state import AsyncJobStateManager
2122
from ami.ml.schemas import PipelineResultsError, PipelineResultsResponse, SourceImageResponse
2223
from ami.users.models import User
2324

2425
logger = logging.getLogger(__name__)
2526

2627

27-
class TestProcessNatsPipelineResultError(TestCase):
28+
class TestProcessNatsPipelineResultError(TransactionTestCase):
2829
"""E2E tests for process_nats_pipeline_result with error handling."""
2930

3031
def setUp(self):
@@ -237,38 +238,46 @@ def test_process_nats_pipeline_result_mixed_results(self, mock_manager_class):
237238
self.assertEqual(mock_manager.acknowledge_task.call_count, 3)
238239

239240
@patch("ami.jobs.tasks.TaskQueueManager")
240-
def test_process_nats_pipeline_result_error_concurrent_locking(self, mock_manager_class):
241+
def test_process_nats_pipeline_result_concurrent_updates(self, mock_manager_class):
241242
"""
242-
Test that error results respect locking mechanism.
243+
Test that concurrent workers update state independently without contention.
243244
244-
Verifies race condition handling when multiple workers
245-
process error results simultaneously.
245+
Without a lock, two workers processing different images can both call
246+
update_state and receive valid progress — no retry needed, no blocking.
246247
"""
247-
# Simulate lock held by another task
248-
lock_key = _lock_key(self.job.pk)
249-
cache.set(lock_key, "other-task-id", timeout=60)
250-
251-
# Create error result
252-
error_data = self._create_error_result(image_id=str(self.images[0].pk))
253-
reply_subject = "tasks.reply.test789"
248+
mock_manager = self._setup_mock_nats(mock_manager_class)
254249

255-
# Task should raise retry exception when lock not acquired
256-
# The task internally calls self.retry() which raises a Retry exception
257-
from celery.exceptions import Retry
250+
with ThreadPoolExecutor(max_workers=2) as executor:
251+
# Worker 1 processes images[0]
252+
result_1 = executor.submit(
253+
process_nats_pipeline_result.apply,
254+
kwargs={
255+
"job_id": self.job.pk,
256+
"result_data": self._create_error_result(image_id=str(self.images[0].pk)),
257+
"reply_subject": "reply.concurrent.1",
258+
},
259+
)
258260

259-
with self.assertRaises(Retry):
260-
process_nats_pipeline_result.apply(
261+
# Worker 2 processes images[1] — no retry, no lock to wait for
262+
result_2 = executor.submit(
263+
process_nats_pipeline_result.apply,
261264
kwargs={
262265
"job_id": self.job.pk,
263-
"result_data": error_data,
264-
"reply_subject": reply_subject,
265-
}
266+
"result_data": self._create_error_result(image_id=str(self.images[1].pk)),
267+
"reply_subject": "reply.concurrent.2",
268+
},
266269
)
267270

268-
# Assert: Progress was NOT updated (lock not acquired)
271+
self.assertTrue(result_1.result().successful())
272+
self.assertTrue(result_2.result().successful())
273+
274+
# Both images should be marked as processed
269275
manager = AsyncJobStateManager(self.job.pk)
270276
progress = manager.get_progress("process")
271-
self.assertEqual(progress.processed, 0)
277+
self.assertIsNotNone(progress)
278+
self.assertEqual(progress.processed, 2)
279+
self.assertEqual(progress.total, 3)
280+
self.assertEqual(mock_manager.acknowledge_task.call_count, 2)
272281

273282
@patch("ami.jobs.tasks.TaskQueueManager")
274283
def test_process_nats_pipeline_result_error_job_not_found(self, mock_manager_class):

0 commit comments

Comments
 (0)