"""Job handlers. Importing this module registers them with the queue.""" import logging import uuid from datetime import UTC, datetime, timedelta from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.ingestion.queue import enqueue, job_handler from app.models import ( AuthSession, Conversation, ConversationMode, Document, DocumentStatus, Job, JobStatus, ) from app.rag.indexing import reindex_document, remove_chunks logger = logging.getLogger("pablan.queue") RETENTION_CLEANUP = "retention_cleanup" INDEX_DOCUMENT = "index_document" REINDEX_ALL = "reindex_all" @job_handler(INDEX_DOCUMENT) async def index_document(db: AsyncSession, job: Job) -> None: """(Re)build the chunks of one document; drop them if it is not published.""" document_id = uuid.UUID(job.payload["document_id"]) document = await db.get(Document, document_id) if document is None: logger.info( "index skipped, document gone", extra={"event": "index_skipped", "document_id": str(document_id)}, ) return if document.status == DocumentStatus.published: await reindex_document(db, document) else: await remove_chunks(db, document.id) @job_handler(REINDEX_ALL) async def reindex_all(db: AsyncSession, job: Job) -> None: """Fan out one index_document job per published document. Never embeds the corpus in this handler itself — the queue holds the claim transaction open for the whole handler run. """ document_ids = ( ( await db.execute( select(Document.id).where(Document.status == DocumentStatus.published) ) ) .scalars() .all() ) for document_id in document_ids: await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document_id)}) logger.info( "reindex fan-out", extra={"event": "reindex_all", "document_count": len(document_ids)}, ) @job_handler(RETENTION_CLEANUP) async def retention_cleanup(db: AsyncSession, job: Job) -> None: """GDPR retention: drop old query conversations and expired auth sessions. Messages go with their conversation via ON DELETE CASCADE. Reschedules itself daily. """ now = datetime.now(UTC) cutoff = now - timedelta(days=get_settings().query_retention_days) conversations_deleted = ( await db.execute( delete(Conversation).where( Conversation.mode == ConversationMode.query, Conversation.updated_at < cutoff, ) ) ).rowcount sessions_deleted = ( await db.execute(delete(AuthSession).where(AuthSession.expires_at < now)) ).rowcount await enqueue(db, RETENTION_CLEANUP, run_after=now + timedelta(days=1)) logger.info( "retention cleanup", extra={ "event": "retention_cleanup", "conversations_deleted": conversations_deleted, "auth_sessions_deleted": sessions_deleted, }, ) async def ensure_retention_scheduled(db: AsyncSession) -> None: """Idempotent startup bootstrap: exactly one pending retention job.""" existing = ( await db.execute( select(Job.id).where( Job.type == RETENTION_CLEANUP, Job.status == JobStatus.pending ) ) ).first() if existing is None: await enqueue(db, RETENTION_CLEANUP) await db.commit()