Summary
cancel_task() in backend/secuscan/executor.py writes status = 'cancelled' to the database immediately after calling task.cancel(), before the asyncio CancelledError has propagated and before execute_task()'s except asyncio.CancelledError handler has run. That handler contains the UPDATE tasks SET duration_seconds = ... WHERE id = ? AND status = 'running' guard. Because cancel_task() has already flipped the status to 'cancelled', the WHERE status = 'running' predicate never matches, so duration_seconds is never written for any cancelled task. This is a persistent data-loss race condition.
Affected Code
cancel_task() (executor.py ~line 543)
async def cancel_task(self, task_id: str):
task = self._running_tasks.get(task_id)
if task:
task.cancel()
# race: status written here, before CancelledError propagates
await db.execute(
"UPDATE tasks SET status = 'cancelled' WHERE id = ?",
(task_id,)
)
execute_task() CancelledError handler (executor.py)
except asyncio.CancelledError:
await db.execute(
"""UPDATE tasks
SET status = 'cancelled', duration_seconds = ?
WHERE id = ? AND status = 'running'""",
(elapsed, task_id)
)
The WHERE status = 'running' guard is the correct place to write duration_seconds, but cancel_task() pre-empts it by writing 'cancelled' first.
Impact
- Data loss:
duration_seconds is NULL for every cancelled task. Any analytics, billing, or audit logic that reads this field gets incorrect data silently.
- Masked bug surface: the double-write means the
execute_task handler silently becomes a no-op for cancellations, so any future logic added inside that handler will also be silently skipped.
- Non-deterministic: under load, the window between
task.cancel() and CancelledError propagation varies, making this difficult to catch in tests.
Fix
Remove the premature status update from cancel_task() and let the CancelledError handler in execute_task() own the final state write:
async def cancel_task(self, task_id: str):
task = self._running_tasks.get(task_id)
if task:
task.cancel()
# do not write status here; execute_task's CancelledError handler owns it
If a cancellation signal needs to be visible before the handler runs, use a separate status = 'cancelling' intermediate state that the handler checks for in its WHERE clause.
Environment
- File:
backend/secuscan/executor.py
- Functions:
cancel_task(), execute_task()
- Trigger: any call to
DELETE /tasks/{task_id} or POST /tasks/{task_id}/cancel while the task is running
Summary
cancel_task()inbackend/secuscan/executor.pywritesstatus = 'cancelled'to the database immediately after callingtask.cancel(), before the asyncioCancelledErrorhas propagated and beforeexecute_task()'sexcept asyncio.CancelledErrorhandler has run. That handler contains theUPDATE tasks SET duration_seconds = ... WHERE id = ? AND status = 'running'guard. Becausecancel_task()has already flipped the status to'cancelled', theWHERE status = 'running'predicate never matches, soduration_secondsis never written for any cancelled task. This is a persistent data-loss race condition.Affected Code
cancel_task()(executor.py ~line 543)execute_task()CancelledError handler (executor.py)The
WHERE status = 'running'guard is the correct place to writeduration_seconds, butcancel_task()pre-empts it by writing'cancelled'first.Impact
duration_secondsisNULLfor every cancelled task. Any analytics, billing, or audit logic that reads this field gets incorrect data silently.execute_taskhandler silently becomes a no-op for cancellations, so any future logic added inside that handler will also be silently skipped.task.cancel()andCancelledErrorpropagation varies, making this difficult to catch in tests.Fix
Remove the premature status update from
cancel_task()and let theCancelledErrorhandler inexecute_task()own the final state write:If a cancellation signal needs to be visible before the handler runs, use a separate
status = 'cancelling'intermediate state that the handler checks for in itsWHEREclause.Environment
backend/secuscan/executor.pycancel_task(),execute_task()DELETE /tasks/{task_id}orPOST /tasks/{task_id}/cancelwhile the task is running