-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathtasks.py
More file actions
505 lines (413 loc) · 19 KB
/
Copy pathtasks.py
File metadata and controls
505 lines (413 loc) · 19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
import datetime
import functools
import logging
import time
from collections.abc import Callable
from typing import TYPE_CHECKING
from asgiref.sync import async_to_sync
from celery.signals import task_failure, task_postrun, task_prerun
from django.db import transaction
from ami.ml.orchestration.async_job_state import AsyncJobStateManager
from ami.ml.orchestration.nats_queue import TaskQueueManager
from ami.ml.schemas import PipelineResultsError, PipelineResultsResponse
from ami.tasks import default_soft_time_limit, default_time_limit
from config import celery_app
if TYPE_CHECKING:
from ami.jobs.models import JobState
logger = logging.getLogger(__name__)
# Minimum success rate. Jobs with fewer than this fraction of images
# processed successfully are marked as failed. Also used in MLJob.process_images().
FAILURE_THRESHOLD = 0.5
@celery_app.task(bind=True, soft_time_limit=default_soft_time_limit, time_limit=default_time_limit)
def run_job(self, job_id: int) -> None:
from ami.jobs.models import Job
try:
job = Job.objects.get(pk=job_id)
except Job.DoesNotExist as e:
raise e
# self.retry(exc=e, countdown=1, max_retries=1)
else:
job.logger.info(f"Running job {job}")
try:
job.run()
except Exception as e:
job.logger.error(f'Job #{job.pk} "{job.name}" failed: {e}')
raise
else:
job.refresh_from_db()
job.logger.info(f"Finished job {job}")
@celery_app.task(
bind=True,
max_retries=0, # don't retry since we already have retry logic in the NATS queue
soft_time_limit=300, # 5 minutes
time_limit=360, # 6 minutes
)
def process_nats_pipeline_result(self, job_id: int, result_data: dict, reply_subject: str) -> None:
"""
Process a single pipeline result asynchronously.
This task:
1. Deserializes the pipeline result
2. Saves it to the database
3. Updates progress by removing processed image IDs from Redis
4. Acknowledges the task via NATS
Args:
job_id: The job ID
result_data: Dictionary containing the pipeline result
reply_subject: NATS reply subject for acknowledgment
"""
from ami.jobs.models import Job, JobState # avoid circular import
_, t = log_time()
# Validate with Pydantic - check for error response first
error_result = None
if "error" in result_data:
error_result = PipelineResultsError(**result_data)
processed_image_ids = {str(error_result.image_id)} if error_result.image_id else set()
failed_image_ids = processed_image_ids # Same as processed for errors
pipeline_result = None
else:
pipeline_result = PipelineResultsResponse(**result_data)
processed_image_ids = {str(img.id) for img in pipeline_result.source_images}
failed_image_ids = set() # No failures for successful results
state_manager = AsyncJobStateManager(job_id)
progress_info = state_manager.update_state(processed_image_ids, stage="process", failed_image_ids=failed_image_ids)
if not progress_info:
# Acknowledge the task to prevent retries, since we don't know the state
_ack_task_via_nats(reply_subject, logger)
_fail_job(job_id, "Redis state missing for job")
return
try:
complete_state = JobState.SUCCESS
if progress_info.total > 0 and (progress_info.failed / progress_info.total) > FAILURE_THRESHOLD:
complete_state = JobState.FAILURE
_update_job_progress(
job_id,
"process",
progress_info.percentage,
complete_state=complete_state,
processed=progress_info.processed,
remaining=progress_info.remaining,
failed=progress_info.failed,
)
_, t = t(f"TIME: Updated job {job_id} progress in PROCESS stage progress to {progress_info.percentage*100}%")
job = Job.objects.get(pk=job_id)
job.logger.info(f"Processing pipeline result for job {job_id}, reply_subject: {reply_subject}")
job.logger.info(
f" Job {job_id} progress: {progress_info.processed}/{progress_info.total} images processed "
f"({progress_info.percentage*100}%), {progress_info.remaining} remaining, {progress_info.failed} failed, "
f"{len(processed_image_ids)} just processed"
)
if error_result:
job.logger.error(
f"Pipeline returned error for job {job_id}, image {error_result.image_id}: {error_result.error}"
)
except Job.DoesNotExist:
# don't raise and ack so that we don't retry since the job doesn't exists
logger.error(f"Job {job_id} not found")
_ack_task_via_nats(reply_subject, logger)
return
acked = False
try:
# Save to database (this is the slow operation)
detections_count, classifications_count, captures_count = 0, 0, 0
if pipeline_result:
# should never happen since otherwise we could not be processing results here
assert job.pipeline is not None, "Job pipeline is None"
job.pipeline.save_results(results=pipeline_result, job_id=job.pk)
job.logger.info(f"Successfully saved results for job {job_id}")
_, t = t(
f"Saved pipeline results to database with {len(pipeline_result.detections)} detections"
f", percentage: {progress_info.percentage*100}%"
)
# Calculate detection and classification counts from this result
detections_count = len(pipeline_result.detections)
classifications_count = sum(len(detection.classifications) for detection in pipeline_result.detections)
captures_count = len(pipeline_result.source_images)
_ack_task_via_nats(reply_subject, job.logger)
acked = True
# Update job stage with calculated progress
progress_info = state_manager.update_state(
processed_image_ids,
stage="results",
)
if not progress_info:
_fail_job(job_id, "Redis state missing for job")
return
# update complete state based on latest progress info after saving results
complete_state = JobState.SUCCESS
if progress_info.total > 0 and (progress_info.failed / progress_info.total) > FAILURE_THRESHOLD:
complete_state = JobState.FAILURE
_update_job_progress(
job_id,
"results",
progress_info.percentage,
complete_state=complete_state,
detections=detections_count,
classifications=classifications_count,
captures=captures_count,
)
except Exception as e:
error = f"Error processing pipeline result for job {job_id}: {e}"
if not acked:
error += ". NATS will re-deliver the task message."
job.logger.error(error)
def _fail_job(job_id: int, reason: str) -> None:
from ami.jobs.models import Job, JobState
from ami.ml.orchestration.jobs import cleanup_async_job_resources
try:
with transaction.atomic():
job = Job.objects.select_for_update().get(pk=job_id)
if job.status in (JobState.CANCELING, *JobState.final_states()):
return
job.update_status(JobState.FAILURE, save=False)
job.finished_at = datetime.datetime.now()
job.save(update_fields=["status", "progress", "finished_at"])
job.logger.error(f"Job {job_id} marked as FAILURE: {reason}")
cleanup_async_job_resources(job.pk, job.logger)
except Job.DoesNotExist:
logger.error(f"Cannot fail job {job_id}: not found")
cleanup_async_job_resources(job_id, logger)
def _ack_task_via_nats(reply_subject: str, job_logger: logging.Logger) -> None:
try:
async def ack_task():
async with TaskQueueManager() as manager:
return await manager.acknowledge_task(reply_subject)
ack_success = async_to_sync(ack_task)()
if ack_success:
job_logger.info(f"Successfully acknowledged task via NATS: {reply_subject}")
else:
job_logger.warning(f"Failed to acknowledge task via NATS: {reply_subject}")
except Exception as ack_error:
job_logger.error(f"Error acknowledging task via NATS: {ack_error}")
# Don't fail the task if ACK fails - data is already saved
def _get_current_counts_from_job_progress(job, stage: str) -> tuple[int, int, int]:
"""
Get current detections, classifications, and captures counts from job progress.
Args:
job: The Job instance
stage: The stage name to read counts from
Returns:
Tuple of (detections, classifications, captures) counts, defaulting to 0 if not found
"""
try:
stage_obj = job.progress.get_stage(stage)
# Initialize defaults
detections = 0
classifications = 0
captures = 0
# Search through the params list for our count values
for param in stage_obj.params:
if param.key == "detections":
detections = param.value or 0
elif param.key == "classifications":
classifications = param.value or 0
elif param.key == "captures":
captures = param.value or 0
return detections, classifications, captures
except (ValueError, AttributeError):
# Stage doesn't exist or doesn't have these attributes yet
return 0, 0, 0
def _update_job_progress(
job_id: int, stage: str, progress_percentage: float, complete_state: "JobState", **state_params
) -> None:
from ami.jobs.models import Job, JobState # avoid circular import
with transaction.atomic():
job = Job.objects.select_for_update().get(pk=job_id)
# For results stage, accumulate detections/classifications/captures counts
if stage == "results":
current_detections, current_classifications, current_captures = _get_current_counts_from_job_progress(
job, stage
)
# Add new counts to existing counts
new_detections = state_params.get("detections", 0)
new_classifications = state_params.get("classifications", 0)
new_captures = state_params.get("captures", 0)
state_params["detections"] = current_detections + new_detections
state_params["classifications"] = current_classifications + new_classifications
state_params["captures"] = current_captures + new_captures
# Don't overwrite a stage with a stale progress value.
# This guards against the race where a slower worker calls _update_job_progress
# after a faster worker has already marked further progress.
try:
existing_stage = job.progress.get_stage(stage)
progress_percentage = max(existing_stage.progress, progress_percentage)
# Explicitly preserve FAILURE: once a stage is marked FAILURE it should
# never regress to a non-failure state, regardless of enum ordering.
if existing_stage.status == JobState.FAILURE:
complete_state = JobState.FAILURE
except (ValueError, AttributeError):
pass # Stage doesn't exist yet; proceed normally
# Determine the status to write:
# - Stage complete (100%): use complete_state (SUCCESS or FAILURE)
# - Stage incomplete but FAILURE already determined: keep FAILURE visible
# - Stage incomplete, no failure: mark as in-progress (STARTED)
if progress_percentage >= 1.0:
status = complete_state
elif complete_state == JobState.FAILURE:
status = JobState.FAILURE
else:
status = JobState.STARTED
job.progress.update_stage(
stage,
status=status,
progress=progress_percentage,
**state_params,
)
if job.progress.is_complete():
job.status = complete_state
job.progress.summary.status = complete_state
job.finished_at = datetime.datetime.now() # Use naive datetime in local time
job.logger.info(f"Updated job {job_id} progress in stage '{stage}' to {progress_percentage*100}%")
job.save()
# Clean up async resources for completed jobs that use NATS/Redis
if job.progress.is_complete():
job = Job.objects.get(pk=job_id) # Re-fetch outside transaction
cleanup_async_job_if_needed(job)
def check_stale_jobs(hours: int | None = None, dry_run: bool = False) -> list[dict]:
"""
Find jobs stuck in a running state past the cutoff and revoke them.
For each stale job, checks Celery for a terminal task status. REVOKED is
always trusted. For async_api jobs, SUCCESS and FAILURE are only accepted
when job.progress.is_complete() — NATS workers may still be delivering
results after the Celery task finishes. All other cases result in revocation.
Async resources (NATS/Redis) are cleaned up in both branches.
Returns a list of dicts describing what was done to each job.
"""
import datetime
from celery import states
from celery.result import AsyncResult
from django.db import transaction
from ami.jobs.models import Job, JobDispatchMode, JobState
if hours is None:
hours = Job.FAILED_CUTOFF_HOURS
cutoff = datetime.datetime.now() - datetime.timedelta(hours=hours)
stale_pks = list(
Job.objects.filter(
status__in=JobState.running_states(),
updated_at__lt=cutoff,
).values_list("pk", flat=True)
)
results = []
for pk in stale_pks:
with transaction.atomic():
try:
job = Job.objects.select_for_update().get(
pk=pk,
status__in=JobState.running_states(),
updated_at__lt=cutoff,
)
except Job.DoesNotExist:
# Another concurrent run already handled this job.
continue
celery_state = None
if job.task_id:
try:
celery_state = AsyncResult(job.task_id).state
except Exception:
logger.warning(
"Failed to fetch Celery state for stale job %s (task_id=%s)",
job.pk,
job.task_id,
exc_info=True,
)
# Treat as unknown state — job will be revoked below.
# Only trust terminal Celery states. For async_api jobs, SUCCESS and
# FAILURE are only accepted when progress is complete — NATS workers may
# still be delivering results after the Celery task finishes.
is_terminal = celery_state in states.READY_STATES
is_async_api = job.dispatch_mode == JobDispatchMode.ASYNC_API
if is_async_api and celery_state in {states.SUCCESS, states.FAILURE} and not job.progress.is_complete():
is_terminal = False
previous_status = job.status
if is_terminal:
if not dry_run:
job.update_status(celery_state, save=False)
job.finished_at = datetime.datetime.now()
job.save()
else:
if not dry_run:
job.update_status(JobState.REVOKED, save=False)
job.finished_at = datetime.datetime.now()
job.save()
# Async resource cleanup runs outside the transaction — it makes network
# calls (NATS/Redis) that should not hold the DB row lock.
if not dry_run:
job.refresh_from_db()
cleanup_async_job_if_needed(job)
if is_terminal:
results.append({"job_id": job.pk, "action": "updated", "state": celery_state})
else:
results.append({"job_id": job.pk, "action": "revoked", "previous_status": previous_status})
return results
def cleanup_async_job_if_needed(job) -> None:
"""
Clean up async resources (NATS/Redis) if this job uses them.
Only jobs with ASYNC_API dispatch mode use NATS/Redis resources.
This function is safe to call for any job - it checks if cleanup is needed.
Args:
job: The Job instance
"""
from ami.jobs.models import JobDispatchMode
if job.dispatch_mode == JobDispatchMode.ASYNC_API:
# import here to avoid circular imports
from ami.ml.orchestration.jobs import cleanup_async_job_resources
cleanup_async_job_resources(job.pk, job.logger)
@task_prerun.connect(sender=run_job)
def pre_update_job_status(sender, task_id, task, **kwargs):
# in the prerun signal, set the job status to PENDING
update_job_status(sender, task_id, task, "PENDING", **kwargs)
@task_postrun.connect(sender=run_job)
def update_job_status(sender, task_id, task, state: str, retval=None, **kwargs):
from ami.jobs.models import Job, JobState
job_id = task.request.kwargs["job_id"]
if job_id is None:
logger.error(f"Job id is None for task {task_id}")
return
try:
job = Job.objects.get(pk=job_id)
except Job.DoesNotExist:
try:
job = Job.objects.get(task_id=task_id)
except Job.DoesNotExist:
logger.error(f"No job found for task {task_id} or job_id {job_id}")
return
# Guard only SUCCESS state - let FAILURE, REVOKED, RETRY pass through immediately
# SUCCESS should only be set when all stages are actually complete
# This prevents premature SUCCESS when async workers are still processing
if state == JobState.SUCCESS and not job.progress.is_complete():
job.logger.info(
f"Job {job.pk} task completed but stages not finished - " "deferring SUCCESS status to progress handler"
)
return
job.update_status(state)
# Clean up async resources for revoked jobs
if state == JobState.REVOKED:
cleanup_async_job_if_needed(job)
@task_failure.connect(sender=run_job, retry=False)
def update_job_failure(sender, task_id, exception, *args, **kwargs):
from ami.jobs.models import Job, JobState
job = Job.objects.get(task_id=task_id)
job.update_status(JobState.FAILURE, save=False)
job.logger.error(f'Job #{job.pk} "{job.name}" failed: {exception}')
job.save()
# Clean up async resources for failed jobs
cleanup_async_job_if_needed(job)
def log_time(start: float = 0, msg: str | None = None) -> tuple[float, Callable]:
"""
Small helper to measure time between calls.
Returns: elapsed time since the last call, and a partial function to measure from the current call
Usage:
_, tlog = log_time()
# do something
_, tlog = tlog("Did something") # will log the time taken by 'something'
# do something else
t, tlog = tlog("Did something else") # will log the time taken by 'something else', returned as 't'
"""
end = time.perf_counter()
if start == 0:
dur = 0.0
else:
dur = end - start
if msg and start > 0:
logger.info(f"{msg}: {dur:.3f}s")
new_start = time.perf_counter()
return dur, functools.partial(log_time, new_start)