|
27 | 27 | from alembic.script.revision import ResolutionError |
28 | 28 | from alembic.util.exc import CommandError |
29 | 29 | from sqlalchemy import Connection, create_engine, text |
| 30 | +from sqlalchemy.pool import NullPool |
30 | 31 |
|
31 | 32 | from ._pg_search import normalize_pg_search_tokenizer, pg_search_bm25_columns |
32 | 33 | from ._vector_index import ( |
@@ -247,7 +248,14 @@ def run_migrations( |
247 | 248 | # 2. After acquiring the lock, COMMIT the transaction on the advisory-lock |
248 | 249 | # connection itself before running migrations. pg_advisory_lock is |
249 | 250 | # 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) |
251 | 259 | with engine.connect() as conn: |
252 | 260 | logger.debug(f"Acquiring migration advisory lock for schema '{schema_name}' (id={lock_id})...") |
253 | 261 | while True: |
@@ -400,7 +408,7 @@ def check_migration_status( |
400 | 408 | return None, None |
401 | 409 |
|
402 | 410 | # 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) |
404 | 412 | with engine.connect() as connection: |
405 | 413 | context = MigrationContext.configure(connection) |
406 | 414 | current_rev = context.get_current_revision() |
@@ -573,7 +581,7 @@ def ensure_embedding_dimension( |
573 | 581 | """ |
574 | 582 | schema_name = schema or "public" |
575 | 583 |
|
576 | | - engine = create_engine(to_libpq_url(database_url)) |
| 584 | + engine = create_engine(to_libpq_url(database_url), poolclass=NullPool) |
577 | 585 | with engine.connect() as conn: |
578 | 586 | # Check if memory_units table exists (proxy for schema being initialized) |
579 | 587 | table_exists = conn.execute( |
@@ -622,7 +630,7 @@ def ensure_vector_extension( |
622 | 630 | """ |
623 | 631 | schema_name = schema or "public" |
624 | 632 |
|
625 | | - engine = create_engine(to_libpq_url(database_url)) |
| 633 | + engine = create_engine(to_libpq_url(database_url), poolclass=NullPool) |
626 | 634 | with engine.connect() as conn: |
627 | 635 | # Detect which vector extension should be used |
628 | 636 | target_ext = _detect_vector_extension(conn, vector_extension) |
@@ -836,7 +844,7 @@ def ensure_text_search_extension( |
836 | 844 | schema_name = schema or "public" |
837 | 845 | pg_search_tokenizer = normalize_pg_search_tokenizer(pg_search_tokenizer) |
838 | 846 |
|
839 | | - engine = create_engine(to_libpq_url(database_url)) |
| 847 | + engine = create_engine(to_libpq_url(database_url), poolclass=NullPool) |
840 | 848 | with engine.connect() as conn: |
841 | 849 | # Tables with search_vector columns to check |
842 | 850 | tables_to_check = [ |
@@ -1129,3 +1137,129 @@ def ensure_text_search_extension( |
1129 | 1137 |
|
1130 | 1138 | conn.commit() |
1131 | 1139 | 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