Spaces:
Build error
Build error
Upload folder using huggingface_hub
Browse files
alembic/env.py
CHANGED
|
@@ -10,8 +10,35 @@ sys.path.append(str(Path(__file__).parent.parent))
|
|
| 10 |
|
| 11 |
from app.database.base import Base
|
| 12 |
from app.database import models_intents
|
|
|
|
| 13 |
from app.core.config import settings
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
config = context.config
|
| 16 |
# fileConfig defaults to disable_existing_loggers=True, which silences every
|
| 17 |
# logger that already exists -- including the application's own. That is
|
|
@@ -36,6 +63,7 @@ def run_migrations_offline() -> None:
|
|
| 36 |
target_metadata=target_metadata,
|
| 37 |
literal_binds=True,
|
| 38 |
dialect_opts={"paramstyle": "named"},
|
|
|
|
| 39 |
)
|
| 40 |
|
| 41 |
with context.begin_transaction():
|
|
@@ -52,7 +80,9 @@ def run_migrations_online() -> None:
|
|
| 52 |
|
| 53 |
with connectable.connect() as connection:
|
| 54 |
context.configure(
|
| 55 |
-
connection=connection,
|
|
|
|
|
|
|
| 56 |
)
|
| 57 |
|
| 58 |
with context.begin_transaction():
|
|
|
|
| 10 |
|
| 11 |
from app.database.base import Base
|
| 12 |
from app.database import models_intents
|
| 13 |
+
from app.database import models_onchain
|
| 14 |
from app.core.config import settings
|
| 15 |
|
| 16 |
+
# Tables that are deliberately not ORM-mapped. UsageTracker and arf-gateway
|
| 17 |
+
# create and query these with raw SQL -- the Go service needs them without
|
| 18 |
+
# going through Python at all -- so they have no entry in Base.metadata.
|
| 19 |
+
# Autogenerate compares the database against that metadata, so without this
|
| 20 |
+
# it sees them as tables to DROP.
|
| 21 |
+
#
|
| 22 |
+
# models_onchain is imported above for the same reason: it was missing, and
|
| 23 |
+
# `alembic check` duly proposed dropping onchain_rationales.
|
| 24 |
+
|
| 25 |
+
UNMANAGED_TABLES = {
|
| 26 |
+
"api_keys",
|
| 27 |
+
"monthly_counts",
|
| 28 |
+
"usage_log",
|
| 29 |
+
"idempotency_keys",
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def include_object(object_, name, type_, reflected, compare_to):
|
| 34 |
+
"""Keep unmanaged tables out of autogenerate/check comparisons."""
|
| 35 |
+
if type_ == "table" and name in UNMANAGED_TABLES:
|
| 36 |
+
return False
|
| 37 |
+
if type_ == "index" and getattr(object_, "table", None) is not None:
|
| 38 |
+
return object_.table.name not in UNMANAGED_TABLES
|
| 39 |
+
return True
|
| 40 |
+
|
| 41 |
+
|
| 42 |
config = context.config
|
| 43 |
# fileConfig defaults to disable_existing_loggers=True, which silences every
|
| 44 |
# logger that already exists -- including the application's own. That is
|
|
|
|
| 63 |
target_metadata=target_metadata,
|
| 64 |
literal_binds=True,
|
| 65 |
dialect_opts={"paramstyle": "named"},
|
| 66 |
+
include_object=include_object,
|
| 67 |
)
|
| 68 |
|
| 69 |
with context.begin_transaction():
|
|
|
|
| 80 |
|
| 81 |
with connectable.connect() as connection:
|
| 82 |
context.configure(
|
| 83 |
+
connection=connection,
|
| 84 |
+
target_metadata=target_metadata,
|
| 85 |
+
include_object=include_object,
|
| 86 |
)
|
| 87 |
|
| 88 |
with context.begin_transaction():
|
alembic/versions/f1a2b3c4d5e6_add_tenant_id_to_intents_and_beta_state.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add tenant_id to intents and beta_state, and fix the beta_state unique index
|
| 2 |
+
|
| 3 |
+
The models have declared `tenant_id` on both tables since multi-tenancy
|
| 4 |
+
landed, but no migration ever created the column. Nothing caught it: the
|
| 5 |
+
test suite builds its schema with `create_all` from the models, so tests
|
| 6 |
+
see a column production does not have, and the migration check only
|
| 7 |
+
asserted that `alembic upgrade head` *succeeds* -- never that the schema it
|
| 8 |
+
produces matches the models.
|
| 9 |
+
|
| 10 |
+
Production surfaced it the moment `intents` first existed (arf-api-004 had
|
| 11 |
+
been rolling the whole chain back, hiding this behind a missing table):
|
| 12 |
+
|
| 13 |
+
psycopg2.errors.UndefinedColumn:
|
| 14 |
+
column intents.tenant_id does not exist
|
| 15 |
+
|
| 16 |
+
Also corrects `ix_beta_state_category`, which d36deffe7fa2 created as
|
| 17 |
+
UNIQUE on `category` alone. That is wrong for a multi-tenant table -- it
|
| 18 |
+
lets exactly one tenant hold any given category, and the second tenant to
|
| 19 |
+
write one gets an integrity error. The model's intent is the composite
|
| 20 |
+
`uq_beta_state_tenant_category` on (tenant_id, category).
|
| 21 |
+
|
| 22 |
+
Both columns are declared NOT NULL in the models. This adds them nullable,
|
| 23 |
+
then tightens to NOT NULL only when no NULL rows remain, so the migration
|
| 24 |
+
cannot fail on a database that already holds rows -- a failure here would
|
| 25 |
+
abort the whole chain in a single transaction, which is exactly the
|
| 26 |
+
arf-api-004 outage. A database left with NULLs keeps the column nullable
|
| 27 |
+
and says so in the deploy log rather than taking the service down.
|
| 28 |
+
|
| 29 |
+
Revision ID: f1a2b3c4d5e6
|
| 30 |
+
Revises: 0c89c9727bc0
|
| 31 |
+
Create Date: 2026-09-14 14:30:00.000000
|
| 32 |
+
|
| 33 |
+
"""
|
| 34 |
+
from typing import Sequence, Union
|
| 35 |
+
|
| 36 |
+
from alembic import op
|
| 37 |
+
import sqlalchemy as sa
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# revision identifiers, used by Alembic.
|
| 41 |
+
revision: str = 'f1a2b3c4d5e6'
|
| 42 |
+
down_revision: Union[str, Sequence[str], None] = '0c89c9727bc0'
|
| 43 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 44 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _add_tenant_id(table: str, index_name: str, fk_name: str) -> None:
|
| 48 |
+
bind = op.get_bind()
|
| 49 |
+
inspector = sa.inspect(bind)
|
| 50 |
+
|
| 51 |
+
columns = {c['name'] for c in inspector.get_columns(table)}
|
| 52 |
+
if 'tenant_id' not in columns:
|
| 53 |
+
op.add_column(table, sa.Column('tenant_id', sa.String(length=64), nullable=True))
|
| 54 |
+
|
| 55 |
+
indexes = {ix['name'] for ix in inspector.get_indexes(table)}
|
| 56 |
+
if index_name not in indexes:
|
| 57 |
+
op.create_index(index_name, table, ['tenant_id'], unique=False)
|
| 58 |
+
|
| 59 |
+
fks = {fk.get('name') for fk in inspector.get_foreign_keys(table)}
|
| 60 |
+
if fk_name not in fks:
|
| 61 |
+
op.create_foreign_key(fk_name, table, 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
|
| 62 |
+
|
| 63 |
+
# Tighten to NOT NULL only if nothing would violate it. On an empty or
|
| 64 |
+
# fully-populated table this reaches the models' declared schema; on a
|
| 65 |
+
# table with legacy NULLs it stays nullable instead of aborting the
|
| 66 |
+
# entire migration transaction.
|
| 67 |
+
orphans = bind.execute(
|
| 68 |
+
sa.text(f"SELECT COUNT(*) FROM {table} WHERE tenant_id IS NULL") # noqa: S608
|
| 69 |
+
).scalar()
|
| 70 |
+
if orphans:
|
| 71 |
+
print(
|
| 72 |
+
f"[{revision}] {table}: {orphans} row(s) have a NULL tenant_id; "
|
| 73 |
+
f"leaving the column nullable. Backfill them and then run "
|
| 74 |
+
f"ALTER TABLE {table} ALTER COLUMN tenant_id SET NOT NULL."
|
| 75 |
+
)
|
| 76 |
+
return
|
| 77 |
+
op.alter_column(table, 'tenant_id', existing_type=sa.String(length=64), nullable=False)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def upgrade() -> None:
|
| 81 |
+
"""Upgrade schema."""
|
| 82 |
+
_add_tenant_id('intents', 'ix_intents_tenant_id', 'fk_intents_tenant_id')
|
| 83 |
+
_add_tenant_id('beta_state', 'ix_beta_state_tenant_id', 'fk_beta_state_tenant_id')
|
| 84 |
+
|
| 85 |
+
bind = op.get_bind()
|
| 86 |
+
inspector = sa.inspect(bind)
|
| 87 |
+
|
| 88 |
+
# d36deffe7fa2 made this UNIQUE on category alone, which is wrong once
|
| 89 |
+
# the table is partitioned by tenant. Recreate it non-unique and put the
|
| 90 |
+
# uniqueness on (tenant_id, category), as the model declares.
|
| 91 |
+
beta_indexes = {ix['name']: ix for ix in inspector.get_indexes('beta_state')}
|
| 92 |
+
category_index = beta_indexes.get('ix_beta_state_category')
|
| 93 |
+
if category_index is not None and category_index.get('unique'):
|
| 94 |
+
op.drop_index('ix_beta_state_category', table_name='beta_state')
|
| 95 |
+
op.create_index('ix_beta_state_category', 'beta_state', ['category'], unique=False)
|
| 96 |
+
|
| 97 |
+
constraints = {uc['name'] for uc in inspector.get_unique_constraints('beta_state')}
|
| 98 |
+
constraints |= set(beta_indexes)
|
| 99 |
+
if 'uq_beta_state_tenant_category' not in constraints:
|
| 100 |
+
op.create_unique_constraint(
|
| 101 |
+
'uq_beta_state_tenant_category', 'beta_state', ['tenant_id', 'category']
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def downgrade() -> None:
|
| 106 |
+
"""Downgrade schema."""
|
| 107 |
+
bind = op.get_bind()
|
| 108 |
+
inspector = sa.inspect(bind)
|
| 109 |
+
|
| 110 |
+
if 'uq_beta_state_tenant_category' in {
|
| 111 |
+
uc['name'] for uc in inspector.get_unique_constraints('beta_state')
|
| 112 |
+
}:
|
| 113 |
+
op.drop_constraint('uq_beta_state_tenant_category', 'beta_state', type_='unique')
|
| 114 |
+
|
| 115 |
+
for table, index_name, fk_name in (
|
| 116 |
+
('beta_state', 'ix_beta_state_tenant_id', 'fk_beta_state_tenant_id'),
|
| 117 |
+
('intents', 'ix_intents_tenant_id', 'fk_intents_tenant_id'),
|
| 118 |
+
):
|
| 119 |
+
insp = sa.inspect(bind)
|
| 120 |
+
if fk_name in {fk.get('name') for fk in insp.get_foreign_keys(table)}:
|
| 121 |
+
op.drop_constraint(fk_name, table, type_='foreignkey')
|
| 122 |
+
if index_name in {ix['name'] for ix in insp.get_indexes(table)}:
|
| 123 |
+
op.drop_index(index_name, table_name=table)
|
| 124 |
+
if 'tenant_id' in {c['name'] for c in insp.get_columns(table)}:
|
| 125 |
+
op.drop_column(table, 'tenant_id')
|