fix(relay): rename duplicate channel names through the relay

Replace the direct-SQL repair, which needed every relay pod stopped, with
renames that go through the relay's normal kind:9002 path:

- A relay operator may send a name-only kind:9002 for any channel. Every
  other privileged change still needs a channel owner or admin. A
  moderator gets no new right.
- scripts/maintenance/plan_duplicate_listed_channel_renames.sql lists the
  renames (read-only). rename_duplicate_listed_channel_names.sh sends each
  one with `buzz channels update`, signed by an operator key, then reads
  the plan again and fails if any rename remains. The relay enforces the
  name rule and publishes kind:39000 as for any rename, so relays keep
  serving while it runs.
- Remove `buzz-admin reconcile-channel-groups --metadata` and the SQL
  repair script; nothing needs them now.
- Refuse a kind:9007 with no h tag at ingest, before it is stored. Such a
  create used to run in side effects, so a refusal there (a taken name)
  still answered accepted. Every first-party client sends h.

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>
This commit is contained in:
Logan Johnson
2026-10-30 15:43:59 -04:00
parent c49c29124e
commit 9d97eb24e5
11 changed files with 479 additions and 565 deletions

View File

@@ -0,0 +1,46 @@
-- OUT-OF-BAND MAINTENANCE, read-only. rename_duplicate_listed_channel_names.sh
-- runs this query and sends each rename it lists to the relay.
--
-- Lists the renames that give every listed channel in one community a name
-- that no other listed channel there has, ignoring case. A listed channel is
-- an open, not unlisted stream or forum that is not deleted; an archived one
-- still counts. This is the rule the relay enforces on create and rename
-- (`LISTED_CHANNEL_SQL` in crates/buzz-db/src/store/channel.rs).
--
-- In each group of channels that share a name, one channel keeps it: the
-- oldest unarchived one, or the oldest archived one when all are archived.
-- Every other channel in the group gets "<name>-<first 8 characters of its
-- id>", or "<name>-<id>" when another listed channel already has that name.
-- It lists nothing for a community that is not active.
--
-- psql -X -v ON_ERROR_STOP=1 -v host=<community host> -f scripts/maintenance/plan_duplicate_listed_channel_renames.sql
WITH listed AS (
SELECT ch.id, ch.name, ch.archived_at, ch.created_at
FROM channels ch
JOIN communities co ON co.id = ch.community_id
WHERE co.host = :'host'
AND co.deletion_state = 'active' AND co.deleted_at IS NULL
AND ch.visibility = 'open' AND NOT ch.unlisted
AND ch.channel_type IN ('stream', 'forum') AND ch.deleted_at IS NULL
),
duplicates AS (
SELECT id, name,
left(name, 246) || '-' || left(id::text, 8) AS short_name,
left(name, 218) || '-' || id::text AS long_name
FROM (
SELECT id, name, row_number() OVER (
PARTITION BY lower(name)
ORDER BY archived_at IS NOT NULL, created_at, id
) AS place
FROM listed
) ranked
WHERE place > 1
)
SELECT d.id AS channel_id, d.name,
CASE WHEN EXISTS (
SELECT 1 FROM listed other
WHERE other.id <> d.id AND lower(other.name) = lower(d.short_name)
) THEN d.long_name ELSE d.short_name END AS new_name
FROM duplicates d
ORDER BY d.id;

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# OUT-OF-BAND MAINTENANCE: renames listed channels that share a name in one
# community, so the contract migration's unique index can build.
#
# It reads the renames from plan_duplicate_listed_channel_renames.sql and sends
# each one to the relay as a kind:9002 rename, signed by a relay operator. The
# relay applies a rename the way it applies any other: it refuses a name that
# another listed channel has, and publishes the new kind:39000, so clients
# show the new name. Relays keep serving while it runs.
#
# Run it only after every serving relay enforces the name rule and accepts an
# operator rename. An older relay refuses the rename and can create new
# duplicates.
#
# A rename that fails is reported and skipped: the relay refused it (for
# example, because a channel took the new name meanwhile), or the CLI could
# not reach the relay. At the end the script reads the plan again and fails
# if any rename remains; run it again.
#
# Usage:
# DATABASE_URL=postgres://... BUZZ_PRIVATE_KEY=<operator key> \
# scripts/maintenance/rename_duplicate_listed_channel_names.sh [--dry-run] <community host>
#
# BUZZ_PRIVATE_KEY must belong to a relay operator (RELAY_OPERATOR_PUBKEYS, or
# an `operator` row in relay_operators), not a moderator. BUZZ_RELAY_URL
# defaults to wss://<community host>. BUZZ_CLI defaults to `buzz`.
set -euo pipefail
usage() {
echo "usage: $0 [--dry-run] <community host>" >&2
exit 2
}
dry_run=false
if [[ ${1:-} == --dry-run ]]; then
dry_run=true
shift
fi
[[ $# -eq 1 ]] || usage
host=$1
: "${DATABASE_URL:?set DATABASE_URL}"
if ! $dry_run; then
: "${BUZZ_PRIVATE_KEY:?set BUZZ_PRIVATE_KEY to a relay operator key}"
fi
relay_url=${BUZZ_RELAY_URL:-wss://$host}
buzz=${BUZZ_CLI:-buzz}
plan_sql="$(cd "$(dirname "$0")" && pwd)/plan_duplicate_listed_channel_renames.sql"
plan=$(mktemp)
trap 'rm -f "$plan"' EXIT
# Every field ends with a NUL byte. Postgres text cannot hold one, so any
# name, with any control character in it, stays one field.
read_plan() {
psql "$DATABASE_URL" -X -q -v ON_ERROR_STOP=1 -v host="$host" \
-A -t -z -0 -f "$plan_sql" >"$plan"
}
read_plan
# The plan is the loop's stdin, so the CLI reads /dev/null instead.
renamed=0
failed=0
while IFS= read -r -d '' channel && IFS= read -r -d '' name &&
IFS= read -r -d '' new_name; do
if $dry_run; then
printf 'would rename %s from %q to %q\n' "$channel" "$name" "$new_name"
elif BUZZ_RELAY_URL=$relay_url "$buzz" channels update \
--channel "$channel" --name "$new_name" </dev/null >/dev/null; then
printf 'renamed %s from %q to %q\n' "$channel" "$name" "$new_name"
renamed=$((renamed + 1))
else
printf 'could not rename %s from %q to %q\n' \
"$channel" "$name" "$new_name" >&2
failed=$((failed + 1))
fi
done <"$plan"
$dry_run && exit 0
read_plan
# Three fields, so three NUL bytes, for each rename.
remaining=$(($(tr -cd '\000' <"$plan" | wc -c) / 3))
echo "renamed $renamed, failed $failed, remaining $remaining"
if [[ $remaining -ne 0 ]]; then
echo "duplicate listed channel names remain in $host; run the script again" >&2
exit 1
fi

View File

@@ -1,161 +0,0 @@
-- OUT-OF-BAND MAINTENANCE: do not run from relay startup migrations.
--
-- Gives every listed open channel a name that no other listed open channel in
-- its community has, ignoring case. A listed channel is an open, not unlisted
-- stream or forum that is not deleted; an archived one still counts. This is
-- the rule the relay enforces on create and rename (`LISTED_CHANNEL_SQL` in
-- crates/buzz-db/src/store/channel.rs). It repairs the duplicates created
-- before that rule, so the contract migration's unique index can build.
--
-- Run it only after every serving relay enforces the rule. An older relay can
-- create a new duplicate while the script runs.
--
-- In each group of channels that share a name, one channel keeps it: the
-- oldest unarchived one, or the oldest archived one when all are archived.
-- Every other channel in the group becomes "<name>-<first 8 characters of its
-- id>", or "<name>-<id>" when another listed channel already has that name.
-- The script prints a NOTICE for each rename. When both names are taken, it
-- leaves the channel unchanged with a NOTICE, and the validation below
-- reports it.
--
-- The rename writes only the channel row. Clients read the name from the
-- relay-signed kind:39000, so republish it for each renamed channel:
--
-- buzz-admin reconcile-channels --channel <id> --metadata
--
-- Each batch renames up to 100 channels in one community and commits. It
-- takes the community deletion lock first and skips a community that is not
-- active. It locks every row of the batch before it takes the relay's name
-- lock for any new name, in the order the relay takes them. After it locks a
-- row, it checks again that the row is still a listed duplicate, because a
-- relay may have changed it meanwhile. The script is idempotent: a second run
-- finds nothing to rename.
--
-- Run it outside a transaction, so that each batch can commit:
--
-- psql -v ON_ERROR_STOP=1 -f scripts/maintenance/rename_duplicate_listed_channel_names.sql
DO $$
DECLARE
target UUID;
skipped UUID[] := '{}';
batch UUID[];
duplicate UUID;
live RECORD;
new_name TEXT;
renamed INT;
BEGIN
LOOP
SELECT ch.community_id INTO target
FROM channels ch
WHERE ch.visibility = 'open' AND NOT ch.unlisted
AND ch.channel_type IN ('stream', 'forum') AND ch.deleted_at IS NULL
AND ch.community_id <> ALL (skipped)
GROUP BY ch.community_id, lower(ch.name)
HAVING count(*) > 1
LIMIT 1;
EXIT WHEN target IS NULL;
PERFORM set_config('lock_timeout', '5s', true);
PERFORM pg_advisory_xact_lock_shared(community_deletion_lock_key(target));
IF NOT EXISTS (
SELECT 1 FROM communities
WHERE id = target AND deletion_state = 'active' AND deleted_at IS NULL
) THEN
RAISE NOTICE 'skipped community %: it is not active', target;
skipped := skipped || target;
COMMIT;
CONTINUE;
END IF;
-- Lock every row of the batch before any name lock, in the order
-- the relay takes them.
SELECT array_agg(locked.id ORDER BY locked.id) INTO batch
FROM (
WITH ranked AS (
SELECT id, row_number() OVER (
PARTITION BY lower(name)
ORDER BY archived_at IS NOT NULL, created_at, id
) AS place
FROM channels
WHERE community_id = target
AND visibility = 'open' AND NOT unlisted
AND channel_type IN ('stream', 'forum') AND deleted_at IS NULL
)
SELECT ch.id
FROM channels ch
JOIN ranked ON ranked.id = ch.id
WHERE ch.community_id = target AND ranked.place > 1
ORDER BY ch.id
LIMIT 100
FOR NO KEY UPDATE OF ch
) locked;
renamed := 0;
FOREACH duplicate IN ARRAY coalesce(batch, '{}')
LOOP
-- The ranking used the statement's snapshot. Now that the row is
-- locked, read it again: a relay may have renamed it, made it
-- private, or renamed the channel that kept the name.
SELECT ch.name, ch.archived_at, ch.created_at INTO live
FROM channels ch
WHERE ch.community_id = target AND ch.id = duplicate
AND ch.visibility = 'open' AND NOT ch.unlisted
AND ch.channel_type IN ('stream', 'forum') AND ch.deleted_at IS NULL;
CONTINUE WHEN NOT FOUND OR NOT EXISTS (
SELECT 1 FROM channels other
WHERE other.community_id = target AND other.id <> duplicate
AND lower(other.name) = lower(live.name)
AND other.visibility = 'open' AND NOT other.unlisted
AND other.channel_type IN ('stream', 'forum') AND other.deleted_at IS NULL
AND (other.archived_at IS NOT NULL, other.created_at, other.id)
< (live.archived_at IS NOT NULL, live.created_at, duplicate)
);
new_name := NULL;
FOREACH new_name IN ARRAY ARRAY[
left(live.name, 246) || '-' || left(duplicate::text, 8),
left(live.name, 218) || '-' || duplicate::text
] LOOP
PERFORM pg_advisory_xact_lock(hashtextextended(
'buzz_channel_name:' || target::text || ':' || lower(new_name), 0));
EXIT WHEN NOT EXISTS (
SELECT 1 FROM channels other
WHERE other.community_id = target AND other.id <> duplicate
AND lower(other.name) = lower(new_name)
AND other.visibility = 'open' AND NOT other.unlisted
AND other.channel_type IN ('stream', 'forum') AND other.deleted_at IS NULL
);
new_name := NULL;
END LOOP;
IF new_name IS NULL THEN
RAISE NOTICE 'left channel % in community % as "%": both new names are taken',
duplicate, target, live.name;
CONTINUE;
END IF;
UPDATE channels SET name = new_name, updated_at = NOW()
WHERE community_id = target AND id = duplicate;
RAISE NOTICE 'renamed channel % in community % from "%" to "%"',
duplicate, target, live.name, new_name;
renamed := renamed + 1;
END LOOP;
-- A batch that found nothing to rename would find the same community
-- again; skip it so the loop ends. The validation below reports it.
IF renamed = 0 THEN
skipped := skipped || target;
END IF;
COMMIT;
END LOOP;
END
$$;
-- Validation. Expected result: zero rows. A row names a community the script
-- skipped (not active) or a duplicate created while it ran; rerun the script.
SELECT community_id, lower(name) AS name, count(*) AS channels
FROM channels
WHERE visibility = 'open' AND NOT unlisted
AND channel_type IN ('stream', 'forum') AND deleted_at IS NULL
GROUP BY community_id, lower(name)
HAVING count(*) > 1;