| name | postgres-concurrent-schema-init-deadlock |
| description | Fix PostgreSQL deadlock errors caused by concurrent schema initialization in worker processes.
Use when: (1) psycopg2.errors.DeadlockDetected during CREATE INDEX/TABLE IF NOT EXISTS,
(2) Multiple Cloud Run jobs, Kubernetes pods, or worker processes start simultaneously,
(3) Error shows "Process X waits for RowExclusiveLock... blocked by process Y",
(4) init_schema() or migration code runs at worker startup. The key insight: "IF NOT EXISTS"
is NOT truly concurrent-safe - PostgreSQL still acquires locks that can deadlock.
|
| author | Claude Code |
| version | 1.0.0 |
| date | "2026-01-29T00:00:00.000Z" |
PostgreSQL Concurrent Schema Init Deadlock
Problem
Multiple worker processes (Cloud Run jobs, K8s pods, serverless functions) starting
simultaneously all try to run schema initialization code, causing PostgreSQL deadlocks
even when using "IF NOT EXISTS" clauses.
Context / Trigger Conditions
- Error:
psycopg2.errors.DeadlockDetected: deadlock detected
- Log shows:
Process X waits for RowExclusiveLock on relation... blocked by process Y
- Multiple workers/jobs starting at roughly the same time
- Each worker calls
init_schema() or runs migrations at startup
- Using
CREATE TABLE IF NOT EXISTS or CREATE INDEX IF NOT EXISTS
Why This Happens
PostgreSQL's IF NOT EXISTS is not concurrent-safe:
CREATE INDEX IF NOT EXISTS still acquires locks before checking existence
- Multiple processes acquiring locks on different objects can deadlock
- Even "safe" DDL can conflict when executed concurrently
Solution
Option 1: Skip Init in Production (Recommended)
Schema already exists - don't run init_schema() in workers:
with Database() as db:
Option 2: Use Advisory Locks
Serialize schema init with PostgreSQL advisory locks:
def init_schema_safe(self):
cursor = self._cursor()
cursor.execute("SELECT pg_advisory_lock(12345)")
try:
self.init_schema()
finally:
cursor.execute("SELECT pg_advisory_unlock(12345)")
self.conn.commit()
Option 3: Separate Migration Step
Run migrations as a separate job before starting workers:
python -m src.migrate
gcloud run jobs execute worker-job
Option 4: Lock Timeout + Retry
Set lock timeout and retry on deadlock:
def init_schema_with_retry(self, max_retries=3):
for attempt in range(max_retries):
try:
cursor = self._cursor()
cursor.execute("SET lock_timeout = '5s'")
self.init_schema()
return
except psycopg2.errors.DeadlockDetected:
self.conn.rollback()
if attempt == max_retries - 1:
raise
time.sleep(random.uniform(1, 3))
Verification
After applying fix:
- Start multiple workers simultaneously
- Check logs for absence of deadlock errors
- Verify all workers start successfully
Example
Before (deadlocks with 6 concurrent Cloud Run jobs):
with VineDatabase() as db:
db.init_schema()
After (no deadlocks):
with VineDatabase() as db:
Notes
- This applies to any concurrent worker pattern: Cloud Run, Celery, Kubernetes, Lambda
- The deadlock can be intermittent - depends on exact timing of worker starts
CREATE TABLE IF NOT EXISTS is generally safer than CREATE INDEX IF NOT EXISTS
- Cloud Run jobs often start simultaneously when triggered, making this common
- Consider using database migration tools (Alembic, Flyway) with proper locking
References