Files
gh-christianlouis-docuelevate/app/tasks/batch_tasks.py
T

670 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)}