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.
Back up before migrating (DatabaseBackup)¶
safe_upgrade refuses the destructive migration, but sometimes the DROP is
intentional. The right order then is backup → force=True → verify.
DatabaseBackup dumps from the very same DATABASE_URL the service uses:
# scripts/deploy.py
from pathlib import Path
from tempest_fastapi_sdk import DatabaseBackup
from src.core.settings import settings
backup: DatabaseBackup = DatabaseBackup(settings.DATABASE_URL)
written: Path = backup.backup()
print(f"dump written to {written}")
The +asyncpg / +aiosqlite suffix is stripped for you — you pass the
application URL and keep no second variable just for backups. Without
output=, the file lands in backups/<db>_<YYYYMMDD-HHMMSS>.<ext>.
| Backend | backup() uses |
Format |
|---|---|---|
postgresql |
pg_dump |
custom (-Fc) by default; a .sql output (or plain=True) writes a text dump |
sqlite |
file copy | the .sqlite file itself |
Restoring mirrors it — the format comes from the extension:
from pathlib import Path
from tempest_fastapi_sdk import DatabaseBackup
from src.core.settings import settings
backup = DatabaseBackup(settings.DATABASE_URL)
backup.restore(Path("backups/app_20260727-104500.dump"))
clean=True (default) drops existing objects before recreating, so the restore
is a faithful copy: pg_restore --clean --if-exists for the custom format,
DROP SCHEMA public CASCADE ahead of psql -f for plain, file overwrite for
SQLite. Pass clean=False to restore on top of an existing database.
Database in a container: docker_container=¶
When Postgres runs in its own container, installing postgresql-client in the
application image — and keeping it on a version compatible with the server — is
dead weight carried for a nightly job. The database image already ships the
pg_dump that matches it exactly:
from pathlib import Path
from tempest_fastapi_sdk import DatabaseBackup
from src.core.settings import settings
backup = DatabaseBackup(settings.DATABASE_URL, docker_container="app-db")
written: Path = backup.backup(Path("backups/app.dump"))
backup.restore(written)
With docker_container set, pg_dump runs inside the container and the
dump comes back over stdout into the local file; restore goes the other way,
with the file fed to pg_restore/psql on stdin. Without it, nothing changes.
Three details that make this mode work
-h/-pare dropped. The URL's host and port describe how the application reaches the database from outside; inside the container that route does not exist. User and database still come from the URL.- The password crosses by name. The command carries
-e PGPASSWORDwith no value: Docker copies it from the calling process's environment. Spelling it-e PGPASSWORD=…would put the password in the container's command line, where anypson the host reads it. - Nothing is copied in. Restore streams over stdin instead of
docker cp, so no temporary file is left inside the container and there is no window where one sits there half written.
What the mode does require is docker on the caller's PATH — and that is
what BackupToolMissingError checks here, in place of pg_dump.
The two errors you will hit first
BackupToolMissingError—pg_dump/pg_restore/psql(ordocker, in the mode above) is not onPATH. An app container rarely ships the Postgres client; installpostgresql-clientin the image that runs the deploy, usedocker_container=, or run the backup elsewhere.UnsupportedBackupBackendError— a dialect with no strategy (MySQL, SQL Server). Only Postgres and SQLite are covered.
Both are raised before backups/ is created, so a failure never leaves
an empty directory behind for someone to mistake for a finished backup.
Synchronous on purpose
pg_dump is a process and a file copy is disk I/O — neither gains anything
from async. Call these from a CLI command or a deploy script; from async
code, use asyncio.to_thread(backup.backup).
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 AsyncGenerator
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) -> AsyncGenerator[None, 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.DatabaseBackup(url).backup()/.restore(path)— per-dialect dump and restore (Postgres viapg_dump/pg_restore, SQLite by file copy) off the service's ownDATABASE_URL.GracefulShutdownMiddlewarereplies503while draining andwait_drained()waits for in-flight requests — driven from thelifespan.