Skip to content

Commit 460fc63

Browse files
authored
feat(migrations): parallelize tenant schema migrations (#2203)
Add HINDSIGHT_API_MIGRATION_CONCURRENCY to migrate tenant schemas concurrently (each in its own spawn process; per-schema work stays sequential). NullPool on migration engines bounds per-worker connections. Validated at 20k schemas: no-op resweep ~60min->~11min (5x) at concurrency=12, peak 29 connections, 0 errors. Default 1 (sequential).
1 parent e20a36c commit 460fc63

9 files changed

Lines changed: 342 additions & 79 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ HINDSIGHT_API_LOG_LEVEL=info
5959
# HINDSIGHT_API_READ_DATABASE_URL= # Optional read-replica URL. When set, recall queries (semantic, BM25, graph, temporal) flow through a separate pool against this URL, offloading the primary. Typically points to a read-only endpoint (CNPG's <cluster>-ro service or Aurora reader endpoint).
6060
# HINDSIGHT_API_MIGRATION_DATABASE_URL= # Direct PostgreSQL URL for migrations (bypasses PgBouncer). Falls back to DATABASE_URL.
6161
# HINDSIGHT_API_DATABASE_SCHEMA=public # PostgreSQL schema name (default: public)
62+
# HINDSIGHT_API_MIGRATION_CONCURRENCY=1 # Tenant schemas to migrate concurrently (PG only, each in its own process; per-schema work stays sequential). Each worker has ~1-2s startup cost + uses ~3 DB connections, so it only pays off with many schemas (tens+) or slow migrations; keep concurrency*3 <= spare max_connections. Default: 1 (sequential).
6263

6364
# Vector Extension (Optional - uses pgvector by default)
6465
# Options: "pgvector" (default), "vchord", "pgvectorscale" (DiskANN)

hindsight-api-slim/hindsight_api/admin/cli.py

Lines changed: 16 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -258,12 +258,7 @@ async def _run_migration(
258258
embedding_dimension: int | None = None,
259259
) -> list[str]:
260260
"""Resolve database URL and run migrations for one schema or all discovered schemas."""
261-
from ..migrations import (
262-
ensure_embedding_dimension,
263-
ensure_text_search_extension,
264-
ensure_vector_extension,
265-
run_migrations,
266-
)
261+
from ..migrations import run_migrations_for_schemas
267262

268263
is_pg0, instance_name, _ = parse_pg0_url(db_url)
269264
if is_pg0:
@@ -284,32 +279,21 @@ async def _run_migration(
284279
# Preserve order while removing duplicates.
285280
schemas = list(dict.fromkeys(schemas))
286281

287-
for schema in schemas:
288-
run_migrations(resolved_url, schema=schema, migration_database_url=config.migration_database_url)
289-
290-
if embedding_dimension is not None:
291-
for schema in schemas:
292-
ensure_embedding_dimension(
293-
resolved_url,
294-
embedding_dimension,
295-
schema=schema,
296-
vector_extension=config.vector_extension,
297-
)
298-
299-
for schema in schemas:
300-
ensure_vector_extension(
301-
resolved_url,
302-
vector_extension=config.vector_extension,
303-
schema=schema,
304-
)
305-
306-
for schema in schemas:
307-
ensure_text_search_extension(
308-
resolved_url,
309-
text_search_extension=config.text_search_extension,
310-
pg_search_tokenizer=config.text_search_extension_pg_search_tokenizer,
311-
schema=schema,
312-
)
282+
# Migrate up to `migration_concurrency` schemas at once (each in its own
283+
# process); within a schema the work stays sequential. Run off the event
284+
# loop so the process pool's blocking joins don't stall it.
285+
await asyncio.to_thread(
286+
run_migrations_for_schemas,
287+
resolved_url,
288+
schemas,
289+
concurrency=config.migration_concurrency,
290+
migration_database_url=config.migration_database_url,
291+
embedding_dimension=embedding_dimension,
292+
vector_extension=config.vector_extension,
293+
text_search_extension=config.text_search_extension,
294+
pg_search_tokenizer=config.text_search_extension_pg_search_tokenizer,
295+
ensure_extensions=True,
296+
)
313297

314298
return schemas
315299

hindsight-api-slim/hindsight_api/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,7 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]:
479479

480480
# Database migrations
481481
ENV_RUN_MIGRATIONS_ON_STARTUP = "HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP"
482+
ENV_MIGRATION_CONCURRENCY = "HINDSIGHT_API_MIGRATION_CONCURRENCY"
482483

483484
# Database connection pool
484485
ENV_DB_POOL_MIN_SIZE = "HINDSIGHT_API_DB_POOL_MIN_SIZE"
@@ -900,6 +901,10 @@ def _parse_strategy_boosts(raw: str | None) -> dict[str, str]:
900901

901902
# Database migrations
902903
DEFAULT_RUN_MIGRATIONS_ON_STARTUP = True
904+
# Number of tenant schemas to migrate concurrently. Each schema runs in its own
905+
# process (Alembic's command.upgrade() is not thread-safe); within a schema the
906+
# work is always sequential. 1 = fully sequential (the safe default).
907+
DEFAULT_MIGRATION_CONCURRENCY = 1
903908

904909
# Database connection pool
905910
DEFAULT_DB_POOL_MIN_SIZE = 5
@@ -1538,6 +1543,7 @@ class HindsightConfig:
15381543

15391544
# Database migrations
15401545
run_migrations_on_startup: bool
1546+
migration_concurrency: int
15411547

15421548
# Database connection pool
15431549
db_pool_min_size: int
@@ -2449,6 +2455,7 @@ def from_env(cls) -> "HindsightConfig":
24492455
memory_defense=None,
24502456
# Database migrations
24512457
run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true",
2458+
migration_concurrency=int(os.getenv(ENV_MIGRATION_CONCURRENCY, str(DEFAULT_MIGRATION_CONCURRENCY))),
24522459
# Database connection pool
24532460
db_pool_min_size=int(os.getenv(ENV_DB_POOL_MIN_SIZE, str(DEFAULT_DB_POOL_MIN_SIZE))),
24542461
db_pool_max_size=int(os.getenv(ENV_DB_POOL_MAX_SIZE, str(DEFAULT_DB_POOL_MAX_SIZE))),

hindsight-api-slim/hindsight_api/engine/memory_engine.py

Lines changed: 31 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2605,50 +2605,39 @@ async def verify_llm():
26052605
tenants = await self._tenant_extension.list_tenants()
26062606
if tenants:
26072607
logger.info(f"Running migrations on {len(tenants)} schema(s)...")
2608-
for tenant in tenants:
2609-
schema = tenant.schema
2610-
if schema:
2611-
schema = self._backend.normalize_schema(schema)
2612-
self._backend.run_migrations(self.db_url, schema=schema)
2613-
logger.info("Schema migrations completed")
2614-
2615-
# PG-specific post-migration steps: ensure vector/text search extensions
2616-
# and embedding dimensions match configuration. These are no-ops for
2617-
# non-PG backends since they use different indexing strategies.
2618-
if self._backend.supports_bm25:
2619-
from ..migrations import (
2620-
ensure_embedding_dimension,
2621-
ensure_text_search_extension,
2622-
ensure_vector_extension,
2623-
)
2624-
2625-
if tenants:
2626-
for tenant in tenants:
2627-
schema = tenant.schema
2628-
if schema:
2629-
ensure_embedding_dimension(
2630-
self.db_url,
2631-
self.embeddings.dimension,
2632-
schema=schema,
2633-
vector_extension=config.vector_extension,
2634-
)
2635-
2636-
for tenant in tenants:
2637-
schema = tenant.schema
2638-
if schema:
2639-
ensure_vector_extension(
2640-
self.db_url, vector_extension=config.vector_extension, schema=schema
2641-
)
2642-
2608+
if self._database_backend_type == "postgresql":
2609+
# PG: fan out across schemas (up to migration_concurrency, each
2610+
# in its own process) and fold the PG-specific post-migration
2611+
# extension/dimension sync into the same per-schema unit. Run
2612+
# off the event loop so the process pool's blocking joins don't
2613+
# stall it.
2614+
from ..migrations import run_migrations_for_schemas
2615+
2616+
schemas = [tenant.schema for tenant in tenants if tenant.schema]
2617+
await asyncio.to_thread(
2618+
run_migrations_for_schemas,
2619+
self.db_url,
2620+
schemas,
2621+
concurrency=config.migration_concurrency,
2622+
migration_database_url=config.migration_database_url,
2623+
embedding_dimension=self.embeddings.dimension,
2624+
vector_extension=config.vector_extension,
2625+
text_search_extension=config.text_search_extension,
2626+
pg_search_tokenizer=config.text_search_extension_pg_search_tokenizer,
2627+
ensure_extensions=self._backend.supports_bm25,
2628+
)
2629+
else:
2630+
# Oracle and other backends: Alembic's non-thread-safe globals
2631+
# and the absence of per-schema extension steps make parallelism
2632+
# unnecessary; run sequentially via the backend's own runner.
2633+
# normalize_schema() maps PG's "public" default to None (the
2634+
# connecting user's schema) on Oracle.
26432635
for tenant in tenants:
2644-
schema = tenant.schema
2645-
if schema:
2646-
ensure_text_search_extension(
2647-
self.db_url,
2648-
text_search_extension=config.text_search_extension,
2649-
pg_search_tokenizer=config.text_search_extension_pg_search_tokenizer,
2650-
schema=schema,
2636+
if tenant.schema:
2637+
self._backend.run_migrations(
2638+
self.db_url, schema=self._backend.normalize_schema(tenant.schema)
26512639
)
2640+
logger.info("Schema migrations completed")
26522641

26532642
logger.info(f"Connecting to database at {mask_network_location(self.db_url)}")
26542643

hindsight-api-slim/hindsight_api/migrations.py

Lines changed: 139 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from alembic.script.revision import ResolutionError
2828
from alembic.util.exc import CommandError
2929
from sqlalchemy import Connection, create_engine, text
30+
from sqlalchemy.pool import NullPool
3031

3132
from ._pg_search import normalize_pg_search_tokenizer, pg_search_bm25_columns
3233
from ._vector_index import (
@@ -247,7 +248,14 @@ def run_migrations(
247248
# 2. After acquiring the lock, COMMIT the transaction on the advisory-lock
248249
# connection itself before running migrations. pg_advisory_lock is
249250
# session-level, so the lock survives the COMMIT.
250-
engine = create_engine(migration_url)
251+
# NullPool: do not retain the connection in a pool after the migration.
252+
# Each schema migration opens a few short-lived engines (here plus the
253+
# ensure_* steps); with the default QueuePool those connections linger
254+
# until GC, and running many schemas in parallel (migration_concurrency)
255+
# multiplies that footprint and exhausts max_connections — observed as
256+
# "FATAL: sorry, too many clients already" sweeping 20k schemas at
257+
# concurrency 12. NullPool closes the connection on return.
258+
engine = create_engine(migration_url, poolclass=NullPool)
251259
with engine.connect() as conn:
252260
logger.debug(f"Acquiring migration advisory lock for schema '{schema_name}' (id={lock_id})...")
253261
while True:
@@ -400,7 +408,7 @@ def check_migration_status(
400408
return None, None
401409

402410
# Get current revision from database
403-
engine = create_engine(to_libpq_url(database_url))
411+
engine = create_engine(to_libpq_url(database_url), poolclass=NullPool)
404412
with engine.connect() as connection:
405413
context = MigrationContext.configure(connection)
406414
current_rev = context.get_current_revision()
@@ -573,7 +581,7 @@ def ensure_embedding_dimension(
573581
"""
574582
schema_name = schema or "public"
575583

576-
engine = create_engine(to_libpq_url(database_url))
584+
engine = create_engine(to_libpq_url(database_url), poolclass=NullPool)
577585
with engine.connect() as conn:
578586
# Check if memory_units table exists (proxy for schema being initialized)
579587
table_exists = conn.execute(
@@ -622,7 +630,7 @@ def ensure_vector_extension(
622630
"""
623631
schema_name = schema or "public"
624632

625-
engine = create_engine(to_libpq_url(database_url))
633+
engine = create_engine(to_libpq_url(database_url), poolclass=NullPool)
626634
with engine.connect() as conn:
627635
# Detect which vector extension should be used
628636
target_ext = _detect_vector_extension(conn, vector_extension)
@@ -836,7 +844,7 @@ def ensure_text_search_extension(
836844
schema_name = schema or "public"
837845
pg_search_tokenizer = normalize_pg_search_tokenizer(pg_search_tokenizer)
838846

839-
engine = create_engine(to_libpq_url(database_url))
847+
engine = create_engine(to_libpq_url(database_url), poolclass=NullPool)
840848
with engine.connect() as conn:
841849
# Tables with search_vector columns to check
842850
tables_to_check = [
@@ -1129,3 +1137,129 @@ def ensure_text_search_extension(
11291137

11301138
conn.commit()
11311139
logger.info(f"Successfully migrated text search to {text_search_extension}")
1140+
1141+
1142+
def _migrate_one_schema_pg(
1143+
database_url: str,
1144+
schema: str,
1145+
*,
1146+
migration_database_url: str | None,
1147+
embedding_dimension: int | None,
1148+
vector_extension: str,
1149+
text_search_extension: str,
1150+
pg_search_tokenizer: str | None,
1151+
ensure_extensions: bool,
1152+
) -> str:
1153+
"""Run migrations + post-migration extension setup for a SINGLE PG schema.
1154+
1155+
Module-level (not a closure) so it is picklable and can run inside a
1156+
``ProcessPoolExecutor`` worker. The steps run strictly in order — this is
1157+
the per-tenant sequential unit; parallelism happens only *across* schemas.
1158+
Returns the schema name on success; raises on the first failing step so the
1159+
caller can attribute the failure back to this schema.
1160+
"""
1161+
run_migrations(database_url, schema=schema, migration_database_url=migration_database_url)
1162+
if embedding_dimension is not None:
1163+
ensure_embedding_dimension(
1164+
database_url,
1165+
embedding_dimension,
1166+
schema=schema,
1167+
vector_extension=vector_extension,
1168+
)
1169+
if ensure_extensions:
1170+
ensure_vector_extension(database_url, vector_extension=vector_extension, schema=schema)
1171+
ensure_text_search_extension(
1172+
database_url,
1173+
text_search_extension=text_search_extension,
1174+
schema=schema,
1175+
pg_search_tokenizer=pg_search_tokenizer,
1176+
)
1177+
return schema
1178+
1179+
1180+
def _make_migration_executor(max_workers: int):
1181+
"""Build the executor that runs per-schema migrations in parallel.
1182+
1183+
Each schema must run in its OWN process — Alembic's ``command.upgrade()``
1184+
uses non-thread-safe module globals (serialized in-process by
1185+
``_alembic_lock``), so a thread pool would not actually run two upgrades at
1186+
once. ``spawn`` gives every worker a clean interpreter on all platforms,
1187+
avoiding the fork-of-a-multithreaded-process deadlock hazard (the API server
1188+
holds threads/pools when migrations run on startup).
1189+
1190+
Factored out so tests can substitute an in-process executor.
1191+
"""
1192+
import multiprocessing
1193+
from concurrent.futures import ProcessPoolExecutor
1194+
1195+
return ProcessPoolExecutor(max_workers=max_workers, mp_context=multiprocessing.get_context("spawn"))
1196+
1197+
1198+
def run_migrations_for_schemas(
1199+
database_url: str,
1200+
schemas: list[str],
1201+
*,
1202+
concurrency: int = 1,
1203+
migration_database_url: str | None = None,
1204+
embedding_dimension: int | None = None,
1205+
vector_extension: str = "pgvector",
1206+
text_search_extension: str = "native",
1207+
pg_search_tokenizer: str | None = None,
1208+
ensure_extensions: bool = True,
1209+
) -> None:
1210+
"""Run PostgreSQL migrations for many schemas, up to ``concurrency`` at once.
1211+
1212+
Within a schema the work is always sequential (migrate → embedding dim →
1213+
vector ext → text-search ext). Across schemas, when ``concurrency > 1`` each
1214+
schema is migrated in its OWN process: Alembic's ``command.upgrade()`` relies
1215+
on non-thread-safe module-level globals (serialized in-process by
1216+
``_alembic_lock``), so threads would gain nothing — separate interpreters
1217+
each get a clean Alembic context. Per-schema advisory locks
1218+
(``_get_schema_lock_id``) keep concurrent processes from colliding on the
1219+
same schema across replicas.
1220+
1221+
``database_url`` must already be resolved (e.g. an embedded ``pg0`` instance
1222+
started in the parent) — workers receive it verbatim and only connect.
1223+
1224+
Failures are collected per schema and re-raised together so one bad tenant
1225+
does not hide the status of the others.
1226+
"""
1227+
if not schemas:
1228+
return
1229+
1230+
worker_kwargs = dict(
1231+
migration_database_url=migration_database_url,
1232+
embedding_dimension=embedding_dimension,
1233+
vector_extension=vector_extension,
1234+
text_search_extension=text_search_extension,
1235+
pg_search_tokenizer=pg_search_tokenizer,
1236+
ensure_extensions=ensure_extensions,
1237+
)
1238+
1239+
effective = max(1, min(concurrency, len(schemas)))
1240+
if effective == 1:
1241+
# Inline, in-process — no subprocess overhead for the common single
1242+
# tenant / sequential case (and keeps embedded pg0 dev simple).
1243+
for schema in schemas:
1244+
_migrate_one_schema_pg(database_url, schema, **worker_kwargs)
1245+
return
1246+
1247+
logger.info("Migrating %d schema(s) with concurrency=%d", len(schemas), effective)
1248+
errors: dict[str, BaseException] = {}
1249+
with _make_migration_executor(effective) as executor:
1250+
futures = {
1251+
executor.submit(_migrate_one_schema_pg, database_url, schema, **worker_kwargs): schema for schema in schemas
1252+
}
1253+
for future in futures:
1254+
schema = futures[future]
1255+
try:
1256+
future.result()
1257+
except Exception as exc: # noqa: BLE001 — aggregate per-schema, re-raise below
1258+
errors[schema] = exc
1259+
logger.error("Migration failed for schema '%s': %s", schema, exc)
1260+
1261+
if errors:
1262+
failed = ", ".join(sorted(errors))
1263+
raise RuntimeError(
1264+
f"Database migrations failed for {len(errors)} of {len(schemas)} schema(s): {failed}"
1265+
) from next(iter(errors.values()))

0 commit comments

Comments
 (0)