""" Hindsight Admin CLI - backup and restore operations. """ import asyncio import io import json import logging import zipfile from datetime import datetime, timezone from pathlib import Path from typing import Any import asyncpg import typer from ..config import DEFAULT_DATABASE_SCHEMA, HindsightConfig from ..extensions import TenantExtension, load_extension from ..pg0 import parse_pg0_url, resolve_database_url def _fq_table(table: str, schema: str) -> str: """Get fully-qualified table name with schema prefix.""" return f"{schema}.{table}" # Setup logging logging.basicConfig( level=logging.INFO, format="%(message)s", ) logger = logging.getLogger(__name__) app = typer.Typer(name="hindsight-admin", help="Hindsight administrative commands") # Tables to backup/restore in dependency order # Import must happen in this order due to foreign key constraints BACKUP_TABLES = [ "banks", "documents", "entities", "chunks", "memory_units", "unit_entities", "entity_cooccurrences", "memory_links", ] MANIFEST_VERSION = "1" async def _backup(database_url: str, output_path: Path, schema: str = "public") -> dict[str, Any]: """Backup all tables to a zip file using binary COPY protocol.""" conn = await asyncpg.connect(database_url) try: tables: dict[str, Any] = {} manifest: dict[str, Any] = { "version": MANIFEST_VERSION, "created_at": datetime.now(timezone.utc).isoformat(), "schema": schema, "tables": tables, } # Use a transaction with REPEATABLE READ isolation to get a consistent # snapshot across all tables. This prevents race conditions where # entity_cooccurrences could reference entities created after the # entities table was backed up. async with conn.transaction(isolation="repeatable_read"): with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: for i, table in enumerate(BACKUP_TABLES, 1): typer.echo(f" [{i}/{len(BACKUP_TABLES)}] Backing up {table}...", nl=False) buffer = io.BytesIO() # Use binary COPY for exact type preservation # asyncpg requires schema_name as separate parameter await conn.copy_from_table(table, schema_name=schema, output=buffer, format="binary") data = buffer.getvalue() zf.writestr(f"{table}.bin", data) # Get row count for manifest qualified_table = _fq_table(table, schema) row_count = await conn.fetchval(f"SELECT COUNT(*) FROM {qualified_table}") tables[table] = { "rows": row_count, "size_bytes": len(data), } typer.echo(f" {row_count} rows") zf.writestr("manifest.json", json.dumps(manifest, indent=2)) return manifest finally: await conn.close() async def _restore(database_url: str, input_path: Path, schema: str = "public") -> dict[str, Any]: """Restore all tables from a zip file using binary COPY protocol.""" conn = await asyncpg.connect(database_url) try: with zipfile.ZipFile(input_path, "r") as zf: # Read and validate manifest manifest: dict[str, Any] = json.loads(zf.read("manifest.json")) if manifest.get("version") != MANIFEST_VERSION: raise ValueError(f"Unsupported backup version: {manifest.get('version')}") # Use a transaction for atomic restore - either all tables are # restored or none are, preventing partial/inconsistent state. async with conn.transaction(): typer.echo(" Clearing existing data...") # Truncate tables in reverse order (respects FK constraints) for table in reversed(BACKUP_TABLES): qualified_table = _fq_table(table, schema) await conn.execute(f"TRUNCATE TABLE {qualified_table} CASCADE") # Restore tables in forward order for i, table in enumerate(BACKUP_TABLES, 1): filename = f"{table}.bin" if filename not in zf.namelist(): typer.echo(f" [{i}/{len(BACKUP_TABLES)}] {table}: skipped (not in backup)") continue expected_rows = manifest["tables"].get(table, {}).get("rows", "?") typer.echo(f" [{i}/{len(BACKUP_TABLES)}] Restoring {table}... {expected_rows} rows") data = zf.read(filename) buffer = io.BytesIO(data) # asyncpg requires schema_name as separate parameter await conn.copy_to_table(table, schema_name=schema, source=buffer, format="binary") # Refresh materialized view typer.echo(" Refreshing materialized views...") await conn.execute(f"REFRESH MATERIALIZED VIEW {_fq_table('memory_units_bm25', schema)}") return manifest finally: await conn.close() async def _run_backup(db_url: str, output: Path, schema: str = "public") -> dict[str, Any]: """Resolve database URL and run backup.""" is_pg0, instance_name, _ = parse_pg0_url(db_url) if is_pg0: typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...") resolved_url = await resolve_database_url(db_url) return await _backup(resolved_url, output, schema) async def _run_restore(db_url: str, input_file: Path, schema: str = "public") -> dict[str, Any]: """Resolve database URL and run restore.""" is_pg0, instance_name, _ = parse_pg0_url(db_url) if is_pg0: typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...") resolved_url = await resolve_database_url(db_url) return await _restore(resolved_url, input_file, schema) @app.command() def backup( output: Path = typer.Argument(..., help="Output file path (.zip)"), schema: str = typer.Option("public", "--schema", "-s", help="Database schema to backup"), ): """Backup the Hindsight database to a zip file.""" config = HindsightConfig.from_env() if not config.database_url: typer.echo("Error: Database URL not configured.", err=True) typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True) raise typer.Exit(1) if output.suffix != ".zip": output = output.with_suffix(".zip") typer.echo(f"Backing up database (schema: {schema}) to {output}...") manifest = asyncio.run(_run_backup(config.database_url, output, schema)) total_rows = sum(t["rows"] for t in manifest["tables"].values()) typer.echo(f"Backed up {total_rows} rows across {len(BACKUP_TABLES)} tables") typer.echo(f"Backup saved to {output}") @app.command() def restore( input_file: Path = typer.Argument(..., help="Input backup file (.zip)"), schema: str = typer.Option("public", "--schema", "-s", help="Database schema to restore to"), yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), ): """Restore the database from a backup file. WARNING: This deletes all existing data.""" config = HindsightConfig.from_env() if not config.database_url: typer.echo("Error: Database URL not configured.", err=True) typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True) raise typer.Exit(1) if not input_file.exists(): typer.echo(f"Error: File not found: {input_file}", err=True) raise typer.Exit(1) if not yes: typer.confirm( "This will DELETE all existing data and replace it with the backup. Continue?", abort=True, ) typer.echo(f"Restoring database (schema: {schema}) from {input_file}...") manifest = asyncio.run(_run_restore(config.database_url, input_file, schema)) total_rows = sum(t["rows"] for t in manifest["tables"].values()) typer.echo(f"Restored {total_rows} rows across {len(BACKUP_TABLES)} tables") typer.echo("Restore complete") async def _run_migration( db_url: str, schema: str | None = None, base_schema: str = DEFAULT_DATABASE_SCHEMA, embedding_dimension: int | None = None, ) -> list[str]: """Resolve database URL and run migrations for one schema or all discovered schemas.""" from ..migrations import ( ensure_embedding_dimension, ensure_text_search_extension, ensure_vector_extension, run_migrations, ) is_pg0, instance_name, _ = parse_pg0_url(db_url) if is_pg0: typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...") resolved_url = await resolve_database_url(db_url) config = HindsightConfig.from_env() if schema: schemas = [schema] else: tenant_extension = load_extension("TENANT", TenantExtension) schemas = [base_schema or DEFAULT_DATABASE_SCHEMA] if tenant_extension: tenants = await tenant_extension.list_tenants() schemas.extend(tenant.schema for tenant in tenants if tenant.schema) # Preserve order while removing duplicates. schemas = list(dict.fromkeys(schemas)) for schema in schemas: run_migrations(resolved_url, schema=schema) if embedding_dimension is not None: for schema in schemas: ensure_embedding_dimension( resolved_url, embedding_dimension, schema=schema, vector_extension=config.vector_extension, ) for schema in schemas: ensure_vector_extension( resolved_url, vector_extension=config.vector_extension, schema=schema, ) for schema in schemas: ensure_text_search_extension( resolved_url, text_search_extension=config.text_search_extension, schema=schema, ) return schemas @app.command(name="run-db-migration") def run_db_migration( schema: str | None = typer.Option( None, "--schema", "-s", help="Database schema to run migrations on. If omitted, migrate the base schema and all discovered tenant schemas.", ), embedding_dimension: int | None = typer.Option( None, "--embedding-dimension", help="Expected embedding dimension to enforce after migrations. Omit to skip dimension sync.", ), ): """Run database migrations to the latest version.""" config = HindsightConfig.from_env() if not config.database_url: typer.echo("Error: Database URL not configured.", err=True) typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True) raise typer.Exit(1) if schema: typer.echo(f"Running database migrations for schema: {schema}...") else: typer.echo("Running database migrations for base schema and all discovered tenant schemas...") schemas = asyncio.run( _run_migration( config.database_url, schema=schema, base_schema=config.database_schema, embedding_dimension=embedding_dimension, ) ) typer.echo(f"Database migrations completed successfully for {len(schemas)} schema(s)") async def _decommission_worker(db_url: str, worker_id: str, schema: str = "public") -> int: """Release all tasks owned by a worker, setting them back to pending status.""" is_pg0, instance_name, _ = parse_pg0_url(db_url) if is_pg0: typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...") resolved_url = await resolve_database_url(db_url) conn = await asyncpg.connect(resolved_url) try: table = _fq_table("async_operations", schema) result = await conn.fetch( f""" UPDATE {table} SET status = 'pending', worker_id = NULL, claimed_at = NULL, updated_at = now() WHERE worker_id = $1 AND status = 'processing' RETURNING operation_id """, worker_id, ) return len(result) finally: await conn.close() @app.command(name="decommission-worker") def decommission_worker( worker_id: str = typer.Argument(..., help="Worker ID to decommission"), schema: str = typer.Option("public", "--schema", "-s", help="Database schema"), yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), ): """Release all tasks owned by a worker (sets status back to pending). Use this command when a worker has crashed or been removed without graceful shutdown. All tasks that were being processed by the worker will be released back to the queue so other workers can pick them up. """ config = HindsightConfig.from_env() if not config.database_url: typer.echo("Error: Database URL not configured.", err=True) typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True) raise typer.Exit(1) if not yes: typer.confirm( f"This will release all tasks owned by worker '{worker_id}' back to pending. Continue?", abort=True, ) typer.echo(f"Decommissioning worker '{worker_id}' (schema: {schema})...") count = asyncio.run(_decommission_worker(config.database_url, worker_id, schema)) if count > 0: typer.echo(f"Released {count} task(s) from worker '{worker_id}'") else: typer.echo(f"No tasks found for worker '{worker_id}'") def main(): app() if __name__ == "__main__": main()