import datetime import logging import re import time import uuid from collections.abc import Callable from typing import TypedDict import click import sqlalchemy as sa from sqlalchemy.orm import Session, sessionmaker from extensions.ext_database import db from libs.datetime_utils import naive_utc_now from services.clear_free_plan_tenant_expired_logs import ClearFreePlanTenantExpiredLogs from services.retention.conversation.messages_clean_policy import create_message_clean_policy from services.retention.conversation.messages_clean_service import MessagesCleanService from services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs import WorkflowRunCleanup from services.retention.workflow_run.db_retry import run_with_db_retry from services.retention.workflow_run.tenant_prefix import tenant_prefix_condition from tasks.remove_app_and_related_data_task import delete_draft_variables_batch logger = logging.getLogger(__name__) _HEX_PREFIXES = tuple("0123456789abcdef") _TARGET_MONTH_PATTERN = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$") class WorkflowRunArchivePlanRow(TypedDict): tenant_prefix: str total_tenants: int workflow_runs: int workflow_node_executions: int paid_tenants: int unpaid_tenants: int class WorkflowRunArchiveTenantPlan(TypedDict): archive_tenant_ids: list[str] | None paid_tenant_ids: list[str] unpaid_tenant_ids: list[str] class WorkflowRunArchivePrefixStats(TypedDict): tenant_ids: list[str] workflow_runs: int workflow_node_executions: int def _normalize_utc_datetime(value: datetime.datetime) -> datetime.datetime: if value.tzinfo is None: return value.replace(tzinfo=datetime.UTC) return value.astimezone(datetime.UTC) def _parse_tenant_prefixes(prefixes: str | None) -> list[str]: if not prefixes: return [] parsed = [] for raw_prefix in prefixes.split(","): prefix = raw_prefix.strip().lower() if not prefix: continue if len(prefix) != 1 or prefix not in _HEX_PREFIXES: raise click.UsageError("--tenant-prefixes must be a comma-separated list of hex digits, e.g. 0,1,a,f.") parsed.append(prefix) return sorted(set(parsed)) def _parse_comma_separated_ids(raw_ids: str | None, *, param_name: str) -> list[str] | None: """Keep an omitted scope unset while rejecting an explicitly empty scope.""" if raw_ids is None: return None parsed = sorted({raw_id.strip() for raw_id in raw_ids.split(",") if raw_id.strip()}) if not parsed: raise click.BadParameter(f"{param_name} must not be empty") return parsed def _parse_archive_target_month(target_month: str) -> tuple[int, int]: """Validate the V2 catalog month selector and return its numeric components.""" if not _TARGET_MONTH_PATTERN.fullmatch(target_month): raise click.BadParameter("target-month must use YYYY-MM format", param_hint="--target-month") year_text, month_text = target_month.split("-", maxsplit=1) return int(year_text), int(month_text) def _parse_archive_catalog_cursor(after_catalog_id: str | None) -> str | None: """Normalize the exclusive V2 catalog keyset cursor when one is provided.""" if after_catalog_id is None: return None try: return str(uuid.UUID(after_catalog_id)) except ValueError as exc: raise click.BadParameter( "after-catalog-id must be a UUID returned by the same V2 operation and scope", param_hint="--after-catalog-id", ) from exc def _get_archive_candidate_tenant_ids_by_prefix( session: Session, prefix: str, *, start_from: datetime.datetime | None, end_before: datetime.datetime, ) -> list[str]: from graphon.enums import WorkflowExecutionStatus from models.workflow import WorkflowRun from services.retention.workflow_run.archive_paid_plan_workflow_run import WorkflowRunArchiver conditions = [ WorkflowRun.created_at < end_before, WorkflowRun.status.in_(WorkflowExecutionStatus.ended_values()), WorkflowRun.type.in_(WorkflowRunArchiver.ARCHIVED_TYPE), tenant_prefix_condition(WorkflowRun.tenant_id, prefix), ] if start_from is not None: conditions.append(WorkflowRun.created_at >= start_from) tenant_ids = session.scalars( sa.select(WorkflowRun.tenant_id).where(*conditions).distinct().order_by(WorkflowRun.tenant_id) ).all() return list(tenant_ids) def _filter_paid_workflow_archive_tenant_ids(tenant_ids: list[str]) -> tuple[list[str], list[str]]: from configs import dify_config from enums.cloud_plan import CloudPlan from services.billing_service import BillingService tenant_ids = sorted(set(tenant_ids)) if not tenant_ids: return [], [] if not dify_config.BILLING_ENABLED: return tenant_ids, [] plans = BillingService.get_plan_bulk_with_cache(tenant_ids) paid_tenant_ids = [ tenant_id for tenant_id in tenant_ids if plans.get(tenant_id) and plans[tenant_id].get("plan") in (CloudPlan.PROFESSIONAL, CloudPlan.TEAM) ] unpaid_tenant_ids = sorted(set(tenant_ids) - set(paid_tenant_ids)) return paid_tenant_ids, unpaid_tenant_ids def _run_archive_command_db_retry[T](operation_name: str, operation: Callable[[], T]) -> T: return run_with_db_retry(operation_name, operation, logger=logger) def _get_archive_candidate_tenant_ids_with_retry( session_maker: sessionmaker[Session], prefix: str, *, start_from: datetime.datetime | None, end_before: datetime.datetime, ) -> list[str]: def fetch_tenant_ids() -> list[str]: with session_maker() as session: return _get_archive_candidate_tenant_ids_by_prefix( session, prefix, start_from=start_from, end_before=end_before, ) return _run_archive_command_db_retry(f"workflow archive tenant resolve for prefix {prefix}", fetch_tenant_ids) def _get_archive_plan_prefix_stats( session_maker: sessionmaker[Session], prefix: str, *, start_from: datetime.datetime | None, end_before: datetime.datetime, ) -> WorkflowRunArchivePrefixStats: from graphon.enums import WorkflowExecutionStatus from models.workflow import WorkflowNodeExecutionModel, WorkflowRun from services.retention.workflow_run.archive_paid_plan_workflow_run import WorkflowRunArchiver def fetch_prefix_stats() -> WorkflowRunArchivePrefixStats: with session_maker() as session: tenant_ids = _get_archive_candidate_tenant_ids_by_prefix( session, prefix, start_from=start_from, end_before=end_before, ) run_conditions = [ WorkflowRun.created_at < end_before, WorkflowRun.status.in_(WorkflowExecutionStatus.ended_values()), WorkflowRun.type.in_(WorkflowRunArchiver.ARCHIVED_TYPE), tenant_prefix_condition(WorkflowRun.tenant_id, prefix), ] if start_from is not None: run_conditions.append(WorkflowRun.created_at >= start_from) workflow_runs = ( session.scalar(sa.select(sa.func.count()).select_from(WorkflowRun).where(*run_conditions)) or 0 ) candidate_runs = sa.select(WorkflowRun.id).where(*run_conditions).subquery() workflow_node_executions = ( session.scalar( sa.select(sa.func.count()) .select_from(WorkflowNodeExecutionModel) .join(candidate_runs, WorkflowNodeExecutionModel.workflow_run_id == candidate_runs.c.id) ) or 0 ) return WorkflowRunArchivePrefixStats( tenant_ids=tenant_ids, workflow_runs=workflow_runs, workflow_node_executions=workflow_node_executions, ) return _run_archive_command_db_retry(f"workflow archive plan for prefix {prefix}", fetch_prefix_stats) def _resolve_archive_tenant_ids_from_plan( *, session_maker: sessionmaker[Session], tenant_ids: str | None, tenant_prefixes: list[str], start_from: datetime.datetime | None, end_before: datetime.datetime, ) -> WorkflowRunArchiveTenantPlan: """ Resolve the archive tenant scope once before scanning workflow_runs. Prefix rollout should use the tenant list collected by the same planning path, then archive by tenant_id IN (...). Scanning workflow_runs with a tenant prefix range in every archive run is too expensive on the large production table this command is meant to shrink. """ if tenant_ids: requested_tenant_ids = [tid.strip() for tid in tenant_ids.split(",") if tid.strip()] elif tenant_prefixes: requested_tenant_ids = [] for prefix in tenant_prefixes: requested_tenant_ids.extend( _get_archive_candidate_tenant_ids_with_retry( session_maker, prefix, start_from=start_from, end_before=end_before, ) ) else: return WorkflowRunArchiveTenantPlan( archive_tenant_ids=None, paid_tenant_ids=[], unpaid_tenant_ids=[], ) paid_tenant_ids, unpaid_tenant_ids = _filter_paid_workflow_archive_tenant_ids(requested_tenant_ids) return WorkflowRunArchiveTenantPlan( archive_tenant_ids=paid_tenant_ids, paid_tenant_ids=paid_tenant_ids, unpaid_tenant_ids=unpaid_tenant_ids, ) def _safe_remove_scoped_session(context: str) -> None: try: db.session.remove() except Exception: logger.warning("Ignoring DB scoped-session cleanup error after %s", context, exc_info=True) try: db.session.registry.clear() except Exception: logger.warning("Ignoring DB scoped-session registry cleanup error after %s", context, exc_info=True) try: db.engine.dispose() except Exception: logger.warning("Ignoring DB engine dispose error after %s", context, exc_info=True) def _resolve_archive_time_range( *, before_days: int, from_days_ago: int | None, to_days_ago: int | None, start_from: datetime.datetime | None, end_before: datetime.datetime | None, ) -> tuple[int, datetime.datetime | None, datetime.datetime | None]: if (start_from is None) ^ (end_before is None): raise click.UsageError("--start-from and --end-before must be provided together.") if (from_days_ago is None) ^ (to_days_ago is None): raise click.UsageError("--from-days-ago and --to-days-ago must be provided together.") if from_days_ago is not None and to_days_ago is not None: if start_from or end_before: raise click.UsageError("Choose either day offsets or explicit dates, not both.") if from_days_ago <= to_days_ago: raise click.UsageError("--from-days-ago must be greater than --to-days-ago.") now = datetime.datetime.now(datetime.UTC) start_from = now - datetime.timedelta(days=from_days_ago) end_before = now - datetime.timedelta(days=to_days_ago) before_days = 0 if start_from is not None: start_from = _normalize_utc_datetime(start_from) if end_before is not None: end_before = _normalize_utc_datetime(end_before) if start_from and end_before and start_from >= end_before: raise click.UsageError("--start-from must be earlier than --end-before.") return before_days, start_from, end_before @click.command("clear-free-plan-tenant-expired-logs", help="Clear free plan tenant expired logs.") @click.option("--days", prompt=True, help="The days to clear free plan tenant expired logs.", default=30) @click.option("--batch", prompt=True, help="The batch size to clear free plan tenant expired logs.", default=100) @click.option( "--tenant_ids", prompt=True, multiple=True, help="The tenant ids to clear free plan tenant expired logs.", ) def clear_free_plan_tenant_expired_logs(days: int, batch: int, tenant_ids: list[str]): """ Clear free plan tenant expired logs. """ click.echo(click.style("Starting clear free plan tenant expired logs.", fg="white")) ClearFreePlanTenantExpiredLogs.process(days, batch, tenant_ids) click.echo(click.style("Clear free plan tenant expired logs completed.", fg="green")) @click.command("clean-workflow-runs", help="Clean expired workflow runs and related data for free tenants.") @click.option( "--before-days", "--days", default=30, show_default=True, type=click.IntRange(min=0), help="Delete workflow runs created before N days ago.", ) @click.option("--batch-size", default=200, show_default=True, help="Batch size for selecting workflow runs.") @click.option( "--from-days-ago", default=None, type=click.IntRange(min=0), help="Lower bound in days ago (older). Must be paired with --to-days-ago.", ) @click.option( "--to-days-ago", default=None, type=click.IntRange(min=0), help="Upper bound in days ago (newer). Must be paired with --from-days-ago.", ) @click.option( "--start-from", type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]), default=None, help="Optional lower bound (inclusive) for created_at; must be paired with --end-before.", ) @click.option( "--end-before", type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]), default=None, help="Optional upper bound (exclusive) for created_at; must be paired with --start-from.", ) @click.option( "--dry-run", is_flag=True, help="Preview cleanup results without deleting any workflow run data.", ) def clean_workflow_runs( before_days: int, batch_size: int, from_days_ago: int | None, to_days_ago: int | None, start_from: datetime.datetime | None, end_before: datetime.datetime | None, dry_run: bool, ): """ Clean workflow runs and related workflow data for free tenants. """ from extensions.otel.runtime import flush_telemetry if (start_from is None) ^ (end_before is None): raise click.UsageError("--start-from and --end-before must be provided together.") if (from_days_ago is None) ^ (to_days_ago is None): raise click.UsageError("--from-days-ago and --to-days-ago must be provided together.") if from_days_ago is not None and to_days_ago is not None: if start_from or end_before: raise click.UsageError("Choose either day offsets or explicit dates, not both.") if from_days_ago <= to_days_ago: raise click.UsageError("--from-days-ago must be greater than --to-days-ago.") now = datetime.datetime.now() start_from = now - datetime.timedelta(days=from_days_ago) end_before = now - datetime.timedelta(days=to_days_ago) before_days = 0 if from_days_ago is not None and to_days_ago is not None: task_label = f"{from_days_ago}to{to_days_ago}" elif start_from is None: task_label = f"before-{before_days}" else: task_label = "custom" start_time = datetime.datetime.now(datetime.UTC) click.echo(click.style(f"Starting workflow run cleanup at {start_time.isoformat()}.", fg="white")) try: WorkflowRunCleanup( days=before_days, batch_size=batch_size, start_from=start_from, end_before=end_before, dry_run=dry_run, task_label=task_label, ).run() finally: flush_telemetry() end_time = datetime.datetime.now(datetime.UTC) elapsed = end_time - start_time click.echo( click.style( f"Workflow run cleanup completed. start={start_time.isoformat()} " f"end={end_time.isoformat()} duration={elapsed}", fg="green", ) ) @click.command( "archive-workflow-runs-plan", help="Plan workflow run archive rollout by tenant ID first hex digit.", ) @click.option("--before-days", default=90, show_default=True, help="Plan runs older than N days.") @click.option( "--from-days-ago", default=None, type=click.IntRange(min=0), help="Lower bound in days ago (older). Must be paired with --to-days-ago.", ) @click.option( "--to-days-ago", default=None, type=click.IntRange(min=0), help="Upper bound in days ago (newer). Must be paired with --from-days-ago.", ) @click.option( "--start-from", type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]), default=None, help="Plan runs created at or after this timestamp (UTC if no timezone).", ) @click.option( "--end-before", type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]), default=None, help="Plan runs created before this timestamp (UTC if no timezone).", ) @click.option( "--include-archived", is_flag=True, help="Compatibility no-op for V2 bundle archive; plan counts source rows in the requested window.", ) def archive_workflow_runs_plan( before_days: int, from_days_ago: int | None, to_days_ago: int | None, start_from: datetime.datetime | None, end_before: datetime.datetime | None, include_archived: bool, ): """ Print the 16 tenant-prefix rollout rows used to choose archive execution order. Counts use the same workflow run eligibility as archive-workflow-runs: ended runs, supported workflow types, and the requested created_at window. V2 bundle archive does not maintain per-run archive logs, so this plan reports source-table volume. """ before_days, start_from, end_before = _resolve_archive_time_range( before_days=before_days, from_days_ago=from_days_ago, to_days_ago=to_days_ago, start_from=start_from, end_before=end_before, ) plan_end_before = end_before or datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=before_days) if include_archived: click.echo(click.style("--include-archived is a no-op for V2 bundle archive plans.", fg="yellow")) session_maker = sessionmaker(bind=db.engine, expire_on_commit=False) rows: list[WorkflowRunArchivePlanRow] = [] for prefix in _HEX_PREFIXES: try: prefix_stats = _get_archive_plan_prefix_stats( session_maker, prefix, start_from=start_from, end_before=plan_end_before, ) except Exception as exc: logger.exception("Failed to build workflow archive plan for prefix %s", prefix) raise click.ClickException(f"Failed to build workflow archive plan for prefix {prefix}.") from exc tenant_ids = prefix_stats["tenant_ids"] workflow_runs = prefix_stats["workflow_runs"] workflow_node_executions = prefix_stats["workflow_node_executions"] total_tenants = len(tenant_ids) paid_tenant_ids, unpaid_tenant_ids = _filter_paid_workflow_archive_tenant_ids(tenant_ids) rows.append( WorkflowRunArchivePlanRow( tenant_prefix=prefix, total_tenants=total_tenants, workflow_runs=workflow_runs, workflow_node_executions=workflow_node_executions, paid_tenants=len(paid_tenant_ids), unpaid_tenants=len(unpaid_tenant_ids), ) ) click.echo( click.style( f"Workflow archive plan for runs before {plan_end_before.isoformat()}" f"{f' and at/after {start_from.isoformat()}' if start_from else ''}.", fg="white", ) ) click.echo( click.style( "fixed_archive_window=" f"{start_from.isoformat() if start_from else 'unbounded'},{plan_end_before.isoformat()}", fg="white", ) ) click.echo("tenant_prefix,total_tenants,workflow_runs,workflow_node_executions,paid_tenants,unpaid_tenants") for row in rows: click.echo( f"{row['tenant_prefix']},{row['total_tenants']},{row['workflow_runs']}," f"{row['workflow_node_executions']},{row['paid_tenants']},{row['unpaid_tenants']}" ) ordered_rows = sorted( rows, key=lambda row: (row["workflow_runs"] + row["workflow_node_executions"], row["tenant_prefix"]), ) click.echo("suggested_execution_order=" + ",".join(row["tenant_prefix"] for row in ordered_rows)) @click.command( "archive-workflow-runs", help="Archive workflow runs for paid plan tenants to S3-compatible storage.", ) @click.option("--tenant-ids", default=None, help="Optional comma-separated tenant IDs for grayscale rollout.") @click.option( "--tenant-prefixes", default=None, help="Optional comma-separated tenant ID first hex digits for rollout waves, e.g. 0,1,a,f.", ) @click.option("--before-days", default=90, show_default=True, help="Archive runs older than N days.") @click.option( "--from-days-ago", default=None, type=click.IntRange(min=0), help="Lower bound in days ago (older). Must be paired with --to-days-ago.", ) @click.option( "--to-days-ago", default=None, type=click.IntRange(min=0), help="Upper bound in days ago (newer). Must be paired with --from-days-ago.", ) @click.option( "--start-from", type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]), default=None, help="Archive runs created at or after this timestamp (UTC if no timezone).", ) @click.option( "--end-before", type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]), default=None, help="Archive runs created before this timestamp (UTC if no timezone).", ) @click.option("--batch-size", default=10000, show_default=True, help="Maximum workflow runs per archive bundle.") @click.option( "--workers", default=1, show_default=True, type=int, help="Reserved; bundle archive currently runs serially.", ) @click.option( "--run-shard-index", default=None, type=click.IntRange(min=0), help="Zero-based workflow run shard index for parallel cron jobs. Must be paired with --run-shard-total.", ) @click.option( "--run-shard-total", default=None, type=click.IntRange(min=1, max=16), help="Total workflow run shard count for parallel cron jobs. Must be paired with --run-shard-index.", ) @click.option("--limit", default=None, type=int, help="Maximum number of runs to archive.") @click.option("--dry-run", is_flag=True, help="Preview without archiving.") @click.option( "--delete-after-archive", is_flag=True, help="Not supported by bundle archive; use a separate bundle delete workflow after validation.", ) def archive_workflow_runs( tenant_ids: str | None, tenant_prefixes: str | None, before_days: int, from_days_ago: int | None, to_days_ago: int | None, start_from: datetime.datetime | None, end_before: datetime.datetime | None, batch_size: int, workers: int, run_shard_index: int | None, run_shard_total: int | None, limit: int | None, dry_run: bool, delete_after_archive: bool, ): """ Archive workflow runs for paid plan tenants older than the specified days. This command writes V2 tenant/month/shard archive bundles. Each bundle contains Parquet snapshots from: - workflow_runs - workflow_app_logs - workflow_node_executions - workflow_node_execution_offload - workflow_pauses - workflow_pause_reasons - workflow_trigger_logs Source database rows are always preserved by archive. Deletion must be handled by a separate bundle-level delete workflow after manifest, checksum, row-count, and restore-sampling validation. In --dry-run mode, no storage or database writes happen; the command estimates per-table Parquet bytes and object size instead. """ from services.retention.workflow_run.archive_paid_plan_workflow_run import WorkflowRunArchiver run_started_at = datetime.datetime.now(datetime.UTC) click.echo( click.style( f"Starting workflow run archiving at {run_started_at.isoformat()}.", fg="white", ) ) uses_relative_window = start_from is None and end_before is None try: before_days, start_from, end_before = _resolve_archive_time_range( before_days=before_days, from_days_ago=from_days_ago, to_days_ago=to_days_ago, start_from=start_from, end_before=end_before, ) parsed_tenant_prefixes = _parse_tenant_prefixes(tenant_prefixes) except click.UsageError as e: click.echo(click.style(e.message, fg="red")) return plan_end_before = end_before or datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=before_days) if workers < 1: click.echo(click.style("workers must be at least 1.", fg="red")) return if (run_shard_index is None) ^ (run_shard_total is None): click.echo(click.style("run-shard-index and run-shard-total must be provided together.", fg="red")) return if run_shard_index is not None and run_shard_total is not None and run_shard_index >= run_shard_total: click.echo(click.style("run-shard-index must be less than run-shard-total.", fg="red")) return if delete_after_archive: click.echo(click.style("delete-after-archive is not supported by bundle archive.", fg="red")) return if uses_relative_window: click.echo( click.style( "Relative archive windows are evaluated at command start. For multi-day prefix/shard rollout, " "reuse absolute --start-from/--end-before values from archive-workflow-runs-plan.", fg="yellow", ) ) session_maker = sessionmaker(bind=db.engine, expire_on_commit=False) try: tenant_plan = _resolve_archive_tenant_ids_from_plan( session_maker=session_maker, tenant_ids=tenant_ids, tenant_prefixes=parsed_tenant_prefixes, start_from=start_from, end_before=plan_end_before, ) except Exception as exc: logger.exception("Failed to resolve workflow archive tenant plan") raise click.ClickException("Failed to resolve workflow archive tenant plan.") from exc planned_tenant_ids = tenant_plan["archive_tenant_ids"] planned_paid_tenant_ids = tenant_plan["paid_tenant_ids"] if planned_tenant_ids is not None else None paid_tenants = len(tenant_plan["paid_tenant_ids"]) unpaid_tenants = len(tenant_plan["unpaid_tenant_ids"]) if planned_tenant_ids is not None: click.echo( click.style( f"Resolved archive tenant plan: paid_tenants={paid_tenants}, unpaid_tenants={unpaid_tenants}.", fg="white", ) ) if not planned_tenant_ids: click.echo(click.style("No paid tenants matched the archive plan; nothing to archive.", fg="yellow")) return archiver = WorkflowRunArchiver( days=before_days, batch_size=batch_size, start_from=start_from, end_before=end_before, workers=workers, tenant_ids=planned_tenant_ids, tenant_prefixes=parsed_tenant_prefixes, paid_tenant_ids=planned_paid_tenant_ids, run_shard_index=run_shard_index, run_shard_total=run_shard_total, limit=limit, dry_run=dry_run, delete_after_archive=delete_after_archive, ) try: summary = archiver.run() finally: _safe_remove_scoped_session("archive workflow run command") click.echo( click.style( f"Summary: processed={summary.total_runs_processed}, archived={summary.runs_archived}, " f"skipped={summary.runs_skipped}, failed={summary.runs_failed}, " f"bundles_archived={summary.bundles_archived}, bundles_skipped={summary.bundles_skipped}, " f"bundles_failed={summary.bundles_failed}, " f"object_size_bytes={summary.total_object_size_bytes}, time={summary.total_elapsed_time:.2f}s", fg="cyan", ) ) run_finished_at = datetime.datetime.now(datetime.UTC) elapsed = run_finished_at - run_started_at click.echo( click.style( f"Workflow run archiving completed. start={run_started_at.isoformat()} " f"end={run_finished_at.isoformat()} duration={elapsed}", fg="green", ) ) @click.command( "backfill-workflow-run-archive-bundles", help="Backfill workflow-run archive bundle DB index from object-storage manifests.", ) @click.option("--tenant-ids", default=None, help="Optional comma-separated tenant IDs.") @click.option( "--tenant-prefixes", default=None, help="Optional comma-separated tenant ID first hex digits, e.g. 0,1,a,f.", ) @click.option("--year", default=None, type=click.IntRange(min=1, max=9999), help="Optional archive year filter.") @click.option("--month", default=None, type=click.IntRange(min=1, max=12), help="Optional archive month filter.") @click.option("--limit", default=None, type=click.IntRange(min=1), help="Maximum number of manifests to process.") @click.option("--dry-run", is_flag=True, help="Preview without writing workflow_run_archive_bundles.") def backfill_workflow_run_archive_bundles( tenant_ids: str | None, tenant_prefixes: str | None, year: int | None, month: int | None, limit: int | None, dry_run: bool, ) -> None: """ Reconcile `workflow_run_archive_bundles` from V2 archive manifests. This command is meant for bootstrapping the listing/download index after deploy or repairing index drift. The R2 manifests remain the source of truth; this command only mirrors their query metadata into the database. """ from services.retention.workflow_run.archive_bundle_index import WorkflowRunArchiveBundleIndexBackfill if tenant_ids and tenant_prefixes: raise click.UsageError("Choose either --tenant-ids or --tenant-prefixes, not both.") if month is not None and year is None: raise click.UsageError("--month must be used with --year.") parsed_tenant_ids = _parse_comma_separated_ids(tenant_ids, param_name="tenant-ids") parsed_tenant_prefixes = _parse_tenant_prefixes(tenant_prefixes) if not parsed_tenant_ids and not parsed_tenant_prefixes: click.echo( click.style( "No tenant scope supplied; scanning the full workflow-runs/v2/ archive prefix.", fg="yellow", ) ) started_at = datetime.datetime.now(datetime.UTC) click.echo(click.style(f"Starting archive bundle index backfill at {started_at.isoformat()}.", fg="white")) backfill = WorkflowRunArchiveBundleIndexBackfill() summary = backfill.run( tenant_ids=parsed_tenant_ids, tenant_prefixes=parsed_tenant_prefixes or None, year=year, month=month, limit=limit, dry_run=dry_run, ) status = "completed with failures" if summary.bundles_failed else "completed successfully" fg = "red" if summary.bundles_failed else "green" action = "would_upsert" if dry_run else "upserted" action_count = summary.bundles_processed if dry_run else summary.bundles_upserted click.echo( click.style( f"Backfill {status}. manifests_found={summary.manifests_found} " f"bundles_processed={summary.bundles_processed} {action}={action_count} " f"bundles_failed={summary.bundles_failed} runs={summary.workflow_run_count} rows={summary.row_count} " f"archive_bytes={summary.archive_bytes} duration={summary.elapsed_time:.2f}s", fg=fg, ) ) for error in summary.errors[:10]: click.echo(click.style(f" failed {error}", fg="red")) if len(summary.errors) > 10: click.echo(click.style(f" ... and {len(summary.errors) - 10} more failures", fg="red")) def _echo_bundle_archive_operation_summary(summary, *, dry_run: bool) -> None: status = "completed successfully" if summary.bundles_failed == 0 else "completed with failures" fg = "green" if summary.bundles_failed == 0 else "red" cursor_label = "preview_next_catalog_id" if dry_run else "next_catalog_id" cursor_value = summary.preview_next_catalog_id if dry_run else summary.next_catalog_id click.echo( click.style( f"{summary.operation} {status}. " f"bundles_success={summary.bundles_succeeded} bundles_failed={summary.bundles_failed} " f"runs={summary.runs_processed} rows={summary.rows_processed} " f"archive_bytes={summary.archive_bytes} duration={summary.elapsed_time:.2f}s " f"validation_time={summary.validation_time:.2f}s " f"runs_per_second={summary.runs_per_second:.2f} rows_per_second={summary.rows_per_second:.2f} " f"bytes_per_second={summary.bytes_per_second:.2f} {cursor_label}={cursor_value or 'none'}", fg=fg, ) ) if dry_run: click.echo(click.style("Dry-run cursor is preview-only; do not persist it for a destructive run.", fg="yellow")) click.echo(click.style("table,row_count", fg="white")) for table_name in [ "workflow_runs", "workflow_app_logs", "workflow_node_executions", "workflow_node_execution_offload", "workflow_pauses", "workflow_pause_reasons", "workflow_trigger_logs", ]: click.echo(f"{table_name},{summary.table_counts.get(table_name, 0)}") for result in summary.results: if result.success: click.echo( click.style( f" bundle={result.bundle_id} tenant={result.tenant_id} runs={result.run_count} " f"rows={result.row_count} archive_bytes={result.archive_bytes} " f"catalog_id={result.catalog_id} time={result.elapsed_time:.2f}s " f"validation={result.validation_time:.2f}s", fg="white", ) ) else: click.echo( click.style( f" failed bundle={result.bundle_id} tenant={result.tenant_id} " f"catalog_id={result.catalog_id} object_prefix={result.object_prefix} error={result.error}", fg="red", ) ) @click.command( "restore-workflow-runs", help="Restore archived workflow runs from S3-compatible storage.", ) @click.option( "--tenant-ids", required=False, help="Tenant IDs (comma-separated).", ) @click.option("--run-id", required=False, help="Workflow run ID to restore.") @click.option( "--target-month", metavar="YYYY-MM", default=None, help="V2 catalog month to restore; required unless --run-id is used.", ) @click.option( "--after-catalog-id", default=None, help="Exclusive V2 cursor from the same restore month and tenant scope.", ) @click.option("--workers", default=1, show_default=True, type=int, help="V1 --run-id compatibility only.") @click.option("--limit", type=click.IntRange(min=1), default=100, show_default=True, help="Maximum V2 catalog rows.") @click.option("--dry-run", is_flag=True, help="Preview without restoring.") def restore_workflow_runs( tenant_ids: str | None, run_id: str | None, target_month: str | None, after_catalog_id: str | None, workers: int, limit: int, dry_run: bool, ): """ Restore archived workflow runs from storage to the database. Batch restore uses V2 bundle metadata and validates archive objects before writing source rows. This restores: - workflow_runs - workflow_app_logs - workflow_node_executions - workflow_node_execution_offload - workflow_pauses - workflow_pause_reasons - workflow_trigger_logs """ from services.retention.workflow_run.bundle_archive_maintenance import WorkflowRunBundleArchiveMaintenance from services.retention.workflow_run.restore_archived_workflow_run import WorkflowRunRestore parsed_tenant_ids = _parse_comma_separated_ids(tenant_ids, param_name="tenant-ids") if workers < 1: raise click.BadParameter("workers must be at least 1") if run_id is not None and (target_month is not None or after_catalog_id is not None): raise click.UsageError("--target-month and --after-catalog-id are only valid for V2 batch restore.") if run_id is None and target_month is None: raise click.UsageError("--target-month is required for V2 batch restore.") start_time = datetime.datetime.now(datetime.UTC) target_desc = f"workflow run {run_id}" if run_id else f"workflow archive catalog month {target_month}" click.echo( click.style( f"Starting restore of {target_desc} at {start_time.isoformat()}.", fg="white", ) ) if run_id: restorer = WorkflowRunRestore(dry_run=dry_run, workers=workers) results = [restorer.restore_by_run_id(run_id)] end_time = datetime.datetime.now(datetime.UTC) elapsed = end_time - start_time successes = sum(1 for result in results if result.success) failures = len(results) - successes if failures == 0: click.echo( click.style( f"Restore completed successfully. success={successes} duration={elapsed}", fg="green", ) ) else: click.echo( click.style( f"Restore completed with failures. success={successes} failed={failures} duration={elapsed}", fg="red", ) ) return if workers != 1: click.echo( click.style("--workers is ignored for V2 bundle restore; bundles are processed serially.", fg="yellow") ) assert target_month is not None target_year, target_month_number = _parse_archive_target_month(target_month) catalog_cursor = _parse_archive_catalog_cursor(after_catalog_id) bundle_restorer = WorkflowRunBundleArchiveMaintenance(dry_run=dry_run, strict_content_validation=True) summary = bundle_restorer.restore_batch( tenant_ids=parsed_tenant_ids, target_year=target_year, target_month=target_month_number, after_catalog_id=catalog_cursor, limit=limit, ) _echo_bundle_archive_operation_summary(summary, dry_run=dry_run) if summary.bundles_failed: raise click.exceptions.Exit(1) @click.command( "delete-archived-workflow-runs", help="Delete archived workflow runs from the database.", ) @click.option( "--tenant-ids", required=False, help="Tenant IDs (comma-separated).", ) @click.option("--run-id", required=False, help="Workflow run ID to delete.") @click.option( "--target-month", metavar="YYYY-MM", default=None, help="V2 catalog month to delete; required unless --run-id is used.", ) @click.option( "--after-catalog-id", default=None, help="Exclusive V2 cursor from the same delete month and tenant scope.", ) @click.option( "--run-shard-index", default=None, type=click.IntRange(min=0), help="Zero-based archive shard index. Must be paired with --run-shard-total.", ) @click.option( "--run-shard-total", default=None, type=click.IntRange(min=1, max=16), help="Total archive shard count. Must be paired with --run-shard-index.", ) @click.option("--all-pages", is_flag=True, help="Process catalog pages until an empty page is reached.") @click.option( "--limit", type=click.IntRange(min=1), default=100, show_default=True, help="Maximum V2 catalog rows per page.", ) @click.option("--dry-run", is_flag=True, help="Preview without deleting.") @click.option( "--skip-bad-archives", is_flag=True, help="V1 --run-id only: continue when one archive object fails validation.", ) @click.option( "--restore-sample-interval", type=int, default=0, show_default=True, help="Run restore dry-run after every N successful deletes; 0 disables restore sampling.", ) def delete_archived_workflow_runs( tenant_ids: str | None, run_id: str | None, target_month: str | None, after_catalog_id: str | None, run_shard_index: int | None, run_shard_total: int | None, all_pages: bool, limit: int, dry_run: bool, skip_bad_archives: bool, restore_sample_interval: int, ): """ Delete archived workflow runs from the database. Batch delete uses V2 bundle metadata and validates object existence, manifest schema, object size, checksum, row counts, and source/archive content checksums before deleting source rows. Parallel workers may select one exact archive shard; all-pages mode keeps only the current bounded page in memory. `--run-id` keeps the V1 per-run path. """ from services.retention.workflow_run.bundle_archive_maintenance import WorkflowRunBundleArchiveMaintenance from services.retention.workflow_run.delete_archived_workflow_run import ArchivedWorkflowRunDeletion parsed_tenant_ids = _parse_comma_separated_ids(tenant_ids, param_name="tenant-ids") if restore_sample_interval < 0: raise click.BadParameter("restore-sample-interval must be >= 0") if run_id is not None and ( target_month is not None or after_catalog_id is not None or run_shard_index is not None or run_shard_total is not None or all_pages ): raise click.UsageError( "--target-month, --after-catalog-id, --run-shard-index, --run-shard-total, and --all-pages " "are only valid for V2 batch delete." ) if run_id is None and target_month is None: raise click.UsageError("--target-month is required for V2 batch delete.") if run_id is None and skip_bad_archives: raise click.UsageError("--skip-bad-archives is not supported for V2 catalog batches; they fail fast.") if (run_shard_index is None) ^ (run_shard_total is None): raise click.UsageError("--run-shard-index and --run-shard-total must be provided together.") if run_shard_index is not None and run_shard_total is not None and run_shard_index >= run_shard_total: raise click.UsageError("--run-shard-index must be less than --run-shard-total.") start_time = datetime.datetime.now(datetime.UTC) target_desc = f"workflow run {run_id}" if run_id else f"workflow archive catalog month {target_month}" click.echo( click.style( f"Starting delete of {target_desc} at {start_time.isoformat()}.", fg="white", ) ) if run_id: deleter = ArchivedWorkflowRunDeletion( dry_run=dry_run, skip_bad_archives=skip_bad_archives, restore_sample_interval=restore_sample_interval, ) results = [deleter.delete_by_run_id(run_id)] for result in results: if result.success: click.echo( click.style( f"{'[DRY RUN] Would delete' if dry_run else 'Deleted'} " f"workflow run {result.run_id} (tenant={result.tenant_id}, " f"archive_key={result.archive_key}, counts={result.validated_counts})", fg="green", ) ) if result.restore_sampled: sample_status = "passed" if result.restore_sample_success else "failed" click.echo( click.style( f" restore dry-run sample {sample_status} for workflow run {result.run_id}", fg="green" if result.restore_sample_success else "red", ) ) else: click.echo( click.style( f"Failed to delete workflow run {result.run_id}: {result.error}", fg="red", ) ) click.echo( click.style( " runbook: pause this delete window, verify archive storage object and manifest/checksum, " "retry the same run after fixing storage or DB drift, or rerun with --skip-bad-archives " "to quarantine this run and continue the batch.", fg="yellow", ) ) end_time = datetime.datetime.now(datetime.UTC) elapsed = end_time - start_time successes = sum(1 for result in results if result.success) failures = len(results) - successes if failures == 0: click.echo( click.style( f"Delete completed successfully. success={successes} duration={elapsed}", fg="green", ) ) else: click.echo( click.style( f"Delete completed with failures. success={successes} failed={failures} duration={elapsed}", fg="red", ) ) return if restore_sample_interval: click.echo(click.style("--restore-sample-interval is ignored for V2 bundle delete.", fg="yellow")) assert target_month is not None target_year, target_month_number = _parse_archive_target_month(target_month) catalog_cursor = _parse_archive_catalog_cursor(after_catalog_id) shard = ( f"{run_shard_index:02d}-of-{run_shard_total:02d}" if run_shard_index is not None and run_shard_total is not None else None ) bundle_deleter = WorkflowRunBundleArchiveMaintenance(dry_run=dry_run, strict_content_validation=True) if run_shard_total is not None: try: bundle_deleter.validate_catalog_shards( target_year=target_year, target_month=target_month_number, shard_total=run_shard_total, tenant_ids=parsed_tenant_ids, ) except ValueError as exc: logger.exception( "Archive catalog shard preflight failed: target_month=%s shard=%s", target_month, shard, ) raise click.ClickException( f"Archive catalog shard preflight failed for target_month={target_month} shard={shard}: {exc}" ) from exc initial_catalog_cursor = catalog_cursor pages_processed = 0 bundles_succeeded = 0 runs_processed = 0 rows_processed = 0 archive_bytes = 0 while True: summary = bundle_deleter.delete_batch( tenant_ids=parsed_tenant_ids, target_year=target_year, target_month=target_month_number, after_catalog_id=catalog_cursor, limit=limit, shard=shard, ) _echo_bundle_archive_operation_summary(summary, dry_run=dry_run) if summary.bundles_failed: failed_result = next((result for result in summary.results if not result.success), None) failed_catalog_id = failed_result.catalog_id if failed_result is not None else "unknown" page_resume_cursor = summary.preview_next_catalog_id if dry_run else summary.next_catalog_id resume_cursor = page_resume_cursor or catalog_cursor if dry_run: cursor_details = ( f"preview_after_catalog_id={resume_cursor or 'none'} " f"destructive_retry_after_catalog_id={initial_catalog_cursor or 'none'}" ) else: cursor_details = f"resume_after_catalog_id={resume_cursor or 'none'}" click.echo( click.style( f"Delete stopped: target_month={target_month} shard={shard or 'all'} " f"failed_catalog_id={failed_catalog_id} " f"{cursor_details}", fg="red", ) ) raise click.exceptions.Exit(1) if not all_pages: break if summary.bundles_processed == 0: break pages_processed += 1 bundles_succeeded += summary.bundles_succeeded runs_processed += summary.runs_processed rows_processed += summary.rows_processed archive_bytes += summary.archive_bytes next_catalog_id = summary.preview_next_catalog_id if dry_run else summary.next_catalog_id if next_catalog_id is None or (catalog_cursor is not None and next_catalog_id <= catalog_cursor): click.echo( click.style( f"Delete cursor did not advance: target_month={target_month} shard={shard or 'all'} " f"after_catalog_id={catalog_cursor or 'none'} next_catalog_id={next_catalog_id or 'none'}", fg="red", ) ) raise click.exceptions.Exit(1) catalog_cursor = next_catalog_id if all_pages: final_cursor_label = "preview_final_catalog_id" if dry_run else "final_catalog_id" click.echo( click.style( f"Delete all-pages completed successfully. target_month={target_month} shard={shard or 'all'} " f"pages={pages_processed} bundles_success={bundles_succeeded} runs={runs_processed} " f"rows={rows_processed} archive_bytes={archive_bytes} " f"{final_cursor_label}={catalog_cursor or 'none'}", fg="green", ) ) def _find_orphaned_draft_variables(batch_size: int = 1000) -> list[str]: """ Find draft variables that reference non-existent apps. Args: batch_size: Maximum number of orphaned app IDs to return Returns: List of app IDs that have draft variables but don't exist in the apps table """ query = """ SELECT DISTINCT wdv.app_id FROM workflow_draft_variables AS wdv WHERE NOT EXISTS( SELECT 1 FROM apps WHERE apps.id = wdv.app_id ) LIMIT :batch_size """ with db.engine.connect() as conn: result = conn.execute(sa.text(query), {"batch_size": batch_size}) return [row[0] for row in result] class _AppOrphanCounts(TypedDict): variables: int files: int class OrphanedDraftVariableStatsDict(TypedDict): total_orphaned_variables: int total_orphaned_files: int orphaned_app_count: int orphaned_by_app: dict[str, _AppOrphanCounts] def _count_orphaned_draft_variables() -> OrphanedDraftVariableStatsDict: """ Count orphaned draft variables by app, including associated file counts. Returns: Dictionary with statistics about orphaned variables and files """ # Count orphaned variables by app variables_query = """ SELECT wdv.app_id, COUNT(*) as variable_count, COUNT(wdv.file_id) as file_count FROM workflow_draft_variables AS wdv WHERE NOT EXISTS( SELECT 1 FROM apps WHERE apps.id = wdv.app_id ) GROUP BY wdv.app_id ORDER BY variable_count DESC """ with db.engine.connect() as conn: result = conn.execute(sa.text(variables_query)) orphaned_by_app: dict[str, _AppOrphanCounts] = {} total_files = 0 for row in result: app_id, variable_count, file_count = row orphaned_by_app[app_id] = {"variables": variable_count, "files": file_count} total_files += file_count total_orphaned = sum(app_data["variables"] for app_data in orphaned_by_app.values()) app_count = len(orphaned_by_app) return { "total_orphaned_variables": total_orphaned, "total_orphaned_files": total_files, "orphaned_app_count": app_count, "orphaned_by_app": orphaned_by_app, } @click.command() @click.option("--dry-run", is_flag=True, help="Show what would be deleted without actually deleting") @click.option("--batch-size", default=1000, help="Number of records to process per batch (default 1000)") @click.option("--max-apps", default=None, type=int, help="Maximum number of apps to process (default: no limit)") @click.option("-f", "--force", is_flag=True, help="Skip user confirmation and force the command to execute.") def cleanup_orphaned_draft_variables( dry_run: bool, batch_size: int, max_apps: int | None, force: bool = False, ): """ Clean up orphaned draft variables from the database. This script finds and removes draft variables that belong to apps that no longer exist in the database. """ logger = logging.getLogger(__name__) # Get statistics stats = _count_orphaned_draft_variables() logger.info("Found %s orphaned draft variables", stats["total_orphaned_variables"]) logger.info("Found %s associated offload files", stats["total_orphaned_files"]) logger.info("Across %s non-existent apps", stats["orphaned_app_count"]) if stats["total_orphaned_variables"] == 0: logger.info("No orphaned draft variables found. Exiting.") return if dry_run: logger.info("DRY RUN: Would delete the following:") for app_id, data in sorted(stats["orphaned_by_app"].items(), key=lambda x: x[1]["variables"], reverse=True)[ :10 ]: # Show top 10 logger.info(" App %s: %s variables, %s files", app_id, data["variables"], data["files"]) if len(stats["orphaned_by_app"]) > 10: logger.info(" ... and %s more apps", len(stats["orphaned_by_app"]) - 10) return # Confirm deletion if not force: click.confirm( f"Are you sure you want to delete {stats['total_orphaned_variables']} " f"orphaned draft variables and {stats['total_orphaned_files']} associated files " f"from {stats['orphaned_app_count']} apps?", abort=True, ) total_deleted = 0 processed_apps = 0 while True: if max_apps and processed_apps >= max_apps: logger.info("Reached maximum app limit (%s). Stopping.", max_apps) break orphaned_app_ids = _find_orphaned_draft_variables(batch_size=10) if not orphaned_app_ids: logger.info("No more orphaned draft variables found.") break for app_id in orphaned_app_ids: if max_apps and processed_apps >= max_apps: break try: deleted_count = delete_draft_variables_batch(app_id, batch_size) total_deleted += deleted_count processed_apps += 1 logger.info("Deleted %s variables for app %s", deleted_count, app_id) except Exception: logger.exception("Error processing app %s", app_id) continue logger.info("Cleanup completed. Total deleted: %s variables across %s apps", total_deleted, processed_apps) @click.command("clean-expired-messages", help="Clean expired messages.") @click.option( "--start-from", type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]), required=False, default=None, help="Lower bound (inclusive) for created_at.", ) @click.option( "--end-before", type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]), required=False, default=None, help="Upper bound (exclusive) for created_at.", ) @click.option( "--from-days-ago", type=int, default=None, help="Relative lower bound in days ago (inclusive). Must be used with --before-days.", ) @click.option( "--before-days", type=int, default=None, help="Relative upper bound in days ago (exclusive). Required for relative mode.", ) @click.option("--batch-size", default=1000, show_default=True, help="Batch size for selecting messages.") @click.option( "--graceful-period", default=21, show_default=True, help="Graceful period in days after subscription expiration, will be ignored when billing is disabled.", ) @click.option("--dry-run", is_flag=True, default=False, help="Show messages logs would be cleaned without deleting") def clean_expired_messages( batch_size: int, graceful_period: int, start_from: datetime.datetime | None, end_before: datetime.datetime | None, from_days_ago: int | None, before_days: int | None, dry_run: bool, ): """ Clean expired messages and related data for tenants based on clean policy. """ from extensions.otel.runtime import flush_telemetry click.echo(click.style("clean_messages: start clean messages.", fg="green")) start_at = time.perf_counter() try: abs_mode = start_from is not None and end_before is not None rel_mode = before_days is not None if abs_mode and rel_mode: raise click.UsageError( "Options are mutually exclusive: use either (--start-from,--end-before) " "or (--from-days-ago,--before-days)." ) if from_days_ago is not None and before_days is None: raise click.UsageError("--from-days-ago must be used together with --before-days.") if (start_from is None) ^ (end_before is None): raise click.UsageError("Both --start-from and --end-before are required when using absolute time range.") if not abs_mode and not rel_mode: raise click.UsageError( "You must provide either (--start-from,--end-before) or (--before-days [--from-days-ago])." ) if rel_mode: assert before_days is not None if before_days < 0: raise click.UsageError("--before-days must be >= 0.") if from_days_ago is not None: if from_days_ago < 0: raise click.UsageError("--from-days-ago must be >= 0.") if from_days_ago <= before_days: raise click.UsageError("--from-days-ago must be greater than --before-days.") # Create policy based on billing configuration # NOTE: graceful_period will be ignored when billing is disabled. policy = create_message_clean_policy(graceful_period_days=graceful_period) if from_days_ago is not None and before_days is not None: task_label = f"{from_days_ago}to{before_days}" elif start_from is None and before_days is not None: task_label = f"before-{before_days}" else: task_label = "custom" # Create and run the cleanup service if abs_mode: assert start_from is not None assert end_before is not None service = MessagesCleanService.from_time_range( policy=policy, start_from=start_from, end_before=end_before, batch_size=batch_size, dry_run=dry_run, task_label=task_label, ) elif from_days_ago is None: assert before_days is not None service = MessagesCleanService.from_days( policy=policy, days=before_days, batch_size=batch_size, dry_run=dry_run, task_label=task_label, ) else: assert before_days is not None assert from_days_ago is not None now = naive_utc_now() service = MessagesCleanService.from_time_range( policy=policy, start_from=now - datetime.timedelta(days=from_days_ago), end_before=now - datetime.timedelta(days=before_days), batch_size=batch_size, dry_run=dry_run, task_label=task_label, ) stats = service.run() end_at = time.perf_counter() click.echo( click.style( f"clean_messages: completed successfully\n" f" - Latency: {end_at - start_at:.2f}s\n" f" - Batches processed: {stats['batches']}\n" f" - Total messages scanned: {stats['total_messages']}\n" f" - Messages filtered: {stats['filtered_messages']}\n" f" - Messages deleted: {stats['total_deleted']}", fg="green", ) ) except Exception as e: end_at = time.perf_counter() logger.exception("clean_messages failed") click.echo( click.style( f"clean_messages: failed after {end_at - start_at:.2f}s - {str(e)}", fg="red", ) ) raise finally: flush_telemetry() click.echo(click.style("messages cleanup completed.", fg="green")) @click.command("export-app-messages", help="Export messages for an app to JSONL.GZ.") @click.option("--app-id", required=True, help="Application ID to export messages for.") @click.option( "--start-from", type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]), default=None, help="Optional lower bound (inclusive) for created_at.", ) @click.option( "--end-before", type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]), required=True, help="Upper bound (exclusive) for created_at.", ) @click.option( "--filename", required=True, help="Base filename (relative path). Do not include suffix like .jsonl.gz.", ) @click.option("--use-cloud-storage", is_flag=True, default=False, help="Upload to cloud storage instead of local file.") @click.option("--batch-size", default=1000, show_default=True, help="Batch size for cursor pagination.") @click.option("--dry-run", is_flag=True, default=False, help="Scan only, print stats without writing any file.") def export_app_messages( app_id: str, start_from: datetime.datetime | None, end_before: datetime.datetime, filename: str, use_cloud_storage: bool, batch_size: int, dry_run: bool, ): if start_from and start_from >= end_before: raise click.UsageError("--start-from must be before --end-before.") from services.retention.conversation.message_export_service import AppMessageExportService try: validated_filename = AppMessageExportService.validate_export_filename(filename) except ValueError as e: raise click.BadParameter(str(e), param_hint="--filename") from e click.echo(click.style(f"export_app_messages: starting export for app {app_id}.", fg="green")) start_at = time.perf_counter() try: service = AppMessageExportService( app_id=app_id, end_before=end_before, filename=validated_filename, start_from=start_from, batch_size=batch_size, use_cloud_storage=use_cloud_storage, dry_run=dry_run, ) stats = service.run() elapsed = time.perf_counter() - start_at click.echo( click.style( f"export_app_messages: completed in {elapsed:.2f}s\n" f" - Batches: {stats.batches}\n" f" - Total messages: {stats.total_messages}\n" f" - Messages with feedback: {stats.messages_with_feedback}\n" f" - Total feedbacks: {stats.total_feedbacks}", fg="green", ) ) except Exception as e: elapsed = time.perf_counter() - start_at logger.exception("export_app_messages failed") click.echo(click.style(f"export_app_messages: failed after {elapsed:.2f}s - {e}", fg="red")) raise