mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
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:
@@ -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;
|
||||
87
scripts/maintenance/rename_duplicate_listed_channel_names.sh
Executable file
87
scripts/maintenance/rename_duplicate_listed_channel_names.sh
Executable 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
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user