* feat: introduce hindsight-api-slim and hindsight-all-slim packages Closes #552 - Move all source code from hindsight-api/ to new hindsight-api-slim/ - hindsight-api-slim has heavy ML deps (torch, sentence-transformers, transformers, einops, flashrank, mlx, mlx-lm, safetensors) and pg0-embedded as optional extras: [local-ml], [embedded-db], [all] - hindsight-api becomes a zero-code meta-package depending on hindsight-api-slim[all] for full backward compatibility - Add hindsight-all-slim meta-package: hindsight-api-slim + client + embed - hindsight-all updated to depend on hindsight-api-slim[all] - pg0.py: lazy-import pg0 with clear ImportError pointing to [embedded-db] - Dockerfile: replace sed hack with proper uv sync --extra flags - Update release.yml, test.yml, lint.sh, release.sh, CLAUDE.md and all path references throughout the repo * refactor: rename hindsight/ directory to hindsight-all/ * docs: document hindsight-api-slim and hindsight-all-slim package variants Add package variants table and extras explanation to installation.md * docs: remove emojis from installation.md, use professional tone * docs: link Docker slim variant to pip package variants section * docs: consolidate Docker image variants into single table * ci: fix working-directory paths after package restructure - Replace all hindsight-api → hindsight-api-slim in test.yml - Replace hindsight → hindsight-all in test.yml - Add --extra embedded-db to test-embed API install step * ci: add local-ml and embedded-db extras to API sync steps These extras were previously implicit in the old hindsight-api package (which bundled everything). Now that hindsight-api-slim uses optional extras, we must explicitly request local-ml and embedded-db in CI. * ci: add API install step with embedded-db to test-embed smoke test The smoke test starts hindsight-api as a daemon, which requires pg0-embedded. Add a dedicated install step for hindsight-api-slim with embedded-db extra so the daemon can start successfully. * ci: remove --no-install-project when using optional extras When --no-install-project is combined with --extra, the optional deps are not installed because extras require the project to be active. Remove --no-install-project from steps that need local-ml or embedded-db. * ci: fix ordering of uv sync steps to preserve optional extras When uv sync runs for a different workspace member, it removes optional extras installed for other members. Fix by always running extra-requiring API sync last, after other workspace member syncs. Also remove --no-install-project from embedded-db sync in test-embed, as --no-install-project prevents optional extras from being active. * ci: add local-ml extra to test-embed API install for smoke test The smoke test starts the full API server which needs sentence-transformers for local embeddings (default provider). Add local-ml extra to the install. * ci: simplify extras with --all-extras and add slim pip smoke test - Replace explicit --extra local-ml --extra embedded-db with --all-extras for cleaner, more maintainable sync steps - Add test-pip-slim job: tests hindsight-api-slim[embedded-db] without local ML models, using Cohere for embeddings/reranking (mirrors Docker slim smoke test approach) * ci: simplify slim smoke test to health check only (mirrors Docker test)
383 lines
14 KiB
Python
383 lines
14 KiB
Python
"""
|
|
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()
|