## Summary Stacked on https://github.com/block/buzz/pull/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 https://github.com/block/buzz/pull/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 https://github.com/block/buzz/pull/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 https://github.com/block/buzz/pull/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 https://github.com/block/buzz/pull/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 https://github.com/block/buzz/pull/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 https://github.com/block/buzz/pull/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 https://github.com/block/buzz/pull/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 https://github.com/block/buzz/pull/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 https://github.com/block/buzz/pull/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: https://github.com/block/buzz/pull/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 https://github.com/block/buzz/pull/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 https://github.com/block/buzz/pull/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 https://github.com/block/buzz/pull/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 https://github.com/block/buzz/pull/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>
10 KiB
Listed channel name deployment
Two listed channels in a community must not share a name, ignoring case. A listed channel is an open, not unlisted stream or forum that is not deleted. An archived channel still holds its name until it is deleted. Private channels, DMs, workflow and system channels may repeat names.
The rule ships in two releases:
-
Writer release: the first relay release that contains #8782. The relay refuses a create or rename that would give a listed channel a name another listed channel has, with an
invalid:reply. It also reports a write that the contract index refuses with the same reply, so it can run beside that index. It lets a relay operator rename a listed channel, archived or not, with a name-only kind:9002, and ships the rename scriptscripts/maintenance/rename_duplicate_listed_channel_names.sh. -
Contract release. Migration
0083_channels_listed_name_unique.sqladds the unique index that makes the database enforce the rule: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;From this release the index, not a relay check, enforces the rule.
The writer release is a required stop. Do not upgrade past it to the contract release until every step below is complete.
A deployment that plans schema changes from the desired state in schema/
(see database.md) follows the same order: steps 1 to 4 come
first. Its tool must not build idx_channels_listed_name before the renames,
and the contract release must not deploy until the index is valid, because
that release no longer checks names in the relay.
Procedure
-
Deploy the writer release, and wait until no older relay pod serves. Check the image of every pod: a database with no duplicates does not prove that older relays are gone, and an older relay can still create a duplicate.
-
Rename the existing duplicates in each community. Relays keep serving. The script reads a plan from the database (a read-only query) and sends each rename to the relay as a kind:9002, signed by a relay operator. The relay checks the name and publishes the new kind:39000 as for any rename, so clients show the new name.
DATABASE_URL=postgres://... BUZZ_PRIVATE_KEY=<operator key> \ scripts/maintenance/rename_duplicate_listed_channel_names.sh <community host>The key must belong to a relay operator (
RELAY_OPERATOR_PUBKEYS, or anoperatorrow inrelay_operators), not a moderator. It must also be a member of each community:POST /eventschecks community membership before the operator role, and answers 403relay_membership_requiredto a key that is not a member. Add it first:RELAY_URL=wss://<community host> buzz-admin add-member --pubkey <operator npub>The script stops at the first auth refusal and says so.
BUZZ_RELAY_URLdefaults towss://<community host>. The script needspsqlandpython3. Run it with--dry-runfirst to see the plan. The host must matchcommunities.hostexactly; the script stops for a host that no community has.In each group of channels that share a name, the oldest unarchived channel keeps the name (when all are archived, the clients' starter channel, below, if it is one of them, else the oldest archived one). Each other channel becomes
<name>-<first 8 characters of its id>, or<name>-<id>when another listed channel already has that name. The script prints each rename, then reads the plan again and exits 1 if any rename remains: for example, the relay refused a name that a channel took meanwhile. Run it again.The script also renames every channel named
generalorwelcome-everyonewhen all the listed channels with that name are archived. Released desktop and mobile apps create those starter channels when no open, unarchived stream has the name, and the relay refuses that create while an archived channel holds the name. The apps create each starter at a fixed id, a UUIDv5 of the relay's HTTP origin and the name. The script computes those ids withpython3fromBUZZ_RELAY_URL(so it must be the URL clients use), or fromBUZZ_STARTER_ORIGINwhen set (for a desktop build with its ownBUZZ_RELAY_HTTP), and prints them; check them against a client before the real run. When any channel row has one of those ids, whatever its name or state, the relay refuses the create there, so the script renames nothing for that starter name, and an archived starter at its id keeps the name over its namesakes. Those clients still cannot set up that starter; that needs a relay-side fix, planned separately.If a rename fails on every run because another channel holds its new name, rename that channel by hand with
buzz channels update --channel=<id> --name=<new name>.The relay applies no rename in an archived community, and the script stops for one. Unarchive it first, then add the operator key as a member (
add-memberalso finds only an unarchived community), run the script, and archive it again. Its channels show to members while it is unarchived:buzz-admin communities unarchive --host <community host> \ --owner-pubkey <owner hex> --operator-id <you> --reason "rename duplicate channels" RELAY_URL=wss://<community host> buzz-admin add-member --pubkey <operator npub> # run the rename script for <community host> buzz-admin communities archive --host <community host> \ --owner-pubkey <owner hex> --operator-id <you> --reason "rename duplicate channels"The relay applies no rename in a community that is being deleted either, and the script stops for one. Its duplicates block migration 0083 until its deletion completes. If the deletion is blocked, fix the recorded failure and resume it as operator-community-deletion.md describes; do not deploy the contract release until it completes.
-
Confirm that no duplicate remains in any community:
SELECT co.host, co.archived_at, co.deletion_state, lower(ch.name), count(*) FROM public.channels ch JOIN public.communities co ON co.id = ch.community_id WHERE ch.visibility = 'open' AND NOT ch.unlisted AND ch.channel_type IN ('stream', 'forum') AND ch.deleted_at IS NULL GROUP BY co.host, co.archived_at, co.deletion_state, lower(ch.name) HAVING count(*) > 1;Expect zero rows. Each row names a host to run the script for: an archived community, or one being deleted, needs the steps in step 2.
-
Only for a deployment that migrates out of band (auto-migration off) and whose
channelstable is too large for the migration's budgets: build the index concurrently first. Otherwise skip this step. Migration 0083 builds the index itself with a plainCREATE UNIQUE INDEX, under a 1slock_timeoutand a 5sstatement_timeout, which takes milliseconds on a table of a few thousand channels. If the build cannot get its lock or finish within those budgets, the migration fails, records nothing, and the relay retries at its next start. A plain build blocks channel writes while it runs; a concurrent one does not.CREATE INDEX CONCURRENTLYcannot run inside a transaction block:CREATE UNIQUE INDEX CONCURRENTLY 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;Then verify that PostgreSQL considers the index valid, ready, live, and an exact match for the definition that migration 0083 expects. Set the
search_pathfirst: withoutpublicon it, PostgreSQL prints the enum casts aspublic.channel_visibilityand the text no longer matches.SET search_path = public, pg_catalog; SELECT i.indisvalid, i.indisready, i.indislive, pg_get_indexdef(i.indexrelid) AS definition FROM pg_index AS i JOIN pg_class AS c ON c.oid = i.indexrelid JOIN pg_namespace AS n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relname = 'idx_channels_listed_name';Expect all three flags to be
true, and this definition:CREATE UNIQUE INDEX idx_channels_listed_name ON public.channels USING btree (community_id, lower((name)::text)) WHERE ((visibility = 'open'::channel_visibility) AND (NOT unlisted) AND (channel_type = ANY (ARRAY['stream'::channel_type, 'forum'::channel_type])) AND (deleted_at IS NULL))The writer release works with the index in place.
-
Apply migration 0083. It refuses to run while duplicates remain. With no index, it builds one; over a prebuilt index it skips its own
CREATE. Then it checks the index and records the migration.- Auto-migration on: deploy the contract release. The relay applies 0083 at startup. It does not start while a duplicate remains, so finish steps 2 and 3 first.
- Auto-migration off: run
buzz-admin migratewith the targetDATABASE_URL, confirm that it records 0083, then deploy the contract release. Nothing at startup checks the index, and the contract release no longer checks names in the relay. Do not run the file withpsql: that does not record the migration ledger, and outside a transaction bothSET LOCALtimeouts do nothing.
Recovery
A concurrent build fails if a duplicate remains or appears while it runs, and leaves an invalid index. Migration 0083 rejects an invalid, unready, non-live, or differently defined index. Remove only that index, outside a transaction, run the rename script again, repeat step 3, and build again (step 4 or 5):
DROP INDEX CONCURRENTLY IF EXISTS public.idx_channels_listed_name;
Rollback
Roll back from the contract release to the writer release, not further: an
older relay cannot report the index's refusal as invalid:. Keep the index.
Run the writer release with auto-migration off (BUZZ_AUTO_MIGRATE=false, or
migrate.autoMigrate=false in the Helm chart). With it on, the writer release
refuses a database that has migration 0083, which it does not know
(VersionMissing).