mirror of
https://github.com/langgenius/dify.git
synced 2026-07-25 13:38:31 +08:00
355 lines
15 KiB
Python
355 lines
15 KiB
Python
"""
|
|
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()
|