""" Scheduled batch processing tasks for DocuElevate. This module provides Celery tasks that can be scheduled via Celery Beat and managed through the admin UI (``/admin/scheduled-jobs``): Core batch jobs --------------- - ``process_new_documents`` – Queue documents that have never been processed. - ``reprocess_failed_documents`` – Re-queue documents whose processing failed. - ``cleanup_temp_files`` – Remove stale files from the ``workdir/tmp`` directory. Maintenance / housekeeping jobs -------------------------------- - ``expire_shared_links`` – Auto-revoke SharedLinks whose ``expires_at`` has passed. - ``prune_processing_logs`` – Delete old rows from ``processing_logs`` and ``settings_audit_log`` to prevent unbounded table growth. - ``prune_old_notifications`` – Delete old read ``in_app_notifications`` rows. - ``backfill_missing_metadata`` – Re-trigger AI metadata extraction for completed files that have OCR text but no ``ai_metadata``. - ``sync_search_index`` – Index documents in Meilisearch that have OCR text / metadata but are not yet in the search index. Each task records its execution result back to the ``ScheduledJob`` table so the admin UI can display last-run times and statuses. """ import json import logging import os from datetime import datetime, timedelta, timezone from pathlib import Path from app.celery_app import celery from app.config import settings from app.database import SessionLocal from app.models import ( FileProcessingStep, FileRecord, InAppNotification, ProcessingLog, ScheduledJob, SettingsAuditLog, SharedLink, ) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- _PROCESSING_STEPS = { "create_file_record", "check_text", "extract_text", "process_with_ocr", "process_with_azure_document_intelligence", "extract_metadata_with_gpt", "embed_metadata_into_pdf", "finalize_document_storage", "send_to_all_destinations", } def _update_job_status(job_name: str, status: str, detail: str) -> None: """Persist run status back to the ScheduledJob row for display in the UI.""" try: with SessionLocal() as db: job = db.query(ScheduledJob).filter(ScheduledJob.name == job_name).first() if job: job.last_run_at = datetime.now(timezone.utc) job.last_run_status = status job.last_run_detail = detail db.commit() except Exception as exc: # pragma: no cover – best-effort status update logger.warning("Could not update ScheduledJob status for %s: %s", job_name, exc) # --------------------------------------------------------------------------- # Task: process new (unprocessed) documents # --------------------------------------------------------------------------- @celery.task(name="app.tasks.batch_tasks.process_new_documents") def process_new_documents() -> dict: """ Queue all documents that have never been processed. A document is considered *new* when it has no ``FileProcessingStep`` rows that match the core pipeline steps. The task loads each qualifying ``FileRecord``, verifies that the original file still exists on disk, and dispatches ``process_document`` for each one. Returns a summary dict with ``queued`` and ``skipped`` counts. """ from app.tasks.process_document import process_document # avoid circular import job_name = "process-new-documents" logger.info("[batch] Starting process_new_documents task") try: with SessionLocal() as db: # Files that already have at least one processing step recorded. processed_file_ids = ( db.query(FileProcessingStep.file_id) .filter(FileProcessingStep.step_name.in_(_PROCESSING_STEPS)) .distinct() .subquery() ) # Candidate files: non-duplicate records with no processing steps yet. candidates = ( db.query(FileRecord) .filter(FileRecord.is_duplicate.is_(False)) .filter(~FileRecord.id.in_(db.query(processed_file_ids.c.file_id))) .all() ) queued = 0 skipped = 0 for record in candidates: if not record.local_filename or not os.path.exists(record.local_filename): logger.warning( "[batch] Skipping file_id=%s — local file not found: %s", record.id, record.local_filename, ) skipped += 1 continue process_document.delay( record.local_filename, original_filename=record.original_filename, file_id=record.id, owner_id=record.owner_id, ) queued += 1 detail = f"Queued {queued} document(s) for processing; skipped {skipped} (file not on disk)." logger.info("[batch] process_new_documents: %s", detail) _update_job_status(job_name, "success", detail) return {"queued": queued, "skipped": skipped} except Exception as exc: detail = f"Error: {exc}" logger.error("[batch] process_new_documents failed: %s", exc, exc_info=True) _update_job_status(job_name, "failed", detail) return {"queued": 0, "skipped": 0, "error": str(exc)} # --------------------------------------------------------------------------- # Task: reprocess failed documents # --------------------------------------------------------------------------- @celery.task(name="app.tasks.batch_tasks.reprocess_failed_documents") def reprocess_failed_documents() -> dict: """ Re-queue documents whose most-recent processing attempt failed. Only files that have at least one ``FileProcessingStep`` with ``status == "failure"`` **and** no currently ``in_progress`` steps are selected so that actively-running jobs are not interrupted. Returns a summary dict with ``queued`` and ``skipped`` counts. """ from app.tasks.process_document import process_document # avoid circular import job_name = "reprocess-failed-documents" logger.info("[batch] Starting reprocess_failed_documents task") try: with SessionLocal() as db: # Files with at least one failed step. failed_file_ids = ( db.query(FileProcessingStep.file_id) .filter(FileProcessingStep.step_name.in_(_PROCESSING_STEPS)) .filter(FileProcessingStep.status == "failure") .distinct() .subquery() ) # Exclude files that are currently being processed. in_progress_file_ids = ( db.query(FileProcessingStep.file_id) .filter(FileProcessingStep.status == "in_progress") .distinct() .subquery() ) candidates = ( db.query(FileRecord) .filter(FileRecord.is_duplicate.is_(False)) .filter(FileRecord.id.in_(db.query(failed_file_ids.c.file_id))) .filter(~FileRecord.id.in_(db.query(in_progress_file_ids.c.file_id))) .all() ) queued = 0 skipped = 0 for record in candidates: if not record.local_filename or not os.path.exists(record.local_filename): logger.warning( "[batch] Skipping file_id=%s — local file not found: %s", record.id, record.local_filename, ) skipped += 1 continue process_document.delay( record.local_filename, original_filename=record.original_filename, file_id=record.id, owner_id=record.owner_id, ) queued += 1 detail = f"Re-queued {queued} failed document(s); skipped {skipped} (file not on disk)." logger.info("[batch] reprocess_failed_documents: %s", detail) _update_job_status(job_name, "success", detail) return {"queued": queued, "skipped": skipped} except Exception as exc: detail = f"Error: {exc}" logger.error("[batch] reprocess_failed_documents failed: %s", exc, exc_info=True) _update_job_status(job_name, "failed", detail) return {"queued": 0, "skipped": 0, "error": str(exc)} # --------------------------------------------------------------------------- # Task: clean up temporary files # --------------------------------------------------------------------------- #: Files in ``workdir/tmp`` that are older than this threshold are deleted. _TEMP_FILE_MAX_AGE_HOURS: int = 24 @celery.task(name="app.tasks.batch_tasks.cleanup_temp_files") def cleanup_temp_files(max_age_hours: int = _TEMP_FILE_MAX_AGE_HOURS) -> dict: """ Delete stale files from the ``workdir/tmp`` directory. A file is considered stale when **both** of the following are true: 1. Its modification time is older than *max_age_hours* (default 24 h). 2. No ``FileRecord.local_filename`` points to it **or** the file is not referenced by any active in-progress processing step. This prevents accidental deletion of files that are being actively processed by the pipeline. Args: max_age_hours: Minimum age (in hours) before a temp file is eligible for deletion. Defaults to 24. Returns: A summary dict with ``deleted`` and ``skipped`` counts. """ job_name = "cleanup-temp-files" logger.info("[batch] Starting cleanup_temp_files (max_age_hours=%s)", max_age_hours) tmp_dir = Path(settings.workdir) / "tmp" if not tmp_dir.exists(): detail = "workdir/tmp does not exist; nothing to clean." logger.info("[batch] cleanup_temp_files: %s", detail) _update_job_status(job_name, "success", detail) return {"deleted": 0, "skipped": 0} cutoff = datetime.now(timezone.utc) - timedelta(hours=max_age_hours) deleted = 0 skipped = 0 errors = 0 try: with SessionLocal() as db: # Collect filenames actively referenced by in-progress processing steps. in_progress_filenames: set[str] = set() in_progress_records = ( db.query(FileRecord.local_filename) .join(FileProcessingStep, FileProcessingStep.file_id == FileRecord.id) .filter(FileProcessingStep.status == "in_progress") .distinct() .all() ) for row in in_progress_records: if row.local_filename: in_progress_filenames.add(os.path.basename(row.local_filename)) # Also collect all filenames referenced by FileRecord.local_filename # that point into workdir/tmp (files still in the tmp pipeline). active_tmp_filenames: set[str] = set() tmp_dir_str = str(tmp_dir.resolve()) active_records = ( db.query(FileRecord.local_filename).filter(FileRecord.local_filename.like(f"{tmp_dir_str}%")).all() ) for row in active_records: if row.local_filename: active_tmp_filenames.add(os.path.basename(row.local_filename)) protected_basenames = in_progress_filenames | active_tmp_filenames for entry in tmp_dir.iterdir(): if not entry.is_file(): continue # Check modification time. try: mtime = datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc) except OSError: # pragma: no cover – only reachable if file vanishes between iterdir() and stat() skipped += 1 continue if mtime >= cutoff: skipped += 1 continue if entry.name in protected_basenames: logger.debug("[batch] cleanup_temp_files: keeping protected file %s", entry.name) skipped += 1 continue try: entry.unlink() logger.debug("[batch] cleanup_temp_files: deleted %s", entry) deleted += 1 except OSError as exc: logger.warning("[batch] cleanup_temp_files: could not delete %s: %s", entry, exc) errors += 1 detail = f"Deleted {deleted} stale temp file(s); skipped {skipped} (too new or protected); {errors} error(s)." status = "failed" if errors and not deleted else "success" logger.info("[batch] cleanup_temp_files: %s", detail) _update_job_status(job_name, status, detail) return {"deleted": deleted, "skipped": skipped, "errors": errors} except Exception as exc: detail = f"Error: {exc}" logger.error("[batch] cleanup_temp_files failed: %s", exc, exc_info=True) _update_job_status(job_name, "failed", detail) return {"deleted": 0, "skipped": 0, "errors": 1, "error": str(exc)} # --------------------------------------------------------------------------- # Task: expire stale shared links # --------------------------------------------------------------------------- @celery.task(name="app.tasks.batch_tasks.expire_shared_links") def expire_shared_links() -> dict: """ Auto-revoke SharedLinks whose ``expires_at`` timestamp has passed. The ``_is_link_valid`` helper in the shared-links API already blocks access at request time, but the database rows remain flagged as ``is_active=True``. This task sweeps those rows and sets ``is_active=False`` + ``revoked_at`` so the management UI reflects the true state and counts are accurate. Returns a summary dict with ``revoked`` count. """ job_name = "expire-shared-links" logger.info("[batch] Starting expire_shared_links task") try: now = datetime.now(timezone.utc) with SessionLocal() as db: stale = ( db.query(SharedLink) .filter( SharedLink.is_active.is_(True), SharedLink.expires_at.isnot(None), SharedLink.expires_at < now, ) .all() ) for link in stale: link.is_active = False link.revoked_at = now db.commit() revoked = len(stale) detail = f"Revoked {revoked} expired shared link(s)." logger.info("[batch] expire_shared_links: %s", detail) _update_job_status(job_name, "success", detail) return {"revoked": revoked} except Exception as exc: detail = f"Error: {exc}" logger.error("[batch] expire_shared_links failed: %s", exc, exc_info=True) _update_job_status(job_name, "failed", detail) return {"revoked": 0, "error": str(exc)} # --------------------------------------------------------------------------- # Task: prune old processing logs # --------------------------------------------------------------------------- #: Default retention period for processing logs and audit log rows. _LOG_RETENTION_DAYS: int = 30 @celery.task(name="app.tasks.batch_tasks.prune_processing_logs") def prune_processing_logs(retention_days: int = _LOG_RETENTION_DAYS) -> dict: """ Delete ``processing_logs`` and ``settings_audit_log`` rows older than *retention_days* (default 30) to prevent unbounded table growth. Rows for the most recent *retention_days* days are kept so that recent activity is still visible in the logs/audit UI. Args: retention_days: Number of days of history to keep (default 30). Returns: A summary dict with ``processing_logs_deleted`` and ``audit_log_deleted`` counts. """ job_name = "prune-processing-logs" logger.info("[batch] Starting prune_processing_logs (retention_days=%s)", retention_days) cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days) try: with SessionLocal() as db: pl_deleted = db.query(ProcessingLog).filter(ProcessingLog.timestamp < cutoff).delete() al_deleted = db.query(SettingsAuditLog).filter(SettingsAuditLog.changed_at < cutoff).delete() db.commit() detail = ( f"Deleted {pl_deleted} processing log row(s) and " f"{al_deleted} settings audit log row(s) older than {retention_days} days." ) logger.info("[batch] prune_processing_logs: %s", detail) _update_job_status(job_name, "success", detail) return {"processing_logs_deleted": pl_deleted, "audit_log_deleted": al_deleted} except Exception as exc: detail = f"Error: {exc}" logger.error("[batch] prune_processing_logs failed: %s", exc, exc_info=True) _update_job_status(job_name, "failed", detail) return {"processing_logs_deleted": 0, "audit_log_deleted": 0, "error": str(exc)} # --------------------------------------------------------------------------- # Task: prune old in-app notifications # --------------------------------------------------------------------------- #: Default retention period for read notifications. _NOTIFICATION_RETENTION_DAYS: int = 30 @celery.task(name="app.tasks.batch_tasks.prune_old_notifications") def prune_old_notifications(retention_days: int = _NOTIFICATION_RETENTION_DAYS) -> dict: """ Delete ``in_app_notifications`` rows that are already read and older than *retention_days* days (default 30) to prevent unbounded table growth. Unread notifications are always kept regardless of age so users do not miss important alerts. Args: retention_days: Number of days of read-notification history to keep (default 30). Returns: A summary dict with ``deleted`` count. """ job_name = "prune-old-notifications" logger.info("[batch] Starting prune_old_notifications (retention_days=%s)", retention_days) cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days) try: with SessionLocal() as db: deleted = ( db.query(InAppNotification) .filter( InAppNotification.is_read.is_(True), InAppNotification.created_at < cutoff, ) .delete() ) db.commit() detail = f"Deleted {deleted} old read notification(s) older than {retention_days} days." logger.info("[batch] prune_old_notifications: %s", detail) _update_job_status(job_name, "success", detail) return {"deleted": deleted} except Exception as exc: detail = f"Error: {exc}" logger.error("[batch] prune_old_notifications failed: %s", exc, exc_info=True) _update_job_status(job_name, "failed", detail) return {"deleted": 0, "error": str(exc)} # --------------------------------------------------------------------------- # Task: backfill missing AI metadata # --------------------------------------------------------------------------- #: Maximum number of files to process per backfill run. _METADATA_BACKFILL_BATCH_SIZE: int = 50 @celery.task(name="app.tasks.batch_tasks.backfill_missing_metadata") def backfill_missing_metadata(batch_size: int = _METADATA_BACKFILL_BATCH_SIZE) -> dict: """ Re-trigger AI metadata extraction for documents that have OCR text but no ``ai_metadata``. This handles the common case where a document was processed before the AI metadata extraction step was configured (e.g., before an OpenAI API key was added), or where the extraction previously failed. Only files that are **not** currently in-progress and have non-empty ``ocr_text`` are selected. A configurable *batch_size* caps the number of tasks queued per run to avoid overwhelming the AI provider. Args: batch_size: Maximum number of files to queue per run (default 50). Returns: A summary dict with ``queued`` count. """ from app.tasks.extract_metadata_with_gpt import extract_metadata_with_gpt # avoid circular import job_name = "backfill-missing-metadata" logger.info("[batch] Starting backfill_missing_metadata (batch_size=%s)", batch_size) try: with SessionLocal() as db: # Files currently being processed — skip them. in_progress_file_ids = ( db.query(FileProcessingStep.file_id) .filter(FileProcessingStep.status == "in_progress") .distinct() .subquery() ) candidates = ( db.query(FileRecord) .filter(FileRecord.is_duplicate.is_(False)) .filter(FileRecord.ocr_text.isnot(None)) .filter(FileRecord.ocr_text != "") .filter((FileRecord.ai_metadata.is_(None)) | (FileRecord.ai_metadata == "")) .filter(~FileRecord.id.in_(db.query(in_progress_file_ids.c.file_id))) .limit(batch_size) .all() ) queued = 0 for record in candidates: filename = record.local_filename or record.original_filename or f"file_{record.id}" extract_metadata_with_gpt.delay( filename, record.ocr_text, file_id=record.id, ) queued += 1 detail = f"Queued {queued} document(s) for AI metadata backfill." logger.info("[batch] backfill_missing_metadata: %s", detail) _update_job_status(job_name, "success", detail) return {"queued": queued} except Exception as exc: detail = f"Error: {exc}" logger.error("[batch] backfill_missing_metadata failed: %s", exc, exc_info=True) _update_job_status(job_name, "failed", detail) return {"queued": 0, "error": str(exc)} # --------------------------------------------------------------------------- # Task: sync Meilisearch search index # --------------------------------------------------------------------------- #: Maximum documents to index per sync run. _SEARCH_SYNC_BATCH_SIZE: int = 100 @celery.task(name="app.tasks.batch_tasks.sync_search_index") def sync_search_index(batch_size: int = _SEARCH_SYNC_BATCH_SIZE) -> dict: """ Index documents in Meilisearch that have OCR text or AI metadata but are not yet present in the search index. This is useful after: - Enabling Meilisearch for the first time on an existing installation. - Recovering from a Meilisearch index wipe or migration. - Documents processed before search indexing was added to the pipeline. The task queries the Meilisearch index for existing document IDs, then finds ``FileRecord`` rows that have processable content (``ocr_text`` or ``ai_metadata``) but are absent from the index, and re-indexes them. A configurable *batch_size* caps the number of documents indexed per run. Args: batch_size: Maximum number of documents to index per run (default 100). Returns: A summary dict with ``indexed`` and ``skipped`` counts. """ from app.utils.meilisearch_client import get_meilisearch_client, index_document job_name = "sync-search-index" logger.info("[batch] Starting sync_search_index (batch_size=%s)", batch_size) client = get_meilisearch_client() if client is None: detail = "Meilisearch is not configured; skipping search index sync." logger.info("[batch] sync_search_index: %s", detail) _update_job_status(job_name, "success", detail) return {"indexed": 0, "skipped": 0, "reason": "meilisearch_not_configured"} try: # Fetch the set of file_ids already in the Meilisearch index. index = client.get_index(settings.meilisearch_index_name) # Fetch up to 10 000 IDs — sufficient to determine gaps for most installs. existing_result = index.get_documents({"fields": ["file_id"], "limit": 10000}) existing_ids: set[int] = {doc["file_id"] for doc in existing_result.results if "file_id" in doc} except Exception as exc: detail = f"Error fetching existing Meilisearch IDs: {exc}" logger.error("[batch] sync_search_index: %s", detail) _update_job_status(job_name, "failed", detail) return {"indexed": 0, "skipped": 0, "error": str(exc)} try: with SessionLocal() as db: # Files with indexable content that are not already in the index. candidates = ( db.query(FileRecord) .filter(FileRecord.is_duplicate.is_(False)) .filter( (FileRecord.ocr_text.isnot(None) & (FileRecord.ocr_text != "")) | (FileRecord.ai_metadata.isnot(None) & (FileRecord.ai_metadata != "")) ) .filter(~FileRecord.id.in_(existing_ids) if existing_ids else True) # type: ignore[arg-type] .limit(batch_size) .all() ) indexed = 0 skipped = 0 for record in candidates: metadata: dict = {} if record.ai_metadata: try: metadata = json.loads(record.ai_metadata) except (json.JSONDecodeError, ValueError): pass success = index_document(record, record.ocr_text or "", metadata) if success: indexed += 1 else: skipped += 1 detail = f"Indexed {indexed} document(s) into Meilisearch; {skipped} skipped (indexing error)." logger.info("[batch] sync_search_index: %s", detail) _update_job_status(job_name, "success", detail) return {"indexed": indexed, "skipped": skipped} except Exception as exc: detail = f"Error: {exc}" logger.error("[batch] sync_search_index failed: %s", exc, exc_info=True) _update_job_status(job_name, "failed", detail) return {"indexed": 0, "skipped": 0, "error": str(exc)}