honcho/scripts/bootstrap_shared_schema.py

130 lines
5.1 KiB
Python

# honcho/scripts/bootstrap_shared_schema.py
"""Bootstrap the shared-tenant partitioned schema on a fresh shared database."""
# region ai
# This is deliberately NOT an Alembic migration. As a chained revision it would
# run after the per-tenant migration history that already creates these tables
# (non-partitioned): ``alembic upgrade head`` would collide (DuplicateTable)
# and, worse, it would run on existing single-tenant instances via
# ``init_db()`` and error there too. Instead this is a standalone bootstrap
# that the migration/consolidation track runs explicitly against the fresh
# shared database that per-tenant data is consolidated into. How that database
# is provisioned and version-stamped is owned by the migration track.
#
# The DDL is generated from the declarative models (``src.models.Base.metadata``):
# the models are the single source of truth, the compiler renders ``PARTITION
# BY`` from each table's ``postgresql_partition_by`` option, and this stays in
# lockstep with the data model. Only the per-table HASH partitions (which the
# model layer does not enumerate) are created explicitly, and the set of
# partitioned tables is derived from the models — never hand-listed — so a newly
# partitioned model can't silently ship a parent with zero partitions (which
# would fail every insert).
#
# Statements run in AUTOCOMMIT: the partitioned parents times ``PARTITION_COUNT``
# partitions, plus their composite FKs (each child FK locks every partition of
# the referenced parent), accumulate tens of thousands of locks, which overflows
# ``max_locks_per_transaction`` if run in a single transaction. Committing per
# statement releases each partition's locks incrementally.
#
# Prerequisite: the ``vector`` extension must already be installed (for the
# embedding columns and their HNSW indexes).
# endregion
import os
import sys
# ai: Add the project root to the path (this script is run from the scripts directory).
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, project_root)
from sqlalchemy import Connection, Table, text # noqa: E402
from sqlalchemy.schema import CreateIndex, CreateTable # noqa: E402
# region ai
# Importing Base from src.models (rather than src.db) also registers every model
# on Base.metadata as a side effect — that populated metadata is the schema this
# script builds.
# endregion
from src.models import Base # noqa: E402 # pyright: ignore
# region ai
# HASH(tenant_id) partition count. Sized so each partition stays around a couple
# GB — well under a node's memory, with headroom for growth. Contention is not the
# driver (write volume is low); this is a maintenance/pruning choice.
# endregion
PARTITION_COUNT = 128
def partitioned_tables() -> set[str]:
"""Names of the tables declared with HASH(tenant_id) partitioning."""
# region ai
# Derived from the models, never hand-listed: a hand list drifts out of
# sync, and a partitioned parent with no partitions fails every insert.
# endregion
return {
table.name
for table in Base.metadata.tables.values()
if table.dialect_options["postgresql"].get("partition_by")
}
def _qualified(table: Table) -> str:
return f'"{table.schema}"."{table.name}"' if table.schema else f'"{table.name}"'
def _create_hash_partitions(conn: Connection, table: Table) -> None:
schema_prefix = f'"{table.schema}".' if table.schema else ""
for remainder in range(PARTITION_COUNT):
conn.execute(
text(
f'CREATE TABLE {schema_prefix}"{table.name}_p{remainder:03d}"'
+ f" PARTITION OF {_qualified(table)}"
+ f" FOR VALUES WITH (MODULUS {PARTITION_COUNT}, REMAINDER {remainder})"
)
)
def bootstrap_shared_schema(conn: Connection) -> None:
"""Create the shared partitioned schema on ``conn``."""
# region ai
# ``conn`` must be in AUTOCOMMIT (see the module docstring on lock
# accumulation).
# endregion
schema = Base.metadata.schema
if schema and schema != "public":
conn.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{schema}"'))
partitioned = partitioned_tables()
# region ai
# Create tables in FK-dependency order (tenants first); a partitioned parent's
# HASH partitions are created immediately after it.
# endregion
for table in Base.metadata.sorted_tables:
conn.execute(CreateTable(table))
if table.name in partitioned:
_create_hash_partitions(conn, table)
# region ai
# Indexes after every partition exists, so each partitioned index cascades to
# all partitions.
# endregion
for table in Base.metadata.sorted_tables:
for index in table.indexes:
conn.execute(CreateIndex(index))
if __name__ == "__main__":
# region ai
# The app's async engine exposes a sync engine; CONNECTION_URI must point at
# the fresh shared database.
# endregion
from src.db import engine # noqa: E402
connection = engine.sync_engine.connect().execution_options(
isolation_level="AUTOCOMMIT"
)
with connection as conn:
bootstrap_shared_schema(conn)
print("Shared-tenant schema bootstrapped.")