mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
## 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>
206 lines
10 KiB
Markdown
206 lines
10 KiB
Markdown
# 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](https://github.com/block/buzz/pull/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:
|
|
|
|
```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;
|
|
```
|
|
|
|
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](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.
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
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](operator-community-deletion.md)
|
|
describes; do not deploy the contract release until it completes.
|
|
3. Confirm that no duplicate remains in any community:
|
|
|
|
```sql
|
|
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:
|
|
|
|
```sql
|
|
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.
|
|
|
|
```sql
|
|
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:
|
|
|
|
```text
|
|
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):
|
|
|
|
```sql
|
|
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`).
|