Safe deploys (migrations + graceful shutdown)¶
Two classic deploy risks: a migration that deletes data by accident, and a rollout that severs in-flight requests when the old pod dies. This recipe covers the two defenses the SDK ships.
Safe migrations: safe_upgrade¶
AlembicHelper.safe_upgrade() runs the upgrade only if no pending
migration is destructive. It scans each pending revision's def upgrade()
for data-deleting calls — op.drop_table, op.drop_column,
op.drop_constraint (and batch_op variants) — and, if it finds one,
raises DestructiveMigrationError without touching the database.
from tempest_fastapi_sdk import AlembicHelper, DestructiveMigrationError
def deploy_migrations() -> None:
"""Apply migrations on deploy, blocking accidental DROPs."""
helper: AlembicHelper = AlembicHelper(db_url="postgresql+asyncpg://...")
try:
helper.safe_upgrade("head")
except DestructiveMigrationError as exc:
# CI/CD fails here — someone must review and unblock with force.
for revision, op in exc.offences:
print(f"blocked: {revision} → {op}")
raise
The scan looks at the migration code, not the generated SQL — so it
never false-positives on the table rebuild SQLite does in batch mode. A
drop_* in downgrade() (the normal, expected path) is ignored.
Allowing an intentional DROP¶
When the DROP is intentional (you took a backup, you reviewed it), pass
force=True — the destructive operations are logged and the upgrade runs:
from tempest_fastapi_sdk import AlembicHelper
helper: AlembicHelper = AlembicHelper(db_url="postgresql+asyncpg://...")
helper.safe_upgrade("head", force=True) # I know what I'm doing
Inspect only
helper.pending_destructive_ops("head") returns the list of
(revision, operation) without running anything — handy for a CI step
that only reports.
force=True deletes data
DROP COLUMN / DROP TABLE are irreversible. Only use force=True
after a backup and human review.
Graceful shutdown: drain in-flight requests¶
On a rollout the orchestrator sends SIGTERM and, after a grace period,
SIGKILL. If a request is still running when the worker dies, it's
severed — an intermittent 502. GracefulShutdownMiddleware:
- Once draining, replies
503+Retry-Afterto new requests, so the load balancer stops routing to this pod. - Counts in-flight requests;
wait_drained()waits for them to finish (with a timeout) before the process exits.
You hold the instance and drive draining from the lifespan (uvicorn runs
the lifespan shutdown on SIGTERM — and it owns the signal handling):
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from fastapi import FastAPI
from starlette.middleware.base import BaseHTTPMiddleware
from tempest_fastapi_sdk import GracefulShutdownMiddleware
shutdown: GracefulShutdownMiddleware = GracefulShutdownMiddleware(drain_timeout=25.0)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Drain in-flight requests on shutdown."""
yield
shutdown.begin_drain()
await shutdown.wait_drained()
app: FastAPI = FastAPI(lifespan=lifespan)
app.add_middleware(BaseHTTPMiddleware, dispatch=shutdown.dispatch)
Set the orchestrator's grace period a little above drain_timeout,
and uvicorn's --timeout-graceful-shutdown to match.
The signal belongs to your server
uvicorn already installs SIGTERM handlers and triggers the lifespan
shutdown — drive draining from there. The opt-in
install_signal_handlers() is only for servers that do not manage
signals themselves; it chains the previous handler and is a no-op off
the main thread.
Recap¶
AlembicHelper.safe_upgrade()refuses destructive migrations (DestructiveMigrationError);force=Trueallows them;pending_destructive_ops()only inspects.GracefulShutdownMiddlewarereplies503while draining andwait_drained()waits for in-flight requests — driven from thelifespan.