feat: add cancel, pause, and resume flow control for scans
Some checks failed
Deploy / deploy (push) Failing after 18s
Some checks failed
Deploy / deploy (push) Failing after 18s
Users running large scans can now pause (keep partial results, resume
later), cancel (stop permanently, partial results preserved), or resume
a paused scan which races through cache hits before continuing.
Backend:
- Extend scans.status CHECK to include 'paused' and 'cancelled'
- Add _migrate_add_pause_cancel_status() table-recreation migration
- scan_processor: _running_tasks/_cancel_reasons registries,
cancel_scan_task/pause_scan_task/stop_scan_task helpers,
CancelledError handler in process_scan(), start_resume_processor()
- api_server: POST /scans/{id}/pause|cancel|resume endpoints with
rate limits (30/min pause+cancel, 10/min resume); list_scans now
accepts paused/cancelled as status filter values
Frontend:
- Scan.status type extended with 'paused' | 'cancelled'
- scanApi.pause/cancel/resume added
- StatusChip: amber PauseCircle chip for paused, grey Ban for cancelled
- ScanDetails: context-aware action row with inline-confirm for
Pause and Cancel; Resume button for paused scans
Tests: 129 total (58 new) across test_scan_control.py,
test_scan_processor_control.py, and additions to existing suites
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,34 @@ from searcher_v3 import search_multiple_routes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Task registry — tracks running asyncio tasks so they can be cancelled.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_running_tasks: dict[int, asyncio.Task] = {}
|
||||
_cancel_reasons: dict[int, str] = {}
|
||||
|
||||
|
||||
def cancel_scan_task(scan_id: int) -> bool:
|
||||
"""Cancel the background task for a scan. Returns True if a task was found and cancelled."""
|
||||
task = _running_tasks.get(scan_id)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def pause_scan_task(scan_id: int) -> bool:
|
||||
"""Signal the running task to stop with status='paused'. Returns True if task was found."""
|
||||
_cancel_reasons[scan_id] = 'paused'
|
||||
return cancel_scan_task(scan_id)
|
||||
|
||||
|
||||
def stop_scan_task(scan_id: int) -> bool:
|
||||
"""Signal the running task to stop with status='cancelled'. Returns True if task was found."""
|
||||
_cancel_reasons[scan_id] = 'cancelled'
|
||||
return cancel_scan_task(scan_id)
|
||||
|
||||
|
||||
def _write_route_incremental(scan_id: int, destination: str,
|
||||
dest_name: str, dest_city: str,
|
||||
@@ -156,10 +184,10 @@ async def process_scan(scan_id: int):
|
||||
|
||||
logger.info(f"[Scan {scan_id}] Scan details: {origin} -> {country_or_airports}, {start_date_str} to {end_date_str}")
|
||||
|
||||
# Update status to 'running'
|
||||
# Update status to 'running' and record when processing started
|
||||
cursor.execute("""
|
||||
UPDATE scans
|
||||
SET status = 'running', updated_at = CURRENT_TIMESTAMP
|
||||
SET status = 'running', started_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (scan_id,))
|
||||
conn.commit()
|
||||
@@ -192,6 +220,7 @@ async def process_scan(scan_id: int):
|
||||
UPDATE scans
|
||||
SET status = 'failed',
|
||||
error_message = ?,
|
||||
completed_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (f"Failed to resolve airports: {str(e)}", scan_id))
|
||||
@@ -294,11 +323,12 @@ async def process_scan(scan_id: int):
|
||||
"SELECT COALESCE(SUM(flight_count), 0) FROM routes WHERE scan_id = ?", (scan_id,)
|
||||
).fetchone()[0]
|
||||
|
||||
# Update scan to completed
|
||||
# Update scan to completed and record finish time
|
||||
cursor.execute("""
|
||||
UPDATE scans
|
||||
SET status = 'completed',
|
||||
total_flights = ?,
|
||||
completed_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (total_flights_saved, scan_id))
|
||||
@@ -306,6 +336,24 @@ async def process_scan(scan_id: int):
|
||||
|
||||
logger.info(f"[Scan {scan_id}] ✅ Scan completed successfully! {routes_saved} routes saved with {total_flights_saved} flights")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
reason = _cancel_reasons.pop(scan_id, 'cancelled')
|
||||
logger.info(f"[Scan {scan_id}] Scan {reason} by user request")
|
||||
try:
|
||||
if conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE scans
|
||||
SET status = ?,
|
||||
completed_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (reason, scan_id))
|
||||
conn.commit()
|
||||
except Exception as update_error:
|
||||
logger.error(f"[Scan {scan_id}] Failed to update {reason} status: {str(update_error)}")
|
||||
raise # must re-raise so asyncio marks the task as cancelled
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Scan {scan_id}] ❌ Scan failed with error: {str(e)}", exc_info=True)
|
||||
|
||||
@@ -317,6 +365,7 @@ async def process_scan(scan_id: int):
|
||||
UPDATE scans
|
||||
SET status = 'failed',
|
||||
error_message = ?,
|
||||
completed_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (str(e), scan_id))
|
||||
@@ -340,5 +389,28 @@ def start_scan_processor(scan_id: int):
|
||||
asyncio.Task: The background task
|
||||
"""
|
||||
task = asyncio.create_task(process_scan(scan_id))
|
||||
_running_tasks[scan_id] = task
|
||||
task.add_done_callback(lambda _: _running_tasks.pop(scan_id, None))
|
||||
logger.info(f"[Scan {scan_id}] Background task created")
|
||||
return task
|
||||
|
||||
|
||||
def start_resume_processor(scan_id: int):
|
||||
"""
|
||||
Resume processing a paused scan as a background task.
|
||||
|
||||
The API endpoint has already reset status to 'pending' and cleared counters.
|
||||
process_scan() will transition the status to 'running' and re-run all routes,
|
||||
getting instant cache hits for already-queried routes.
|
||||
|
||||
Args:
|
||||
scan_id: The ID of the paused scan to resume
|
||||
|
||||
Returns:
|
||||
asyncio.Task: The background task
|
||||
"""
|
||||
task = asyncio.create_task(process_scan(scan_id))
|
||||
_running_tasks[scan_id] = task
|
||||
task.add_done_callback(lambda _: _running_tasks.pop(scan_id, None))
|
||||
logger.info(f"[Scan {scan_id}] Resume task created")
|
||||
return task
|
||||
|
||||
Reference in New Issue
Block a user