Migration Locking¶
dbwarden uses per-engine native locking to prevent concurrent schema mutation. This page explains how it works, what happens when it fails, and how to recover from a stuck lock.
How locking works¶
When dbwarden migrate runs, it:
- Acquires a native lock on the database engine (advisory lock for PostgreSQL, named lock for MySQL, BEGIN IMMEDIATE for SQLite, lease for ClickHouse)
- Writes a status row in the
dbwarden_locktable for observability - Starts a background heartbeat to detect stale workers
- Executes all pending migrations
- Stops the heartbeat and releases the lock on success or failure
The lock mechanism varies by engine:
| Engine | Lock Type | Grade | Auto-release on crash |
|---|---|---|---|
| PostgreSQL | Session-level advisory lock | A | Yes (connection teardown) |
| MySQL/MariaDB | Named user lock (GET_LOCK) | A | Yes (connection teardown) |
| SQLite | BEGIN IMMEDIATE transaction | B | Yes (journal cleanup) |
| ClickHouse | Lease row with fencing token | C | After TTL expiry |
If a second migrate invocation starts while the first holds the lock, it fails immediately with:
dbwarden does not retry on lock failure by default. The calling process (CI job, deploy script) must decide whether to retry or abort.
Inspecting lock state¶
Output when unlocked:
Output when locked:
Migration lock status
State: RUNNING
Health: HEALTHY
Execution: abc123def456ghi7
Host: deploy-runner-7
PID: 1234
Migration: V042
Acquired: 2026-08-22 12:03:14Z
Heartbeat: 2026-08-22 12:04:01Z
Health verdicts:
- HEALTHY: Lock held, heartbeat is fresh
- STUCK: Lock held, heartbeat is stale (process may be paused or dead)
- DEAD: Lock free, status row shows dead worker
- AVAILABLE: No lock held
- COMPLETE: Migration completed successfully
- FAILED: Migration failed
- INSPECTING: Recovery inspection in progress
- NEEDS_REVIEW: Human intervention required
Heartbeat¶
dbwarden runs a background heartbeat that updates the last_heartbeat_at timestamp every 15 seconds. This allows other processes to detect stale or dead workers.
On native-lock engines (PostgreSQL, MySQL), heartbeat failure is an observability event only; the migration continues. On SQLite, there is no heartbeat because the migration transaction holds the write lock. On fallback engines (ClickHouse), heartbeat failure is fatal after a configurable grace period.
The heartbeat runs on a separate connection to avoid interfering with the migration connection.
Stuck lock recovery¶
A lock becomes stuck when:
- The migration process was killed (SIGKILL, OOM, machine restart)
- A CI job was cancelled mid-run
- A deploy container was stopped before migrate completed
- The process is paused (GC, SIGSTOP, CPU starvation)
Before unlocking, confirm no migration is running:
# Check if the PID from lock-status is still alive
ps aux | grep <pid>
# Or check your deployment logs / CI job status
If the process is genuinely dead:
# 1. Confirm lock state and holder
$ dbwarden lock-status --database primary
# 2. Inspect migration history to see what ran last
$ dbwarden history --database primary
# 3. Check pending migrations
$ dbwarden status --database primary
# 4. Release the stale lock
$ dbwarden unlock --database primary --force
# 5. Retry migration
$ dbwarden migrate --database primary
The --force flag skips the confirmation prompt. Without it, unlock shows holder diagnostics and requires confirmation.
Recovery state machine¶
When a new worker acquires the lock and detects a dead predecessor, it enters the recovery state machine:
AVAILABLE → RUNNING → COMPLETE → AVAILABLE (loop back)
→ FAILED → AVAILABLE (loop back)
→ DEAD (detected by new acquirer)
→ INSPECTING
→ COMPLETE (all steps applied)
→ RUNNING (resume safely)
→ NEEDS_REVIEW (human decision)
→ AVAILABLE (loop back)
→ RUNNING (retry)
The INSPECTING state compares the recorded migration checksum with the candidate migration. If they match, the migration can be resumed. If they don't match, the worker transitions to NEEDS_REVIEW and waits for human intervention.
INSPECTING procedure¶
When a new worker acquires the lock and detects a dead predecessor (DEAD state), it executes the INSPECTING procedure:
- Compare checksums: The recorded
migration_checksumis compared with the candidate migration's checksum - Walk history: The migration history table is checked to determine the last durably recorded step
- Verify catalog state: On transactional engines (PostgreSQL, SQLite), verify catalog state matches history
- Reconcile catalog: On non-transactional engines (MySQL, ClickHouse), reconcile catalog against step list
- Apply recovery policy: Based on the configured
recovery_policy, transition to RUNNING (resume) or NEEDS_REVIEW (halt)
Recovery policy¶
Configure the recovery policy in dbwarden.py:
Policies:
| Policy | Behavior |
|---|---|
halt (default) |
Transition to NEEDS_REVIEW, print inspection report, exit with code 78 |
resume_idempotent |
Resume only if all remaining statements are idempotent; else NEEDS_REVIEW |
force |
Re-run remaining steps unconditionally (audit-logged) |
Re-entrancy protection¶
dbwarden prevents nested acquisition of the same advisory lock on PostgreSQL. If a worker attempts to acquire a lock it already holds, the acquisition is refused with an error. This prevents lock corruption from nested migration runs.
Heartbeat fatal on fallback engines¶
On ClickHouse (fallback engine), if the heartbeat fails repeatedly (exceeding fatal_grace), the migration run is aborted immediately. This prevents a worker from executing DDL after losing its lease.
ClickHouse per-statement fence check¶
For ClickHouse, dbwarden performs a per-statement fence check before each DDL statement. This validates that the worker still owns the lease by checking the fencing token matches. If the token has advanced (another worker acquired the lease), the migration is aborted with a LockError.
POSSIBLE_CONCURRENT_EXECUTION detection¶
After a ClickHouse migration completes, dbwarden re-reads the lease and checks if the fencing token has advanced during the run. If it has, a POSSIBLE_CONCURRENT_EXECUTION warning is printed, indicating another worker may have executed DDL concurrently.
TCP keepalive¶
dbwarden enables TCP keepalive on migration connections to prevent idle-timeout kills:
- MySQL/MariaDB: 60s idle, 3 probes, 30s interval (configurable via
tcp_keepaliveconfig) - PostgreSQL: Uses default OS settings
- SQLite: N/A (file-based)
SQLite busy_timeout¶
For SQLite, dbwarden sets a configurable busy_timeout to control how long BEGIN IMMEDIATE waits for the write lock:
class Primary(DbwardenDatabase):
database_name = "primary"
sqlite_busy_timeout = 5000 # Wait up to 5 seconds
Default is 0 (fail fast). Set to a positive value to wait for the write lock.
Prepared transactions prohibition¶
dbwarden prohibits 2PC (prepared transactions) on the migration connection. Advisory locks interact badly with prepared transactions, which can lead to lock leaks or corruption.
Advisory lock operational details
For PgBouncer detection (double PID check), replica detection (pg_is_in_recovery()), re-entrancy protection, and the session-scoped lock lifecycle, see DDL Semantics.
AUDIT-level logging¶
dbwarden logs all unlock operations at AUDIT level for compliance and forensics. The audit log includes:
- Operator identity (user, host, PID)
- Target diagnostics (host, PID, execution ID)
- Outcome (success/failure)
Example audit log entry:
AUDIT: unlock executed by user=deploy host=ci-runner-1 pid=5678
target_host=deploy-runner-7 target_pid=1234 target_execution=abc123def456 outcome=success
v1 lock phase-out¶
dbwarden v2 uses native locking with the dbwarden_lock table schema. The v1 lock schema (with locked, owner_token columns) is no longer supported. If a v1 table exists, it will be automatically dropped and recreated with the v2 schema on first use.
Per-statement history recording¶
For non-transactional engines (MySQL, ClickHouse), dbwarden can record history per-statement instead of per-migration file. This enables finer-grained recovery but may impact performance.
class Primary(DbwardenDatabase):
database_name = "primary"
per_statement_history = True # Default: False
When enabled, each DDL statement is recorded to the history table before the next statement begins. This is the only resumption signal on non-transactional engines.
Default is 0 (fail fast). Set to a positive value to wait for the write lock.
When NOT to use unlock¶
Do not run unlock if:
- You are unsure whether a migration process is still running
- The lock status shows HEALTHY with a recent heartbeat
- Multiple processes share a database and you cannot confirm all are idle
Releasing a lock held by a live migration process will allow a second migration to start concurrently, which can corrupt schema state.
ClickHouse coordination profiles¶
ClickHouse has no session-scoped user locks, so dbwarden uses a lease-based approach with coordination profiles. For the full explanation of why each profile exists and how to set up your deployment, see ClickHouse Locking.
| Profile | Description | Grade |
|---|---|---|
| CH-0 | Lease row with fencing token | C |
| CH-1 | CH-0 + strict idempotency enforcement | C |
| CH-2 | CH-1 + Keeper-backed lock | C |
| CH-3 | Migration proxy choke point | A- |
| CH-4 | Singleton executor | A* |
CH-0 and CH-1 are implemented in core. CH-2, CH-3, and CH-4 require external infrastructure and are documented as deployment options.
CH-1 idempotency¶
Under CH-1, non-idempotent statements are refused:
- Idempotent:
CREATE TABLE IF NOT EXISTS,DROP TABLE IF EXISTS,ALTER TABLE ADD COLUMN IF NOT EXISTS - Non-idempotent:
RENAME,MODIFY COLUMN,UPDATE,DELETE - Unknown: treated as non-idempotent for safety
Preventing concurrent migration in CI¶
In CI/CD, run migrations from a single job with no parallelism:
# GitHub Actions: serialize via job dependency
jobs:
migrate:
runs-on: ubuntu-latest
steps:
- run: dbwarden migrate --database primary
deploy:
needs: migrate
...
If your pipeline can trigger multiple concurrent deploys, add a concurrency group:
cancel-in-progress: false queues the second run instead of cancelling it, which avoids orphaned locks from killed jobs.
Configuration¶
| Key | Default | Description |
|---|---|---|
lock_namespace |
"default" |
Lock scope (allows independent lock streams) |
Distributed locking with Redis¶
For multi-instance deployments where multiple application replicas could
trigger migrations concurrently, the dbwarden-redis plugin provides
a Redis-backed distributed lock:
from redis.asyncio import Redis
from dbwarden_redis import migration_lock
redis = Redis.from_url("redis://localhost:6379")
async with migration_lock(redis):
await run_migration(
sql_statements,
version="0002",
migration_operation="apply",
filename="primary__0002_feature.sql",
)
The Redis lock uses SET NX EX with a configurable TTL (default 60 seconds).
If the application crashes while holding the lock, Redis releases it
automatically after the TTL expires.
Database-level vs Redis lock¶
| Aspect | Database lock | Redis lock |
|---|---|---|
| Scope | CLI commands (migrate, rollback, downgrade) |
Any code path you wrap |
| Storage | dbwarden_lock table in the target database |
Redis key |
| TTL | Heartbeat-based stale detection (default 45s); native locks auto-release on connection close | Configurable TTL (default 60s) |
| Failure mode | Detects stale locks via heartbeat; recovery state machine guides recovery | Auto-released after TTL |
| External dependency | None (uses the database itself) | Redis required |
Both locks can be used independently or together; they guard different entry points. The database lock protects the CLI; the Redis lock protects any entry point you wrap.
See the dbwarden-redis plugin for full documentation.
Lifespan integration¶
The dbwarden_lifespan context manager wraps migration logic and
engine disposal into a single FastAPI-compatible lifespan. When using
the Redis lock in a lifespan, acquire the lock before entering the
migration context:
from contextlib import asynccontextmanager
from fastapi import FastAPI
from dbwarden_fastapi import dbwarden_lifespan
from dbwarden_redis import migration_lock
@asynccontextmanager
async def lifespan(app: FastAPI):
redis = Redis.from_url("redis://localhost:6379")
async with migration_lock(redis):
async with dbwarden_lifespan(mode="migrate", allow_in_production=True):
yield
See also: Safe Deployment | CI/CD Patterns | lock commands
The FastAPI lifespan helper lives in the dbwarden-fastapi plugin: dbwarden-fastapi.
The Redis lock lives in the dbwarden-redis plugin: dbwarden-redis.