Skip to content

fix: bound boot migration lock waits on PostgreSQL (TC-1176) - #316

Merged
samgbafa merged 2 commits into
mainfrom
skgbafa/tc-1176-296-lock-timeout
Oct 17, 2026
Merged

samgbafa merged 2 commits into
mainfrom
skgbafa/tc-1176-296-lock-timeout

Conversation

@samgbafa

@samgbafa samgbafa commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Refs TC-1176. Follow-up to #296. Review F1 is addressed in be06fdf.

Problem

On PostgreSQL, boot migrations take strong table locks with no lock_timeout, so a boot can block indefinitely behind another session. While it waits, every later query on that table queues behind it. Two paths reach this on the first boot after 1.20.6:

  • Normalize delegation identifiers #291 m20261014_000000_delegation_cid_alias runs CREATE TABLE delegation_cid_alias … REFERENCES delegation. That takes SHARE ROW EXCLUSIVE on delegation, so it waits behind any open write transaction on it. This was review F1.
  • db: page account session discovery #296 m20261015_000000_delegation_cursor_index drops and recreates idx_delegation_delegator. The drop takes ACCESS EXCLUSIVE, so it waits behind any open transaction that has touched delegation.

Transaction behaviour (verified)

  • sea-orm-migration 1.1.19, src/migrator.rs:262-271: on PostgreSQL every pending migration runs in one begin() … commit(). MySQL and SQLite get no transaction.
  • src/migrator.rs:386-397: each migration's up() and its seaql_migrations insert run on that same connection.
  • tinycloud-core/src/db.rs migrate() (called at boot from SpaceDatabase::new_with_encryption) wraps this on PostgreSQL in an outer transaction, so the migrator's BEGIN becomes a savepoint. SQLite and MySQL call Migrator::up directly.

Change

  • db::migrate(), PostgreSQL only: runs SET LOCAL lock_timeout = '30s' once on the outer transaction, next to the TC-1054 statement_timeout exemption. This covers every boot migration, including Normalize delegation identifiers #291, db: page account session discovery #296 and future ones.
    • Being SET LOCAL, it ends with that transaction and never reaches pooled sessions.
    • It bounds each lock wait, not how long a migration runs.
  • A 55P03 error from anywhere in the migrations becomes DbErr::Migration: "a migration gave up after waiting 30s (lock_timeout) for a table lock; another session holds a conflicting lock (SQLSTATE 55P03: …). Nothing was applied; end that session and restart." Other errors pass through unchanged.
    • sea-orm-migration returns the failing statement's error without the migration's name (migrator.rs:386). Its Applying migration '<name>' log line, printed just before, names it.
  • db: page account session discovery #296's migration body is back to exactly its 212a826 form. The per-migration SET LOCAL / TO DEFAULT was redundant, and TO DEFAULT would have reset the outer setting for later migrations.
  • SQLite and MySQL are unchanged. CREATE INDEX CONCURRENTLY is deliberately not used.
  • The CHANGELOG and README migration-timeout notes now describe the boot-wide limit.

Why 30s: deploys run with the writer stopped, so anything legitimately holding a lock is a short transaction that 30s outlasts. A stuck session fails the boot clearly instead of hanging it.

Evidence (native PostgreSQL 16.15, no Docker)

Unit tests

Test RED GREEN
postgres_boot_from_1_20_6_fails_fast_behind_an_open_delegation_write (new, F1) at bddcabf: still waiting after 45s fails at 30.009s with 55P03
postgres_cursor_index_fails_fast_while_delegation_is_locked (#296) at 212a826: still waiting after 45s fails at 30.003s
  • F1 test setup: it starts from the 1.20.6 schema (25 migrations) and holds an open UPDATE delegation … transaction while db::migrate runs.
  • F1 test, after the failure: still 25 migration rows, no delegation_cid_alias, and SHOW lock_timeout = 0 on the single-connection pool that ran the migrations.
  • F1 test, after release: all 27 migrations apply, and lock_timeout is still 0 after the commit.
  • F1 test, mutation check: changing the setting to a session-level SET makes the leak assertion fail ("30s" vs "0").
  • db: page account session discovery #296 test, after the failure: the indexes and their validity are identical to before, and there is no migration row. The retry succeeds.

Real-binary boot. The database was first booted with a v1.20.6 binary, which left 25 rows. A second session then held an open write (RowExclusiveLock) on delegation.

Boot Result
bddcabf still blocked after 61s on Lock/relation: CREATE TABLE IF NOT EXISTS "delegation_cid_alias"
be06fdf Applying migration 'm20261014_000000_delegation_cid_alias', then exits 1 at 30s with the error above
  • After the failed boot: 25 rows, no alias table, index still (delegator), 0 invalid indexes.
  • After the lock is released: healthy in 1s with 27 rows, the alias table and (delegator, id).

Existing tests

  • cargo test -p tinycloud-core against PG passes all targets (417 lib tests), including every postgres_* and migration test.
  • The 6 PG tests in postgres_pool_timeouts pass, including migrations running with no statement timeout. Its 4 black-hole tests need iptables, so they run in CI only.
  • cargo fmt --all -- --check and RUSTFLAGS=-Dwarnings cargo clippy -p tinycloud-core --all-targets are clean.

Each lock test takes about 30s because it uses the real limit.

On PostgreSQL, m20261015_000000_delegation_cursor_index (#296) dropped and
recreated idx_delegation_delegator with no lock_timeout, so a boot blocked
indefinitely if any session held a lock on delegation. It now sets
SET LOCAL lock_timeout = '30s' for its index rebuild and resets it after.
sea-orm-migration runs all PostgreSQL migrations in one transaction, so a
lock-wait failure rolls back the index change and the migration row; boot
fails with an error naming the lock wait. SQLite and MySQL are unchanged.
@samgbafa

Copy link
Copy Markdown
Contributor Author

APPROVE: see TC-1176 (head bddcabf)

Review F1: on the first boot after 1.20.6, #291's CREATE TABLE
delegation_cid_alias ... REFERENCES delegation runs before #296 and takes
SHARE ROW EXCLUSIVE on delegation, so it still waited forever behind an
open write transaction.

Set SET LOCAL lock_timeout = '30s' once on the outer migrate()
transaction, next to the TC-1054 statement_timeout exemption, so it covers
every boot migration. Remove the per-migration SET LOCAL / TO DEFAULT from
#296, which would have reset the outer setting for later migrations. Map
55P03 from anywhere in the migrations to a DbErr::Migration naming the
lock wait; sea-orm-migration does not expose the failing migration's name
in the error (its 'Applying migration' log line names it).

Tests: from the 1.20.6 schema, an open write on delegation makes the boot
fail at 30s with no migration applied or recorded and no lock_timeout left
on the pooled session; after release the boot applies everything.
@samgbafa samgbafa changed the title fix: bound lock wait in delegation cursor index migration (TC-1176) fix: bound boot migration lock waits on PostgreSQL (TC-1176) Oct 17, 2026
@samgbafa

Copy link
Copy Markdown
Contributor Author

APPROVE: see TC-1176 (head be06fdf)

@samgbafa
samgbafa merged commit 74fbd1e into main Oct 17, 2026
20 checks passed
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.

1 participant