""" Prepare monthly workflow-run archive downloads. Console requests create a short-lived Redis task and Celery runs this module in the background. The DB bundle index is the online lookup source: this preparer never lists archive storage, and it validates the indexed bundle set against the stable download id before packaging archive Parquet objects into one user-facing CSV ZIP file. """ import datetime import hashlib import io import logging import re import zipfile from collections.abc import Sequence from typing import cast import pyarrow as pa import pyarrow.compute as pc import pyarrow.csv as pa_csv import pyarrow.parquet as pq from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker from core.helper.csv_sanitizer import CSVSanitizer from extensions.ext_database import db from libs.archive_storage import ArchiveStorage, get_archive_storage, get_export_storage from models.workflow import WorkflowRunArchiveBundle from services.retention.workflow_run.archive_bundle_index import ( ARCHIVE_BUNDLE_ROOT_PREFIX, ArchiveBundleManifest, ArchiveBundleTableManifestEntry, decode_archive_bundle_manifest, ) from services.retention.workflow_run.archive_download_task_cache import ( WorkflowRunArchiveDownloadStatus, WorkflowRunArchiveDownloadTask, WorkflowRunArchiveDownloadTaskCache, build_archive_download_id, ) from services.retention.workflow_run.constants import ( ARCHIVE_BUNDLE_FORMAT, ARCHIVE_BUNDLE_MANIFEST_NAME, ARCHIVE_BUNDLE_SCHEMA_VERSION, ) logger = logging.getLogger(__name__) ARCHIVE_DOWNLOAD_ROOT_PREFIX = "workflow-runs/downloads/v1/" ARCHIVE_DOWNLOAD_MIME_TYPE = "application/zip" _CSV_FORMULA_PREFIX_PATTERN = f"^[{re.escape(''.join(CSVSanitizer.FORMULA_CHARS))}]" class WorkflowRunArchiveDownloadPreparer: """ Build one ready-to-download CSV ZIP for a Redis archive download task. The output object is deterministic for a given `download_id`, so retrying a failed task overwrites the same temporary object instead of creating unbounded duplicate files. Source archive bundles are read from the archive bucket, while the prepared ZIP is written to the export bucket so object lifecycle policies can expire downloads without touching long-lived archives. """ archive_storage: ArchiveStorage | None download_storage: ArchiveStorage | None cache: WorkflowRunArchiveDownloadTaskCache session_factory: sessionmaker[Session] def __init__( self, *, storage: ArchiveStorage | None = None, archive_storage: ArchiveStorage | None = None, download_storage: ArchiveStorage | None = None, cache: WorkflowRunArchiveDownloadTaskCache | None = None, session_factory: sessionmaker[Session] | None = None, ) -> None: self.archive_storage = archive_storage or storage self.download_storage = download_storage or storage self.cache = cache or WorkflowRunArchiveDownloadTaskCache() self.session_factory = session_factory or sessionmaker(bind=db.engine, expire_on_commit=False) def prepare(self, *, tenant_id: str, download_id: str) -> WorkflowRunArchiveDownloadTask | None: """Prepare a ZIP for an existing Redis task and persist terminal task state.""" with self.cache.lock(tenant_id=tenant_id, download_id=download_id): task = self.cache.get(tenant_id=tenant_id, download_id=download_id) if task is None: logger.info("Workflow run archive download task expired before preparation: %s", download_id) return None if task.status != WorkflowRunArchiveDownloadStatus.PENDING: logger.info("Skipping workflow run archive download task in %s state: %s", task.status, download_id) return task processing_task = self._mark_processing(task) try: archive_storage = self.archive_storage or get_archive_storage() download_storage = self.download_storage or get_export_storage() bundles = self._get_task_bundles(processing_task) payload = self._build_zip_payload(archive_storage, processing_task, bundles) storage_key = build_archive_download_storage_key(processing_task) download_storage.put_object(storage_key, payload) return self._mark_ready(processing_task, storage_key=storage_key, file_size_bytes=len(payload)) except Exception as exc: logger.exception("Failed to prepare workflow run archive download %s", download_id) return self._mark_failed(processing_task, error=str(exc)) def _get_task_bundles(self, task: WorkflowRunArchiveDownloadTask) -> list[WorkflowRunArchiveBundle]: with self.session_factory() as session: return _list_task_bundles(session, task) def _build_zip_payload( self, storage: ArchiveStorage, task: WorkflowRunArchiveDownloadTask, bundles: Sequence[WorkflowRunArchiveBundle], ) -> bytes: zip_root = f"workflow-run-logs-{task.year:04d}-{task.month:02d}" csv_buffers: dict[str, io.BytesIO] = {} csv_headers_written: set[str] = set() for bundle in bundles: object_prefix = _build_archive_bundle_object_prefix(task, bundle) _, manifest = _load_and_validate_manifest(storage, task, bundle, object_prefix) for table_name in sorted(manifest["tables"]): entry = manifest["tables"][table_name] object_key = entry["object_key"] table_payload = storage.get_object(object_key) _validate_table_payload(object_key=object_key, entry=entry, payload=table_payload) csv_payload = _parquet_payload_to_csv( table_payload, include_header=table_name not in csv_headers_written, ) if not csv_payload: continue csv_buffers.setdefault(table_name, io.BytesIO()).write(csv_payload) csv_headers_written.add(table_name) buffer = io.BytesIO() with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive: for table_name, csv_buffer in sorted(csv_buffers.items()): archive.writestr(f"{zip_root}/{table_name}.csv", csv_buffer.getvalue()) return buffer.getvalue() def _mark_processing(self, task: WorkflowRunArchiveDownloadTask) -> WorkflowRunArchiveDownloadTask: now = datetime.datetime.now(datetime.UTC) processing_task = task.model_copy( update={ "status": WorkflowRunArchiveDownloadStatus.PROCESSING, "error": None, "updated_at": now, "started_at": task.started_at or now, } ) self.cache.save(processing_task) return processing_task def _mark_ready( self, task: WorkflowRunArchiveDownloadTask, *, storage_key: str, file_size_bytes: int, ) -> WorkflowRunArchiveDownloadTask: now = datetime.datetime.now(datetime.UTC) ready_task = task.model_copy( update={ "status": WorkflowRunArchiveDownloadStatus.READY, "file_name": build_archive_download_file_name(task), "storage_key": storage_key, "file_size_bytes": file_size_bytes, "error": None, "updated_at": now, "finished_at": now, } ) return self._save_terminal_task(task, ready_task) def _mark_failed(self, task: WorkflowRunArchiveDownloadTask, *, error: str) -> WorkflowRunArchiveDownloadTask: now = datetime.datetime.now(datetime.UTC) failed_task = task.model_copy( update={ "status": WorkflowRunArchiveDownloadStatus.FAILED, "error": error, "updated_at": now, "finished_at": now, } ) return self._save_terminal_task(task, failed_task) def _save_terminal_task( self, processing_task: WorkflowRunArchiveDownloadTask, terminal_task: WorkflowRunArchiveDownloadTask, ) -> WorkflowRunArchiveDownloadTask: with self.cache.lock(tenant_id=processing_task.tenant_id, download_id=processing_task.download_id): current = self.cache.get( tenant_id=processing_task.tenant_id, download_id=processing_task.download_id, ) if ( current is None or current.status != WorkflowRunArchiveDownloadStatus.PROCESSING or current.celery_task_id != processing_task.celery_task_id ): return current or terminal_task self.cache.save(terminal_task) return terminal_task def build_archive_download_file_name(task: WorkflowRunArchiveDownloadTask) -> str: """Return the browser download filename for one monthly archive.""" return f"workflow-run-logs-{task.year:04d}-{task.month:02d}.zip" def build_archive_download_storage_key(task: WorkflowRunArchiveDownloadTask) -> str: """Return the deterministic object-store key for a prepared download ZIP.""" return ( f"{ARCHIVE_DOWNLOAD_ROOT_PREFIX}tenant_prefix={task.tenant_id[0].lower()}/tenant_id={task.tenant_id}/" f"year={task.year:04d}/month={task.month:02d}/{task.download_id}.zip" ) def _list_task_bundles(session: Session, task: WorkflowRunArchiveDownloadTask) -> list[WorkflowRunArchiveBundle]: stmt = ( select(WorkflowRunArchiveBundle) .where( WorkflowRunArchiveBundle.tenant_id == task.tenant_id, WorkflowRunArchiveBundle.year == task.year, WorkflowRunArchiveBundle.month == task.month, ) .order_by(WorkflowRunArchiveBundle.shard, WorkflowRunArchiveBundle.bundle_id) ) indexed_bundles = list(session.scalars(stmt)) if task.bundle_refs: requested_refs = [(ref.shard, ref.bundle_id) for ref in task.bundle_refs] else: requested_bundle_ids = set(task.bundle_ids) requested_refs = [ (bundle.shard, bundle.bundle_id) for bundle in indexed_bundles if bundle.bundle_id in requested_bundle_ids ] bundle_by_ref = {(bundle.shard, bundle.bundle_id): bundle for bundle in indexed_bundles} missing_refs = [ref for ref in requested_refs if ref not in bundle_by_ref] if missing_refs: raise ValueError(f"archive bundle index is missing requested bundles: {missing_refs}") bundles = [bundle_by_ref[ref] for ref in requested_refs] if len(bundles) != task.bundle_count: raise ValueError(f"archive bundle count changed: expected={task.bundle_count}, actual={len(bundles)}") download_id = build_archive_download_id( tenant_id=task.tenant_id, year=task.year, month=task.month, bundle_refs=requested_refs, ) if download_id != task.download_id: raise ValueError("archive download id no longer matches indexed bundle set") return bundles def _build_archive_bundle_object_prefix( task: WorkflowRunArchiveDownloadTask, bundle: WorkflowRunArchiveBundle, ) -> str: return ( f"{ARCHIVE_BUNDLE_ROOT_PREFIX}tenant_prefix={task.tenant_id[0].lower()}/tenant_id={task.tenant_id}/" f"year={task.year:04d}/month={task.month:02d}/shard={bundle.shard}/bundle={bundle.bundle_id}" ) def _load_and_validate_manifest( storage: ArchiveStorage, task: WorkflowRunArchiveDownloadTask, bundle: WorkflowRunArchiveBundle, object_prefix: str, ) -> tuple[bytes, ArchiveBundleManifest]: manifest_key = f"{object_prefix}/{ARCHIVE_BUNDLE_MANIFEST_NAME}" manifest_data = storage.get_object(manifest_key) manifest = decode_archive_bundle_manifest(manifest_data) _validate_manifest(task=task, bundle=bundle, manifest=manifest, object_prefix=object_prefix) return manifest_data, manifest def _validate_manifest( *, task: WorkflowRunArchiveDownloadTask, bundle: WorkflowRunArchiveBundle, manifest: ArchiveBundleManifest, object_prefix: str, ) -> None: if manifest["schema_version"] != ARCHIVE_BUNDLE_SCHEMA_VERSION: raise ValueError(f"unsupported archive bundle schema version: {manifest['schema_version']}") if manifest["archive_format"] != ARCHIVE_BUNDLE_FORMAT: raise ValueError(f"unsupported archive bundle format: {manifest['archive_format']}") if manifest["tenant_id"] != task.tenant_id: raise ValueError(f"manifest tenant_id mismatch: expected={task.tenant_id}, actual={manifest['tenant_id']}") if manifest["year"] != task.year: raise ValueError(f"manifest year mismatch: expected={task.year}, actual={manifest['year']}") if manifest["month"] != task.month: raise ValueError(f"manifest month mismatch: expected={task.month}, actual={manifest['month']}") if manifest["shard"] != bundle.shard: raise ValueError(f"manifest shard mismatch: expected={bundle.shard}, actual={manifest['shard']}") if manifest["bundle_id"] != bundle.bundle_id: raise ValueError(f"manifest bundle_id mismatch: expected={bundle.bundle_id}, actual={manifest['bundle_id']}") if manifest["object_prefix"] != object_prefix: raise ValueError( f"manifest object_prefix mismatch: expected={object_prefix}, actual={manifest['object_prefix']}" ) if not manifest["tables"]: raise ValueError("manifest tables must not be empty") for table_name, raw_entry in manifest["tables"].items(): entry = cast(ArchiveBundleTableManifestEntry, raw_entry) expected_object_key = f"{object_prefix}/{table_name}.parquet" if entry["object_key"] != expected_object_key: raise ValueError( f"manifest object_key mismatch for {table_name}: " f"expected={expected_object_key}, actual={entry['object_key']}" ) def _validate_table_payload( *, object_key: str, entry: ArchiveBundleTableManifestEntry, payload: bytes, ) -> None: if len(payload) != entry["size_bytes"]: raise ValueError(f"archive object size mismatch for {object_key}") checksum = hashlib.md5(payload).hexdigest() if checksum != entry["checksum"]: raise ValueError(f"archive object checksum mismatch for {object_key}") def _parquet_payload_to_csv(payload: bytes, *, include_header: bool) -> bytes: table = pq.read_table(io.BytesIO(payload)) if table.num_columns == 0: return b"" for index, field in enumerate(table.schema): if pa.types.is_string(field.type) or pa.types.is_large_string(field.type): sanitized_column = pc.replace_substring_regex( # pyrefly: ignore[missing-attribute] table.column(index), pattern=_CSV_FORMULA_PREFIX_PATTERN, replacement=r"'\0", max_replacements=1, ) table = table.set_column(index, field, sanitized_column) buffer = io.BytesIO() pa_csv.write_csv( table, buffer, write_options=pa_csv.WriteOptions(include_header=include_header), ) return buffer.getvalue()