359 lines
14 KiB
Python
359 lines
14 KiB
Python
"""
|
|
Database migration management using Alembic.
|
|
|
|
This module provides programmatic access to run database migrations
|
|
on application startup. It is designed to be safe for concurrent
|
|
execution using PostgreSQL advisory locks to coordinate between
|
|
distributed workers.
|
|
|
|
Supports multi-tenant schema isolation: migrations can target a specific
|
|
PostgreSQL schema, allowing each tenant to have isolated tables.
|
|
|
|
Important: All migrations must be backward-compatible to allow
|
|
safe rolling deployments.
|
|
|
|
No alembic.ini required - all configuration is done programmatically.
|
|
"""
|
|
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
from sqlalchemy import create_engine, text
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Advisory lock ID for migrations (arbitrary unique number)
|
|
MIGRATION_LOCK_ID = 123456789
|
|
|
|
|
|
def _get_schema_lock_id(schema: str) -> int:
|
|
"""
|
|
Generate a unique advisory lock ID for a schema.
|
|
|
|
Uses hash of schema name to create a deterministic lock ID.
|
|
"""
|
|
# Use hash to create a unique lock ID per schema
|
|
# Keep within PostgreSQL's bigint range
|
|
hash_bytes = hashlib.sha256(schema.encode()).digest()[:8]
|
|
return int.from_bytes(hash_bytes, byteorder="big") % (2**31)
|
|
|
|
|
|
def _run_migrations_internal(database_url: str, script_location: str, schema: str | None = None) -> None:
|
|
"""
|
|
Internal function to run migrations without locking.
|
|
|
|
Args:
|
|
database_url: SQLAlchemy database URL
|
|
script_location: Path to alembic scripts
|
|
schema: Target schema (None for default/public)
|
|
"""
|
|
schema_name = schema or "public"
|
|
logger.info(f"Running database migrations to head for schema '{schema_name}'...")
|
|
logger.info(f"Database URL: {database_url}")
|
|
logger.info(f"Script location: {script_location}")
|
|
|
|
# Create Alembic configuration programmatically (no alembic.ini needed)
|
|
alembic_cfg = Config()
|
|
|
|
# Set the script location (where alembic versions are stored)
|
|
alembic_cfg.set_main_option("script_location", script_location)
|
|
|
|
# Set the database URL
|
|
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
|
|
|
|
# Configure logging (optional, but helps with debugging)
|
|
# Uses Python's logging system instead of alembic.ini
|
|
alembic_cfg.set_main_option("prepend_sys_path", ".")
|
|
|
|
# Set path_separator to avoid deprecation warning
|
|
alembic_cfg.set_main_option("path_separator", "os")
|
|
|
|
# If targeting a specific schema, pass it to env.py via config
|
|
# env.py will handle setting search_path and version_table_schema
|
|
if schema:
|
|
alembic_cfg.set_main_option("target_schema", schema)
|
|
|
|
# Run migrations
|
|
command.upgrade(alembic_cfg, "head")
|
|
|
|
logger.info(f"Database migrations completed successfully for schema '{schema_name}'")
|
|
|
|
|
|
def run_migrations(
|
|
database_url: str,
|
|
script_location: str | None = None,
|
|
schema: str | None = None,
|
|
) -> None:
|
|
"""
|
|
Run database migrations to the latest version using programmatic Alembic configuration.
|
|
|
|
This function is safe to call from multiple distributed workers simultaneously:
|
|
- Uses PostgreSQL advisory lock to ensure only one worker runs migrations at a time
|
|
- Other workers wait for the lock, then verify migrations are complete
|
|
- If schema is already up-to-date, this is a fast no-op
|
|
|
|
Supports multi-tenant schema isolation: when a schema is specified, migrations
|
|
run in that schema instead of public. This allows tenant extensions to provision
|
|
new tenant schemas with their own isolated tables.
|
|
|
|
Args:
|
|
database_url: SQLAlchemy database URL (e.g., "postgresql://user:pass@host/db")
|
|
script_location: Path to alembic migrations directory (e.g., "/path/to/alembic").
|
|
If None, defaults to hindsight-api/alembic directory.
|
|
schema: Target PostgreSQL schema name. If None, uses default (public).
|
|
When specified, creates the schema if needed and runs migrations there.
|
|
|
|
Raises:
|
|
RuntimeError: If migrations fail to complete
|
|
FileNotFoundError: If script_location doesn't exist
|
|
|
|
Example:
|
|
# Using default location and public schema
|
|
run_migrations("postgresql://user:pass@host/db")
|
|
|
|
# Run migrations for a specific tenant schema
|
|
run_migrations("postgresql://user:pass@host/db", schema="tenant_acme")
|
|
|
|
# Using custom location (when importing from another project)
|
|
run_migrations(
|
|
"postgresql://user:pass@host/db",
|
|
script_location="/path/to/copied/_alembic"
|
|
)
|
|
"""
|
|
try:
|
|
# Determine script location
|
|
if script_location is None:
|
|
# Default: use the alembic directory inside the hindsight_api package
|
|
# This file is in: hindsight_api/migrations.py
|
|
# Alembic is in: hindsight_api/alembic/
|
|
package_dir = Path(__file__).parent
|
|
script_location = str(package_dir / "alembic")
|
|
|
|
script_path = Path(script_location)
|
|
if not script_path.exists():
|
|
raise FileNotFoundError(
|
|
f"Alembic script location not found at {script_location}. Database migrations cannot be run."
|
|
)
|
|
|
|
# Use schema-specific lock ID for multi-tenant isolation
|
|
lock_id = _get_schema_lock_id(schema) if schema else MIGRATION_LOCK_ID
|
|
schema_name = schema or "public"
|
|
|
|
# Use PostgreSQL advisory lock to coordinate between distributed workers
|
|
engine = create_engine(database_url)
|
|
with engine.connect() as conn:
|
|
# pg_advisory_lock blocks until the lock is acquired
|
|
# The lock is automatically released when the connection closes
|
|
logger.debug(f"Acquiring migration advisory lock for schema '{schema_name}' (id={lock_id})...")
|
|
conn.execute(text(f"SELECT pg_advisory_lock({lock_id})"))
|
|
logger.debug("Migration advisory lock acquired")
|
|
|
|
try:
|
|
# Run migrations while holding the lock
|
|
_run_migrations_internal(database_url, script_location, schema=schema)
|
|
finally:
|
|
# Explicitly release the lock (also released on connection close)
|
|
conn.execute(text(f"SELECT pg_advisory_unlock({lock_id})"))
|
|
logger.debug("Migration advisory lock released")
|
|
|
|
except FileNotFoundError:
|
|
logger.error(f"Alembic script location not found at {script_location}")
|
|
raise
|
|
except SystemExit as e:
|
|
# Catch sys.exit() calls from Alembic
|
|
logger.error(f"Alembic called sys.exit() with code: {e.code}", exc_info=True)
|
|
raise RuntimeError(f"Database migration failed with exit code {e.code}") from e
|
|
except Exception as e:
|
|
logger.error(f"Failed to run database migrations: {e}", exc_info=True)
|
|
raise RuntimeError("Database migration failed") from e
|
|
|
|
|
|
def check_migration_status(
|
|
database_url: str | None = None, script_location: str | None = None
|
|
) -> tuple[str | None, str | None]:
|
|
"""
|
|
Check current database schema version and latest available version.
|
|
|
|
Args:
|
|
database_url: SQLAlchemy database URL. If None, uses HINDSIGHT_API_DATABASE_URL env var.
|
|
script_location: Path to alembic migrations directory. If None, uses default location.
|
|
|
|
Returns:
|
|
Tuple of (current_revision, head_revision)
|
|
Returns (None, None) if unable to determine versions
|
|
"""
|
|
try:
|
|
from alembic.runtime.migration import MigrationContext
|
|
from alembic.script import ScriptDirectory
|
|
from sqlalchemy import create_engine
|
|
|
|
# Get database URL
|
|
if database_url is None:
|
|
database_url = os.getenv("HINDSIGHT_API_DATABASE_URL")
|
|
if not database_url:
|
|
logger.warning(
|
|
"Database URL not provided and HINDSIGHT_API_DATABASE_URL not set, cannot check migration status"
|
|
)
|
|
return None, None
|
|
|
|
# Get current revision from database
|
|
engine = create_engine(database_url)
|
|
with engine.connect() as connection:
|
|
context = MigrationContext.configure(connection)
|
|
current_rev = context.get_current_revision()
|
|
|
|
# Get head revision from migration scripts
|
|
if script_location is None:
|
|
package_dir = Path(__file__).parent
|
|
script_location = str(package_dir / "alembic")
|
|
|
|
script_path = Path(script_location)
|
|
if not script_path.exists():
|
|
logger.warning(f"Script location not found at {script_location}")
|
|
return current_rev, None
|
|
|
|
# Create config programmatically
|
|
alembic_cfg = Config()
|
|
alembic_cfg.set_main_option("script_location", script_location)
|
|
alembic_cfg.set_main_option("path_separator", "os")
|
|
|
|
script = ScriptDirectory.from_config(alembic_cfg)
|
|
head_rev = script.get_current_head()
|
|
|
|
return current_rev, head_rev
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Unable to check migration status: {e}")
|
|
return None, None
|
|
|
|
|
|
def ensure_embedding_dimension(
|
|
database_url: str,
|
|
required_dimension: int,
|
|
schema: str | None = None,
|
|
) -> None:
|
|
"""
|
|
Ensure the embedding column dimension matches the model's dimension.
|
|
|
|
This function checks the current vector column dimension in the database
|
|
and adjusts it if necessary:
|
|
- If dimensions match: no action needed
|
|
- If dimensions differ and table is empty: ALTER COLUMN to new dimension
|
|
- If dimensions differ and table has data: raise error with migration guidance
|
|
|
|
Args:
|
|
database_url: SQLAlchemy database URL
|
|
required_dimension: The embedding dimension required by the model
|
|
schema: Target PostgreSQL schema name (None for public)
|
|
|
|
Raises:
|
|
RuntimeError: If dimension mismatch with existing data
|
|
"""
|
|
schema_name = schema or "public"
|
|
|
|
engine = create_engine(database_url)
|
|
with engine.connect() as conn:
|
|
# Check if memory_units table exists
|
|
table_exists = conn.execute(
|
|
text("""
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_schema = :schema AND table_name = 'memory_units'
|
|
)
|
|
"""),
|
|
{"schema": schema_name},
|
|
).scalar()
|
|
|
|
if not table_exists:
|
|
logger.debug(f"memory_units table does not exist in schema '{schema_name}', skipping dimension check")
|
|
return
|
|
|
|
# Get current column dimension from pg_attribute
|
|
# pgvector stores dimension in atttypmod
|
|
current_dim = conn.execute(
|
|
text("""
|
|
SELECT atttypmod
|
|
FROM pg_attribute a
|
|
JOIN pg_class c ON a.attrelid = c.oid
|
|
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
WHERE n.nspname = :schema
|
|
AND c.relname = 'memory_units'
|
|
AND a.attname = 'embedding'
|
|
"""),
|
|
{"schema": schema_name},
|
|
).scalar()
|
|
|
|
if current_dim is None:
|
|
logger.warning("Could not determine current embedding dimension, skipping check")
|
|
return
|
|
|
|
# pgvector stores dimension directly in atttypmod (no offset like other types)
|
|
current_dimension = current_dim
|
|
|
|
if current_dimension == required_dimension:
|
|
logger.debug(f"Embedding dimension OK: {current_dimension}")
|
|
return
|
|
|
|
logger.info(
|
|
f"Embedding dimension mismatch: database has {current_dimension}, model requires {required_dimension}"
|
|
)
|
|
|
|
# Check if table has data
|
|
row_count = conn.execute(
|
|
text(f"SELECT COUNT(*) FROM {schema_name}.memory_units WHERE embedding IS NOT NULL")
|
|
).scalar()
|
|
|
|
if row_count > 0:
|
|
raise RuntimeError(
|
|
f"Cannot change embedding dimension from {current_dimension} to {required_dimension}: "
|
|
f"memory_units table contains {row_count} rows with embeddings. "
|
|
f"To change dimensions, you must either:\n"
|
|
f" 1. Re-embed all data: DELETE FROM {schema_name}.memory_units; then restart\n"
|
|
f" 2. Use a model with {current_dimension}-dimensional embeddings"
|
|
)
|
|
|
|
# Table is empty, safe to alter column
|
|
logger.info(f"Altering embedding column dimension from {current_dimension} to {required_dimension}")
|
|
|
|
# Drop the HNSW index on embedding column if it exists
|
|
# Only drop indexes that use 'hnsw' and reference the 'embedding' column
|
|
conn.execute(
|
|
text(f"""
|
|
DO $$
|
|
DECLARE idx_name TEXT;
|
|
BEGIN
|
|
FOR idx_name IN
|
|
SELECT indexname FROM pg_indexes
|
|
WHERE schemaname = '{schema_name}'
|
|
AND tablename = 'memory_units'
|
|
AND indexdef LIKE '%hnsw%'
|
|
AND indexdef LIKE '%embedding%'
|
|
LOOP
|
|
EXECUTE 'DROP INDEX IF EXISTS {schema_name}.' || idx_name;
|
|
END LOOP;
|
|
END $$;
|
|
""")
|
|
)
|
|
|
|
# Alter the column type
|
|
conn.execute(
|
|
text(f"ALTER TABLE {schema_name}.memory_units ALTER COLUMN embedding TYPE vector({required_dimension})")
|
|
)
|
|
conn.commit()
|
|
|
|
# Recreate the HNSW index
|
|
conn.execute(
|
|
text(f"""
|
|
CREATE INDEX IF NOT EXISTS idx_memory_units_embedding_hnsw
|
|
ON {schema_name}.memory_units
|
|
USING hnsw (embedding vector_cosine_ops)
|
|
WITH (m = 16, ef_construction = 64)
|
|
""")
|
|
)
|
|
conn.commit()
|
|
|
|
logger.info(f"Successfully changed embedding dimension to {required_dimension}")
|