feat: add cancel, pause, and resume flow control for scans
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:
2026-02-28 18:11:23 +01:00
parent d494e80ff7
commit 9a76d7af82
11 changed files with 1154 additions and 55 deletions

View File

@@ -40,7 +40,7 @@ T = TypeVar('T')
# Import existing modules
from airports import download_and_build_airport_data
from database import get_connection
from scan_processor import start_scan_processor
from scan_processor import start_scan_processor, start_resume_processor, pause_scan_task, stop_scan_task
# =============================================================================
@@ -221,11 +221,13 @@ rate_limiter = RateLimiter()
# Rate limit configurations (requests per minute)
RATE_LIMITS = {
'default': (200, 60), # 200 requests per 60 seconds (~3 req/sec)
'scans': (50, 60), # 50 scan creations per minute
'logs': (100, 60), # 100 log requests per minute
'airports': (500, 60), # 500 airport searches per minute
'schedules': (30, 60), # 30 schedule requests per minute
'default': (200, 60), # 200 requests per 60 seconds (~3 req/sec)
'scans': (50, 60), # 50 scan creations per minute
'logs': (100, 60), # 100 log requests per minute
'airports': (500, 60), # 500 airport searches per minute
'schedules': (30, 60), # 30 schedule requests per minute
'scan_control': (30, 60), # 30 pause/cancel requests per minute
'scan_resume': (10, 60), # 10 resume requests per minute
}
@@ -236,7 +238,11 @@ def get_rate_limit_for_path(path: str) -> tuple[str, int, int]:
Returns:
tuple: (endpoint_name, limit, window)
"""
if '/scans' in path and path.count('/') == 3: # POST /api/v1/scans
if '/scans' in path and (path.endswith('/pause') or path.endswith('/cancel')):
return 'scan_control', *RATE_LIMITS['scan_control']
elif '/scans' in path and path.endswith('/resume'):
return 'scan_resume', *RATE_LIMITS['scan_resume']
elif '/scans' in path and path.count('/') == 3: # POST /api/v1/scans
return 'scans', *RATE_LIMITS['scans']
elif '/logs' in path:
return 'logs', *RATE_LIMITS['logs']
@@ -930,6 +936,8 @@ class Scan(BaseModel):
seat_class: str = Field(..., description="Seat class")
adults: int = Field(..., ge=1, le=9, description="Number of adults")
scheduled_scan_id: Optional[int] = Field(None, description="ID of the schedule that created this scan")
started_at: Optional[str] = Field(None, description="ISO timestamp when scan processing started")
completed_at: Optional[str] = Field(None, description="ISO timestamp when scan completed or failed")
class ScanCreateResponse(BaseModel):
@@ -1254,7 +1262,8 @@ async def create_scan(request: ScanRequest):
SELECT id, origin, country, start_date, end_date,
created_at, updated_at, status, total_routes,
routes_scanned, total_flights, error_message,
seat_class, adults, scheduled_scan_id
seat_class, adults, scheduled_scan_id,
started_at, completed_at
FROM scans
WHERE id = ?
""", (scan_id,))
@@ -1280,7 +1289,9 @@ async def create_scan(request: ScanRequest):
error_message=row[11],
seat_class=row[12],
adults=row[13],
scheduled_scan_id=row[14] if len(row) > 14 else None
scheduled_scan_id=row[14] if len(row) > 14 else None,
started_at=row[15] if len(row) > 15 else None,
completed_at=row[16] if len(row) > 16 else None,
)
logging.info(f"Scan created: ID={scan_id}, origin={scan.origin}, country={scan.country}, dates={scan.start_date} to {scan.end_date}")
@@ -1330,10 +1341,10 @@ async def list_scans(
where_clause = ""
params = []
if status:
if status not in ['pending', 'running', 'completed', 'failed']:
if status not in ['pending', 'running', 'completed', 'failed', 'paused', 'cancelled']:
raise HTTPException(
status_code=400,
detail=f"Invalid status: {status}. Must be one of: pending, running, completed, failed"
detail=f"Invalid status: {status}. Must be one of: pending, running, completed, failed, paused, cancelled"
)
where_clause = "WHERE status = ?"
params.append(status)
@@ -1359,7 +1370,8 @@ async def list_scans(
SELECT id, origin, country, start_date, end_date,
created_at, updated_at, status, total_routes,
routes_scanned, total_flights, error_message,
seat_class, adults, scheduled_scan_id
seat_class, adults, scheduled_scan_id,
started_at, completed_at
FROM scans
{where_clause}
ORDER BY created_at DESC
@@ -1387,7 +1399,9 @@ async def list_scans(
error_message=row[11],
seat_class=row[12],
adults=row[13],
scheduled_scan_id=row[14] if len(row) > 14 else None
scheduled_scan_id=row[14] if len(row) > 14 else None,
started_at=row[15] if len(row) > 15 else None,
completed_at=row[16] if len(row) > 16 else None,
))
# Build pagination metadata
@@ -1428,7 +1442,8 @@ async def get_scan_status(scan_id: int):
SELECT id, origin, country, start_date, end_date,
created_at, updated_at, status, total_routes,
routes_scanned, total_flights, error_message,
seat_class, adults, scheduled_scan_id
seat_class, adults, scheduled_scan_id,
started_at, completed_at
FROM scans
WHERE id = ?
""", (scan_id,))
@@ -1457,7 +1472,9 @@ async def get_scan_status(scan_id: int):
error_message=row[11],
seat_class=row[12],
adults=row[13],
scheduled_scan_id=row[14] if len(row) > 14 else None
scheduled_scan_id=row[14] if len(row) > 14 else None,
started_at=row[15] if len(row) > 15 else None,
completed_at=row[16] if len(row) > 16 else None,
)
except HTTPException:
@@ -1507,6 +1524,155 @@ async def delete_scan(scan_id: int):
raise HTTPException(status_code=500, detail=f"Failed to delete scan: {str(e)}")
@router_v1.post("/scans/{scan_id}/pause")
async def pause_scan(scan_id: int):
"""
Pause a running or pending scan.
Stops the background task and marks the scan as 'paused'.
The scan can be resumed later via POST /scans/{id}/resume.
Returns 409 if the scan is not in a pauseable state (not pending/running).
"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT status FROM scans WHERE id = ?", (scan_id,))
row = cursor.fetchone()
if not row:
conn.close()
raise HTTPException(status_code=404, detail=f"Scan not found: {scan_id}")
if row[0] not in ('pending', 'running'):
conn.close()
raise HTTPException(
status_code=409,
detail=f"Cannot pause a scan with status '{row[0]}'. Only pending or running scans can be paused."
)
cursor.execute("""
UPDATE scans
SET status = 'paused',
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (scan_id,))
conn.commit()
conn.close()
pause_scan_task(scan_id)
logging.info(f"Scan {scan_id} paused")
return {"id": scan_id, "status": "paused"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to pause scan: {str(e)}")
@router_v1.post("/scans/{scan_id}/cancel")
async def cancel_scan(scan_id: int):
"""
Cancel a running or pending scan permanently.
Stops the background task and marks the scan as 'cancelled'.
Partial results are preserved. Use Re-run to start a new scan.
Returns 409 if the scan is not in a cancellable state.
"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT status FROM scans WHERE id = ?", (scan_id,))
row = cursor.fetchone()
if not row:
conn.close()
raise HTTPException(status_code=404, detail=f"Scan not found: {scan_id}")
if row[0] not in ('pending', 'running'):
conn.close()
raise HTTPException(
status_code=409,
detail=f"Cannot cancel a scan with status '{row[0]}'. Only pending or running scans can be cancelled."
)
cursor.execute("""
UPDATE scans
SET status = 'cancelled',
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (scan_id,))
conn.commit()
conn.close()
stop_scan_task(scan_id)
logging.info(f"Scan {scan_id} cancelled")
return {"id": scan_id, "status": "cancelled"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to cancel scan: {str(e)}")
@router_v1.post("/scans/{scan_id}/resume")
async def resume_scan(scan_id: int):
"""
Resume a paused scan.
Resets progress counters and restarts the background worker.
Already-queried routes are instant cache hits so progress races quickly
through them before settling on uncompleted routes.
Returns 409 if the scan is not paused.
"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT status FROM scans WHERE id = ?", (scan_id,))
row = cursor.fetchone()
if not row:
conn.close()
raise HTTPException(status_code=404, detail=f"Scan not found: {scan_id}")
if row[0] != 'paused':
conn.close()
raise HTTPException(
status_code=409,
detail=f"Cannot resume a scan with status '{row[0]}'. Only paused scans can be resumed."
)
# Reset counters so the progress bar starts fresh; the processor will race
# through cache hits before slowing on uncompleted routes.
cursor.execute("""
UPDATE scans
SET status = 'pending',
routes_scanned = 0,
started_at = NULL,
completed_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (scan_id,))
conn.commit()
conn.close()
start_resume_processor(scan_id)
logging.info(f"Scan {scan_id} resumed")
return {"id": scan_id, "status": "pending"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to resume scan: {str(e)}")
@router_v1.get("/scans/{scan_id}/routes", response_model=PaginatedResponse[Route])
async def get_scan_routes(
scan_id: int,