feat(l1): APScheduler hourly cleanup job for abandoned L1 sessions
flip_stale_sessions flips L1WalkSession.status from 'active' to 'abandoned' for rows where last_step_at is older than 24h. Preserves the row for audit; removes it from the L1 dashboard's 'Resume in progress' widget. Runs hourly via APScheduler with max_instances=1 (Lesson 1). Uses the admin session factory (no RLS context at startup). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -221,6 +221,18 @@ async def lifespan(app: FastAPI):
|
||||
max_instances=1,
|
||||
)
|
||||
|
||||
# L1 walk session cleanup: flip stale active sessions to 'abandoned' (hourly)
|
||||
from app.services.l1_session_cleanup import run_cleanup_job as l1_cleanup_run
|
||||
scheduler.add_job(
|
||||
l1_cleanup_run,
|
||||
trigger="interval",
|
||||
hours=1,
|
||||
id="l1_session_cleanup",
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
args=[async_session_maker],
|
||||
)
|
||||
|
||||
# Auto-seed trees in background on PR environments
|
||||
seed_task = None
|
||||
if settings.SEED_ON_DEPLOY:
|
||||
|
||||
49
backend/app/services/l1_session_cleanup.py
Normal file
49
backend/app/services/l1_session_cleanup.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Hourly cleanup job: flip stale active L1WalkSessions to 'abandoned'.
|
||||
|
||||
Sessions with status='active' and last_step_at older than 24h are considered
|
||||
abandoned (L1 closed the browser, customer hung up, etc.). Flipping them
|
||||
removes them from the "Resume in progress" widget while preserving the row
|
||||
for audit/reporting.
|
||||
|
||||
Run via APScheduler interval job, max_instances=1 (Lesson 1).
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.l1_walk_session import L1WalkSession
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def flip_stale_sessions(db: AsyncSession) -> int:
|
||||
"""Flip active sessions to 'abandoned' if last_step_at < now - 24h.
|
||||
|
||||
Returns the number of sessions flipped.
|
||||
"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
stmt = (
|
||||
update(L1WalkSession)
|
||||
.where(L1WalkSession.status == "active")
|
||||
.where(L1WalkSession.last_step_at < cutoff)
|
||||
.values(status="abandoned")
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
await db.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
async def run_cleanup_job(session_factory) -> None:
|
||||
"""APScheduler entry point. Uses the admin session factory (no RLS context)."""
|
||||
async with session_factory() as db:
|
||||
try:
|
||||
count = await flip_stale_sessions(db)
|
||||
if count > 0:
|
||||
logger.info(
|
||||
"l1_session_cleanup: flipped %d sessions to abandoned", count
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("l1_session_cleanup: error during run")
|
||||
Reference in New Issue
Block a user