Skip to content

Fix CREATE TYPE race that can crash the worker on first boot - #436

Open
vedarolap wants to merge 3 commits into
roostorg:mainfrom
vedarolap:fix/postgres-schema-create-race
Open

Fix CREATE TYPE race that can crash the worker on first boot#436
vedarolap wants to merge 3 commits into
roostorg:mainfrom
vedarolap:fix/postgres-schema-create-race

Conversation

@vedarolap

@vedarolap vedarolap commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

On a fresh Postgres volume, osprey-worker and the UI API both call metadata.create_all() at startup. SQLAlchemy's enum creation is check-then-create rather than atomic, so on a fresh volume both processes can see the job_status enum as missing and both issue CREATE TYPE, crashing the loser with:

psycopg2.errors.UniqueViolation: duplicate key value violates unique constraint "pg_type_typname_nsp_index"

This is exactly the newcomer path, since demo.sh removes old volumes on every run.

  • Extracted the create-all call into postgres.create_schema(engine) and wrapped it in a Postgres advisory lock (pg_advisory_lock/pg_advisory_unlock), so only one process creates the schema at a time. By the time a second process acquires the lock, create_all's own existence checks make it a no-op.
  • Added a regression test that spins up two independent engines against a brand-new database and calls create_schema() from two threads at the same time via a barrier, to simulate the two-process race. Verified this test fails reliably (5/5 runs) against the pre-fix code path and passes reliably (5/5 runs) with the fix.

Closes #432

Test plan

  • New regression test (test_create_schema_survives_concurrent_callers_on_a_fresh_database) added in osprey_worker/src/osprey/worker/lib/storage/tests/test_postgres.py
  • Confirmed the test reproduces the race (fails 5/5) when run against the pre-fix code
  • Confirmed the test passes reliably (5/5) with the fix
  • ruff check / ruff format --check pass on changed files
  • mypy passes on changed files
  • Existing storage tests (test_bulk_action_task.py, etc.) still pass against a local Postgres instance

Summary by CodeRabbit

  • Bug Fixes
    • Prevented worker startup failures on fresh PostgreSQL volumes by serializing schema creation so concurrent initialization no longer races on enum type creation.
  • Tests
    • Added a regression test that runs concurrent schema initialization calls and verifies both complete successfully.
  • Documentation
    • Updated the changelog to note the Postgres schema creation concurrency fix.

On a fresh Postgres volume, the worker and UI API both call
metadata.create_all() at startup. SQLAlchemy's enum creation is
check-then-create rather than atomic, so both processes can see the
job_status enum as missing and both issue CREATE TYPE, crashing the
loser with a UniqueViolation. Serialize schema creation behind a
Postgres advisory lock so only one process creates the schema at a
time.

Fixes roostorg#432
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ef3e0cfb-490e-4922-95b1-8d4b4bc04417

📥 Commits

Reviewing files that changed from the base of the PR and between 0947e04 and dfa8ef7.

📒 Files selected for processing (1)
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • CHANGELOG.md

📝 Walkthrough

Walkthrough

PostgreSQL schema creation is serialized with an advisory lock, wired into storage initialization, and covered by a concurrent fresh-database regression test. The changelog documents the job_status enum creation race fix.

Changes

PostgreSQL schema initialization

Layer / File(s) Summary
Advisory-locked schema creation
osprey_worker/src/osprey/worker/lib/storage/postgres.py
Adds a shared advisory lock around metadata.create_all(engine), releases it reliably, and uses the helper during configured initialization.
Concurrent schema regression coverage
osprey_worker/src/osprey/worker/lib/storage/tests/test_postgres.py, CHANGELOG.md
Runs synchronized schema creation calls against a fresh database, verifies no thread errors, cleans up the database, and records the fix.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • roostorg/osprey#402: Both changes modify the storage initialization path around PostgreSQL schema creation.

Suggested reviewers: ayubun

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: preventing the CREATE TYPE startup race that could crash the worker.
Linked Issues check ✅ Passed The PR addresses issue #432 by serializing schema creation and adding a concurrency regression test for fresh-database startup.
Out of Scope Changes check ✅ Passed The changes stay focused on the schema-creation race fix, its test coverage, and a matching changelog note.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@osprey_worker/src/osprey/worker/lib/storage/tests/test_postgres.py`:
- Around line 49-55: After each timed join in the thread coordination block,
explicitly assert that the thread is no longer alive using thread.is_alive().
Keep the existing errors assertion, so timeout failures are reported immediately
while normal thread exceptions remain covered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8886eed6-4471-4b14-8fbb-6de60c340b4f

📥 Commits

Reviewing files that changed from the base of the PR and between a5768a5 and 0947e04.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • osprey_worker/src/osprey/worker/lib/storage/postgres.py
  • osprey_worker/src/osprey/worker/lib/storage/tests/test_postgres.py

Comment on lines +49 to +55
threads = [threading.Thread(target=_create_schema) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=10)

assert not errors, errors

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fail the test explicitly if a thread times out.

In Python, thread.join(timeout=10) returns silently without raising an exception when the timeout expires. If this concurrency test were to deadlock, the threads would hang, join would return silently, and errors would remain empty. The test would pass the assert not errors check and then fail confusingly during the drop_database teardown (because the hung threads hold active connections).

Checking thread.is_alive() after joining ensures that deadlocks are reported clearly as test failures rather than teardown errors.

🐛 Proposed fix to handle thread timeouts
         threads = [threading.Thread(target=_create_schema) for _ in range(2)]
         for thread in threads:
             thread.start()
         for thread in threads:
             thread.join(timeout=10)
+            if thread.is_alive():
+                raise TimeoutError("Schema creation thread timed out and may be deadlocked")
 
         assert not errors, errors
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
threads = [threading.Thread(target=_create_schema) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=10)
assert not errors, errors
threads = [threading.Thread(target=_create_schema) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=10)
if thread.is_alive():
raise TimeoutError("Schema creation thread timed out and may be deadlocked")
assert not errors, errors
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@osprey_worker/src/osprey/worker/lib/storage/tests/test_postgres.py` around
lines 49 - 55, After each timed join in the thread coordination block,
explicitly assert that the thread is no longer alive using thread.is_alive().
Keep the existing errors assertion, so timeout failures are reported immediately
while normal thread exceptions remain covered.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CREATE TYPE race can crash the worker on first boot

2 participants