Repository navigation
fix(db): auto-migrate prebuild-guarded indexes on small databases - #8802
Conversation
🔐 Codex Security Review
Review SummaryOverall Risk: NONE
FindingsNo concrete security, correctness, or reliability findings were identified. Notes
Generated by Codex Security Review | |
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Automated review at exact head 538cda70edc88e4d5778a7475ea35b30d5a3537e (base f3a7bab098a530ba12b909cb7121b4fa9ce70731). No blocking findings.
The staged runner holds up. With sqlx 0.9, run_to(target) only limits which pending migrations get applied. On a database already at 76–78 the extra run_to calls just validate checksums, and the _sqlx_migrations check skips the builds. A dirty database or one with unknown migrations fails before any build runs. All five definitions in INLINE_INDEX_BUILDS produce exactly the pg_get_indexdef strings 0076 and 0078 verify, including the WHERE (source_channel_id IS NOT NULL) predicate. Each build commits on its own on the connection that keeps holding the schema/destruction lock, and a failed build rolls back without leaving an invalid index behind. buzz-admin migrate goes through db.migrate() → run_migrations, so the deployment doc is right that it builds the indexes too.
We also ran it against a throwaway Postgres 17. Both new tests pass. A real upgrade works too: apply the base's SQL migrations 1–75, seed artifact heads, revisions and a membership, then run the head's run_migrations. It reaches 78 with all five indexes valid, ready and live, and existing rows are unchanged. A repeat startup and two simultaneous migration calls both succeed. A same-named index with the wrong definition fails closed at 75 with the documented error, and the retry succeeds after dropping it. That upgrade also covers the path the new test doesn't: its final phase prebuilds the 0076 index, so the test never shows a successful inline build of that one.
MINOR (docs): docs/database.md now contradicts itself. Rule 6 still says an index on a populated table MUST be built CONCURRENTLY, and the next sentence says the migration builds a missing one inline. Rule 3 says a migration MUST NOT scan a table while holding a write-blocking lock, and a plain CREATE INDEX does exactly that. A future author can't satisfy all three. It'd be clearer to make the bounded inline build an explicit exception in both rules, keep the concurrent prebuild for tables too big for the budget, and move the "0076 and 0078 did" history into docs/artifact-sync-deployment.md. migrations/AGENTS.md should get the same wording.
MINOR (docs): docs/artifact-sync-deployment.md still opens with "For a populated database, first build the revision lookup index outside a transaction" and still says 0078 "refuses a populated table whose index is missing". Both are now true only for direct SQL application. Saying up front that startup migration handles small databases, and the concurrent prebuild is for large ones, would keep operators from doing a manual step they no longer need.
MINOR: build_indexes_before_migration maps every sqlx::Error to "migration N blocked … prebuild them concurrently". That advice is wrong for a dropped connection or a permission error. The underlying error is included, so this isn't misleading for long. Still, adding the prebuild hint only for lock or statement timeouts (SQLSTATE 55P03 / 57014) and passing other errors through unchanged would be more accurate.
|
Data point from a self-hosted relay that hit this on It doesn't take any artifact data. The description scopes this to databases with artifact rows, but So a relay with a single channel member hits it, which is effectively every deployment in use. A failed upgrade can't be rolled back by changing the image. Migrations commit one at a time, so With a rolling update the old pod keeps serving, so this looks fine until that pod restarts, and reverting the tag doesn't help. We confirmed that message by running the old image's Workaround, for anyone landing here from that error: build the four indexes from From reading the diff, indexes prebuilt this way are skipped by the |
Migrations 0076 and 0078 refuse any populated table whose index was not prebuilt concurrently, so every auto-migrating relay with artifact data crashloops on upgrade, including the Helm chart default. Both migrations are checksum-frozen, so the startup runner now builds their missing indexes first under the same 1s lock and 5s statement budgets migration 0049 uses. Small databases upgrade like empty ones; large ones still fail closed with an error pointing at docs/artifact-sync-deployment.md, and prebuilt indexes are skipped before CREATE INDEX takes its lock. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Review follow-up: one transaction kept every earlier ShareLock until the last build committed, so writes to artifact_heads could stay blocked across all four 0078 builds. Commit each build on its own, and describe partial progress accurately in the deployment doc and test. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com>
…usals Migration 0081 refuses a populated nip_fi_profile_heads whose name index was not prebuilt, so every auto-migrating relay with verified profiles crash-loops on that version. The startup runner now builds its index first, like 0076 and 0078, under the same bounded budgets. Rule 6 now says a migration applies its own change on a populated database and never refuses one; operator prebuilds are an option for large databases, not a precondition. A source test rejects any migration that refuses a populated table unless it is a frozen one with an inline build, and the populated upgrade test boots a database with data through every embedded migration. The budget-exceeded hint now applies only to lock and statement timeouts. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com>
538cda7 to
64cce23
Compare
With auto-migration on, migrations are the whole upgrade procedure, so an operator prebuild is only for deployments that apply migrations out of band. Drop the separate populated-upgrade testing rule; the populated boot test and the prebuild-guard source test cover it. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com>
The artifact test's run_migrations_through(80) bypasses the startup runner's inline builds, so 0078 refused its seeded rows before 0081 was reached. Seed verified profiles in their own test from 0080 instead. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Rule 3 forbade any write-blocking scan, contradicting Rule 6's bounded inline CREATE INDEX. Limit it to large tables and allow the budgeted build. State that the prebuild-guard lint matches only its literal wording, and that 0078 refuses populated tables only when applied as SQL. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com>
|
🤖 Thanks Will — all three addressed at a481b5b: Rules 3/6 reconciled (bounded inline build is the explicit exception; CONCURRENTLY prebuild only when auto-migrate is off), artifact-sync doc leads with the startup build, and the prebuild hint is limited to SQLSTATE 55P03/57014 with other errors passed through. This also covers 0081 now. |
Main took 0081 and 0082, so the migration is now 0083. With BUZZ_AUTO_MIGRATE on, a migration that refuses a populated table crash-loops every relay with data (#8802, docs/database.md Rule 6). The migration no longer refuses a channels table with rows. As 0049 does, it skips CREATE when to_regclass finds a prebuilt index, else builds the index with a plain CREATE UNIQUE INDEX under the 1s lock and 5s statement budgets, then verifies its definition and flags. A busy or very large table fails on those budgets, records nothing, and the relay retries at its next start. It still refuses to run while listed duplicates remain: the index cannot exist over them, and the renames must go through the relay. Tests that migrate a populated database no longer prebuild the index. The listed-name migration test now checks that a held channel writer makes the inline build fail on its lock budget, and that a populated table then migrates with no operator step. The rollout doc makes the concurrent prebuild an out-of-band option only. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Logan Johnson <loganj@squareup.com>
## Summary Stacked on #8782 (merged), which makes the relay refuse a second open channel with a name that another channel in the community already has. That check lives in relay code, so an older relay, a direct database write or a missed code path can still create a duplicate. This PR adds the same rule to the database: a unique index on channel name, ignoring case, for the channels that show in the channel list. With the index in place, a duplicate fails at the write, whatever wrote it. The relay's own name check from #8782 is no longer needed, so this PR removes it. Clients still get the same refusal, `invalid: an open channel named "…" already exists`. The migration builds the index itself, also on a populated `channels` table, so an auto-migrating relay upgrades with no operator index step (the rule from #8802). It still refuses to run while duplicate names remain, because the index cannot exist over them. `docs/channel-name-uniqueness-deployment.md` gives the full rollout. ## Details - **The index** (migration `0083_channels_listed_name_unique.sql`): ```sql CREATE UNIQUE INDEX idx_channels_listed_name ON public.channels (community_id, lower(name)) WHERE visibility = 'open' AND NOT unlisted AND channel_type IN ('stream', 'forum') AND deleted_at IS NULL; ``` It covers open, not unlisted streams and forums that are not deleted, archived ones included. Private channels, DMs and unlisted channels may still repeat names. - **The migration builds the index inline, as `0049` does.** When `to_regclass` finds no index, it runs a plain `CREATE UNIQUE INDEX` under `lock_timeout` 1s and `statement_timeout` 5s. On a busy or very large table it fails on those budgets, records nothing, and the relay retries at its next start. When the index already exists (a concurrent prebuild), it skips `CREATE` so it never requests the write-blocking lock. In both cases it then checks that the index is valid, ready and has the exact expected definition. - **One refusal stays: duplicate names.** The migration refuses to run while listed duplicates remain, and its error names the rename script. The renames must go through the relay so clients get the new kind:39000, and a migration must not change rows (`docs/database.md` Rule 3). This is the "fails closed until its repair has run" case in `migrations/AGENTS.md`, so the writer release from #8782 and the rename are a required stop. - **Relay.** A write that takes the name of a concurrent, uncommitted write waits for it, and fails if that write commits. So the post-write check and the per-name lock from #8782 are removed. The store already maps the index violation to the same "name taken" error. - **Desired-state schema.** `schema/tables/public/channels.sql` has the same index, and the schema parity test compares its live shape between the desired-state schema and the migrations. - **Rollout** (`docs/channel-name-uniqueness-deployment.md`), in order: 1. Deploy #8782 and wait until no older relay pod serves. 2. Rename the existing duplicates in each community with that PR's script, while relays keep serving. The script needs `psql` and `python3`, and the operator key must be a member of each community. The doc covers archived communities and communities being deleted. 3. Confirm that no duplicate remains. 4. Only for a deployment that migrates out of band and is too large for the budgets: build the index with `CREATE UNIQUE INDEX CONCURRENTLY` and verify it. 5. Deploy this release. With auto-migration on, the relay applies 0083 at startup. With it off, run `buzz-admin migrate` first, never the file through `psql`. Rollback goes to the #8782 release only. Keep the index, and keep auto-migration off. - **Test fixtures.** Tests that inserted two open channels with one name now use distinct names. Tests that migrate a populated database through 0083 no longer prebuild the index. ### Related issue None found. Follows #8782. ### Testing - Migration tests (`listed_channel_name_postgres_tests.rs`) start from a database migrated through 0082: - With duplicates, 0083 fails and changes nothing. - After the rename plan is applied, a channel writer that holds its lock makes the inline build fail on the 1s lock budget, with nothing recorded. - With no writer, the populated table migrates with no prebuild, and a raw duplicate insert fails on the index. - An index with a different definition, and an invalid one, are refused. A valid prebuilt index is accepted, and not replaced, while another transaction is writing to `channels`. - The store and relay name tests from #8782 pass with the relay check removed: the index refuses the duplicate, and the client still gets `invalid:`. - The rename plan and script tests from #8782 run on a database at 0082, the state the script runs in. - Local Postgres runs on `108737dc0`, one fresh database per test as the nextest wrapper does: 38 of 40 buzz-db migration-lane tests pass (both listed-name tests, the migration parity test, the thread-window and invite-upgrade tests that migrate populated databases through 0083). The 2 `event_follow_up` contract tests fail locally the same way on main. All 49 buzz-db channel and channel-member store tests pass. ### Database review checklist - [x] Phase: contract. Before it: #8782 (writer release, required stop) and its rename script. Nothing after it. - [x] No trigger, function, procedure, rule or foreign key. - [x] No migration mutates rows. The renames happen before it, through the relay, with the script in #8782. - [x] Never refuses a table because it has rows. Fails closed only with duplicates (repair not run) or with an invalid or different index; then 0083 raises and the relay does not start. The release notes must name the release that contains #8782 as a required stop. - [x] Rule 4: `migrations/`, `schema/tables/public/channels.sql` and the parity test. A live catalog comparison covers the index. - [x] Deployed relay: the #8782 relay answers `invalid:` beside the index (its tests pass with the index present). A relay older than that still writes, but gets a database error, not `invalid:`, for a duplicate. That is why #8782 is a required stop. - [x] Timeouts and index build: `lock_timeout` 1s, `statement_timeout` 5s. The migration builds the index inline (plain `CREATE UNIQUE INDEX`, skipped when `to_regclass` finds a prebuilt one). Only out-of-band deployments too large for the budgets prebuild it with `CREATE UNIQUE INDEX CONCURRENTLY` (doc step 4). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Automated review at exact head a481b5bbd5c157d49cd8f78d60e43014272e9c44 (base ec21753e1b4ca3cd0f208b5fb42cb22a1e29a433). No blocking findings.
The three notes from the last review are all addressed. Rules 3 and 6 now agree, the artifact sync doc describes startup behavior first, and the prebuild hint only appears on a lock or statement timeout (55P03 / 57014).
The new 0081 entry in INLINE_INDEX_BUILDS is byte-for-byte the CREATE INDEX … USING gin (%s) string that 0081 checks with pg_get_indexdef. It's a GIN to_tsvector('simple', …) expression index with no predicate or extension dependency. The staged sequence (through 75, build 76, through 77, build 78, through 80, build 81, then the rest) is correct for fresh databases, ones already at 76–81, and dirty ones: with sqlx 0.9, run_to caps which pending migrations run, and dirty history fails before any build. frozen_prebuild_guards_have_inline_builds can't pass on an empty scan, because it also pins the list to [76, 78, 81]. Adding a temporary 0083 with a 'prebuild …' guard makes it fail.
We also ran it against a throwaway Postgres. All three new tests pass. A real upgrade works too: apply the base's migrations through 0080, seed a verified profile head, artifact heads/revisions and a channel member, then run the head's run_migrations. It reaches 0082 with the 0081 index valid, ready and live, and every seeded row unchanged. A restart is a no-op. While a writer lock is held on nip_fi_profile_heads, startup fails after about 1s with the 0081 hint pointing at docs/profile-search.md. 0081 isn't recorded and no partial index is left, and the retry succeeds once the lock is released. With a correct index prebuilt, startup succeeds even while the writer lock is still held, and the index keeps its OID, so the build was skipped. A same-named index with the wrong definition fails closed at 0081.
MINOR (docs): A large relay with auto-migration on that goes over the budget is told to "prebuild them concurrently as described in docs/profile-search.md" (or docs/artifact-sync-deployment.md). Those docs and Rule 6 now present the concurrent prebuild as the step for deployments with auto-migration off. That leaves an operator who hits the error on an auto-migrating relay unsure whether the procedure applies to them. One sentence in each doc saying an auto-migrating relay that fails the budget can prebuild concurrently and restart would fix it. Same gap in docs/thread-window-deployment.md, which Rule 6 links to: lines 12–16 still say all brownfield deployments must prebuild before starting the relay. That intro should get the same small/auto-migrating vs. large/out-of-band split.
MINOR (PR description): The Tests section describes a single four-phase populated_database_auto_migrates_through_every_migration. The code now has two tests: populated_artifact_tables_auto_migrate_through_every_migration and populated_verified_profiles_auto_migrate_through_every_migration.
…-media * origin/main: fix(mobile): offer a new identity beside saved keys on unclaimed accounts (#9107) fix(desktop): show upgrade guidance (BUZZ-517) (#9033) fix(mobile): skip the read-state snapshot when nothing needs publishing (#9093) fix(mobile): keep verified names on screen in channels with over 1,024 members (#9111) mobile: reuse channel message rows that did not change (#9115) fix(mobile): back off push bootstrap retries (#9092) fix(mobile): keep reply counts on screen while a reconnect catches up (#9100) fix(mobile): inflate gzip read-state snapshot responses (#9089) perf(relay): cache the community-active check on socket admission (#9109) fix(db): auto-migrate prebuild-guarded indexes on small databases (#8802) docs: mark desktop/ as the deprecated Buzz 0.X app (#8840) Signed-off-by: Brad Seiler <seiler@squareup.com>
…dary * origin/main: fix(mobile): focus the search field when search opens (#9120) perf(mobile): validate each verified profile once, not on every publish (#9101) fix(mobile): offer a new identity beside saved keys on unclaimed accounts (#9107) fix(desktop): show upgrade guidance (BUZZ-517) (#9033) fix(mobile): skip the read-state snapshot when nothing needs publishing (#9093) fix(mobile): keep verified names on screen in channels with over 1,024 members (#9111) mobile: reuse channel message rows that did not change (#9115) fix(mobile): back off push bootstrap retries (#9092) fix(mobile): keep reply counts on screen while a reconnect catches up (#9100) fix(mobile): inflate gzip read-state snapshot responses (#9089) perf(relay): cache the community-active check on socket admission (#9109) fix(db): auto-migrate prebuild-guarded indexes on small databases (#8802) Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Problem
Migrations
0076,0078and0081raise an exception on any table with at least one row unless its index was prebuilt withCREATE INDEX CONCURRENTLY. A relay that runs migrations at startup (BUZZ_AUTO_MIGRATE, on by default in the Helm chart) and has data in those tables therefore crash-loops on upgrade:0081(#9019) hits every auto-migrating relay that has published NIP-FI verified profiles.0076/0078(#8523) hit any relay with artifact data or channel members. Each of these tables is small enough on such relays that a plain index build takes milliseconds.The root cause is
docs/database.mdRule 6. It told authors that an index on a populated table MUST be built concurrently by an operator ahead of time, so migrations were written to refuse populated tables. With auto-migration that refusal is a crash loop, not a safe stop.Fix
The migrations are already applied on some databases, so they can't be edited: sqlx would reject the changed checksums. Instead,
run_migrations_lockedbuilds their missing indexes first. For each entry inINLINE_INDEX_BUILDS(0076,0078,0081), it runs migrations through the version before it, then builds any missing index.Each build is a plain
CREATE INDEXin its own transaction, under the same 1slock_timeoutand 5sstatement_timeoutthe migrations use. That is the bounded inline build0049already does:55P03/57014) gets the "prebuild concurrently" hint, pointing at that migration's own doc. Other errors pass through unchanged. The blocked migration is not recorded.to_regclassand skipped beforeCREATE INDEX, so deployments that prebuilt concurrently never request the writer-conflicting lock.CONCURRENTLYis not used in this path. The runner holds the migration advisory lock, and another replica blocked on that lock holds a snapshot that a concurrent build would wait on.Rules
docs/database.mdRule 6: withBUZZ_AUTO_MIGRATEon, every migration must apply its own change on a populated database and must never refuse one because it has rows. A new index is built inline under the migration's budgets. Only deployments that apply migrations out of band (auto-migration off) prebuild concurrently.AGENTS.md(step 4) andmigrations/AGENTS.mdrepeat the never-refuse rule.docs/artifact-sync-deployment.mdanddocs/profile-search.mddescribe the startup behavior first and the concurrent prebuild as the out-of-band path.Tests
populated_database_auto_migrates_through_every_migration(Postgres) migrates to0075, seeds artifact rows, and checks four phases:0076build fails closed.0076index prebuilt, the build is skipped.0078: a blocked0078build keeps the builds before it committed.0081, a final startup succeeds. It asserts that the applied versions equal every embedded migration and that all six indexes are valid, ready and live.frozen_prebuild_guards_have_inline_builds(no database) scansmigrations/. It fails if any migration refuses a populated table (RAISE EXCEPTION 'prebuild …) without an inline build, and if a new guard is added.migration_execution_cannot_bypass_schema_destruction_lockallows exactly one productionrun_to, insiderun_migrations_locked.What got simpler
helm upgradeis the whole upgrade procedure.🤖 Generated with Claude Code