refactor(db): introduce Alembic migrations (replace boot-time _run_migrations) (#121) - #153
Open
lucastononro wants to merge 3 commits into
Open
refactor(db): introduce Alembic migrations (replace boot-time _run_migrations) (#121)#153lucastononro wants to merge 3 commits into
lucastononro wants to merge 3 commits into
Conversation
…grations) (#121) Scaffolds backend/alembic/ wired to the app's async engine (settings.database_url) and Base.metadata, with a single autogenerated initial revision reflecting today's full schema. init_db() now runs `alembic upgrade head` on boot (in a worker thread, since env.py drives its own asyncio.run()); a DB that already has the full schema but no alembic_version table gets `stamp head` instead, so existing deployments don't get destructive re-creation attempts. _run_migrations is kept in place but unused, documented as dead code for rollback reference. Verified schema-equivalence of `alembic upgrade head` (fresh DB) vs `create_all + _run_migrations` (legacy path) on both SQLite and Postgres: only difference found is two redundant duplicate indexes the legacy code created under typo'd names, no logical schema divergence. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
This was referenced Jul 19, 2026
- Document the single-instance boot-time-migration assumption (Greptile
P2 on db.py:598): the stamp-vs-upgrade decision and the Alembic run
are not atomic, so concurrent replicas booting against the same empty
DB could race on CREATE TABLE. Noted in backend/AGENTS.md ("Database")
and at the decision site in init_db(), with the recommended mitigation
(one-off init job) if the app is ever scaled beyond one instance. No
code-level locking added: docker-compose runs a single backend
container, and DB-specific advisory locking would risk the proven
startup behavior for a scenario the deployment doesn't have.
- Add tests/test_alembic_migrations.py: covers the stamp-vs-upgrade
heuristic (_pre_alembic_schema_present) on empty / full-legacy /
already-stamped / partial-legacy DBs, plus schema-sanity runs of
`upgrade head` (fresh DB grows the full schema + alembic_version) and
`stamp head` (records version without touching schema; next boot takes
the upgrade path).
- Fix a latent logging bug the new tests surfaced: alembic/env.py's
fileConfig() defaulted to disable_existing_loggers=True, which — since
env.py runs in-process at app startup via init_db() — silently
disabled every app logger created before migrations ran (and broke
caplog-based tests ordered after an Alembic test). Now passes
disable_existing_loggers=False.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
Owner
Author
Greptile review follow-up (commit 7dfaf43)1 finding assessed — addressed via docs + a targeted comment (no behavior change to the migration path), plus a test set and one latent bug fix the new tests surfaced. Addressed
Test set added (
|
- render_as_batch=True in both offline and online alembic contexts so future ALTER-style migrations work on SQLite (Greptile 4/5 note); no-op on Postgres. - ruff format (0.15.22) the generated initial-schema revision file, unblocking Backend Lint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lucastononro
added a commit
that referenced
this pull request
Jul 29, 2026
) Stack wave 5, PR 4/4. Greptile P2 stamp-vs-upgrade atomicity addressed on the PR branch by author fixup 7dfaf43 (documented single-instance assumption + regression tests + fileConfig logger fix). Outstanding 4/5 render_as_batch note fixed in 7fe06d7, plus ruff format of the initial-schema revision unblocking Backend Lint.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
backend/alembic/wired to the app's own async engine (config.settings.database_url) anddb.Base.metadata— no second, hand-maintained DB-URL config.models.py, that reflects today's full schema end-state (i.e. whatcreate_all+_run_migrationsproduce together, sincemodels.pyalready carries every column/nullability/FK change_run_migrationsused to apply at runtime).db.py:init_db()now runs Alembic on boot instead ofcreate_all+_run_migrations: fresh DB →alembic upgrade head(creates everything + stamps); existing DB that already has today's schema but noalembic_versiontable →alembic stamp head(marks current, no DDL, no destructive re-run)._run_migrationsis left in place, unused, with a docstring explaining why (dead code / rollback reference — see git history to restore the call if Alembic ever needs to be rolled back).Verified vs deferred
Verified:
alembic upgrade headfrom empty and (b) a DB built via the legacycreate_all+_run_migrationspath, on both SQLite and Postgres. Only difference found: the legacy path creates two redundant duplicate indexes under typo'd names (ix_dataset_versions_source_experiment,ix_registered_models_source_session— missing the_idsuffix) that shadow the index SQLAlchemy already creates fromindex=Trueinmodels.py. No column, type, nullability, FK, or PK differences — the new initial revision does not recreate that duplication.init_db()creates the schema and stampsalembic_version; (2) pre-existing DB built via the legacy path (noalembic_version) →init_db()stamps head without touching schema; (3) repeated boots on an already-stamped DB → idempotent no-op upgrade.alembic upgrade head/alembic downgrade -1/--autogenerateall work from the CLI on both SQLite and Postgres.Base.metadata.create_all/drop_alland don't go throughinit_db(), so this refactor doesn't touch their setup path.ruff check/ruff format --checkclean on all changed files.Deferred / known limitation: the stamp-vs-upgrade decision uses a heuristic (
_pre_alembic_schema_presentindb.py) — if a handful of "marker" tables (projects,sessions,experiments,registered_models,dataset_versions) all exist and there's noalembic_versiontable, it assumes the DB is fully current and stamps. This holds for any DB that's been through a boot of the old_run_migrationspath (which ran on every startup), but an older, partially-migrated DB that somehow skipped that isn't handled — it would fall through toupgrade headand surface a loud DDL error rather than silently stamping an incomplete schema. Not expected in practice, but flagging it as an assumption rather than a proven invariant for every possible historical DB state.Changes
backend/requirements.txt: addalembic>=1.13.0,<2.0.0.backend/alembic.ini,backend/alembic/env.py,backend/alembic/script.py.mako: scaffold, DB URL driven fromconfig.settings(async engine cookbook pattern —env.pycallsasyncio.run()internally, safe both from the CLI and fromdb.py'sasyncio.to_threadcall since a fresh worker thread has no running loop).backend/alembic/versions/0bae8e0f765d_initial_schema.py: the initial revision (autogenerated).backend/db.py:_run_migrationsmarked dead/documented; new_pre_alembic_schema_present,_alembic_config,_run_alembic_synchelpers;init_db()rewritten to drive Alembic instead ofcreate_all/_run_migrations.backend/AGENTS.md: updated the migrations guidance and "before you ship" checklist to describe the Alembic workflow instead of "add a startup migration indb.py:init_db()".Test plan
docker compose upstill builds the correct schema (verified viainit_db()against an empty DB on both SQLite and Postgres)stamp headpath against a DB built by the legacycreate_all+_run_migrationspath)pytest tests/ -v— 296 passed, 8 skipped (unchanged)alembic upgrade head/alembic downgrade -1/alembic revision --autogenerateall exercised on SQLite and Postgresalembic/versions/0bae8e0f765d_initial_schema.py)Closes #121
🤖 Generated with Claude Code
Greptile Summary
This PR replaces the hand-rolled
_run_migrationsboot-time migration path with a proper Alembic setup, wiringenv.pyto the app's existing async engine andBase.metadata. The initial revision is autogenerated from the currentmodels.pyend-state, andinit_db()now runsalembic upgrade head(fresh DB) oralembic stamp head(legacy DB with schema but no version table) viaasyncio.to_thread.backend/db.py:_run_migrationsmarked dead code (retained as rollback reference); three new helpers (_pre_alembic_schema_present,_alembic_config,_run_alembic_sync) drive the stamp-vs-upgrade decision and Alembic execution.backend/alembic/: Full scaffold —env.pyfollows the official async cookbook pattern (NullPool engine,run_syncwrapper,asyncio.run()in worker thread,disable_existing_loggers=Falseto preserve app logging);versions/0bae8e0f765d_initial_schema.pycaptures the complete current schema.backend/tests/test_alembic_migrations.py: Four heuristic unit tests plus two end-to-end integration tests (upgrade head and stamp head) against a real SQLite file, usingmonkeypatchto redirectsettings.database_url.Confidence Score: 5/5
Safe to merge — the migration path is well-tested on both SQLite and Postgres, known limitations are explicitly documented in code and AGENTS.md, and no schema data is destroyed during the transition.
The stamp-vs-upgrade logic is straightforward and correct, the async/thread boundary is handled exactly as the Alembic cookbook prescribes, and the initial revision was verified schema-equivalent against the legacy path on both dialects. All previously flagged concerns are either addressed in documentation or acknowledged as intentional trade-offs for the single-instance deployment model.
Files Needing Attention: No files require special attention. backend/db.py carries the most complexity but is thoroughly commented and tested.
Important Files Changed
Reviews (3): Last reviewed commit: "fix(review): batch-mode SQLite migration..." | Re-trigger Greptile