Files
buzz/docs/channel-name-uniqueness-deployment.md
Logan Johnson 700e690193 feat(db): enforce unique listed channel names with an index (#8787)
## 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>
2026-11-04 22:47:23 -05:00

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:

  1. 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 script scripts/maintenance/rename_duplicate_listed_channel_names.sh.

  2. Contract release. Migration 0083_channels_listed_name_unique.sql adds 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

  1. 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.

  2. 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 an operator row in relay_operators), not a moderator. It must also be a member of each community: POST /events checks community membership before the operator role, and answers 403 relay_membership_required to 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_URL defaults to wss://<community host>. The script needs psql and python3. Run it with --dry-run first to see the plan. The host must match communities.host exactly; 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 general or welcome-everyone when 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 with python3 from BUZZ_RELAY_URL (so it must be the URL clients use), or from BUZZ_STARTER_ORIGIN when set (for a desktop build with its own BUZZ_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-member also 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.

  3. 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.

  4. Only for a deployment that migrates out of band (auto-migration off) and whose channels table 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 plain CREATE UNIQUE INDEX, under a 1s lock_timeout and a 5s statement_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 CONCURRENTLY cannot 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_path first: without public on it, PostgreSQL prints the enum casts as public.channel_visibility and 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.

  5. 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 migrate with the target DATABASE_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 with psql: that does not record the migration ledger, and outside a transaction both SET LOCAL timeouts 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).