Skip to content

feat(server): in-app online-DDL lane for non-transactional/locking DDL (JEF-580) - #151

Merged
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-580-in-app-online-ddl-lane-for-non-transactional-locking-ddl-adr
Jul 28, 2026
Merged

feat(server): in-app online-DDL lane for non-transactional/locking DDL (JEF-580)#151
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-580-in-app-online-ddl-lane-for-non-transactional-locking-ddl-adr

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

Summary

Implements ADR 0021: builds the in-app online-DDL lane that runs CREATE/DROP INDEX CONCURRENTLY outside the transactional sqlx::migrate! migrator.

  • server/src/online_ddl.rs (new): a declarative DESIRED_INDEXES list plus a runner. Seeded with the existing covering index (metric_series_rollups_name_bucket_covering_idx), copied verbatim from migration 0017 — since it already exists and is valid in prod, the lane's first run there is a no-op that proves the mechanism without changing anything (JEF-591 will later narrow this entry; out of scope here).
  • Reconciliation is driven by pg_index.indisvalid, not IF NOT EXISTS: absent → build, valid → no-op, INVALID (left by an interrupted CONCURRENTLY build) → drop + rebuild.
  • Runs on a dedicated PgConnection (db::online_ddl_connect_options: statement_timeout=0, lock_timeout=3s, bounded maintenance_work_mem=64MB for the Pi's limited RAM), outside any transaction, guarded by its own pg_try_advisory_lock key (0x004A_4546_5F35_3830, distinct from sqlx's own hash-derived migration lock) so exactly one replica builds during a rollout and others skip cleanly.
  • main.rs: the lane is tokio::spawned only after the HTTP listener binds, so an index build never blocks boot or /healthz. Fire-and-forget — a failure is logged (tracing::error!), not fatal; reads fall back to whatever index already covers the query.

Test plan

Added 5 tests in server/src/online_ddl.rs against a real Postgres (DATABASE_URL), covering every reconcile state plus the seeded no-op case:

  • absent_index_is_built — no index → reconcile builds it, ends Valid.
  • valid_index_is_a_noop — pre-built valid index → reconcile leaves its pg_class oid unchanged (no drop/rebuild).
  • invalid_index_is_dropped_and_rebuilt — force an INVALID index via a CREATE UNIQUE INDEX CONCURRENTLY that fails on a duplicate-key violation, then reconcile drops and rebuilds it to Valid.
  • advisory_lock_contention_skips_cleanly — hold the lock on a separate connection first; a contended run returns Ok(()) and leaves the index Absent (never attempted).
  • seeded_covering_index_run_is_a_noop — against the real seeded index (built by migration 0017), a full run() leaves its oid unchanged.

Commands run:

  • cargo fmt --check — clean
  • cargo check — clean
  • cargo clippy --all-targets -- -D warnings — clean
  • DATABASE_URL=postgres://watcher:watcher@localhost:5432/watcher cargo test --locked133 passed, 0 failed (67 lib unit tests incl. the 5 new ones, 66 integration tests)

Also ran /soundcheck:pr-review (no Critical/High findings — all SQL string interpolation is over hardcoded constants, never user input) and a /simplify pass (factored a shared build_index helper out of the absent/invalid reconcile branches, deduped a repeated test oid-fetch into relation_oid).

Closes JEF-580

…L (JEF-580)

Implements ADR 0021: CREATE/DROP INDEX CONCURRENTLY moves out of the
transactional sqlx::migrate! (which can't run CONCURRENTLY at all, and
holds an advisory lock for the whole run) into a new server/src/online_ddl.rs
lane, spawned after the HTTP listener binds so boot/readiness never wait
on a build and a failure only logs (fail-soft; reads fall back to
whatever index already covers the query).

- Declarative desired-index list, seeded with the existing covering index
  (metric_series_rollups_name_bucket_covering_idx) copied verbatim from
  migration 0017 -- against prod, where it's already valid, the lane's
  first run is a no-op that proves the mechanism.
- Reconciles via pg_index.indisvalid rather than IF NOT EXISTS: absent ->
  build, valid -> no-op, INVALID (left by an interrupted build) -> drop
  + rebuild.
- Runs on a dedicated connection (db::online_ddl_connect_options:
  statement_timeout=0, lock_timeout=3s, bounded maintenance_work_mem),
  outside any transaction, guarded by its own pg_try_advisory_lock key
  (distinct from sqlx's) so exactly one replica builds during a rollout.

Tests (against a real Postgres) cover all four reconcile states plus the
no-op seeding case: absent->build, valid->no-op, invalid->drop+rebuild,
lock contention skips cleanly, and a run against the already-valid
seeded covering index changes nothing.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
@thejefflarson
thejefflarson merged commit ed50815 into main Jul 28, 2026
5 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-580-in-app-online-ddl-lane-for-non-transactional-locking-ddl-adr branch July 28, 2026 04:03
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