fix: run hyperhealth migrations once, and never stamp on failure - #314
Conversation
Two problems, one boot path.
1. Migrations ran three times per boot.
The Dockerfile CMD runs `alembic upgrade head`, then execs uvicorn
with --workers 2. Each worker runs the FastAPI lifespan, and lifespan
also shelled out to `alembic upgrade head`. So the CMD migrated once
and the two workers migrated again, concurrently, racing each other.
Logs showed two hyperhealth.startup and two migrations_completed
events on every boot.
Migrations belong in the CMD, before uvicorn forks. Removed the
subprocess call from lifespan (and the now-unused subprocess import).
2. `|| alembic stamp 001` could mark an unmigrated database as current.
001 is head. `alembic stamp 001` writes the version row and creates
nothing else. Proved with alembic's offline SQL mode:
alembic upgrade head --sql -> CREATE TABLE alert_policies
CREATE TABLE self_heal_policies
CREATE TABLE check_definitions
CREATE TABLE check_results
INSERT INTO alembic_version_hyperhealth ('001')
alembic stamp 001 --sql -> INSERT INTO alembic_version_hyperhealth ('001')
If `upgrade head` failed for any reason that left the DB reachable --
a lock timeout, a conflicting object, a failing step -- the fallback
declared the schema current without building it, and every later
`upgrade head` became a no-op. On 2026-06-24 the lifespan copy timed
out after 30s on every boot and was swallowed at level=warning.
CMD is now `alembic upgrade head && exec uvicorn ...`. On failure the
container exits; compose has restart: unless-stopped and waits for
postgres condition: service_healthy, so it retries against a live DB
instead of lying about the schema.
Verified on the running stack after rebuild:
migrations_* app events 2 -> 0
alembic invocations 3 -> 1
hyperhealth.startup 2 (unchanged; that is --workers 2)
alembic_version_hyperhealth still 001, all four tables present
api healthy, worker healthy, 0 restarts
The version table is alembic_version_hyperhealth, dedicated in
migrations/env.py, so none of this touches core's shared alembic_version.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 8 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Follow-up to the finding flagged in #313. Two problems on one boot path.
1. Migrations ran three times per boot
The Dockerfile CMD runs
alembic upgrade head, then execs uvicorn with--workers 2. Each worker runs the FastAPIlifespan, andlifespanalso shelled out toalembic upgrade head. So the CMD migrated once and the two workers migrated again — concurrently, racing each other.Visible in the logs on every boot:
Migrations belong in the CMD, before uvicorn forks. Removed the
subprocess.runfromlifespan(and the now-unusedsubprocessimport).2.
|| alembic stamp 001could mark an unmigrated database as current001is head.alembic stamp 001writes the version row and creates nothing else. Proved with alembic's offline SQL mode, zero risk:If
upgrade headfailed for any reason that left the DB reachable — a lock timeout, a conflicting object, a failing migration step — the fallback declared the schema current without building it, and every subsequentupgrade headbecame a no-op.This isn't hypothetical: on 2026-06-24 the
lifespancopy timed out after 30s on every boot and was swallowed atlevel: warning.The CMD is now
alembic upgrade head && exec uvicorn .... On failure the container exits. Compose hasrestart: unless-stoppedanddepends_on: postgres condition: service_healthy, so it retries against a live database rather than lying about the schema.Verification (on the running stack, after rebuild)
migrations_*app events per boothyperhealth.startupevents--workers 2)alembic_version_hyperhealthstill001;alembic heads→001 (head)alert_policies,self_heal_policies,check_definitions,check_resultsall presenthyperhealth-apihealthy,hyperhealth-workerhealthy, 0 restartsGET /health→{"status":"ok","service":"hyperhealth","version":"1.1.0"}The version table is
alembic_version_hyperhealth, dedicated inmigrations/env.py, so none of this touches core's sharedalembic_version.Not verified
check_definitionsandcheck_resultsare both 0 rows after the rebuild. I captured the version and table list beforehand but not the row counts, so I cannot prove they were 0 before. The change cannot delete rows — it removes a no-opupgrade head(the DB was already at head) and a fallback that only ever writes a version row. If those tables are meant to be seeded,Makefile:95runsseed_checks.py.--workers 2is left as-is. It is whylifespanruns twice, which is expected and harmless now thatlifespanno longer migrates.How this was found
Chasing an AST-sweep hit at
main.py:111(subprocess.runinsideasync def lifespan). That turned out not to be a loop-blocking bug — uvicorn runs lifespan before accepting connections, so blocking there costs nothing. But reading the logs to confirm that surfaced this instead.🤖 Generated with Claude Code