Skip to content

Commit ffa3c8c

Browse files
bokelleyclaude
andauthored
fix(v3-ref-seller): run alembic upgrade head at boot (#421) (#733)
* fix(v3-ref-seller): run alembic upgrade head at boot (#421) Replaces ``Base.metadata.create_all`` at boot in ``app.py`` and ``seed.py`` with ``alembic upgrade head``. ``create_all`` is idempotent on table existence but blind to column renames, type changes, and new columns on existing tables — adopters running this example past first boot were silently dropping schema changes. Running migrations at boot puts the example on the same evolution path as ``migrate.py`` and CI. ``alembic.command.upgrade`` is synchronous and opens its own engine; the boot path runs it through ``asyncio.to_thread`` so the async surface stays unblocked. Closes #421. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(deps): add alembic to dev extra for v3-ref-seller boot The v3 reference seller boot path now imports alembic.command at runtime (replaces Base.metadata.create_all). CI installs .[dev] but didn't pull alembic, breaking the v3-storyboard CI job with ImportError. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3071b18 commit ffa3c8c

4 files changed

Lines changed: 77 additions & 28 deletions

File tree

examples/v3_reference_seller/README.md

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -243,22 +243,16 @@ where the framework picks it up.
243243
`create_media_buy` async approval path) and webhook delivery.
244244
Both classes ship in the SDK; this seller's `app.py` uses the
245245
in-memory variants for fast iteration.
246-
- **Alembic migrations**`Base.metadata.create_all` runs at boot
247-
(idempotent on table existence). Production sellers wire Alembic
248-
(see the Migrations section below).
249246
- **Admin CRUD API** — separate Starlette app for tenant / agent
250247
CRUD. Patterns to come; for now use `seed.py` and direct SQL.
251248

252249
## Migrations
253250

254-
The app boots with `Base.metadata.create_all` — idempotent on table
255-
existence, but **blind to column renames, type changes, and new columns
256-
on existing tables**. For local fast-iteration this is fine. Once you
257-
have production data, use Alembic to evolve the schema safely.
258-
259-
> ⚠️ **`create_all` is unsafe for schema evolution once production data
260-
> exists.** Column renames and type changes applied after first boot
261-
> will not be detected and will silently leave the schema stale.
251+
The app boots by running `alembic upgrade head` — column renames,
252+
type changes, and new columns on existing tables all propagate
253+
through the migration scripts under `alembic/versions/`. The same
254+
path runs from `seed.py`, `migrate.py`, and CI, so the schema you
255+
develop against matches what ships.
262256

263257
### Install Alembic
264258

examples/v3_reference_seller/seed.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,42 @@
2929

3030
import asyncio
3131
import os
32+
from pathlib import Path
3233

3334
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
34-
from src.models import Account, Base, BuyerAgent, Tenant
35+
from src.models import Account, BuyerAgent, Tenant
36+
37+
38+
def _run_alembic_upgrade_head(db_url: str) -> None:
39+
"""Run ``alembic upgrade head`` against ``db_url``.
40+
41+
Mirrors the production entrypoint in ``migrate.py`` so seed runs
42+
against the same schema-evolution path as production — column
43+
renames and type changes propagate instead of being silently
44+
skipped by ``Base.metadata.create_all``.
45+
"""
46+
from alembic import command
47+
from alembic.config import Config
48+
49+
ini_path = Path(__file__).parent / "alembic.ini"
50+
# env.py reads DATABASE_URL from os.environ; export so the alembic
51+
# script picks up the same URL the seed script connects with.
52+
os.environ["DATABASE_URL"] = db_url
53+
alembic_cfg = Config(str(ini_path))
54+
alembic_cfg.set_main_option("sqlalchemy.url", db_url)
55+
command.upgrade(alembic_cfg, "head")
3556

3657

3758
async def main() -> None:
3859
db_url = os.environ.get(
3960
"DATABASE_URL",
4061
"postgresql+asyncpg://postgres@localhost/adcp",
4162
)
63+
# Run migrations in a thread — alembic.command.upgrade is sync and
64+
# opens its own engine, so calling it directly from an async context
65+
# is safe via asyncio.to_thread.
66+
await asyncio.to_thread(_run_alembic_upgrade_head, db_url)
4267
engine = create_async_engine(db_url)
43-
async with engine.begin() as conn:
44-
await conn.run_sync(Base.metadata.create_all)
4568
sm = async_sessionmaker(engine, expire_on_commit=False)
4669

4770
async with sm() as session:

examples/v3_reference_seller/src/app.py

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
Boot sequence:
77
88
1. Connect SQLAlchemy async engine + sessionmaker.
9-
2. Create schema (idempotent ``Base.metadata.create_all``).
9+
2. Evolve schema by running ``alembic upgrade head``.
1010
3. Wire the upstream HTTP client. The platform calls
1111
:meth:`adcp.decisioning.DecisioningPlatform.upstream_for` per
1212
request, which builds a pooled :class:`UpstreamHttpClient` from the
@@ -70,7 +70,6 @@
7070

7171
from .audit import make_sink as make_audit_sink
7272
from .buyer_registry import make_registry as make_buyer_registry
73-
from .models import Base
7473
from .platform import V3ReferenceSeller
7574
from .tenant_router import SqlSubdomainTenantRouter
7675

@@ -178,16 +177,41 @@ def validate_token(token: str) -> Principal | None:
178177
return validate_token
179178

180179

181-
async def _bootstrap_schema_and_load_tokens(engine, sessionmaker) -> dict[str, Principal]:
182-
"""Bootstrap the schema (idempotent ``CREATE TABLE IF NOT EXISTS``)
183-
AND load the bearer-token map in the same event loop, then dispose
184-
the engine before returning.
180+
def _run_alembic_upgrade_head(db_url: str) -> None:
181+
"""Run ``alembic upgrade head`` against ``db_url``.
182+
183+
Mirrors the production entrypoint in ``migrate.py`` so boot evolves
184+
the schema via the same migration scripts adopters run in CI. The
185+
previous ``Base.metadata.create_all`` path silently skipped column
186+
renames and type changes on existing tables; running migrations at
187+
boot eliminates that drift.
188+
"""
189+
from pathlib import Path
190+
191+
from alembic import command
192+
from alembic.config import Config
193+
194+
ini_path = Path(__file__).resolve().parent.parent / "alembic.ini"
195+
# env.py reads DATABASE_URL from os.environ; export so the alembic
196+
# script picks up the same URL the app connects with.
197+
os.environ["DATABASE_URL"] = db_url
198+
alembic_cfg = Config(str(ini_path))
199+
alembic_cfg.set_main_option("sqlalchemy.url", db_url)
200+
command.upgrade(alembic_cfg, "head")
185201

186-
Production adopters use Alembic — this entrypoint sticks with
187-
``create_all`` for fast iteration. Token loading happens here
188-
(rather than separately) because ``BearerTokenAuth.validate_token``
189-
must be sync for ``transport="both"``, so we pay one DB scan at
190-
boot and serve every subsequent request from memory.
202+
203+
async def _bootstrap_schema_and_load_tokens(
204+
db_url: str, engine, sessionmaker
205+
) -> dict[str, Principal]:
206+
"""Run ``alembic upgrade head`` AND load the bearer-token map in
207+
the same event loop, then dispose the engine before returning.
208+
209+
Migrations propagate schema changes (column renames, type changes,
210+
new columns on existing tables) that ``create_all`` silently
211+
skipped. Token loading happens here (rather than separately)
212+
because ``BearerTokenAuth.validate_token`` must be sync for
213+
``transport="both"``, so we pay one DB scan at boot and serve every
214+
subsequent request from memory.
191215
192216
asyncpg binds connection-internal Future objects to the loop they
193217
were opened on. Bootstrapping via ``asyncio.run`` runs on a
@@ -196,9 +220,12 @@ async def _bootstrap_schema_and_load_tokens(engine, sessionmaker) -> dict[str, P
196220
``RuntimeError: got Future attached to a different loop`` on the
197221
first request. Dispose before returning so uvicorn opens a fresh
198222
pool on its own loop.
223+
224+
``alembic.command.upgrade`` is synchronous and opens its own
225+
engine; ``asyncio.to_thread`` runs it off the event loop so the
226+
async surface stays unblocked.
199227
"""
200-
async with engine.begin() as conn:
201-
await conn.run_sync(Base.metadata.create_all)
228+
await asyncio.to_thread(_run_alembic_upgrade_head, db_url)
202229
token_map = await _load_token_map(sessionmaker)
203230
await engine.dispose()
204231
return token_map
@@ -240,7 +267,7 @@ def main() -> None:
240267
engine = create_async_engine(db_url, pool_size=10, max_overflow=20)
241268
sessionmaker = async_sessionmaker(engine, expire_on_commit=False)
242269

243-
token_map = asyncio.run(_bootstrap_schema_and_load_tokens(engine, sessionmaker))
270+
token_map = asyncio.run(_bootstrap_schema_and_load_tokens(db_url, engine, sessionmaker))
244271

245272
router = SqlSubdomainTenantRouter(sessionmaker=sessionmaker)
246273
audit_sink = make_audit_sink(sessionmaker)

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@ dev = [
117117
# so we don't need to boot the JS mock-server in the Python pytest
118118
# CI run.
119119
"respx>=0.20.0",
120+
# v3 reference seller boots `alembic upgrade head` instead of
121+
# Base.metadata.create_all so column renames and type changes
122+
# propagate. Required at runtime by examples/v3_reference_seller/
123+
# src/app.py and seed.py.
124+
"alembic>=1.13.0",
120125
]
121126
docs = [
122127
"pdoc3>=0.10.0",

0 commit comments

Comments
 (0)