Files
buzz/docs/profile-search-deployment.md
Matt Toohey bb550dbadf fix(search): match the resolved kind-0 head; judge rewrite by expression
Addresses the two open findings from the review of PR #8044. The third,
the stale migration-version pin, was fixed in c2b3426d6.

Profile search applied `search_tsv @@ query` before `DISTINCT ON (pubkey)`,
so it kept the newest *matching* live row, not the current head. With two
live kind-0 rows for one pubkey, an older `Wes` and a newer `Alice`, a
search for `wes` dropped the current row and returned the superseded one,
exposing a name and `about` text the user had removed. Make the head
subquery the row source instead: it resolves each pubkey's newest live
kind-0 row in the replaceable store's `created_at DESC, id ASC` order with
only the identity fences inside it (community, `deleted_at IS NULL`,
`kind = 0`, authors), and the search predicate, channel scope, and
since/until apply to the resolved head, as they would on a store holding
one row per person. The outer wrapper that carried `created_at` and
`search_tsv` through `heads` goes away because the outer query reads them
from the subquery directly; the artifact-head predicate and `kind = ANY`
are skipped for kind 0 since the subquery pins the kind. Non-kind-0
searches emit the same SQL as before; `push_authors` serves both paths.

`ProfileSearchPolicy::rewrite_pending` required `events_populated`, so a
database that was populated when 0056 ran and emptied afterwards reported
`ok` at relay startup and from `buzz-admin profile-search-policy` while
every new profile still indexed its whole JSON, because 0056 never reruns.
Judge the rewrite on the live expression alone and drop the
`events_populated` field and its query. The relay warning no longer says
"populated", and docs/profile-search-deployment.md records that emptying
the table does not help.

Tests. `kind0_search_resolves_the_current_head_before_matching` seeds a
superseded `Wes` head under a current `Alice` head and asserts that `wes`
finds nobody, `alice` finds the current row, and an `until` window that
excludes the current head finds nobody rather than the stale row. With
the search predicate moved back into the head subquery it fails at the
first assertion (the stale row returns) while the existing duplicate-heads
test still passes. `profile_search_policy_probe_tracks_the_maintenance_
rewrite` gains the sequence populated upgrade, `DELETE FROM events`, probe
still pending, a new avatar-only profile matches `needle`, maintenance
rewrite clears the probe and reindexes that row. The
`empty_table_is_never_pending` unit test goes with the term it covered.

Plan check on a scratch copy of the local database (PostgreSQL 17.9)
seeded with 20k chat rows, 3,000 profiles including 300 with ~50 KB avatar
tsvectors, and 500 duplicate heads: the head subquery is bounded to the
community's kind-0 rows (`idx_events_addressable` on mixed partitions),
3,500 rows sorted in ~1 MB; 1.7 ms against 0.4 ms for the old shape on the
post-0056 expression and 6.7 ms against 4.8 ms on the whole-JSON
expression. On that data the old shape returned the renamed `Wes 7` for
`wes`; the new shape does not.

Verified with the lld linker workaround for sqlx proc-macros on this
machine: `scripts/postgres-test-run.sh -p buzz-search --tests` (24
passed), `scripts/postgres-test-run.sh -p buzz-db --lib -E
'test(migration::postgres_tests)'` (13 passed), `cargo test -p buzz-db
--lib search_policy`, `cargo test -p buzz-admin profile_search`, `cargo
fmt --all --check`, and `cargo clippy -p buzz-search -p buzz-db -p
buzz-admin -p buzz-relay --all-targets -- -D warnings`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
2026-10-02 16:55:27 +10:00

6.4 KiB

Profile search deployment

Migration 0056_profile_search_text_fields.sql changes how kind-0 profiles are indexed for search. Before it, events.search_tsv tokenized the whole profile JSON, so an inline base64 avatar contributed thousands of lexemes and one shared agent avatar could make a hundred profiles match a name prefix none of them carry. After it, kind 0 routes through profile_search_tsv(content), which indexes the profile's name, contact, and about fields only.

At relay startup the migration:

  1. Refuses to run on PostgreSQL older than 16, naming the requirement (profile_search_tsv uses pg_input_is_valid).
  2. Creates profile_search_tsv unconditionally.
  3. Rewrites the generated column only when events is empty.

A populated database keeps its current expression. Replacing a generated STORED column copies every partition of events and rebuilds the GIN index under ACCESS EXCLUSIVE for as long as the copy takes, which a Kubernetes startup probe can kill and repeat. The query-side name-first ordering in buzz-search keeps profile lookups usable in the meantime, and the relay logs this at every boot until the rewrite has run:

WARN Profile search still indexes whole kind-0 JSON on this database; run the maintenance rewrite script=scripts/maintenance/profile_search_text_fields.sql procedure=docs/profile-search-deployment.md

Who needs the rewrite

Any deployment that held events before starting a relay version with migration 0056. Fresh installs do not. Emptying events later does not help: the migration has already run, so the old expression stays and every profile published afterwards indexes its whole JSON again. Check without side effects, from a shell with the relay's DATABASE_URL:

buzz-admin profile-search-policy

The command opens one read-only session and prints the live expression and outcome: ok (exit 0), rewrite_pending (exit 2), or missing (exit 5, the column does not exist yet). The same probe drives the startup warning, so the two never disagree.

The equivalent SQL:

SELECT pg_get_expr(d.adbin, d.adrelid) AS expression
FROM pg_attrdef AS d
JOIN pg_attribute AS a ON a.attrelid = d.adrelid AND a.attnum = d.adnum
WHERE d.adrelid = 'events'::regclass
  AND a.attname = 'search_tsv';

The rewrite is pending when the expression does not mention profile_search_tsv(content).

Sizing

The rewrite evaluates the search expression for every row of every events partition, then builds the GIN index. Time is dominated by tokenizing the indexed chat content (roughly 20 MB of content per second, single-threaded). Transient disk is about a second copy of the heap and TOAST plus the new index plus WAL.

SELECT kind, count(*), pg_size_pretty(sum(octet_length(content)))
FROM events GROUP BY kind ORDER BY 2 DESC;

SELECT pg_size_pretty(sum(pg_total_relation_size(inhrelid)))
FROM pg_inherits WHERE inhparent = 'events'::regclass;

Rehearse on a clone of the production database, time it, and schedule a window of that length. Event reads and writes on the relay stall for the whole window: ACCESS EXCLUSIVE blocks SELECT as well as INSERT, so clients see history requests hang and publishes fail until the transaction commits.

Running it

psql -v ON_ERROR_STOP=1 -f scripts/maintenance/profile_search_text_fields.sql

Requirements:

  • Table owner. ALTER TABLE events requires the role that owns events, normally the relay's application role. A read-only or admin role that lacks ownership fails with must be owner of table events before changing anything.
  • No statement timeout. ADD COLUMN ... GENERATED ... STORED runs as one statement for the full copy. A statement_timeout on the role or the psql wrapper (bastion tooling commonly defaults to tens of seconds) cancels it and rolls everything back. Disable it for the session, for example SET statement_timeout = 0; before \i, or use the wrapper's option for running without a timeout.
  • A started relay. The script refuses to run until the relay has started once on 0056 and created profile_search_tsv.

Behaviour on failure:

  • The script sets lock_timeout = '5s'. If a long-running writer holds a conflicting lock, the script fails with canceling statement due to lock timeout and nothing changes; rerun when the table is quieter. This is deliberate: a pending ACCESS EXCLUSIVE request queues every new reader and writer behind it, so waiting longer blocks traffic, not just the script.
  • Everything runs in one transaction. A cancelled or failed run rolls back to the previous column and index; there is no partial state to clean up.
  • Rerunning after success is a no-op: the script notices the kind-0 arm and returns with a NOTICE.

There is no cheap undo. Reverting means another full rewrite with the previous expression, so record the output of the pg_get_expr query above before you start.

Verification

Run buzz-admin profile-search-policy again; outcome is ok and the expression's kind-0 arm reads WHEN (kind = 0) THEN profile_search_tsv(content). The next relay start logs no profile-search warning.

Stored vectors shrink to the profile's text fields. Profiles with an inline avatar previously carried thousands of lexemes; afterwards they carry a handful:

SELECT encode(pubkey, 'hex') AS pubkey, length(search_tsv) AS lexemes
FROM events
WHERE kind = 0 AND content LIKE '%"picture":"data:%'
ORDER BY lexemes DESC
LIMIT 5;

Relation to nip_rs_search_allowlist.sql

scripts/maintenance/nip_rs_search_allowlist.sql is the older rewrite that converts a pre-0008 blocklist expression to the fresh-install allowlist. It now produces the kind-0 arm as well, at the same cost, so a brownfield database needs one rewrite, not two: run the allowlist script if you also want the allowlist policy, otherwise run profile_search_text_fields.sql, which wraps whatever expression the database has today. Running the profile script after the allowlist script is a no-op.

Changing profile_search_tsv later

CREATE OR REPLACE FUNCTION recomputes nothing already stored: every existing row keeps the vector the old body produced, and only new or updated rows see the change. Any later fix to the function therefore ships with its own maintenance rewrite and this procedure, the same way 0056 does. Never DROP the function: events.search_tsv depends on it, and a CASCADE drops the column.