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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user