@@ -1229,3 +1229,198 @@ def test_jobs_health_check_runs_lost_images_before_stale_jobs(self, mock_manager
12291229
12301230 job .refresh_from_db ()
12311231 self .assertEqual (job .status , JobState .SUCCESS .value )
1232+
1233+
1234+ class TestCheckStaleJobsReaperGuard (TransactionTestCase ):
1235+ """Reaper guard for async_api jobs: SUCCESS/FAILURE Celery state is only
1236+ accepted when AsyncJobStateManager.all_tasks_processed() reports True. The
1237+ earlier guard read Job.progress.is_complete() — racy under concurrent
1238+ _update_job_progress writes since #1261 dropped select_for_update. Job 2521
1239+ landed REVOKED with NATS+Redis fully drained because a slower committer
1240+ clobbered the SUCCESS write. This class verifies the new Redis-direct path,
1241+ the absent-state fallback to progress.is_complete() with a WARNING, and
1242+ that sync_api jobs are unaffected.
1243+ """
1244+
1245+ def setUp (self ):
1246+ cache .clear ()
1247+ self .project = Project .objects .create (name = "Reaper Guard Project" )
1248+ self .pipeline = Pipeline .objects .create (name = "Reaper Pipeline" , slug = "reaper-pipeline" )
1249+ self .pipeline .projects .add (self .project )
1250+ self .collection = SourceImageCollection .objects .create (name = "Reaper Coll" , project = self .project )
1251+
1252+ def tearDown (self ):
1253+ cache .clear ()
1254+
1255+ def _stale_async_job (self , * , task_id : str = "reaper-task" ) -> Job :
1256+ job = Job .objects .create (
1257+ job_type_key = MLJob .key ,
1258+ project = self .project ,
1259+ name = "reaper async job" ,
1260+ pipeline = self .pipeline ,
1261+ source_image_collection = self .collection ,
1262+ dispatch_mode = JobDispatchMode .ASYNC_API ,
1263+ )
1264+ job .task_id = task_id
1265+ job .update_status (JobState .STARTED , save = True )
1266+ return job
1267+
1268+ def _mark_stale (self , job : Job ) -> None :
1269+ """Push updated_at back past STALLED_JOBS_MAX_MINUTES via raw update so
1270+ Job.save() side-effects (auto_now) don't undo it. Call AFTER any helper
1271+ that touches the job model."""
1272+ Job .objects .filter (pk = job .pk ).update (
1273+ updated_at = datetime .datetime .now () - datetime .timedelta (minutes = Job .STALLED_JOBS_MAX_MINUTES + 1 )
1274+ )
1275+ job .refresh_from_db ()
1276+
1277+ def _set_progress_clobbered (self , job : Job , total : int , processed : int ) -> None :
1278+ """Mimic the 2521 Job.progress shape: process stage at processed/total
1279+ with status STARTED, even though Redis was actually fully drained."""
1280+ progress = job .progress
1281+ collect = progress .get_stage ("collect" )
1282+ collect .progress = 1.0
1283+ collect .status = JobState .SUCCESS
1284+ progress .update_stage (
1285+ "process" ,
1286+ progress = processed / total ,
1287+ status = JobState .STARTED ,
1288+ processed = processed ,
1289+ remaining = total - processed ,
1290+ failed = 0 ,
1291+ )
1292+ progress .update_stage (
1293+ "results" ,
1294+ progress = processed / total ,
1295+ status = JobState .STARTED ,
1296+ captures = processed ,
1297+ )
1298+ job .save ()
1299+
1300+ def _set_progress_complete (self , job : Job ) -> None :
1301+ progress = job .progress
1302+ for key in ("collect" , "process" , "results" ):
1303+ stage = progress .get_stage (key )
1304+ stage .progress = 1.0
1305+ stage .status = JobState .SUCCESS
1306+ job .save ()
1307+
1308+ @patch ("celery.result.AsyncResult" )
1309+ def test_async_celery_success_redis_empty_progress_clobbered_lands_success (self , mock_async_result ):
1310+ """The 2521 case. Pre-fix, this came back REVOKED because the reaper
1311+ consulted progress.is_complete() (False, due to clobber). Post-fix,
1312+ Redis says all_tasks_processed() → True, so SUCCESS is honored."""
1313+ from ami .jobs .tasks import check_stale_jobs
1314+
1315+ job = self ._stale_async_job ()
1316+ ids = [str (i ) for i in range (10 )]
1317+ manager = AsyncJobStateManager (job .pk )
1318+ manager .initialize_job (ids )
1319+ manager .update_state (set (ids ), stage = "process" )
1320+ manager .update_state (set (ids ), stage = "results" )
1321+ # Clobber: progress shows mid-flight even though Redis is drained.
1322+ self ._set_progress_clobbered (job , total = 10 , processed = 9 )
1323+ self ._mark_stale (job )
1324+
1325+ from celery import states as celery_states
1326+
1327+ mock_async_result .return_value .state = celery_states .SUCCESS
1328+
1329+ check_stale_jobs ()
1330+
1331+ job .refresh_from_db ()
1332+ self .assertEqual (
1333+ job .status ,
1334+ JobState .SUCCESS .value ,
1335+ f"clobbered progress should not block SUCCESS when Redis says drained; got { job .status } " ,
1336+ )
1337+
1338+ @patch ("celery.result.AsyncResult" )
1339+ def test_async_celery_success_redis_pending_lands_revoked (self , mock_async_result ):
1340+ """Redis still has pending ids → genuine in-flight; reaper revokes."""
1341+ from ami .jobs .tasks import check_stale_jobs
1342+
1343+ job = self ._stale_async_job ()
1344+ ids = [str (i ) for i in range (10 )]
1345+ AsyncJobStateManager (job .pk ).initialize_job (ids )
1346+ # No SREMs — pending sets still full.
1347+ self ._mark_stale (job )
1348+
1349+ from celery import states as celery_states
1350+
1351+ mock_async_result .return_value .state = celery_states .SUCCESS
1352+
1353+ check_stale_jobs ()
1354+
1355+ job .refresh_from_db ()
1356+ self .assertEqual (job .status , JobState .REVOKED .value )
1357+
1358+ @patch ("celery.result.AsyncResult" )
1359+ def test_async_celery_success_redis_absent_progress_complete_lands_success (self , mock_async_result ):
1360+ """Redis state is gone (cleaned up / never initialized). Reaper falls
1361+ back to progress.is_complete(). Complete progress → SUCCESS + WARNING."""
1362+ from ami .jobs .tasks import check_stale_jobs
1363+
1364+ job = self ._stale_async_job ()
1365+ # Don't initialize Redis state.
1366+ self ._set_progress_complete (job )
1367+ self ._mark_stale (job )
1368+
1369+ from celery import states as celery_states
1370+
1371+ mock_async_result .return_value .state = celery_states .SUCCESS
1372+
1373+ with self .assertLogs ("ami.jobs.tasks" , level = "WARNING" ) as cm :
1374+ check_stale_jobs ()
1375+
1376+ job .refresh_from_db ()
1377+ self .assertEqual (job .status , JobState .SUCCESS .value )
1378+ self .assertTrue (any ("Redis state absent" in m for m in cm .output ))
1379+
1380+ @patch ("celery.result.AsyncResult" )
1381+ def test_async_celery_success_redis_absent_progress_incomplete_lands_revoked (self , mock_async_result ):
1382+ """Redis absent + progress incomplete → REVOKED via fallback + WARNING."""
1383+ from ami .jobs .tasks import check_stale_jobs
1384+
1385+ job = self ._stale_async_job ()
1386+ # Don't initialize Redis. Default progress is fresh (not complete).
1387+ self ._mark_stale (job )
1388+
1389+ from celery import states as celery_states
1390+
1391+ mock_async_result .return_value .state = celery_states .SUCCESS
1392+
1393+ with self .assertLogs ("ami.jobs.tasks" , level = "WARNING" ) as cm :
1394+ check_stale_jobs ()
1395+
1396+ job .refresh_from_db ()
1397+ self .assertEqual (job .status , JobState .REVOKED .value )
1398+ self .assertTrue (any ("Redis state absent" in m for m in cm .output ))
1399+
1400+ @patch ("celery.result.AsyncResult" )
1401+ def test_sync_api_celery_success_lands_success_without_redis_check (self , mock_async_result ):
1402+ """sync_api jobs skip the Redis guard entirely — Celery's terminal
1403+ state is authoritative. No Redis state initialized; if the new path
1404+ leaked into sync_api this would REVOKE."""
1405+ from ami .jobs .tasks import check_stale_jobs
1406+
1407+ job = Job .objects .create (
1408+ job_type_key = MLJob .key ,
1409+ project = self .project ,
1410+ name = "reaper sync job" ,
1411+ pipeline = self .pipeline ,
1412+ source_image_collection = self .collection ,
1413+ dispatch_mode = JobDispatchMode .SYNC_API ,
1414+ )
1415+ job .task_id = "reaper-sync-task"
1416+ job .update_status (JobState .STARTED , save = True )
1417+ self ._mark_stale (job )
1418+
1419+ from celery import states as celery_states
1420+
1421+ mock_async_result .return_value .state = celery_states .SUCCESS
1422+
1423+ check_stale_jobs ()
1424+
1425+ job .refresh_from_db ()
1426+ self .assertEqual (job .status , JobState .SUCCESS .value )
0 commit comments