mirror of
https://github.com/block/buzz.git
synced 2026-11-06 01:11:33 +00:00
285ea2ec2bf37cda744dcbbc70a00fe1c0538ac1
864 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3d26ed7dfd |
feat(relay): match verified names in profile search (#9019)
## Summary `POST /profiles/search` only matches kind:0 `name` and `display_name`. In communities that publish NIP-FI verified profiles, a person's surname or directory username often appears only in their relay-signed kind:30382, so typeahead cannot find them by it. - Where the community advertises verified profiles, the search also matches the `display_name`, `username` and `full_name` tags of each current kind:30382 head signed by the relay key. - Verified names need a trailing prefix of at least three characters, as in NIP-50, so a short prefix doesn't make the relay rank every verified head. kind:0 keeps one-character typeahead. Lookups from heads to `events` also match `created_at`, so Postgres prunes to one partition. - Results are paged by person. Each person returns their current kind:0, when they have one, then their current kind:30382, when they have one, whichever matched. A person is ranked by their best match across both sources, using the existing tiers. - NIP-11 `federated_identity.public_profile` gains `searchable: true`, so clients can rely on the response instead of a separate kind:30382 read. - Migration `0081` adds a GIN expression index over the head's name tags on `nip_fi_profile_heads` (built-in `jsonb_path_query_first` and `to_tsvector`, no new functions). The table holds one row per verified person. Like `0078`, the migration builds the index only while the table is empty; populated databases prebuild it with `CREATE INDEX CONCURRENTLY` (`docs/profile-search.md`), and the migration then checks it is valid and has the expected definition. Compatibility: the request schema is unchanged. Existing clients that keep only kind:0 events work as before and also find people who match only by verified name. Trust stays pinned to the relay `self` key; the event shape follows NIP-85 user assertions, but discovery is relay-advertised rather than kind:10040. ## Testing - `buzz-search` Postgres integration: new `profile_names_match_verified_names` covers forged-key heads, replaced heads, other communities, people without kind:0, per-person grouping, paging, and words split across sources; the existing profile-name tests pass unchanged. The test fails when the author filter is inverted. - `buzz-relay` end to end in enforce mode: `profile_search_matches_verified_names_where_advertised` publishes through the real writer and finds the person by surname only where verified profiles are advertised. - Desired-state and migration index definitions match. - `profile_names_use_verified_name_index` asserts the search scans the new index. Migration `0081` checked against an empty table, a rerun, a populated table without the index (raises), the documented concurrent prebuild, and an index with a different definition (raises). - Latency, 50k people each with a kind:0 and a verified head, release build, local Postgres, best of 5, 30 people per page: 11–22 ms with verified names versus 1–8 ms kind:0 only. `EXPLAIN` shows a bitmap scan on the new index. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Kalvin Chau <kalvin@block.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
865b150371 | fix(db): use ScratchDb in the storage usage delivery test (#9032) | ||
|
|
0a06c0b3cb |
feat(relay): log reads, logins and connections on access target (#8376)
🤖 Adds an access log for relay reads, logins, connections and HTTP requests on the dedicated `buzz_relay::access` target. It writes to stdout, not `audit_log`, so reads never take the audit advisory lock. The log is shaped for volume: one line per HTTP request, one line per WebSocket read, and one line per WebSocket connection. ## Lines per operation | Operation | Lines | |---|---| | HTTP request on a matched route | 1 (`http`), except the skip list below | | HTTP request matching no route | 1 (`http`, `route="<unmatched>"`) at status 400 and above, otherwise none | | WebSocket connection | 1 (`close`, carrying the login) | | WebSocket `REQ` / `COUNT` / NIP-AR sync | 1 each (`req` / `count` / `sync`), plus the `buzz_ws_reads_total` counter | | WebSocket NIP-42 login | 1 (`auth`) per decided or cut-off attempt, capped after the decision as below | | Audio login | 1 (`auth`, at its final admission check) | | WebSocket `EVENT` | 1 (`Event ingested`, not on the access target) | ## HTTP requests One middleware, `http_access_log`, writes every HTTP line. It sits between `track_metrics` and `http_trace_layer`, so rebuilt errors, body-limit refusals and localhost refusals all reach it. It writes the line when the response is ready, which is not proof the body was delivered. A request dropped before then logs `request=aborted` at WARN with no `status`. - Every line carries `route` (the matched route template, never the raw path), `peer_addr` when the listener has one, `status`, `time_to_response_ms` and `http_method` (the HTTP verb). The verb stays on the line itself because the `http.request` span is absent under an access-only filter and when a pending request is dropped. - Handlers record what they learn on the request's `AccessSlot`: the login (`method`, `pubkey`, `pubkey_verified`, `community`), the decision, the filter summary, and the route's own fields. The decision is `outcome` (`success` / `denied` / `failed` / `challenged`) with a fixed `reason`, or `undecided` when the route recorded none. A refusal is final: a later admission doesn't replace it, so a replayed NIP-98 proof that verifies and is then refused logs `failed` / `nip98_replay`. - `outcome` describes the login. What the route did is in `status` and the route's fields: a rejected `/events` write logs `outcome=success` with `status=400`, `accepted=false` and its `reject_reason`. - Route fields: - `/query` and `/count`: the filter summary and `result_count`. An incremental `/query` response (`{changes:[...]}`) counts its changes, removals included. Artifact reads record their summary before the query runs, so a read dropped mid-query keeps it. - `/events`: `accepted`, `kind`, `event_id`, and on a refused write the parse `category` / `line` / `column` or a fixed `reject_reason` (such as `invalid` or `restricted`), never the ingest message. - Invite mint: `invite_id`, `expires_at` and `max_uses`. Invite claim: `joined`. - Admin feedback attachment reads: `feedback_id` and `attachment_sha256`. With `BUZZ_ADMIN_AUTH=disabled` they log `method=network_trusted` with no key, and Host or Origin refusals log their fixed reason. - Every other route logs only the login fields. - No field can hold an invite code, a token, or a Blossom read hash. Media routes log the `/media/{sha256_ext}` template. - Skip list: NIP-11 and other non-upgrade requests on `/`, `/info`, `/.well-known/nostr.json`, `/health`, `/_liveness`, `/_readiness`, and the SPA and static files the relay serves when enabled (web assets, invite landing pages, git web pages, the admin bundle) at any status. Successful WebSocket upgrades (101) log no `http` line. Refusals before the upgrade on `/` and the audio path log one, with the status actually sent. - Routes that aren't Nostr-key logins (the command-token disconnect endpoint, the workflow webhook, the git server's internal callback) log `outcome=undecided` with no key. - Dev-mode `X-Pubkey` requests log `method=x_pubkey`, `pubkey_verified=false`. - A successful `/query` or `/count` that returned nothing (`result_count=0`) logs at DEBUG: on staging, 93% of `/query` lines were empty polls. Denied, failed, aborted and error reads, reads with no `result_count`, writes and logins keep their levels. - Other successful lines and git's credential-less first request (`challenged`, with its 401) are INFO. Refusals, statuses of 400 and above, and aborted requests are WARN. In Enforce mode the line records the remapped status (for example 403). Denial bodies and headers are unchanged. ## WebSocket reads - `REQ` / `COUNT` / `sync` lines carry `pubkey`, `conn_id`, `sub_id`, the filter summary, the outcome (`served` / `denied` / `aborted` / `error` plus a fixed reason), and `events_queued` (REQ and sync) or `count` (a COUNT that counted). A `REQ` is `served` only once its EOSE is queued to the client. A read logs WARN only when it ends in `error`. `served`, `denied` and `aborted` are INFO, since `aborted` means the read ended before completing: the client disconnected or sent CLOSE, a same-ID `REQ` replaced it, or the session was cancelled. - A NIP-AR sync subscription writes its `sync` line when its initial set completes, not when live delivery ends. Its summary is the registered live filter (kinds and channels, never artifact predicates), and `events_queued` counts the initial set. - `REQ` lines stay at INFO even when empty at EOSE, because the subscription can still deliver live events later. - Filter summary, shared with HTTP `/query` and `/count`: `kinds`, channel ids, authors and `p` values each list the first 10 distinct values. The exact `kind_count` / `channel_count` / `author_count` / `p_count` (summed per filter) appears only when the list is `*` or shows fewer values than the filters held. A dimension that any filter leaves open logs `*`. `since` / `until` / `limit` take the widest bound and are absent when any filter leaves them open, so no field understates what the read covered. `ids` is a count only. NIP-50 search logs `search: true`, never the text. Only well-formed channel ids and pubkeys are listed. An artifact read's summary uses only the standard dimensions of each raw filter, so artifact predicates and client-defined tags (such as `#1`) are never logged. The summary is skipped when the access target is off. Under `=warn`, every WARN line, HTTP or WebSocket, still carries it. - Per connection, only the first 16 reads that are refused before they run (`auth_required`, quota, concurrency, overload) or denied are logged. The rest are counted as `reads_suppressed` on the close line. A refusal that arrives after the close line has been written, from a read still running when the client left, logs its own line, so every refusal is either logged or counted, never both. Served reads are never capped. ## WebSocket read counter `buzz_ws_reads_total{type="req"|"count"|"sync"}` counts every parsed `REQ`, `COUNT` and NIP-AR sync frame before admission, exported at zero from startup. It is a metric, so it counts at any `RUST_LOG`, including production's current `error`. ## WebSocket logins A NIP-42 login writes one `auth` line when it is decided. A successful login logs INFO `outcome=success` at its commit point, decided under the connection's close-reason lock, so a disconnect racing the login (session expiry, admin disconnect, revocation, ban or roster removal) either wins, and the login logs no success, or loses and closes the connection right after the success line. A refused or failed login writes one WARN `auth` line with `outcome`, a fixed `reason` from the shared `AuthReason` code set, `pubkey` and `pubkey_verified`. Root WebSocket `auth` lines carry `peer_addr`. AUTH frames after the login is decided are capped at 16 per connection, and the rest are counted as `auth_suppressed`. An attempt cut off by close or session expiry logs `aborted`. Audio logins log one `auth` line at their final admission check. ## Connections The close line is the connection's only line. It is owned by the upgraded socket, so it is written exactly once however the connection ends: a normal close, a refusal while checking the community is active (`refused=not_admitted`), the connection limit (`refused=connection_limit`), or the handler being dropped. It carries `conn_id`, `community`, `peer_addr`, `duration_ms`, the login, `events_queued` / `bytes_queued` (frames the writer accepted and flushed, not client delivery), and the two suppression counters. ## Error text - Database errors log as `DbError::kind()` (SQLSTATE or variant), including the command-executor event insert. Search errors, on both read paths and in the search-cancel task, log as `SearchError::kind()` / the same SQLx classifier. Workflow database and webhook errors log as their codes, since webhook URLs can be secrets. - Git NIP-98 failures log a fixed code. Git subprocess stderr moved to DEBUG because hook output can contain message text. ## Operator notes `.env.example` documents the target, its `event` values (`http`, `auth`, `req`, `count`, `sync`, `close`), the skip list, the levels, and how to turn it down: `buzz_relay::access=warn` keeps refusals, errors and aborted HTTP requests, and of WebSocket reads only those that end in `error`, and `=off` drops everything. It also lists the removed lines: `NIP-42 auth successful/failed`, `WebSocket connection established/closed`, `event=connect`, `HTTP bridge request`, `Event ingested via pipeline`, `relay invite minted`, `relay member added via invite`, `admin feedback attachment read`, and `addr` (now `peer_addr`). JSON lines no longer repeat the current span as `span`, since `spans` already ends with it. A line logged under an explicit `parent:` span loses that parent's fields. The default OTel export filter sets `buzz_relay::access=off`, so access lines aren't exported a second time as span events. `peer_addr` is the TCP peer. `BUZZ_CLIENT_IP_HEADER` optionally names a request header (for example `cf-connecting-ip` behind Cloudflare or `x-real-ip` behind nginx, matched case-insensitively). When set, `http`, `auth` (root and audio), `close` and connection-limit lines also log `client_ip`: the rightmost comma-separated entry of the header's last field, only if it parses as an IP address. It never replaces `peer_addr`, and it is only trustworthy when every request arrives through a proxy that overwrites the header. --------- Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> |
||
|
|
fa7a3bfbda |
feat(relay): push per-community storage usage to kgoose (#8714)
### Why?
To bill hosted communities for storage in Orb, kgoose needs
per-community storage usage. The relay already computes it in a daily
snapshot, but nothing delivered it anywhere.
## Summary
- Adds opt-in `BUZZ_STORAGE_USAGE_URL`. When it is set, one
leader-elected relay pod checks every 5 minutes for a new storage
snapshot and POSTs it to kgoose's `POST /v1/buzz/storage-usage/record`
(squareup/cash-server#144285) in consecutive batches of up to 50
communities until every community is sent.
- Each batch is `{measured_at, communities: [{community_host,
storage_bytes, storage_objects}]}`. Active communities with no stored
bytes are reported as zero, and re-sends are safe because kgoose bills
each community once per UTC day.
- Retries on 408, 429, 5xx, and transport errors. No migration;
documented in `docs/storage-accounting.md`. kgoose authenticates the
relay as the `buzz` mesh service, so the relay needs mesh egress to
kgoose-builderbot.
### Related issue
None found. Complements #7899 (snapshot history in the DB). Receiving
endpoint: squareup/cash-server#144285.
### Testing
Unit tests cover batching, retry and reject handling, zero-filling, and
a real HTTP delivery that checks the exact kgoose request body. A
Postgres-gated DB test covers the new reads. End to end, a local relay
delivered to a fake endpoint once per new snapshot.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Shu Hui <shu@squareup.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
|
|
6847882b14 |
fix(relay): stop logging unmapped-host NIP-FI denials as server failures (#8997)
## What this does In enforce mode, a request whose Host maps to no community gets NIP-FI's `authorization_unavailable` 503 (`docs/nips/NIP-FI.md`, Rejection and privacy table). tower-http's default classifier treats every 5xx as a failure, so each of these requests also logs a `tower_http::trace::on_failure` error. That line has no host or path, only `503` and `latency: 0 ms`. When a scanner probes a host this relay doesn't serve, it produces thousands of these lines in a minute, and they trip error-rate alerts even though nothing is down. This PR keeps the wire response the same and stops counting these denials as server failures: - `nip_fi_core::community_denial` builds the same `http_denial` response. When the Host is confirmed unmapped, it adds an internal `UnresolvedHost` response extension. Extensions never reach the wire, so the NIP-FI denial contract ([FI-TRACE-DENIAL-ORACLE]) still holds. - Each site that turns a community-resolution failure into a denial now uses it: - the router guard (`nip_fi_assertion_guard`) - the WebSocket upgrade (`check_nip_fi_at_upgrade`) - HTTP admission, for routes the guard exempts (`Stage::Community`) - The HTTP trace layer now uses `RelayResponseClassifier`. It treats marked responses as expected and keeps tower-http's 5xx-is-failure rule for everything else. Other 503s still log as failures. That includes an unavailable JWKS key source on a mapped host, `deny_protected`, a stale snapshot, and a failed database lookup. ## Why not return 4xx for unmapped hosts? NIP-FI.md maps "community/Host resolution unavailable" to 503, and `nip_fi_enforce_unmapped_host_is_503_before_verification` pins that ruling. Changing the status would be a spec change. This PR only changes how the relay logs these denials. ## Database community source With `BUZZ_NIP_FI_COMMUNITY_SOURCE=database`, a not-found host and a failed lookup (DB error, timeout, shed waiter) return the same denial class. The marker is set only when the host is confirmed unmapped: a missing Host, a non-bindable host, or a live negative-cache entry. Resolver outages stay unmarked and still log as failures. With the `env` source, every resolution failure means the host is unmapped, so it is always marked. ## Testing - `nip_fi_enforce_unmapped_host_is_503_before_verification` (guard, root upgrade, audio upgrade) and `enforce_unmapped_host_is_503_before_nip98_and_verifier` (HTTP admission) now also assert the marker. Mutation check: removing the guard marker turns the router test red. - New `nip_fi_mapped_host_dependency_503_is_not_marked_unresolved`: a mapped-host 503 from an unavailable key source is unmarked and still classified as a failure. - New `relay_response_classifier_skips_only_unresolved_host_denials`: covers marked 503, unmarked 503, 500, 200 and 401. - New `only_confirmed_unmapped_hosts_are_marked_as_expected_denials` (database source, all three sites): a negative-cached host and a non-bindable host are marked; a bound host whose lookup fails is not. Mutation check: marking every database failure turns it red. - `cargo clippy -p buzz-relay --all-targets -D warnings` is clean, and the pre-push `rust-tests` lane passed. - Local relay, before and after. Setup: enforce mode, `RUST_LOG=error`, one mapped host, JWKS unreachable on purpose. Both builds got the same requests: | Request | Status (both builds) | `on_failure` errors before | after | |---|---|---|---| | Unmapped host, 4 protected paths | 503 | 4 | 0 | | Unmapped host, WebSocket upgrade on `/` | 503 | 1 | 0 | | Mapped host, no assertion | 401 | 0 | 0 | | Mapped host, well-formed JWT, JWKS unreachable | 503 | 1 | 1 | | Unmapped host, `/assets/` path and `POST /api/invites/claim` | 404 | 0 | 0 | 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Salman Mohammed <smohammed@squareup.com> Co-authored-by: opus <f038cc09b32a776e7d95b65c267e9d851777ee2b6cdbd46a48edba01b1d10ebd@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
152441ae0a |
feat(loadgen): model desktop clients and large-community shape (#9005)
## Summary
The load generator models every community as many small open channels,
and every human as a bare WebSocket client: four REQs, a little history,
then nothing until the run ends. Large deployments don't look like that.
They have thousands of people in a few communities, a handful of very
wide channels, and some people in hundreds of channels. Their desktop
clients also make most of their relay requests over HTTP and on timers.
This PR adds opt-in scenario knobs for both, so a run can reproduce that
shape and compare the desktop client before and after its recent
request-volume fixes.
Every new key defaults to the previous behavior. Existing scenarios
parse, assign members and run exactly as before.
### Shape
| Key | Default | Meaning |
|---|---|---|
| `shape.private_channels` | `false` | `seed` creates channels private
and adds every member with kind:9000 before posting history. Members are
the humans and agents assigned to each channel up to the largest ramp
step, plus the hot thread's root and authors. Events carry up to 100
members each and run `seed_concurrency` at a time. A rerun adds nobody
new, and any member the relay refuses fails the seed. |
| `shape.wide_channels` | `0` | The first K channels of each community
are wide channels. |
| `population.wide_member_fraction` | `0.0` | Share of humans that join
every wide channel on top of their own. |
| `population.wide_activity_share` | unset | Share of its typing,
messages and reactions a member of the wide channels sends there; the
rest goes to its regular channels. Unset keeps the uniform pick, which
sends half of a 3-wide/3-regular member's traffic to the wide channels
and makes fan-out, not reads, the bottleneck. |
| `population.tiers` | `[]` | `[{ fraction, channels }]`: that share of
humans joins `channels` channels instead of `channels_per_member`. |
Assignment is deterministic by global index, so `seed` and every
`--shard` agree without coordination. With no wide channels and no tiers
it is the old assignment, and a test pins that.
### Desktop clients (`[desktop]`)
| Key | Default | Meaning |
|---|---|---|
| `fraction` | `0.0` | Share of humans that run the desktop model
instead of the basic human. |
| `mode` | `"current"` | `legacy` is the client before its volume fixes;
`current` is after them. |
| `windows`, `threads` | `3`, `1` | Open channel windows and threads,
which are refreshed on connect. |
| `tick_secs` | `60` | Recovery tick period. |
| `backstop_secs` | `900` | `current` only: each tick item (subscription
replacement, roster, unread repair) waits this long since it last ran. |
| `renewal_secs` | `0` | Identity renewal period. `0` disables renewal,
and the first renewal is jittered. |
A desktop human keeps the basic human's presence, typing, messages and
reactions. What it adds:
- **Cold start.** It reads NIP-11 and opens its profile and membership
subscriptions. Its channel subscriptions are batched per mode:
- up to 10 channels: one replaying batch;
- `legacy` with more than 10: one per-channel batch per 10 channels;
- `current` with more than 10: a live-only subscription per 2048
channels, plus replays of 10 that are closed at EOSE.
It also makes the app's HTTP reads: roster, channel names, sidebar,
profiles, statuses, emoji, read state, unread repair, reminders, window
catch-up plus notices for each window, and root plus thread page for
each thread. A window catch-up follows the cursor in the relay's window
bounds (kind 39006) for up to five older pages, until the bounds say
history is exhausted.
- **Route establishment.** `legacy` makes every read as it connects.
`current` reads NIP-11, names and sidebar up front, and the rest as each
subscription reaches EOSE, by its replay outcome. A replay of 500
events, a missed deadline, or the live-only batch over 10 channels is a
cut, and so is a route the previous connection never caught up. Only an
EOSE catches a route up: one recovered after a missed deadline cannot be
resumed or carried by the next connection.
- A continuous profile route skips its read (unless one is owed).
- A continuous membership route only repairs unreads, without the
read-state snapshot, unless a snapshot read failed since; then it runs
the full repair.
- A cut global reads in full, and channel routes always recover windows
and threads.
- **Ticks and renewals.** These follow each mode:
- `legacy`: every tick replaces subscriptions and rereads roster and
unreads, and a renewal is a full reconnect;
- `current`: tick items wait for the backstop, measured from the last
replacement, the last successful roster read, and the last full unread
repair. A failed roster read, unread repair or read-state snapshot is
owed and retries on the next tick; snapshot debt is tracked apart from
repair debt, so a repair without the snapshot never settles it. A
renewal carries its routes: nothing is read while every replay keeps
continuity, and once one is cut every route establishes as on a
reconnect. With more than 10 channels the live-only batch is always cut,
so the channels recover and unreads are repaired, but globals are not
reread.
- **Reporting.** Each request type is its own op (`desktop_roster`,
`desktop_unread`, `desktop_replay_eose`, …).
- **Bounds.** At most 4 REQs set up at once, with a 10 s EOSE timeout. A
tick replacement that misses it is closed, the batch it would have
replaced stays, and the next tick retries. At most 6 HTTP reads in
flight, with a 30 s timeout. A read that comes due while the same read
is running or queued is not added again, and each read leaves the
running set as soon as it settles, so a failed read can retry while a
slow one is still under way. Connects and failed sessions back off. A
stop abandons pending EOSE waits without counting them as failures.
- **Not modeled.** The app also establishes channel routes when a tick
replacement reaches EOSE; the generator does not.
`perf/LOADGEN.md` documents the knobs and the model.
`perf/scenarios/local-desktop.toml` is a short local example with every
knob on.
## What got simpler
- `human()`'s schedule loop (presence, typing, messages, reactions,
session end) moved into one `Activity`. The basic and desktop humans
share it, so neither client duplicates the schedule. The basic human's
random draw order is unchanged.
- Membership stays in one place. `member_channels` remains the single
assignment function, and `channel_members` derives from it rather than
from a second formula, so seeding and the clients can't drift apart.
The desktop model is a new concept: about 1,420 lines of model in its
own module, plus about 1,300 lines of tests. Everything else goes
through existing seams: `Stats`/`Op`, `connect`, `Backoff`, `observe`,
`roots` and `Net`.
Identified debt, not fixed here: CI's `test-unit` runs `buzz-test-client
--lib` with the filter `loadgen::`, so the five crate-root `tests::` in
`lib.rs` (relay message parsing, auth and text event building,
`collect_until_eose`) never run in CI. That gap predates this PR, which
doesn't touch `lib.rs`.
## Testing
- `cargo fmt --check`, `cargo clippy -p buzz-test-client --all-targets
-- -D warnings`, and `cargo test -p buzz-test-client` (plain parallel):
84 lib tests pass.
- Wide activity: a seeded `Rng` over 100k picks gives each share within
1%, and an unset share makes the identical draws to before. A run
against a loopback relay checks that a human's typing lands only in its
regular channels at share 0, and only in its wide channels at share 1.
- Scenario: validation of every new key; deterministic wide-channel and
tier assignment that honors the fractions; `channel_members` inverts the
assignment up to the peak; the default assignment is unchanged; the
shipped scenarios parse.
- Seed: with private channels, a fake relay sees the following, in
order:
- every 9007 before any 9000, and every 9000 before any post;
- owner signatures;
- ≤100 members per event;
- exactly the expected members per channel;
- posts authored by channel members.
A refused member fails the seed, and open channels add nobody.
- Desktop: request planning, window bounds validation, route
establishment, and tick and owed-read rules are unit-tested. End-to-end
tests drive clients against loopback WS and HTTP fakes. The WS fake
replays a planned number of events per subscription and can delay an
EOSE or drop a connection. The HTTP fake answers window pages with
relay-signed bounds, an older aux event and equal-timestamp rows,
refuses unknown cursors, and can fail a read once. They check:
- exact cold-start counts per op for `legacy` and `current` at 5 and 25
channels;
- a window catch-up sends the head, then exactly the bounds' cursor
(`until` + `before_id` of the last row, not the older aux event), and
stops when the bounds say history is exhausted even though that page
came back full;
- after a `current` renewal, 499 replayed events everywhere read nothing
over HTTP; 500 on the channel batch adds channel recovery plus a covered
unread repair; 500 on membership adds roster, read state, unread and
reminders plus channel recovery; 500 on profiles adds profiles, statuses
and emoji plus channel recovery and a covered repair;
- after a dropped connection, complete global replays read only NIP-11,
channel recovery and a covered repair; truncated ones reread both
globals in full;
- with renewals shorter than the backstop, read-state snapshots still
recur at the backstop;
- a tick replacement past its EOSE deadline is closed, the old batch
stays, the next tick replaces it, and one batch wire stays open;
- a failed roster and unread read retry on the next tick, and successful
reads do not run again;
- a failed roster read retries on the next tick while an unrelated slow
read is still running;
- after a failed snapshot, a continuous membership establishment before
any tick still reads the snapshot;
- a route whose EOSE missed its deadline is recovered, and after a
renewal with a complete replay it is recovered in full again rather than
carried;
- `legacy` ticks replace batches (new REQ before the CLOSE of the old
one) and reread;
- `current` ticks do nothing until the backstop, then replace live-only;
- `legacy` renewals are full reconnects;
- `current` renewals carry small sessions without rereading globals or
channels, and recover channels in large ones.
Each of the nine review-fix tests was checked against a mutant that
restores the old behavior, and every mutant fails its test.
- Smoke: a local relay with Postgres and Redis. I ran `seed` twice, then
`run` of `local-desktop.toml` (60 s) once per mode.
- Both seeds were accepted (780 of 780 posts each), including private
channels and memberships for 340 people. Neither run aborted, no client
was cancelled, and no op recorded an error.
- Each run used 18 desktop clients:
| op | `current` | `legacy` |
|---|---|---|
| `desktop_live_eose` | 266 | 483 |
| `desktop_replay_eose` | 24 | 0 |
| `desktop_roster` | 72 | 289 |
| `desktop_read_state` / `desktop_unread` | 72 / 99 | 289 / 289 |
| `desktop_profiles` | 18 | 73 |
| `desktop_nip11` | 18 | 73 |
| `desktop_window` | 372 | 596 |
Window reads are below the first smoke (406 and 1086) because catch-ups
stop where the relay's bounds say history is exhausted, rather than
paging while pages came back full. They are above the previous revision
(179 and 470) because each read now leaves the running set as soon as it
settles, so a channel recovery is no longer dropped as a duplicate of a
read that already finished inside a still-running batch.
- The first smoke caught two relay contract mismatches that the fakes
had missed:
- top-level window pages need `before_id` with `until`;
- the read-state snapshot answers with an object.
Both are fixed, and the fakes now enforce both.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
88697f1c59 |
fix(relay): apply kind 5 / 9005 deletions atomically (#8944)
## Summary Kind 5 and 9005 deletions now store the event and soft-delete their targets in one transaction. Before this PR the deletion committed first. The target soft-delete ran afterwards as a best-effort side effect, and a failure there was only logged. Resending the deletion then returned `duplicate:` without retrying. A crash between the two commits had the same result. Either way the relay accepted a deletion while its target stayed live. The bridge `include_aux` closure still compensates for those rows. Its second hop returns deletions of the reactions and edits it found, so clients can cancel them. This PR keeps that hop, because rows already torn this way can't be repaired by a resend: - Ingest rejects events more than 900 seconds from server time. - Clients don't resend deletions that were accepted. - During a rolling deploy, old pods keep writing torn rows until they are replaced. Dropping the hop waits for a follow-up, once existing torn rows have had a verified repair and no old writers remain. ### Changes - **Atomic deletion.** Kind 9005, and kind 5 with `e` targets, go through `Db::insert_deletion_event`, driven by `side_effects::persist_deletion`. The write stores the event and its mentions, soft-deletes the targets and updates their thread counters. It follows the existing `persisted_atomically` path used for workflow deletion and kinds 9000/9002. - A failure stores nothing and rejects the event, so the client can resend. - A resend of a stored deletion inside the ingest window soft-deletes any target still live and is dispatched as inserted. It doesn't repair older rows. - Live 39005, reaction-table cleanup and the 9005 system message still run after the commit and stay best-effort. - Kind 5 deletions of `a` coordinates stay a side effect. - **Closure unchanged.** The channel window and legacy thread-reply paths keep both hops, and the docs (`docs/bridge-channel-window.md`, `docs/nips/NIP-CW.md`) are unchanged. ### What got simpler Deletion no longer has a window where the event is accepted but not applied. `handle_standard_deletion_event` is now only the `a`-coordinate path. Target planning and soft-delete for `e` targets live in one place, `persist_deletion`. ### Follow-ups (not in this PR) - Repair the existing torn rows: soft-delete the live targets of stored kind 5 / 9005 `e` deletions and fix their thread counters. Then drop the closure's deletion-of-aux hop. - Hop 1 still probes every partition once per row id. A partition bound or the indexed `reactions` table would cut that. ### Related issue N/A, found while investigating relay database CPU. ### Testing - `torn_deletions_still_cancel_live_targets_in_both_windows` (bridge Postgres) stores kind 5 and 9005 deletions without soft-deleting their reaction and edit targets. It queries the channel window and the legacy thread path with `include_aux`, without a resend, and asserts each live target comes back with its deletion. - `insert_deletion_event_target_failure_stores_nothing_and_resend_applies` (buzz-db Postgres) uses a trigger to make the target's soft-delete fail. It asserts nothing is stored and the target is unchanged, then that a resend applies both. - `insert_deletion_event_resend_repairs_a_live_target` (buzz-db Postgres): a resend repairs a deletion stored without its target's soft-delete, and a further resend changes nothing. - `failed_target_soft_delete_stores_nothing` and `resent_deletion_repairs_a_live_target` (relay Postgres) cover the same two cases through ingest, for kinds 5 and 9005. - `test_channel_window_rows_overlays_and_exact_multiple_exhaustion` (`e2e_nostr_interop`, Relay E2E lane) deletes its reaction and asserts the reaction leaves the closure. - Blox: fmt, clippy (`buzz-db`, `buzz-relay`, `buzz-test-client`), and the `buzz-db` and `buzz-relay` Postgres lanes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b8b425e8b1 |
fix(db): drop a superseded head's mention rows with it (#8948)
## Summary
Replacing a replaceable or addressable event soft-deletes the previous
head, but its `event_mentions` rows stayed behind. Only the hard-delete
kinds (read-state and mesh status) removed theirs, in a separate
statement. No mention-index read (`#p` filters and feeds) returns a
soft-deleted event, so those rows are pure cost: every `#p` read walks
them and throws them away.
A kind:39002 roster republish leaves one dead row per channel member, so
a large channel piles them up with every join. Relay-emitted replaceable
lists with p-tags do the same.
The statement that retires the old head now deletes that head's mention
rows as well, through a data-modifying CTE over `RETURNING id`, on all
three supersede paths:
- the relay's kind:39002 roster writer
(`LockedMemberSnapshot::replace_member_event`)
- `replace_addressable_event` (kind 0/3/10000–19999, including the
relay-signed kind:13535 list)
- the NIP-33 parameterized path
Because the CTE covers every row the retire statement touches, a
coordinate that somehow had more than one live row also gets cleaned up.
The shared `superseded_with_mentions_sql!` macro sits at the crate root
so all three mention-indexing replacement paths use the same statement.
The kind:13534 membership-list and push-lease writers also retire heads,
with a bare `UPDATE`, but neither indexes p-tags, so they leave no
mention rows.
## What got simpler
The hard-delete special case (`if hard_delete_superseded { DELETE FROM
event_mentions … }`) is gone. Both retire statements go through one
macro, so a head and its mention rows leave together on all three paths.
## Not in this PR
- Mention rows already left behind by earlier supersedes.
`docs/database.md` rules out data backfills in migrations, so these get
cleaned up out of band, in batches.
- Other soft-delete paths (NIP-09 deletion, moderation, channel deletion
retiring its 39000–39002 events) also keep mention rows. They are
low-volume, and are noted here as identified debt.
## Tests
`superseded_heads_drop_their_mention_rows` (pg) has one table row per
supersede path: the member-snapshot roster writer (lock, replace,
release), the addressable path, the NIP-33 path, and the two NIP-33
kinds that are hard-deleted (NIP-RS read state and mesh status). In each
case the old head keeps zero mention rows and the new head is indexed.
Soft-delete rows check that the old head stays soft-deleted, and
hard-delete rows check that it is gone. Reverting the macro, any single
call site, or the hard-delete statement alone fails this test.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
298a555e34 |
perf(db): bind the channel scope as one array (#8946)
## Summary Event reads scoped to a set of channels pushed one bind parameter per channel: `channel_id IN ($5, $6, …)`. This applies to a viewer's access scope, which the relay adds to every REQ and `/query` without an `#h`, and to explicit multi-value `#h` filters. As a result, the SQL text depended on the viewer's channel count, and every count was a separate prepared statement. That caused two problems in production: - **Re-planning on every call.** A connection rarely reused a cached plan for these reads, so Postgres planned each one again. With no `since`, the plan covers every `events` partition. For `#p` mention reads (the `event_mentions` join), planning took far longer than executing the query. - **Hidden cost.** Monitoring that groups by statement split one query into one row per channel count, so its total cost never showed up in a single place. This PR binds the set as `channel_id = ANY($n)`, so one statement serves every channel count. On a production replica, the same query prepared with `ANY($5)` moved to Postgres's cached generic plan by its sixth run. Planning time dropped to near zero, and that plan was no slower than the custom one. ## What changed - `push_channel_scope` in `crates/buzz-db/src/store/event.rs` builds the channel-scope predicate as one array bind. - `build_query_events_sql` and `count_events_sql` each had their own copy of this block. Both now call the helper. - Semantics are unchanged: - Access scope still keeps channel-less global events. - Explicit `#h` still matches only the requested channels. - An empty set still means "no channels". ## What got simpler Two duplicated 20-line blocks became one helper. The statement count for channel-scoped reads no longer grows with channel count. ## Generic-plan exposure These statements were already persistent prepared statements, so a cached generic plan was always possible whenever the connection's statement cache held one. This change makes that reuse consistent; it doesn't introduce generic plans. Tag-value reads (`has_tag_values`) still run unnamed, as before. Plain (non-`#p`) access-scoped reads stay on custom plans. A production replica, with a viewer in hundreds of channels, ran the plain page read repeatedly under the default `plan_cache_mode`, and every run used the custom ordered index scan. Postgres estimated the generic plan as far more expensive, and when forced, it was orders of magnitude slower. Postgres switches only when the generic plan is no costlier than the average custom plan, so it never chose this one. The generic plan's cost comes from the `LIMIT` and `kind` values it can't see, not from the array length, and the per-arity `IN (…)` statements had the same blind spot. ## Tests - `channel_scope_sql_does_not_depend_on_channel_count` (unit): for the `#p` join and plain reads, for access scope and explicit `#h`, in both the page SELECT and the COUNT, the SQL for 1 channel equals the SQL for 40 channels. It also checks that the SQL uses `ANY(` and that global rows are retained only for access scope. - `channel_scoped_mention_reads_share_one_prepared_statement` (Postgres): on one connection, access-scoped `#p` reads with 1, 2 and 3 channels leave exactly one prepared `event_mentions` statement. With one mention per channel, one global mention, and one in an unscoped channel, every scope of 0–3 channels, with and without globals, returns and counts exactly its own rows. - The existing scope semantics tests still pass: - `access_scope_is_applied_before_historical_page_limit` - the explicit multi-channel `#h` page and count test - Mutation: reverting the helper to one bind per channel fails both new tests. ## Identified debt (not in this PR) - `get_last_message_at_bulk` still builds an `IN (…)` list per channel. It's a separate, lower-volume statement. - Superseded versions of replaceable roster events (kind 39002) keep their `event_mentions` rows. On large channels, a viewer's roster mention rows grow with every membership change. That's a follow-up. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f6198f6997 |
feat(relay): record NIP-FI enforce denials at parity with shadow (#8974)
## What this does
In enforce mode, NIP-FI denials used to be returned without a reason
counter or a log line. This PR records enforce denials with the same
`WouldDeny` reasons, label sets and log fields as shadow. **Admission
behavior does not change**: statuses, close codes and frame order are
all the same.
- **`buzz_nip_fi_enforce_total{route,stage,outcome,community}`**: the
same shape as `buzz_nip_fi_shadow_total`, including an `admit`/`admit`
stage, so `1 - admit/total` is the enforce denial rate. Each socket or
HTTP request gets exactly one verdict.
-
**`buzz_nip_fi_enforce_session_end_total{route,reason=expired|revoked,community}`**:
once per admitted session.
- **`buzz_nip_fi_enforce_strict_proof_total{route,outcome,community}`**:
the outcome of the proof check that actually decided the request.
Nothing is verified twice.
- **Log lines** `nip-fi enforce deny` and `nip-fi enforce session end`
(info, `mode="enforce"`, from `buzz_relay::nip_fi_shadow`). Shadow's
`nip-fi shadow would-deny` gains `mode="shadow"`. Neither mode logs
admits.
### How each verdict is recorded once
- **Sockets (root, audio).** `EnforceSession` moves Pending →
Admitted(pubkey) → Ended under one std mutex, and records inside the
lock with no awaits.
- Root admits at its AUTH commit; audio admits at its join commit.
- A refusal for a non-NIP-FI reason retires the session without a
record. Audio retires it before rollback.
- A deadline the effect gate sees before the expiry worker is claimed at
that seam.
- **Ends.** Revocation and expiry are recorded only by the terminal
transition that wins (`publish_authorization_denied`), and only while
the socket isn't already cancelled. A community close or cancellation
that wins first records nothing.
- **HTTP.** The router guard scopes each enforced request with one
verdict. An agent token's verdict is deferred to the end of the request:
- the first owner-decided refusal is the verdict, otherwise the request
was admitted;
- membership rechecks, batches, Git success and early returns each count
once.
- **Owner provenance** comes from the decision itself
(`BanVerdict{outcome,source}` from one `restriction_state` read), with
no rereads.
- Reasons: `owner_banned`, `owner_ban_unavailable`, `not_stored_owner`,
`owner_link_unavailable`.
- When the agent's own ban read fails and there's a token owner, the
result is `owner/unavailable`, as shadow records it.
- A ban or failed restriction read that ingest catches on `POST /events`
after membership passed replaces the request's admit with `owner/denied`
when the owner's row decided the ban, or `owner/unavailable` on a failed
read. An agent's own ban is not recorded as its owner's.
## Shadow-to-enforce map
| Shadow signal | Enforce counterpart |
|---|---|
| `buzz_nip_fi_shadow_total` denials: upgrade, guard, HTTP admission,
AUTH pairing, deny set, stale binding | `buzz_nip_fi_enforce_total` with
the same stage/outcome, plus a `nip-fi enforce deny` line |
| `buzz_nip_fi_shadow_total` owner stage: ws, audio, http | the same,
from the original decision: root/audio ban seam and owner link; HTTP
`enforce_membership`, smart Git, Git settings, buzz/v1 ban reread |
| `buzz_nip_fi_shadow_total` `deadline/session_deadline` (pending) |
`EnforceSession::expire`, from the worker's winning terminal or the
effect gate that saw it first |
| `buzz_nip_fi_shadow_total` admit |
`buzz_nip_fi_enforce_total{stage="admit",outcome="admit"}`, one per
socket or request |
| `buzz_nip_fi_shadow_session_end_total{reason=expired\|revoked}` |
`buzz_nip_fi_enforce_session_end_total`, same labels |
| `buzz_nip_fi_shadow_strict_proof_total` |
`buzz_nip_fi_enforce_strict_proof_total`, from the deciding check (a
rejection is the route's 403, or 401 when the proof is missing) |
| `buzz_nip_fi_shadow_disconnect_total{admin,accepted}` |
`buzz_nip_fi_disconnect_total` (`api/nip_fi.rs:160`) |
| `buzz_nip_fi_shadow_disconnect_total{admin,propagation_failure}` |
`buzz_nip_fi_disconnect_propagation_failures_total`
(`api/nip_fi.rs:180–185`) |
| `buzz_nip_fi_shadow_disconnect_total{admin,capacity}` |
`buzz_nip_fi_disconnect_capacity_rejections_total`
(`api/nip_fi.rs:196–201`) |
| `buzz_nip_fi_shadow_disconnect_total{cross_pod,capacity}` |
`buzz_nip_fi_cross_pod_capacity_exceeded_total`
(`api/nip_fi.rs:433–434`) |
| `buzz_nip_fi_shadow_disconnect_total{cross_pod,poison}` |
`buzz_nip_fi_cross_pod_shard_poison_total` (`api/nip_fi.rs:442–443`) |
| `buzz_nip_fi_shadow_disconnect_total{cross_pod,lag}` |
`buzz_nip_fi_disconnect_lag_total` (`main.rs:1596–1602`) |
The enforce series was renamed from `buzz_nip_fi_enforce_denied_total`
to `buzz_nip_fi_enforce_total` when it gained the `admit` stage.
## Tests
Infra-free, in the `test-unit` lane:
- `state::enforce_end_is_recorded_only_by_the_winning_terminal`: root
scan, audio scan and deadline, each against a community close or
cancellation first and FI first.
- `nip_fi_http::enforce_strict_proof_records_the_deciding_check_once`:
pass and reject, and no record when cardinality refuses.
-
`nip_fi_shadow_session::audio_owner_link_refusal_shares_roots_seam_and_reasons`:
calls the shared owner-link helper with route `audio`, not a huddle
socket.
- The existing shadow, upgrade, router and HTTP enforce tests.
Postgres lane:
-
`handlers::auth::enforce_root_auth_records_owner_refusals_from_one_decision`:
clean admit then `expired` through the real expiry worker; banned token
owner, banned stored owner, other stored owner and a failed ban read;
authorization reads counted from the decision only.
-
`handlers::auth::enforce_root_deadline_seen_by_the_gate_first_is_recorded_once`:
both orderings.
- `audio::handler::enforce_join_commit_is_the_audio_admission`: drives
the join-commit seam directly, not a huddle socket or the expiry worker:
committed then expired; archived with an expiry held inside the
rollback; gate-first deadline.
-
`api::bridge::token_owner_reaches_membership_on_every_nip_fi_http_route`:
one verdict per request on every NIP-FI HTTP route, including sidebar,
read-state, Blossom and Git, for an owner ban, a stored-owner conflict
and a clear owner.
-
`api::bridge::token_owner_ban_reread_is_the_fixed_nip_fi_denial_on_http`:
own ban, owner ban and unavailable at the reread, on sidebar and Git
settings.
-
`api::bridge::token_owner_publish_ban_is_the_fixed_nip_fi_denial_on_http`:
a late ban or failed read on `POST /events` at the write, typing,
moderation and relay-admin checks records exactly one owner denial and
no admit when only the owner is banned, and no owner denial when the
agent alone or both are banned.
-
`audio::handler::tests::already_expired_audio_bootstrap_records_the_deadline`:
an already-expired bootstrap records `deadline/session_deadline`, not a
deny-set denial.
Each new test names the mutation that turns it red. These were run red:
- deleting root's `session.admit(pubkey)`
- deleting audio's commit `session.admit(pubkey)`
- deleting `effect_refused`
- deleting enforce from `retire_shadow`
- deleting the commit seam's `expire`
- deleting the cancellation guard
- deleting the own-read-failure provenance
---------
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
|
||
|
|
a9aa1f73b0 |
Enable incremental artifact reads on every relay (#8975)
## Why Compatible clients need incremental artifact reads for local collections on every Buzz relay. ## What Expose incremental artifact reads and capability discovery on every Buzz relay. ## How Serve incremental queries through the existing authentication and authorization checks. Keep the existing work budgets and the separate WebSocket sync capability. The HTTP test verifies capability discovery and a successful incremental query. ## Risk The relay starts no additional background work. Clients that use incremental reads consume database capacity. The net database load is not yet measured. Serving writers must maintain the modification timestamps. ## Testing Read-only SQL checks on November 3, 2026 verify the prerequisites below. Each database records successful migrations 76–78. All five required columns have the expected types, defaults, and nullability. All five indexes have the expected definitions and are valid, ready, and live. | Deployment | Schema and indexes | Running timestamp writers | | --- | --- | --- | | Production `bb-block` | Pass | `f6f6d9a` includes PR 8523 | | Production `bb-public` | Pass | `f3a7bab` includes PR 8523 | | Staging `bb-block` | Pass | `b4509de` includes PR 8523 | The checked columns are `artifact_heads.modified_at`, `artifact_revisions.modified_at`, `artifact_revisions.source_channel_id`, `channel_members.sync_modified_at`, and `communities.artifact_policy_modified_at`. The checked indexes are `idx_artifact_revisions_artifact`, `artifact_heads_modified`, `artifact_heads_channel`, `channel_members_sync`, and `artifact_revisions_moves`. At commit `62b26ad`, all 21 NIP-11 tests and the HTTP capability test pass on Linux with PostgreSQL 17 and Redis. The HTTP test checks capability discovery and a successful incremental query. All required push hooks also pass. No manual testing. Human confirmation remains pending. ## Bigger picture [Client PR 1507](https://github.com/block/buzz-app/pull/1507) uses the advertised capability for local artifact sync. Existing deployments receive this behavior when they deploy the relay version that includes this PR. Generated with Codex --------- Signed-off-by: Daniel Rouleau <drouleau@squareup.com> |
||
|
|
ce47aefdc3 |
fix(relay): send membership notices on channel delete, moderation kick and moderation DM (#8977)
Related: [buzz-app#2215](https://github.com/block/buzz-app/pull/2215) ## Problem A connected client learns about membership changes from kind:44100/44101 notices and then re-reads its channel list. Three relay paths changed a member's channel list without sending a notice, so clients only caught up on their periodic full read: - **Channel delete (9008):** no member was told. - **Moderation kick:** the kicked member wasn't told, and the channel's 39000/39002 discovery wasn't refreshed, so it still listed them. - **Moderation DM:** a new, existing or hidden moderation DM didn't surface live. ## Change - **Channel delete:** `soft_delete_channel_capturing_members` (buzz-db) reads the channel type and active members under deletion's existing membership lock, in the same transaction. A join either commits first and is included, or waits and is then refused. After the commit, discovery cleanup and cache invalidation, the relay sends a type-tagged 44101 to each captured member in a spawned task, eight at a time, outside any lock. The 9008 OK doesn't wait for them, and the task is started before the `channel_deleted` system message is emitted, so a system-message failure can't drop them. An already-deleted channel sends nothing. `soft_delete_channel` keeps its signature and delegates. - **Moderation kick:** `apply_kick_live_side_effects` refreshes 39000/39002 after the removal fence commits, then sends 44101 to the kicked member. If the refresh fails, it returns the error before the notice, so the moderation driver's existing mutation-marker recovery retries. If the channel was deleted since the kick, there's no roster to refresh: it removes the channel's discovery events and treats that as converged, so the action still finalizes. The removal/re-add fence and the re-added early return are unchanged. - **Moderation DM:** `send_moderation_notice` drops both participants' cached channel sets on every pod (`invalidate_membership`), as opening a DM does in `command_executor::handle_dm_open`, so the read the notice prompts includes the DM. After clearing `hidden_at`, it republishes the recipient's NIP-DV kind:30622 visibility snapshot (clients filter the sidebar on it), then emits discovery, then sends 44100. It sends the notice on every delivery, tagged `moderation_source` with the delivery's source id, so two deliveries in the same second each get their own 44100. The content JSON is unchanged. A failed snapshot or discovery returns an error, so the outbox retries. - **Ordering guarantee:** each notice is sent after its change is committed and readable on the relay instance that sends it. Ordering across relay instances, and other instances' cached channel sets, are best-effort, the same as for existing membership notices. - A failed notice delivery is logged and never undoes the change, like the existing 9001 path. There's no new retry system. ## Tests - `membership_notice_postgres_tests.rs`: one journey per path. A `#[cfg(test)]` barrier at the point a notice is signed parks the producer, keyed by recipient. While it's parked, the test reads the channel list (and, for the DM, the 30622 snapshot) as a client would on receipt. The barrier can also pin a notice's `created_at`. - The DM journey pins both deliveries to the same second, hides the DM through the real kind:41012 path between them, and checks what a client sees: a live 44100 for each delivery, with distinct ids, then a channel-list read that lists the DM and a visibility read that no longer hides it. - `moderation_dm_notice_follows_a_cached_channel_list` warms the recipient's channel set with a read before the DM exists, then checks the read on receipt of the notice lists the DM. - `soft_delete_captures_members_at_its_serialization_point` (buzz-db): a join and then a delete queue behind a held membership lock. The delete must return the joiner, and a later join is refused. - `crash_recovery_finalizes_kick_after_channel_deleted` (admin): commits a kick, deletes the channel, then runs the recovery worker. The action finalizes, and the member's `removed_at` is unchanged, so the removal doesn't run again. Each ordering test fails when its prerequisite is moved or removed: the kick notice ahead of the roster refresh, the delete notices ahead of discovery cleanup, the DM without the snapshot republish or the cache drop, the DM notice without its source tag (the second notice coalesces), and the member capture outside the lock. The recovery test fails if a deleted channel's `ChannelNotFound` propagates. No spec files change. This reads a channel delete as removing every member under the existing `NOSTR.md` notice promise. --------- Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> |
||
|
|
d3a4909cca |
perf(search): rank only the newest prefix matches and plan with the real tsquery (#8976)
## Summary Message search is one of the slowest relay queries. Short typeahead prefixes make it worst: `a:*` matches most of a community. **Why it was slow:** the tsquery was built inside a `CROSS JOIN LATERAL` subquery, so the planner saw an InitPlan and couldn't read the `search_tsv` statistics. It fell back to the default 0.5% selectivity for `@@`: for `a:*` on 1M rows it estimated 4,497 rows when 517,065 matched. Every search therefore took every GIN match, ran `ts_rank_cd` on each one, and top-N sorted them before `LIMIT 20`. This PR: 1. **Binds the real tsquery.** `push_tsquery` is unchanged. A separate `SELECT (…)::text` round trip resolves the tsquery, and the search binds it as `$q::tsquery`. An empty tsquery returns no hits without running the search. 2. **Plans each search for its own tsquery.** The search runs with `.persistent(false)`. A cached prepared statement can switch to a generic plan after five runs, and a generic plan can't see the tsquery. Forcing a generic plan lost the gain in testing. 3. **Ranks only the newest 1,000 prefix matches.** Prefix-mode relevance search ranks the newest `PREFIX_RANK_WINDOW` (1,000) matches. The inner `ORDER BY created_at DESC, id DESC LIMIT 1000` matches each partition's primary key read backwards, so for common prefixes Postgres streams the newest rows and stops at 1,000. For rarer terms it keeps the GIN bitmap plan, now with an accurate estimate. Full-text search and profile (kind:0) typeahead still rank every match. ### Behavior change: every human message search (product decision needed) Prefix mode isn't limited to keystroke typeahead. buzz-app (`useSearchMessages.ts`) and legacy desktop (`desktop/src-tauri/src/commands/messages.rs:184`) send `search_mode: "prefix"` for **all** message search. Full-text search runs only on the WS REQ path, which agents and the CLI use. So for people: - Results are the best-ranked of the newest 1,000 matches in the search's scope, not of all matches. If more than 1,000 newer messages also match, an older, more relevant message drops out of ⌘K results. The scope includes `#channel`, `from:` and date bounds, so a narrower search has the same limit within it. People have no full-text fallback. - The bridge honors `page`, and buzz-app loads later pages at 20 per page. Every page ranks the same window, so paging stays consistent, but results end after 1,000 matches (page 50). Full-text search (agents/CLI) and profile (kind:0) typeahead still rank every match. ### Perf risk: matches the backward scan has to walk far to find The planner picks the backward scan when it expects 1,000 matches near the top of the community, and it estimates that by treating each filter as independent. Two known shapes break that assumption. **Terms common in old messages but absent from recent ones.** The planner assumes matches are spread evenly over time. For a term that was common long ago but doesn't appear recently (an old project name, a former teammate), it can pick the backward primary-key scan and walk every newer row before reaching the first match. Reviewers measured this on 1.2M synthetic rows: about 2.05–2.1 s, against 0.7–1.8 s for the old query shape and 1.2 s with the GIN plan forced. The 20 s statement timeout and the #8954 cancel still bound it. The worst case is close to the old `a:*` worst case, but it now scales with community size, not match count. The planned EXPLAIN on production-shaped data will include such a term. **Terms common in channels the searcher can't read.** Human search always adds `channel_id = ANY(<readable channels>) OR channel_id IS NULL` (`build_search_channel_scope_filter`). A term that is common in private channels or other people's DMs can get a high match estimate, take the backward scan, and walk the community's rows to find a few readable matches, where the old shape used the GIN bitmap. This hasn't been reproduced yet; the production-shaped EXPLAIN will include such a term next to the old-only one. The same timeout and cancel bound it. ### Cost - One extra round trip per search to resolve the tsquery (about 0.03 ms of execution). It runs on the same pool checkout as the search (`cancel::fetch_all` takes the caller's connection), so a busy pool queues each search once. An IMMUTABLE SQL function could remove the round trip, but that needs a migration, so it's not done here. ### Related issue - Follows #8954 (cancel abandoned searches, bound the search pool) and #8964 (closed in favor of #8954). - Builds on #8947 (3-character server-side prefix minimum), merged from main. The resolve step passes its minimum. When the trailing prefix is too short, the resolved tsquery is NULL, and the search returns no hits without running the second query. - Client-side pairing: block/buzz-app#2210 (3-character last-word minimum for ⌘K message search). ## Testing **EXPLAIN ANALYZE on a synthetic 1M-row `events` table** (monthly partitions, GIN on `search_tsv`, kinds 9/40002/40008, custom plans, warm cache): | Prefix | Matches | Before | After | Plan after | |---|---|---|---|---| | `a` | 517k | 322 ms | **0.7 ms** | backward primary-key scan per partition, stops at 1,000 | | `th` | 137k | 127 ms | **1.2 ms** | backward primary-key scan, stops at 1,000 | | `a` (community with 10% of rows) | — | ~100 ms | **1–2 ms** | backward primary-key scan | | `deploy` | 8.2k | 15 ms | 5–10 ms | GIN bitmap, accurate estimate | | `d278` | 500 | 1.1 ms | 0.5 ms | GIN bitmap | | rare word | 0 | 0.07 ms | 0.03 ms | GIN bitmap | **Postgres integration tests:** - New test `prefix_relevance_ranks_only_the_newest_matches`. An old, highly relevant message drops out of prefix results behind 1,000 newer matches but still ranks first in full-text search. Prefix pages end at page 50. An old, best-ranked profile still tops profile typeahead behind 1,000 newer profile matches. Removing the window fails the test, and so does applying it to profiles. - `abandoned_search_cancels_its_statement`: the `pg_stat_activity` matcher now looks for `ts_rank_cd(`, because the old `search_query.query` text no longer exists. - Locally, each test ran against a fresh database because `cargo-nextest` isn't installed, so the hosted lane hasn't run yet. 26 of 27 pass. `artifact_search_uses_only_live_current_head` also fails on unmodified main in that local setup, so the failure isn't from this change. **Checks:** `cargo fmt --check` and `cargo clippy -p buzz-search --all-targets -D warnings` are clean. **Not yet done:** - hosted CI; - a manual check against a local relay with ⌘K typeahead; - EXPLAIN against production-shaped data, including a term common only in old messages and a term common only in channels the searcher can't read. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Salman Mohammed <smohammed@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
d44031f418 |
perf(relay): cut writer-pool round trips past the 48k relay ceiling (#8846)
## Summary Five changes that take load off the writer pool and the relay's connection bookkeeping. On a single-relay load rig, the clean ceiling moves from 40k to about 48k WebSocket clients. In the A/B, connect p99 at 48k fell from 4.1 s (abort) to 1.65 s. 1. **Ban/mute restriction cache with a 1 s max age.** HTTP API requests, publishes and ephemeral writes read `(community, pubkey)` restriction state through one cache. An entry is served for 1 s from when its read began, matching the open-channel cache's bound. Socket admission, moderation-command actor checks and the `/buzz/v1` ban recheck still read fresh. An admitted socket outlives the 1 s window, so a banned client that reconnects inside it would otherwise keep a socket open indefinitely. A ban drops the target's entry, and each owned agent's entry, on every pod as part of the live-disconnect fan-out, best-effort. Timeouts, untimeouts and unbans drop it on the pod that handles them. 2. **Fewer round trips per event read.** Replica reads skip the per-read `statement_timeout` tighten, because the reader pool installs the deadline at connect. They keep the savepoint, so a failed or cancelled read leaves the session usable and releases its locks. That takes them from 4 round trips to 3. Writer reads send `BEGIN` and the tighten as one message, which also takes them from 4 to 3. 3. **Concurrent open-channel cache misses share one read.** The first miss in a community takes a per-community gate and reads; the others wait for it and use its result. A waiter whose leader failed reads once on its own and does not queue again. A waiter also stops waiting after the cache's 1 s max age and reads on its own, because the leader's set would be stale by then anyway. So a leader whose read hangs stalls only its own request. 4. **`has_admitted_connection` is one map lookup.** A `(community, pubkey) → admitted count` index is maintained under each socket's pubkey lock in `mark_admitted`, `set_authenticated_identity` and `deregister`. It replaces a scan of every connection on each disconnect. 5. **Host → community cache with a 1 s max age.** Only successful lookups are cached; unmapped hosts and errors go to the database every time. ## What got simpler - The ban handler's separate pod-local cache drop is gone; the disconnect every ban already runs covers it on every pod. - One write-restriction gate instead of two. `enforce_cached_write_restriction`, `enforce_relay_membership_ephemeral` and the `cached_ban` parameter chain through `enforce_membership` / `admit_member` / `deny_banned` are deleted. - The ephemeral path's separate 30 s ban cache is gone, so the relay has one staleness rule for restriction state instead of two. ## Notes - Bans reach the restriction cache on every pod. Every ban path (signed command, report action, relay admin) closes the target's and its agents' sockets clusterwide, and each pod drops those entries in the same step. A dropped Redis publish, or a restriction read already in flight when the drop lands (it can refill the old row), falls back to the 1 s bound. Timeouts on other pods also rely on the 1 s bound, and so do unbans and untimeouts, which fail closed. - On the load rig the restriction-cache hit rate was 28%, because HTTP agents poll every 5 s. A longer TTL with Redis invalidation would raise it, but that is a separate change. ## Load test Single relay pinned to 8 cores, native Postgres 16, a seeded fixture with 2,000 open channels, and a 6-shard generator. Steady ramp with 120 s holds; a step aborts at p99 > 3 s. Each step adds 10% HTTP agents. | clients | base connect p99 | this PR connect p99 | base relay cores | this PR relay cores | |---|---|---|---|---| | 32k | 34 ms | 31 ms | 1.71 | 1.66 | | 40k | 45 ms | 26 ms | 2.33 | 2.15 | | 48k | 4,107 ms (abort) | 1,652 ms | 4.7 | 3.0 | | 56k | n/a | 4,535 ms (abort) | n/a | 4.0 | Open-channel re-reads over the run fell from 72k to 1.6k. The tenant cache hit rate was 94%. 48k is this branch's knee. A later run of the same build aborted at 48k: connect p99 2.2 s, but 29% subscribe errors from the concurrent-handler cap. With a 10 s restriction max age, it passed 48k, at connect p99 1.56 s, 60% hit rate and 10% fewer relay cores, and both arms stopped at 56k. ## Testing - `cargo fmt --check`, `cargo clippy -p buzz-relay -p buzz-db --all-targets -D warnings` - `cargo test -p buzz-db --lib` and `-p buzz-relay --lib`, run in parallel. The only failures were 8 DB-seeding tests that need a migrated default database; the Postgres lane covers them. Commits after that run changed the replica savepoint, the `/buzz/v1` recheck revert and some test edits. - Postgres lane at head: `scripts/postgres-test-run.sh -p buzz-relay -p buzz-db --lib --bins --tests`. All 1,183 tests passed. - Mutants. Each one must turn its new test red: - waiters skipping the gate - a no-op index removal - the tenant cache ignoring age - the restriction cache ignoring age - a waiter waiting on the gate with no limit - a disconnect that keeps the cached restriction row - replica reads dropping the savepoint buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
d005c7bb9c |
fix(search): skip message prefix search below three characters (#8947)
## Summary
Typeahead surfaces (the buzz-app and desktop topbars) send a NIP-50
search with `search_mode: "prefix"` whenever typing pauses. A one- or
two-character trailing `:*` prefix matches a large share of every
indexed message. The GIN index on `search_tsv` can't stop early or
return rows in rank order, so Postgres fetches, rechecks and ranks every
match before it applies `LIMIT`. In a large community, each of these
queries costs seconds of database CPU.
With this change, prefix-mode `search()` returns no hits when the
trailing whitespace token yields a lexeme shorter than three characters
(`PREFIX_MIN_CHARS`). The check runs inside the prefix tsquery SQL, on
the output of the same `to_tsvector('simple', …)` call that decides
which lexemes get `:*`. Punctuation therefore can't pad the count:
`pr.`, `@pr`, `#pr`, `c++`, `don't` and `pre-p` are all held back, and
`project ?` still runs, because `?` yields no `:*` lexeme. When the rule
applies, the tsquery is NULL, so it matches nothing and the GIN index
lookup returns no rows. Completed words in front of the trailing token
don't matter: `"the p"` runs the same broad `p:*` lookup as `"p"`.
Kind:0-only queries (profile typeahead) are exempt because there are few
profiles, and the short-name exact-lexeme ordering relies on one- and
two-character queries. `search_profile_names` is unchanged. Full-text
mode is unchanged.
Only the HTTP bridge (`POST /query` with `search_mode: "prefix"`)
reaches prefix mode. WebSocket REQ search always uses full-text mode, so
this change doesn't affect it.
## What got simpler
The kind:0-only predicate is now computed once (`profile_only`) and
shared by the new minimum and the existing exact-lexeme ordering. The
minimum lives in the one SQL fragment that already tokenizes the query,
so there is no second tokenizer in Rust to keep in sync with Postgres.
No new concepts beyond one constant.
## Known limits
- This bounds the worst case only. A common three-character prefix can
still match enough rows to be expensive. Bounding that needs a product
decision, such as a recency window for typeahead or a different index.
- Two-character CJK words (for example `東京`) get no message typeahead
until a third character. A trailing contraction or hyphenated word with
a short part (`don't`, `e-m`) gets none until the next word is started.
Full-text mode is unaffected.
- Clients should not show "no results" for one- and two-character input.
A client-side minimum is a companion change. That includes the legacy
desktop topbar in this repo (`desktop/src/features/search/hooks.ts`,
which searches at 2 characters globally and 1 in a channel); it is left
out here to keep this PR relay-only.
## Testing
- Extended
`prefix_mode_matches_final_token_prefix_without_changing_full_text` with
a table of prefix queries and the exact hit sets they must return. It
pins no hits for a trailing token that yields a lexeme below three
characters: alone, after a completed word (`project pr`), padded with
punctuation (`pr.`, `@pr`, `#pr`, `...p`), as a contraction (`don't`),
and hyphenated (`pre-p`). It covers `kinds = [9]`, no kinds, mixed `[0,
9]`, and `Newest` sort. Each of those rows has data it would match
without the minimum. The same table has controls that must still match:
`pro.`, `project ?` (punctuation-only trailing token), `pre-pro`, `t
pan` (a short completed word stays exact and isn't limited), and a
one-character kind:0 `p`. A full-text `don't` control shows that
full-text mode is unaffected.
`short_kind0_prefix_prioritizes_exact_lexeme_on_a_noisy_page` pins the
kind:0 exemption.
- Mutation-checked on Blox. Each of these fails a test: removing the
minimum, removing the kind:0 exemption, and applying the minimum to
every lexeme instead of only the trailing ones.
- Synthetic plan check on Blox Postgres (300k rows, GIN on a generated
`simple` tsvector, same `CROSS JOIN LATERAL` query shape). `'pr':*` took
3.6 s and read 10,411 buffers. The NULL tsquery took 0.01 ms: the bitmap
scan returned 0 rows and read no buffers.
- Two existing prefix tests used a two-character trailing token
(`project pl`, `alpha be`). They now use three or more characters
(`project plan`, `alpha bet`) and assert the same behavior.
- Blox: `cargo fmt --check`, `cargo clippy -p buzz-search --all-targets
-D warnings`, `buzz-search` unit tests, and the `buzz-search` Postgres
lane (`scripts/postgres-test-run.sh`).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
83bd8b1cae | fix(loadgen): sign public URLs when dialling connect_addr in plaintext (#8926) | ||
|
|
46da35810d | db: recycle pool connections after 5 minutes instead of 30 (#8951) | ||
|
|
02925ceb6e |
fix(relay): cap bridge searches in flight per user (#8956)
## Summary
Typeahead starts a bridge `/query` search on almost every keystroke. The
client aborts the previous request. But if the abort never reaches the
relay, for example because an intermediary keeps the upstream request
open, every one of those searches keeps running.
Each `(community, pubkey)` may now have **4** bridge searches in flight.
The next search supersedes the oldest one. The superseded request
returns **409** `{"error":"search superseded by a newer search"}`. It is
deliberately not a 429: desktop arms its relay-wide admission gate on
any 429, which would hold every relay request for 10 s, and the CLI
retries a `rate-limited:` 429. Its future is dropped, so #8954's
cancel-on-drop cancels its statement on the server.
- **Newest wins.** The newcomer is never rejected, because the newest
keystroke is the one the user is waiting for.
- **The cap is above 1.** Sessions that share a pubkey keep their
parallel searches: several agent sessions, or people search and message
search running side by side.
- **Bookkeeping lives in process.** `SearchSlots` holds a
`Mutex<HashMap<key, VecDeque<(id, CancellationToken)>>>`. A slot removes
itself by id when its search finishes, fails, is dropped or is
superseded. So a superseded search can never remove the newer search
that replaced it, and a user's entry is removed when their last search
leaves.
- **A search that finishes as it is superseded still returns its
result.** The `select!` is biased toward the search.
The limit is per relay process, not global. That is enough to bound how
much one user can pile onto one process's search pool.
## What got simpler
Nothing was removed. The change adds one small, self-contained module
and one call site. It also wires the new tests into the unit lane
(`Justfile test-unit` and `scripts/run-tests.sh`). The relay's unit lane
is an allowlist, so without that wiring the tests would run nowhere in
CI.
## Tests
`api::bridge::search_slots::tests`:
- `the_search_past_the_cap_supersedes_the_oldest`
- `a_finished_search_frees_its_slot`
- `a_superseded_search_leaves_its_successor_in_place`
- `no_entry_outlives_its_searches`
- `a_superseded_search_is_a_conflict_not_back_pressure`
Each of these mutants made at least one test fail:
- Removing the cap.
- Replacing the by-id removal with `pop_front`.
- Leaving empty user entries in the map.
- Answering supersession with 429.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
949f61dfab | fix(search): cancel abandoned searches and bound the search pool (#8954) | ||
|
|
8f83c63209 |
fix(db): preserve partition reader grants (#8485)
## Summary PostgreSQL does not copy parent table grants to new partitions. A reader can query the `events` parent but fail direct partition reads after the relay creates another month or replaces its catch-all. Copy each managed parent's explicit table-level `SELECT` grants to its new children inside the existing maintenance transaction. The parent ACL supplies the policy; there is no new setting or deployment-specific role name. Writes, column-only grants, owner entries, and grant options are excluded. A grant failure rolls back the table's partition changes. Existing children and manually created or attached partitions still need grants from database provisioning. Apply the [companion initializer fix](https://github.com/squareup/bb-utility/pull/2253) for existing partitions before relying on automatic grants for future ones. ### Related issue No duplicate found. The closest related PR is #8464, which enables catch-all advancement by default; this change independently preserves reader access during that maintenance. ### Testing No manual testing. Production rollout and the Fivetran setup check remain pending. Generated with Codex Signed-off-by: coder2 <4acc49cb93576448ba82e631688c51a40bf03d6a889d78fddf57917d198de10f@buzz.block.builderlab.xyz> Co-authored-by: coder2 <4acc49cb93576448ba82e631688c51a40bf03d6a889d78fddf57917d198de10f@buzz.block.builderlab.xyz> |
||
|
|
5f8d0aab98 |
fix(relay): refuse a second listed open channel with the same name (#8782)
## Summary
Two open channels in one community could have the same name, so the
channel list showed two `#square-ios` rows and people could not tell
them apart. Now the relay refuses to create, rename or open up a channel
when that would give it the same name as another channel in the channel
list, ignoring case. The client gets `invalid: an open channel named "…"
already exists`.
The rule follows Slack: an archived channel keeps its name until it is
deleted. Private channels, DMs and unlisted channels may still repeat
names. So a refusal never reveals that a private channel exists.
The PR also adds a script that renames the duplicates that exist today.
It sends each rename through the relay, so relays keep serving while it
runs. The stacked PR https://github.com/block/buzz/pull/8787 then adds a
database index that enforces the rule. This PR is the release that must
be fully deployed before that index.
## Details
**Which channels the rule covers.** A *listed* channel: open, not
unlisted, a stream or forum, not deleted. Archived channels are
included. The check runs on create, rename, private → open and unlisted
→ listed.
**Relay check** (`crates/buzz-db/src/store/channel.rs`,
`crates/buzz-relay/src/handlers/ingest.rs`):
- The check runs after the write, in the same transaction, under a
per-name lock. So two concurrent writers cannot both commit the same
name.
- A refused create or edit (kind:9007 or kind:9002) gets `OK false
"invalid: an open channel named … already exists"`. Nothing from it is
stored.
- The relay gives the same answer when the database index from
https://github.com/block/buzz/pull/8787 refuses the write. That covers
the rollout, and a rollback from that release.
- A channel create with no `h` tag (the new channel's id) is now refused
before it is stored. Before, such a create ran after the event was
stored, so a refusal (for example, a taken name) still answered
"accepted". All first-party clients send `h`.
**Operator rename** (`crates/buzz-relay/src/handlers/side_effects.rs`).
The rename script needs to rename channels it does not own, archived
ones included. So a relay operator may now send a channel edit that
changes only the name of a listed channel. Everything else still needs
the channel's owner or admin.
- "Operator" means `RELAY_OPERATOR_PUBKEYS`, the owner fallback, or an
`operator` row in `relay_operators`. A moderator is not an operator.
- The rename also applies to an archived listed channel, whatever the
operator's role in that channel, and the channel stays archived. Other
edits of an archived channel are still refused.
- Private channels, unlisted channels and DMs still need the channel's
owner or admin. The relay decides "listed" under the channel row lock,
so a channel made private during the rename is not renamed.
- The rename takes the normal edit path: the name check, the stored
event, the channel metadata event and fan-out.
**Rename script**
(`scripts/maintenance/rename_duplicate_listed_channel_names.sh`, with
the read-only plan `plan_duplicate_listed_channel_renames.sql`):
- In each group of listed channels that share a name, the oldest
unarchived channel keeps the name. Each other channel becomes
`<name>-<first 8 chars of id>`, or `<name>-<id>` when that is also
taken. When all the channels in a group are archived, the oldest keeps
the name.
- **Starter channels.** Released desktop and mobile apps create
`#general` and `#welcome-everyone` when a community has no open,
unarchived stream with that name. Under the new rule, the relay refuses
that create while an archived channel has the name, and the apps' setup
fails. So the script also renames `general` and `welcome-everyone` when
every listed channel with that name is archived. That frees the name for
the apps.
- The apps create each starter at a fixed id, derived from the relay's
address. If a channel already has that id, the relay refuses the create
there whatever its name, so a rename cannot help. For that name the
script renames nothing, and an archived starter at its id keeps the
name. The script computes the ids from `BUZZ_RELAY_URL` (or
`BUZZ_STARTER_ORIGIN` when the apps use a different address) and prints
them, so the operator can check them against a client. A relay-side fix
for those clients is planned separately.
- The operator key goes in `BUZZ_PRIVATE_KEY`. It must be a relay
operator and a member of the community. The script stops at the first
auth refusal and says so.
- The script stops before the plan for an unknown host, an archived
community and a community being deleted, and says what to do.
- A failed rename (the new name is now taken, or the relay can't be
reached) is reported, and the script goes on. At the end it reads the
plan again and exits 1 if any rename remains. `--dry-run` prints the
plan.
- The script replaces the earlier direct-SQL repair and `buzz-admin
reconcile-channels --metadata`, which needed every relay pod stopped.
**Duplicates that exist today** (read on 2026-10-30):
`ml-platform-public` (`d41a31f8…`, `7be55f5a…`) and `square-ios`
(`79f8e9d4…` archived, `fe246d3e…` live).
### Rollout
The full procedure, with exact SQL and recovery steps, is in
`docs/channel-name-uniqueness-deployment.md` in
https://github.com/block/buzz/pull/8787.
1. **Deploy this PR.** Wait until no older relay pod serves; check the
image of every pod. An older relay can still create a duplicate, and it
refuses the operator rename.
2. **Rename the existing duplicates in each community**, with relays
serving. The key must be an `operator`, not a moderator, and a member of
the community (`RELAY_URL=wss://<host> buzz-admin add-member --pubkey
<npub>`).
```sh
DATABASE_URL=postgres://... BUZZ_PRIVATE_KEY=<operator key> \
scripts/maintenance/rename_duplicate_listed_channel_names.sh --dry-run
<community host>
# review the plan, then run it again without --dry-run
```
For the duplicates known today, the newer `ml-platform-public` and the
archived `square-ios` (`79f8e9d4…`) get the `-<id8>` suffix.
3. **Confirm that no duplicate remains** in any community (query in the
doc).
4. **Build the index, then deploy
https://github.com/block/buzz/pull/8787.**
Rollback from https://github.com/block/buzz/pull/8787 goes to this
release only, keeping the index, with auto-migration off.
### Related issue
None found.
### Testing
- **Live local relay.** The script ran against a local relay built at
|
||
|
|
322fdbf086 |
test(relay): hold the replica's second aux page and move media's DB tests (#8740)
## Summary Two buzz-relay test fixes from the 23–30 Oct flake audit. Both are test-only. This is one of six PRs split from the reviewed `flakey-tests` branch (siblings below). ### `thread_window_bridge_restarts_both_aux_hops_after_replica_failure` (3 runs) - **Runs:** 40799617319, 40924179886 and 40930100994 (#8697, docs only), all on 10-29 in PostgreSQL Tests, attempt 1 (jobs 123208043033, 123632125866, 123651641392). None of the branches touches thread_window, and each run passed on a re-run. Signature: `failure_postgres_tests.rs:46`, "barrier must precede closure completion", left: 2, right: 1. - **Cause:** `pause_after_aux_page` polls the request until a `thread_window_aux` span closes, then expects exactly one. The fixture's 1,001 reactions make two replica pages at `AUX_LIMIT` 1000, and each page is one round trip on the transaction already held. A loaded runner can deschedule the test thread after page 2's query is written. Postgres then answers first, and the same poll reads page 2 too. - **Change:** `test_support` gains a `PausingProxy`, a trimmed copy of buzz-db's private one with only the hold-everything switch. The replica reader pool connects through it. `AuxPages` pauses it when the first aux span closes. That runs inside the request's poll, after page 1's response is consumed and before page 2's query is written, so page 2 stays pending however fast Postgres answers. The test resumes the proxy after it terminates the reader's backend. The held page then sees the termination, and the closure restarts on the writer, as before. The other three `pause_after_aux_page` callers read from the writer and pass `None`. ### `api::media` tests that need Postgres (fail every time Flake Check selects them) - **Run:** 40954304982 (#8658), 10-29, Flake check (Rust), job 123730434146. All six `test_state()` tests panic at the community-seed `expect` after the 30 s sqlx acquire timeout. - **Cause:** when a hunk lands outside a test body, Flake Check selects every test in the file. It skips only `postgres_tests::`, `postgres_*` binaries and `external_infra`, and its job has no database. These six tests seed `relay.example` through Postgres but sat in plain `mod tests` without `#[ignore]`. So any PR that changes non-test code in `media.rs` fails that lane, and at least 8 open PRs touch the file. No other lane ran these tests either. - **Change:** move `test_state`, the `media_get_*` helpers, `media_request` and the six tests into `tests::postgres_tests`. Mark each test `#[ignore = "requires Postgres"]`, and point `test_state` at `test_support::database_url()`, as the module's other states do. The PostgreSQL lane now runs them, and Flake Check skips them. The change also drops imports `mod tests` no longer uses, and updates the Justfile comment that gave `api::media` as the reason the infra-free lane is scoped to `api::admin`. - When `media.rs` is staged, the pre-commit secret scan reads the whole file and flags the module's existing public ES256 test key (`TEST_EC_PKCS8_PEM`). The key is now written on one line with a sadscan marker, as `invites.rs` already does. Its bytes are unchanged. ### Related issue None found. **Conflicts with #8451 and #8444**, which both already conflict with `main`. The one-line `TEST_EC_PKCS8_PEM` rewrite is the only new conflict hunk with each. The test move merges cleanly with both. ### Testing Verified locally: - Each of the four `pause_after_aux_page` tests passes when run alone against local Postgres through `scripts/postgres-test-run.sh`. - A forced check, not committed: after the barrier, the test gave page 2 another 500 ms over ten re-polls and asserted the page count stayed at 1. It passed with the pause and failed with `None`, with the CI signature (left: 2, right: 1). A true same-poll burst needs the OS to preempt between sqlx's write and read, so it was not forced directly. - `check-postgres-test-discovery.py` accepts the touched files and all 621 files under `crates/`, and `scripts/test-postgres-test-discovery.sh` passes. Each of the six media tests passes when run alone against local Postgres. - `cargo fmt --check` and `cargo clippy -p buzz-relay --all-targets -- -D warnings` are clean under 1.98.1. Only hosted CI can show the full lanes, which were not run locally. The six media tests have never run in CI, so this PR's PostgreSQL lane is their first hosted run. Pushed with `git push --no-verify`, because the pre-push suites fail when the laptop is under load. ### Readiness An agent review of the combined `flakey-tests` branch (b432453af) found no blockers. `buzz-review-completed` is not added, because the human test step (AGENTS.md "Before opening a PR", step 3) hasn't happened yet. ### Siblings - #8739 ci: Hermit fetch deadline and sccache fallback - #8741 test(mobile): RunnerTests title fade and alert wait - #8742 fix(desktop): deep-linked message hold (product change) - #8743 test(desktop): four specs racing scroll settles and focus - #8744 test(desktop): five settle classes and remote-owned-mentions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
cde6c1bf5b |
feat(relay): auto-join new community members to default channels (#8594)
## Summary Adds **default channels**: relay owners and admins can flag channels that every new community member is automatically joined into. **Setting (contract)** - New `channels.auto_join` column (migration `0080`, constant default `false`, so no table rewrite). - kind:9002 accepts `["auto_join", "true"|"false"]`. Authorization comes from the actor's **relay** roster role (owner/admin), not a channel role, so the actor doesn't need to be a channel member. API tokens need `AdminChannels`. - Only open, unarchived `stream` channels qualify. This is checked under the channel row lock, so one event can open and flag a channel in one step, and a concurrent archive can't slip past the check. - kind:39000 carries a bare `auto_join` tag only while the setting is effective (on, open and unarchived). Every change posts one `auto_join_changed` kind:40099 notice. - SDK `build_update_channel(.., auto_join)`, `buzz channels update --auto-join <true|false>`, a NOSTR.md "Default channels" section, and a NIP-CL tag-table row. **Behaviour** - kind:9021's body is pulled out into `join_channel_as_self`, so auto-join uses the same path as a self-join: the channel's `join_role` applies, and it emits `member_joined` 40099, 39000/39002 and 44100. - `auto_join_default_channels` runs inline right after a member is added at three sites: the v2 invite claim, the v1 invite claim, and kind:9030 add. It does not run on login or reconnect, so a later kind:9022 leave stands. - Best effort: each failure is logged and counted (`buzz_auto_join_failures_total`) but never retried, because the roster membership has already committed. This deliberately departs from Review-Proven Rule 1 and is documented in NOSTR.md. `buzz-admin add-member` writes the roster row only. **Communities without a membership roster** (`BUZZ_REQUIRE_RELAY_MEMBERSHIP=false`) - These have no invite claim or kind:9030 to hook, so the three sites above join nobody there. Instead, after the AUTH `OK`, the relay joins a person to every effective default channel they have **never had a membership row in** (`auto_join_never_joined_channels`, spawned so admission never waits on it). - The channel's own membership history is the once-only record, so this adds no new table, column or migration. A kind:9022 leave or a kind:9001 removal keeps the row with `removed_at`, so the person stays out. A new device or another relay pod finds the row and does nothing. `put_first_member` checks the row again under the `put_member` locks, so two admissions racing each other join once. - A channel flagged later is joined on each person's next admission. A failed membership write leaves no row, so the next admission retries it. - Each channel's join holds the session's NIP-FI effect permit, so once the socket closes or its admission expires, no further channels are joined. - Agents are skipped: any socket with an admitted owner (a NIP-OA tag, a NIP-FI agent token, or a stored `agent_owner_pubkey`). - The kind:9021 side effects moved into `announce_self_join`, so both join paths emit the same `member_joined`, 39000/39002 and 44100. **Review follow-ups** - `get_accessible_channels` now selects `auto_join`. Without it, every call failed with `ColumnNotFound`. - The final authorization branch of the 9002 validator now refuses by default. `list_auto_join_channels` filters to `stream` channels itself. ## Testing - Postgres-lane journeys: - `edit_metadata_postgres_tests`: who may set `auto_join`, the 40099 notice, the effective-state tag, and the refusals. - `claim_auto_joins_default_channels_once`: v2 and v1 claims, and leave then re-claim. - `adding_a_member_auto_joins_default_channels`: kind:9030 add. - Removing the auto-join call at any one of the three sites fails the matching test (checked by mutation). - `root_auth_joins_people_to_default_channels_without_a_roster`: through `handle_auth` on a roster-less community. A person is joined, a NIP-OA agent is not, and a leave holds when the person is admitted again. Mutations checked: removing the spawn, and dropping the agent check. Each check waits for that admission's own auto-join outcome (a skip reports through the same test hook as a finished task) and fails if none arrives. A third mutation drops the agent check and delays the task 3s; it still fails. - `admission_auto_join_stops_when_the_session_ends`: closing the socket before the first or second channel stops the remaining joins; a later admission joins what was skipped. Mutation checked: dropping the effect permit. - `first_member_put_joins_once_and_never_undoes_a_leave` (buzz-db): the person is joined once with the channel's `join_role` (`guest` on an announce channel), a second put does nothing, after a leave the channel is neither listed nor joined, and two racing puts write exactly once. Mutations checked: removing the row check, removing the channel lock, and removing the `NOT EXISTS`. - e2e `test_invite_claim_auto_joins_default_channel` against a local relay. - Unit coverage in the SDK and CLI builders and `group_metadata_tags`. ## What got simpler The roster-less path adds no schema and no new concept: it reuses the channel's membership history as its record. It also shares the kind:9021 join code. It replaces the per-device client workarounds, [buzz-app#2029](https://github.com/block/buzz-app/pull/2029) and [buzz#8795](https://github.com/block/buzz/pull/8795), which are now closed. ## Related - Client half (buzz-app): https://github.com/block/buzz-app/pull/1624 ## Demo Recorded against a local relay built from this branch with throwaway identities (Alice = owner, Bob = new member); no real accounts or hosted relays involved. Relay at 141aa01d4, client at buzz-app bb0d97d86 (browser dev build). **Owner turns on "Starter channel for new members" on two channels.** Alice creates `#general` and `#welcome`, then flips the switch on each (~0:25 and ~0:41). The switch greys out while the kind 9002 is in flight and only turns on once the republished 39000 carries the `auto_join` tag. https://github.com/user-attachments/assets/e590d343-11d3-4385-af43-2cfaf19f8468 **A brand-new member joins and lands in a starter channel.** Bob claims Alice's invite with empty storage; from ~0:18 he is reading `#welcome` with both `#general` and `#welcome` in the sidebar, and at 0:27 opens `#general` as a member with no Join prompt. The relay logged one `auto-joined default channel` line per flagged channel. https://github.com/user-attachments/assets/037a0aaa-b25d-474d-a316-8d25e5e54f89 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com> Signed-off-by: tornquist <tornquist@squareup.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> |
||
|
|
7d00c70805 |
feat(relay): publish NIP-FI verified profiles (BUZZ-433) (#8774)
## Summary Publish historical verified names from valid human NIP-FI assertions as relay-signed kind 30382 events. Keep profile publication optional and separate from access decisions. Disconnects and access changes initiate asynchronous cleanup without retracting profiles verified after the original trigger time. Publish `display_name` as the chosen short display handle and `username` as the account handle. Carry optional `full_name` in the signed assertion into a matching kind 30382 tag. Adding, changing, or removing the full name updates the profile immediately; retractions remove every name field. Older profiles without `full_name` remain valid. Adds opt-in issuer labels, discovery, and schema migration 0079. Deploy and drain all access writers before enabling publication; use a stable relay key and a shared publication-policy revision. Update clients before enabling assertions with `full_name`. ### Related issue Implements the Relay portion of [the verified-profile specification](https://github.com/block/buzz/pull/8745) at `500829a9` (BUZZ-433). This is the closest existing PR found. Companion client: https://github.com/block/buzz-app/pull/2005. Companion issuer: [KGoose/bb-identity #144773](https://github.com/squareup/cash-server/pull/144773), which includes `nostr_public_profile` in valid human assertions. ### Testing Passed the lifecycle test against a disposable local PostgreSQL database, including full-name addition, change, and removal: `cargo test -p buzz-db store::verified_profiles::postgres_tests::profile_lifecycle_retains_assertion_ordering --lib -- --ignored --exact` with `BUZZ_TEST_DATABASE_URL` set to that database. Independent agent review found no remaining blockers. No live Relay test or human verification yet. This remains a draft and does not attest the repository's human-testing checklist. For end-to-end testing, enable publication for a test issuer, admit a member with a signed profile claim, and query kind 30382. Verify short display handles, optional full names, changed names, missing-claim retraction, disconnect cleanup, and a later admission surviving delayed cleanup. Check the companion app against the same relay. Generated with Codex --------- Signed-off-by: Franco Sola <fsola@squareup.com> Signed-off-by: Bradley Axen <baxen@squareup.com> Co-authored-by: Bradley Axen <baxen@squareup.com> Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Codex <noreply@openai.com> |
||
|
|
b7b545f090 |
test(db): drop scratch databases and roles even when a test fails (#8747)
## Summary `buzz-db` tests that create their own databases or roles dropped them as the last line of the test body. A failed assertion or a timeout skipped that line and left the database or role on the shared Postgres server. Seven files carried their own copy of `create_scratch_db_through` and its drop helper. - Adds `ScratchDb` and `ScratchRole` guards to `crates/buzz-db/src/test_support.rs`. Their `Drop` runs `DROP DATABASE IF EXISTS ... WITH (FORCE)` or `DROP ROLE IF EXISTS` on a fresh admin connection, on a separate thread with its own runtime. `ScratchSchema` already works this way, so the guards work under both runtime flavours and while a panicking test unwinds. `WITH (FORCE)` ends any sessions still open on the database, so pools need not be closed first. - A failed drop prints a warning instead of panicking, because a panic during unwinding would abort the test binary. - The seven per-file helpers and every end-of-test `DROP DATABASE` in `buzz-db` now use the guard. The replica-fence masked-activity test's least-privilege role uses `ScratchRole`. Admin pools and `close()` calls that only served cleanup are removed. Test behaviour is otherwise unchanged. - `crates/buzz-db/TESTING.md` now points new tests at the guards. - The second commit fixes a comment in `armed_pool_rejects_old_channel_inserts_through_public_api` that still described the old end-of-test drop. **Existing leftovers need dropping by hand.** This change stops new leftovers but does not remove the ones earlier failed runs already left in dev `buzz-postgres` containers. There were 24 on the author's machine, named `stall_r_*`, `buzz_admin_*`, `floor_probe_*`, `fence_*` and `cxl_*`. List them, check the list, then drop each one: ```sql SELECT datname FROM pg_database WHERE datname ~ '^(stall_r|buzz_admin|floor_probe|fence|cxl)_'; DROP DATABASE "<name>" WITH (FORCE); ``` The guards are private to `buzz-db`. `buzz-push-gateway`, `buzz-admin` and `buzz-relay` tests still create and drop scratch databases by hand, which could get a shared copy of the guards in a follow-up. ### Related issue None found. This continues the test-cleanup work in #8727. ### Testing - Ran the 70 affected `buzz-db` tests against the local `buzz-postgres` container: 68 passed. Neither failure comes from this change. `cluster_global_probe_commits_tokens_and_sessions_prove_coverage` hit the known `MaskedActivity { masked: 1 }` flake under load and passed when run alone. Because it panicked while holding a `ScratchDb`, it also showed a panicking test leaves nothing behind. `serving_lease_statement_timeout_cancels_slow_statement` failed with `VersionMismatch(56)`, because the shared dev database already has another branch's migration 56 applied. - After the run, the server's database and role lists matched the snapshot taken before it. - A temporary test panicked while holding an open transaction, an exclusive table lock and a live session as the scratch role. It left no database or role behind under either runtime flavour and printed no drop warnings. That test is not part of this PR. - `cargo fmt --check` and `cargo clippy -p buzz-db --all-targets -- -D warnings` pass. The tests ran before the commits were cherry-picked onto current main. Only clippy was re-run afterwards. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
d14e95f90e |
test: stop desktop, buzz-agent and mobile tests leaking temp files (#8748)
## Summary Six test files wrote into the shared temp dir on every normal run and never removed what they wrote. **Desktop and buzz-agent (first commit)** - `desktop/src/protectedFeatures/tauriCommand.test.mjs` wrote its fake Tauri CLI and three result files straight into `/tmp` under pid-suffixed names. They now live under one `mkdtempSync` root that an `after()` hook removes. - `crates/buzz-agent/tests/permission_boundary.rs` wrote a `buzz_perm_calllog_*` file for every test whose tool call reached `fake_mcp`, five per run. `call_log_path` now returns a `TempDir` guard with the log path inside it, so the log is deleted when the test ends, including when an assertion panics. - `cancel_kills_inflight_tool_via_mcp_notification` in `crates/buzz-agent/tests/regressions.rs` spawns `buzz-dev-mcp`, which the agent stops with SIGKILL, so the server's `buzz-dev-mcp-*` and `buzz-dev-mcp-session-*` TempDirs were never deleted. The test now passes the server a `TMPDIR` it owns and removes. **Mobile (second commit)** The three mobile test files left 12 `buzz-video-*` files and 4 `transcode_*` dirs per run. - `video_viewer_test.dart` and `video_controls_test.dart` pointed their fake `path_provider` at `Directory.systemTemp`. The viewer downloads each video there as `buzz-video-*`, and its async delete on close never completes under FakeAsync. Each test now gets its own temp dir for the fake, and an `addTearDown` removes it with `deleteSync`. In `video_controls_test.dart` that teardown is registered first, so it runs after the viewer is disposed. - Four `media_upload_test.dart` tests faked `transcodeVideoToMp4` by writing `out.mp4` into a new `systemTemp` `transcode_*` dir. The upload service deletes `out.mp4` but not its dir. The fakes now create that dir under the test's own temp root, which the test already deletes in `finally`. Production code is unchanged. **`buzz-dev-mcp`'s SIGKILL shutdown is left for a follow-up:** the agent still stops the server with SIGKILL, so outside tests the server's temp dirs are still left behind. Sending SIGTERM first is the real fix. ### Related issue None found. This continues the test-cleanup work in #8727. ### Testing Each run below was bracketed by listings of `/tmp` and the per-user temp dir (`/var/folders/.../T`), so new leftovers would show up. - `tauriCommand.test.mjs`: 3/3 pass, with no new `buzz-tauri-*` or `buzz-fake-tauri-*` entries. - `cargo test -p buzz-agent --test permission_boundary`: 9/9 pass, with no new `buzz_perm_calllog_*` files. The tests still check that the call log is written, so the log really is going into the guarded dir. - `cancel_kills_inflight_tool_via_mcp_notification` passes with `buzz-dev-mcp` built, so it ran rather than skipped, and left no new `buzz-dev-mcp-*` dirs. - Mobile: `video_viewer_test` (7 tests), `video_controls_test` (20) and `media_upload_test` (38) pass, each run on its own. With `TMPDIR` pointed at a scratch dir, the old versions left 12 `buzz-video-*` files and 4 `transcode_*` dirs, and the new versions left it empty. - After cherry-picking onto current main, these pass: - `cargo clippy -p buzz-agent --all-targets -- -D warnings` - biome and `node --check` on `tauriCommand.test.mjs` - `dart format` and `flutter analyze` on the three mobile files 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
4cf2a3bd66 |
feat(loadgen): add a hot-thread stressor and teardown (#8746)
## Summary `buzz-loadgen` spreads load evenly: every channel gets the same history and the same short thread chains. That never puts heavy load on a single thread. A busy conversation, such as agents talking to each other, can give one root a huge number of replies, and thread-summary work grows with it (see #8635). This PR adds an optional `[hot_thread]` scenario section so a load test can measure that case. - **Seed:** `seed` posts a hot root in community 0, channel 0, then `seed_replies` replies directly under it. The root comes from a dedicated identity that posts nothing else, so `run` finds it however many other roots the channel has. The replies go out concurrently, like the rest of the seed, so the run starts with the thread already large. - **Run:** `authors` hot-thread clients are scaled by each ramp step's `users`, the same way humans and agents are. Each one replies to the hot root over HTTP `/events` and reads the thread's newest 100 replies over HTTP `/query`, at `replies_per_hour` and `fetches_per_hour`. - **Metrics:** two new report ops, `hot_reply` and `thread_fetch`. The replies are timed, so humans subscribed to that channel also record delivery latency for them. - **No root, failed run:** an author that can't find the seeded root counts a `thread_fetch` error on every attempt, so a run without the hot thread fails instead of quietly measuring nothing. - **Off by default:** the section is disabled while `seed_replies` and `authors` are both 0, so existing scenarios are unchanged. `perf/scenarios/local-smoke.toml` gets a small hot thread, and `perf/LOADGEN.md` documents the knobs. ### Teardown The PR also adds `buzz-loadgen teardown <scenario>`, so a staging fixture can be removed after the load test. - For each community from `target.community_host_template`, it first calls `POST /operator/communities/archive` as the fixture owner, using the operator key `seed` uses. It then calls `POST /operator/communities/delete`. - The deletion `request_id` is derived from `run_id` and the host, so a re-run replays the same requests. That retries failures and reports progress without a `deletion_request_conflict`. - The relay's deletion drain does the actual deletion asynchronously. - If the fixture owner does not own a host, the host is reported `absent` and left untouched. - A scenario without a host template is refused. - Deleted hosts stay as tombstones, so `LOADGEN.md` says each fixture needs a fresh `run_id`. The signing has to happen as each derived owner, which is why this lives in the generator rather than in deployment config. ## What got simpler Nothing got simpler; this adds a scale vector. To keep it small, the PR reuses what's already there: - the seeder's post plan, plus `find_roots` with a content prefix - the existing HTTP helper, whose auth tag is now optional - humans' delivery measurement A separate chain-depth knob and dedicated viewer clients were left out. `shape.thread_depth` already seeds chains up to the relay's depth cap, and humans in the hot channel already receive the replies. ## Testing - `loadgen::seed::tests::the_hot_thread_seeds_direct_replies_under_its_own_root` checks that the hot root and its replies are seeded, that every reply is direct (depth 1), and that a rejected root fails the seed. - `loadgen::run::tests::hot_thread_authors_reply_to_the_hot_root_and_fail_without_it` checks several things: - authors post replies tagged to the hot root; - root lookups query only the hot-root identity, and a newer reply-shaped decoy is never taken as the root; - scheduled reads all target the hot root's channel and id; - the run fails when the root isn't seeded. - `loadgen::run::tests::hot_thread_authors_start_nothing_once_the_run_stops` runs 8 hot authors directly. It holds every in-flight `/events` reply at a gate until the stop is signalled, so each author's next reply is already overdue when the gate opens. It then checks that exactly 8 replies were posted, none started after the stop. Removing the post-`select!` stop check fails the test, and the gate is released before any assertion, so a failure can't hang the job. - The run now signals the stop before the last step's snapshot instead of after building that step's report. Under a loaded parallel test run, work started in that gap leaked into `shutdown`. - `loadgen::teardown::tests::teardown_deletes_only_the_fixture_owners_communities_and_replays_on_rerun` runs teardown twice against a fake operator that checks the owner, requires archive before delete, and conflicts on a different request id. It checks that: - the owned host is archived and deletion-requested; - another owner's host and an unseeded host are reported absent and left untouched; - a 503 on archive is reported as a failure; - the second pass converges to the same result; - a deletion already past membership purge, where archive returns 404, is replayed and reported at its stage under `requested`; - the same run under another host template gets its own request ids: another owner's host is reported absent rather than conflicting with a request id already used. If the request id is replaced with a fresh one per run, the test fails. - `loadgen::teardown::tests::teardown_refuses_a_scenario_without_templated_communities` - All of these run in the existing `loadgen::` unit lane. - Teardown was run end to end at `28192004` against a local relay. The relay had operator keys configured, local S3 behind it, and `buzz-admin deletions drain`. The run covered: - seeding three templated communities; - teardown, with every fixture archived and a deletion request at its derived id; - a re-run that converged; - the drain, which tombstoned the fixtures; - teardown again after the purge, which reported each fixture at `retention_pending` and exited 0; - a re-seed of the torn-down run, which failed at provisioning. A community outside the fixture stayed active, with no deletion request. - At `28192004`, `cargo fmt --check`, `cargo clippy -p buzz-test-client --all-targets -D warnings` and six parallel runs of `cargo test -p buzz-test-client` pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a56966b777 |
feat(relay): NIP-AR sync subscriptions (#8522)
🤖 ## Summary Clients that show a list of artifacts (tasks, config entries and similar records) had no way to get the complete list and then stay current. They could only page through query results, and a page is not proof that the list is complete. This PR adds **sync**. A client sends one subscription and gets every matching artifact as one consistent snapshot, then a signal that the set is complete. After that it gets the current version of each artifact that changes. When the relay cannot guarantee an exact copy, it closes the subscription with a `resync` reason, so the client never misses a change without knowing it. It also adds a **head lookup**. It returns the current version of named artifacts, deleted ones included, so that "create or restore" flows can retry safely. This implements the Sync section of NIP-AR (the artifact protocol spec), which is the first commit of this PR. ## Details - **Request.** A normal `REQ` whose only filter has `"artifact": "current"` and at least one channel in `#h`. This is the same current-state filter as HTTP `/query`; on a `REQ` it becomes a sync, because a `REQ` already means "stored set, `EOSE`, then live". The client does not page: the relay streams the whole set until `EOSE`, so `limit` and `offset` are refused. Other artifact views (`history`, `head`) stay HTTP-only, as on main. Other clients and other `REQ` shapes do not change. - **Initial set.** The relay first registers for live changes and waits until Redis confirms that registration. Then it reads every matching current version in pages ordered by artifact ID, all inside one read-only `REPEATABLE READ` transaction. So all pages come from the same database snapshot. `EOSE` comes only after a complete read. A failed read, an unreadable channel, or a read that takes too long ends with `CLOSED` and no `EOSE`. - **Live changes.** Fan-out never sends the published event to a sync. It only records which artifact changed. The sync then reads that artifact's current version and sends it, one artifact at a time. So a client never gets an older version after a newer one. Changes that arrive during the initial read are held and sent after `EOSE`. Changes are signalled per channel, so a sync skips an artifact whose `type` or `d` does not match its filter: those never change, so it was never in the set. A board that syncs three record types in the same channels no longer gets each change three times. - **When the relay sends `resync`.** The Redis link drops, reconnects, or skips a message it cannot decode. Local fan-out lags. The connection's send queue is full. A moderator redacts an event in the channel. The reader's channel access changes (remove, leave, metadata edit, channel delete). An access change ends the whole sync, also when the sync names other channels. A sync cannot drop one channel and still claim an exact set. - **Publish retry.** When a Redis publish fails for an event kind that syncs use, the relay retries it in the background until it succeeds. - **Relay info (NIP-11).** The relay now lists `sync` (`max_events: 4096`), adds the `head` mode, and sets `websocket_query_extensions: true`. - **No migration.** Sync reads the existing `artifact_heads` table. - **Next to incremental reads (#8523).** #8523 added a separate HTTP protocol, `artifacts.incremental` / `"artifact":"incremental"`. It keeps a saved store up to date after a disconnect, but it does not prove a complete set. The two protocols use different NIP-11 keys and filter values, so they do not collide. They share one copy of the current-state access rule: `artifact_query::predicates`, which #8523 reads through, now applies the view join and calls `push_scope`, and the sync snapshot and head lookup call `push_scope` too. - **A failed access lookup in fan-out resyncs.** The access gate fails closed when a visibility or membership lookup fails. For a sync, that would drop a change with no signal, so the gate also reports those recipients and each one gets `resync: access check failed`. ### Known cost To find out which redactions remove an artifact that a sync covers, the relay would need an extra database read. So every redaction in a synced channel resyncs that channel's syncs, and so does every channel metadata edit. Each resync reads the set again, up to 4,096 artifacts. This is correct, but it adds load to channels where people often delete chat messages. We can narrow it later if it becomes a problem. ### Related issue None found. The design was reviewed as NIP-AR Sync r28. ### Testing - New Postgres tests: keyset pages see one snapshot, and an artifact created after the snapshot opens is in no page; the head view includes deletions; a private or unknown channel is refused. - New relay end-to-end tests on real Postgres and Redis, using the real `REQ` handler, ingest and fan-out: initial set in order, then `EOSE`, then current versions for an update and a delete; a chat message causes no frame; `CLOSE` stops the sync. Other tests: removing the reader from one channel resyncs the whole sync; an unreadable or unknown channel gets `CLOSED restricted` with no `EOSE`; a node-wide loss resyncs. - Unit tests for the parser, request routing, fan-out diversion, the held-change limit, and access-change signals that must arrive before the access filter. - Lifecycle tests from an adversarial review: every frame is checked against the subscription owner, also when a `CLOSE` or a replacing `REQ` arrives while the frame waits for queue room or while a live head read is in progress. A resync recorded while `EOSE` waits ends the sync with no `EOSE`. The held list refuses a change before it stores it (4096 entries, 4 MiB of held removals). - End to end: a relay container built from this branch, with the buzz-app #1523 browser build and the Work plugin. An edit from another client showed in the open board 4 to 25 ms after the write returned, with no refresh. - `cargo clippy --all-targets -D warnings` is clean for the four changed crates. All `buzz-relay` lib tests pass except `api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo`. That test returns 504 on this machine. It is a mesh networking test, and this PR does not touch that 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> |
||
|
|
a57349b237 |
feat(relay): add incremental artifact reads (#8523)
## Summary Buzz clients need to recover task changes after a disconnect. This adds incremental reads of changed artifacts with database modification timestamps and a 120-second overlap. Each result contains the current revision or an explicit removal. A separate read of known IDs confirms accepted edits and restores access state. Pages use a timestamp and a stable ID to handle equal timestamps. Access grants include older records. Access policy changes require discovery and revalidation of saved IDs. The reader does not load a list of all readable channels. Ordinary artifact writes update their own rows. ### Incremental reads and WebSocket Sync This HTTP protocol maintains an eventually consistent local store. It uses `artifacts.incremental` and `"artifact":"incremental"`. It does not prove a complete set at one database snapshot. Absence never proves deletion or loss of access. [WebSocket Sync](https://github.com/block/buzz/pull/8522) serves a separate use case: a complete set at `EOSE`, followed by live updates. It owns `artifacts.sync`. Both protocols can coexist. The second PR to merge must reuse the shared access helper in `artifact_query.rs` when it resolves the overlapping edits. The gate is `BUZZ_ARTIFACT_INCREMENTAL_ENABLED`. Errors use the `incremental_` prefix. Unlisted open channels require membership or an explicit `#h` scope. Listing changes reset incremental progress. Incremental reads exclude TTL artifacts. Known-ID reads return `remove` for them. The `#h` scope limits candidates before pagination. Initial reads omit deleted heads. Later reads retain deletion removals. ### Why the new timestamps are needed `created_at` comes from the event signer. `received_at` records event arrival. Neither advances when access changes or an event is redacted. Sync needs database timestamps for those changes: | Column | Why sync needs it | | --- | --- | | `artifact_heads.modified_at` | Finds current records that change through an edit, deletion, or redaction. | | `artifact_revisions.modified_at` | Dates retained move records. This lets sync remove a task from its former channel when the reader cannot access its new channel. | | `channel_members.sync_modified_at` | Finds grants, rejoins, and removals. A grant must include older tasks whose own timestamps do not change. | | `communities.artifact_policy_modified_at` | Requests recovery after a relevant channel visibility, TTL, or deletion change. Clients revalidate saved IDs. Ordinary task writes do not update it. | The database sets these timestamps with `clock_timestamp()`. The 120-second overlap covers the accepted delay between timestamp assignment and commit visibility. `BUZZ_ARTIFACT_INCREMENTAL_ENABLED` defaults to off. Apply migrations `0076`–`0078` and upgrade all writers before enablement. The 120-second delay bound is an operating assumption. [Deployment steps](https://github.com/block/buzz/blob/drouleau/artifact-snapshots/docs/artifact-sync-deployment.md) cover index prebuilds and rollback. ### Related issue No duplicate found. Related work: [read permissions](https://github.com/block/buzz/pull/8338), [statement limits](https://github.com/block/buzz/pull/8461), and [atomic channel settings](https://github.com/block/buzz/pull/8516). ### Testing Signed HTTP E2E passes on `f3d6ac852e7ce4e2cc53bd891cef87832a868d72` after CI passes. It includes main through `ef4130da4`. A standalone local relay uses a fresh PostgreSQL 17 database, Redis, and signed NIP-98 HTTP requests. All 78 migrations apply through relay startup. All behavioral writes and reads use HTTP. The tests cover create, edit, delta reads, known-ID recovery, private moves, access grants, removals, rejoins, visibility resets, redaction, and deletion. Unlisted tests cover hidden broad discovery, named scope, known-ID reads, member access, and policy resets. They also cover bootstrap across two pages, overlap replay, and 101 move removals across pages. Decoded cursors contain no private revision IDs. With the incremental gate off, `artifacts.incremental` is absent and a signed incremental request returns `503 incremental_unavailable`. Unsigned requests are rejected. Signed tests also cover TTL exclusion, current-ID removals for TTL artifacts, moves into TTL channels, deleted initial heads, and scoped pages with exactly 100 and 3 upserts. A separate local scale test on `babcdb766` reads 10,050 tasks across 60,002 memberships. It uses seeded fixtures and a second relay state with the same signing key. The initial read takes 8.31 seconds over 101 pages with 4,923-byte events. A read with no changes takes 5.70 milliseconds. The candidate keeps the same database query. These are local measurements, not production limits. Cold pages repeat candidate scans and access checks. Writer load needs validation before fleet enablement. ```sh cargo test -p buzz-relay sync_downloads_ten_thousand_tasks_with_sixty_thousand_memberships -- --ignored --nocapture ``` The behavioral run uses `cargo build -p buzz-relay --bin buzz-relay`, then `cargo run -p buzz-relay --example artifact_incremental_final_oct30_f3`. The temporary harness drives the built relay and is removed after the run. The worktree is clean at the tested SHA. It does not exercise a native client. Git and S3 behavior are outside this validation. The [protocol](https://github.com/block/buzz/blob/drouleau/artifact-snapshots/docs/nips/NIP-AR.md#incremental-reads) defines progress, removals, limits, and recovery. Relay verification covers the signed HTTP path. Native client verification belongs to [the client PR](https://github.com/block/buzz-app/pull/1507). Generated with Codex --------- Signed-off-by: Daniel Rouleau <drouleau@squareup.com> |
||
|
|
cc04cdca1a | perf(relay): route author- and #p-scoped global subscriptions by selector (#8666) | ||
|
|
0e8fb49bd0 | test(relay): fix main's build after the test-state helper rename (#8718) | ||
|
|
ef4130da4b |
fix(db): keep lease releases durable and deflake PostgreSQL lane tests (#8676)
## Summary
When the user-deletion worker dropped a lease heartbeat, its pooled
connection could be left inside an open transaction. The worker's final
lease `release` then reported success but never committed, and a rerun
saw the request as "leased by another worker" for up to 60 s. The
heartbeat is now one autocommit `UPDATE`.
The PR also fixes six PostgreSQL-lane test flakes from the 21–28 Oct
2026 CI audit. It moves one relay test off the 100 ms-acquire test
helper before that test starts flaking the same way.
This is one of seven PRs split out of the `flakey-tests` branch
(siblings below). It touches only `buzz-db`, `buzz-deletion` and
`buzz-relay`. Every relay file except `connection.rs` and
`boot_lifecycle.rs` changes only for the helper rename in the last row.
### Related issue
None found. Nothing on `main` (
|
||
|
|
1893c3ab5a |
test: dedupe collect_until_eose by event id and uncap two shell tests (#8677)
## Summary Fixes two CI flakes from the 21–28 Oct 2026 audit, both in test code with no product change: - The relay E2E helper `collect_until_eose` now drops repeat copies of an event id. - Two Windows shell tests drop a 5 s cap that a cold Git Bash start can exceed. This is one of seven PRs split out of the `flakey-tests` branch (siblings below). ### Related issue None found for these two. The buzz-terminal `passwd_shell()` `getpwuid` race (`env_fence_tests::resolve_shell_falls_through_to_passwd_not_the_default`, run 38590095289) is left to open PR #7687, which already replaces `getpwuid`. #8636 and #7976 edit `buzz-test-client/src/lib.rs`, and #7607 and #7915 edit `buzz-dev-mcp/src/shell.rs`, but none of them changes `collect_until_eose` or these timeouts. ### Flaky tests fixed | Test | Failing runs | Cause | Change | |---|---|---|---| | Relay E2E `e2e_project::test_project_tombstone_deletes_coordinate_and_spares_members` (e2e_project.rs:338, "left: 2 right: 1") | 40408678412#1, job 121879630394 (PR #8620, schema-only) | The EVENT handler sends OK, then fans out in a spawned task. `handle_req` registers the live subscription before its history query, and `req.rs`'s `seen_ids` dedups history rows only. If fan-out runs after the REQ is registered, the event arrives once live and once from history, both before EOSE, and `collect_until_eose` kept every frame. | `collect_until_eose` keeps the first copy of each event id, as production clients do. Its doc comment says a test of the relay's own dedup must read frames with `recv_event`. A new unit test, `collect_until_eose_keeps_one_copy_of_each_event_id`, uses a fake relay that sends the duplicate. | | Windows `buzz-dev-mcp` `shell::tests::basic_echo` (shell.rs:1026, exit 124) | 39675698081#1, job 119485363399 (green on re-run) | 124 is the shell tool's own timeout exit code. `basic_echo` capped the shell at 5 s, and it is one of the first tests in the process to start Git Bash, which can take longer than 5 s on a cold runner. | `timeout_ms: None` (the production 120 s default) in `basic_echo` and in `workdir_is_honored`, which has the same exposure. `timeout_fires` still covers timeouts. | Pre-check: no e2e test relies on the relay's own history dedup. Every exact-count assertion uses a single filter. The NIP-33 "only newest remains" assertions would see a failed replacement as two different ids, and this dedup keeps both. Local evidence is from the original `flakey-tests` commit (fc4518d39): - The new unit test: FAIL before, PASS after. - 13 of the 14 e2e files that call the helper, plus `e2e_thread_roots`, against an isolated local relay. All pass, except `e2e_relay` `test_unarchive_emits_member_added_notification`. That test fails by itself 3/5 with the original helper too, and CI's Relay E2E lane doesn't select it. - The Windows failure can't be reproduced on macOS. The Windows Rust lane is the check. Follow-up (out of scope): make the relay deliver each event id at most once per subscription, keeping the live/history overlap. ### Verification This branch was pushed without local re-verification, by the user's choice: local test runs were overloading the machine and failing for that reason. **Hosted CI is the check.** It was pushed with `git push --no-verify`, because the pre-push hook runs clippy and unit tests. - Both files are byte-identical to their final versions on `flakey-tests` (67185df6a). - Checked for this PR: `git diff --check`, `rustfmt --check`, and a conflict-free merge with all six siblings onto `main`. ### Readiness Test-only change. AGENTS.md's readiness steps (agent review, then human confirmation) are still outstanding, so `buzz-review-completed` is not set. ### Merge order Independent of the other six. It merges cleanly onto `main`, and with every sibling, in any order. ### Sibling PRs Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease + lane), #8678 (mobile), #8679 (Tauri apt cache + Windows Rust cap), #8680 (desktop product races), #8681 (desktop E2E state gates), #8682 (desktop E2E composer + row actions). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
efcdf00101 | relay: open the search pool lazily so a dead reader can't block boot (#8662) | ||
|
|
4b21a10c4b |
fix: don't notify workflow owners through their attribution tag (#8694)
A workflow message names its owner in a marked p tag, ["p", owner, "", "owner"], for attribution. #p live, history and push matching, operator listeners, desktop, mobile and the iOS NSE treat that tag as attribution rather than a mention. A workflow that mentions its owner adds a plain p, so the owner is notified only when actually mentioned. NOSTR.md records the #p exception. Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Signed-off-by: Tom Brow <tomb@squareup.com> |
||
|
|
1f16f621d8 |
perf(relay): cut connect-burst CPU in log filtering and open-channel lookup (#8670)
## Summary
A cold relay taking a burst of new connections spent most of its CPU on
two things that don't scale with useful work. This PR removes both.
**Log filtering held a contended lock.** `EnvFilter` (tracing-subscriber
0.3.23) takes a shared `RwLock` read on every span enter and exit, even
when no directive names a span. Every poll of an instrumented future
enters its span, and the relay installs two per-layer `EnvFilter`s (logs
and OTEL), so every worker contends on those locks. Plain `target=level`
directive lists now filter through
`tracing_subscriber::filter::Targets`, which keeps no per-span state.
Directives that name spans or fields still use `EnvFilter`, which is the
only filter that understands them.
**Each reader re-read the community's open channels.** A reader's
accessible channels are its member channels plus the community's listed
open channels, and the open half is the same for every reader. The
per-reader cache stored the union, so every miss fetched every open
channel row again. The lookup is now two cached halves:
- member channels, per reader, cached as before;
- open channels, once per community.
The shared half is used for a bounded time. Cross-pod invalidation is
best-effort, so a pod can miss the drop that follows a channel becoming
private, unlisted or deleted elsewhere. Each cached open set records
when its database read began, and readers use it only for
`OPEN_CHANNELS_MAX_AGE` (1 s) after that. After that the next reader
re-reads it. **Accepted trade-off:** if an invalidation is lost, any
reader on that pod can be granted a just-revoked open channel, history
included, for at most 1 s. That includes a reader with no cache entry,
which on `main` always read the database. This is a new exposure for
those readers. With no bound, the shared set could have served that
channel for its 10 s TTL. Readers with a cached entry already had a 10 s
window on `main`, so for them the bound is tighter than before. An exact
per-community version check would remove the window but needs a schema
change, so it's tracked as a follow-up.
Invalidations still drop the halves. A community-wide accessible-channel
or channel-deleted invalidation drops both halves, and a membership
invalidation drops only that reader's member half. The age bound counts
from the start of the read, so open-set fills need no fence: a fill that
raced a drop is still at most 1 s stale. Uncached callers of
`get_accessible_channel_ids` get the same set as before, now built from
the two halves. Open-set reads are counted by
`buzz_open_channels_cache_hits_total` and
`buzz_open_channels_cache_misses_total{reason="absent"|"expired"}`.
**Metric meaning change:** `buzz_accessible_channels_cache_hits_total`
and `buzz_accessible_channels_cache_misses_total` keep their names but
now count only the per-reader member half. Dashboards that read them as
whole-lookup hit rates should add the open-set counters above.
Empty levels (`target=`) stay on `EnvFilter`. `Targets` reads an empty
level as ERROR while `EnvFilter` reads it as TRACE, so only
`target=level` directives with a non-empty level take the `Targets`
path.
## Results
Measured with the relay load generator (#8636) on one Linux machine. The
relay ran as a release build on 8 pinned cores, with local Postgres,
Redis and MinIO; the generator ran on separate cores. The fixture is one
community with 2,000 open channels. Clients connect in a burst against a
cold relay: 48k WebSocket clients and 4.8k HTTP agents.
Each arm restored the same database snapshot and ran the same generator
binary and scenario. Arms were rotated (base/before/after, then
before/after/base, then after/base/before), and the table shows medians
of three runs per arm. Both columns after base also carry #8666, so the
comparison isolates this PR's design.
- **before** is this PR's first revision, which had an unbounded shared
open set.
- **after** is this head, where the open set is bounded to 1 s.
| | base | before | after |
|---|---|---|---|
| relay cores (mean) | 6.37 | 2.39 | 2.45 |
| connect p99 | 3,870 ms | 40 ms | 42 ms |
| subscribe EOSE p99 | 12,952 ms | 110 ms | 112 ms |
| history EOSE p99 | 1,252 ms | 68 ms | 72 ms |
| live delivery p99 | 26,421 ms | 54 ms | 55 ms |
| HTTP query p99 | 16,426 ms | 44 ms | 43 ms |
| DB tuples returned/s | 1.04M | 43k | 49k |
| DB transactions/s | 4,397 | 4,065 | 4,062 |
| relay RSS peak | 12.6 GB | 10.7 GB | 10.7 GB |
| errors | 16, plus 241k throttled | 0 | 0 |
The 1 s bound costs about 1,400 extra open-set reads per run, within
run-to-run noise of the unbounded version. One run in each of the two
right-hand arms had a slow tail (connect p99 0.8–1.3 s), which the
medians absorb.
An earlier revision bound the shared set to a per-reader digest instead.
That was exact, but in the same kind of A/B its per-miss digest query
returned about 28 times the tuples per second of the unbounded version.
It gave back the database savings, so this revision uses the time bound.
The open-channel gain scales with how many listed open channels a
community has. The filter gain doesn't depend on the fixture.
## Rollout
There's no flag, so the change takes effect on deploy. There's no schema
or data change, so reverting the commit is the rollback.
1. Deploy to staging and compare relay CPU and connect and EOSE p99
under a connection burst.
2. Watch `buzz_open_channels_cache_misses_total{reason="expired"}`.
Expect at most about one per second per active community per pod.
3. Confirm the deployed `RUST_LOG` and `BUZZ_OTEL_FILTER` are plain
`target=level` lists, so they take the `Targets` path. Other values fall
back to `EnvFilter` and keep today's cost.
## What got simpler
- `invalidate_channel_deleted_local` now reuses the community-scoped
accessible-channel drop instead of repeating it.
- Tests seed accessible channels through one `seed_accessible_channels`
helper instead of writing cache internals.
## Tests
- `telemetry::tests::only_plain_target_level_directives_use_targets`
checks which directive strings are plain. It covers span directives,
field directives, whitespace, empty segments, empty targets (`=debug`)
and bad levels.
- The rewritten `env_filter_tests` assert on captured log output rather
than `Display` strings or cached callsite interest, so they are stable
when run in parallel. `span_directives_keep_env_filter_semantics` proves
that `[conn]=debug` still enables events only inside the span.
-
`state::tests::postgres_tests::a_lost_open_set_invalidation_grants_a_revoked_channel_only_until_max_age`
runs two relay states on one database, with no pub/sub between them. For
each revocation (private, deleted, unlisted), it:
1. primes the reader pod;
2. revokes the channel in SQL and invalidates only the writer pod;
3. shows that a new reader is still granted the channel within the
window;
4. ages the cached set past the limit;
5. asserts that a new reader and an earlier cached reader are both
denied.
Dropping the age check fails it. Never caching the open set fails its
within-window control.
- `env_filter_tests::directive_filters_log_what_env_filter_logs` emits
events at every level for six targets, including prefix lookalikes. It
asserts that the filter built for each of 17 directive lists logs
exactly what `EnvFilter` logs for that list. Routing `buzz_relay=` to
`Targets` fails it.
- `state::tests::accessible_channels_join_member_and_open_halves` checks
the merge. The two existing scoped-invalidation tests now also assert
the open half.
- These state tests, and the two existing ones, were not in the `just
test-unit` allowlist. They are infra-free, so they are now selected by
exact name, and `scripts/run-tests.sh` mirrors that.
Identified debt, not fixed here: the per-reader member half keeps the
pre-existing unfenced read-then-insert (a raced fill only reaches that
reader, as before).
`handlers::req::tests::expiry_during_stalled_{history,search}_read_releases_permit_and_claim`
are not selected by any CI lane.
buzz-review-completed
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
1fe120165f |
BUZZ-272: Deny workflow runs for banned owners (#8242)
## Summary A workflow whose saved owner is banned from the community can no longer create runs through any trigger: webhook, channel-event, cron, or signed command trigger. Every one of those doors already calls `WorkflowEngine::check_owner_authority`, which re-checks the owner's channel role. That gate now also reads the owner's authoritative restriction state. A ban denies the run, and a failed lookup fails closed. Unbanning restores admission. On the webhook door the denial is the same generic 404 as before, and no run is queued. On the event and cron paths, a ban denial is logged at WARN at most once per workflow every ten minutes, and at DEBUG in between. A failed lookup and every other denial still warn every time. The handler-local ban lookup in `api/bridge.rs` is removed because the shared gate covers it. This is a point-in-time check: a ban committed after the lookup can race with run creation. It does not cancel existing runs. Community timeouts do not block runs in this change. This PR also carries the shared mesh receive fix needed for independent validation (see Shared carry). ### Related BUZZ-272. - Invitations: [#8237](https://github.com/block/buzz/pull/8237). - Route coverage: [#8239](https://github.com/block/buzz/pull/8239). - Live-session revalidation: [#8241](https://github.com/block/buzz/pull/8241). - Staging runbook: [#8235](https://github.com/block/buzz/pull/8235). ### Testing Postgres lane: - `on_event_denies_run_while_owner_banned`: a banned owner's channel-event trigger creates no run, and after unban it creates one. - `cron_tick_skips_banned_owners_without_consuming_the_claim`: one tick skips a banned owner and an agent whose human owner is banned, while an unbanned workflow on the same tick still fires. After unban, a tick for the same scheduled instant fires both, so the denial did not consume their scheduled-fire claim. - `on_event_denies_run_when_restriction_lookup_fails`: the event path fails closed when the restriction read errors, and fires once the read succeeds. - `webhook_admission_enforces_owner_ban_tenant_scope_and_lookup_failure`: the HTTP door returns the same generic 404 for a wrong secret, a banned owner, a forbidden role and a failed restriction lookup, with no run created. It also covers another tenant's allowed control and unban. Unit lane: - `ban_denial_warns_once_per_interval_while_other_denials_always_warn`. Results: - At `81c367f48`, CI passed: Unit Tests 572/572, PostgreSQL Tests 1169/1169 and Relay E2E. A local-relay E2E also passed: - unbanned webhook: 202 and one run; - banned webhook: generic 404 and no run; - banned event trigger: no run; - after unban: one run. - At `72c8d8bbc` (merged with main), CI passed: Unit Tests (including all 171 buzz-workflow unit tests), PostgreSQL Tests 1227/1227 (all four ban tests listed by name), and both `Build (linux/amd64|arm64)` image jobs, which build with `--locked`. A Blox run at the same head also passed `cargo metadata --locked`, fmt and clippy, and the buzz-workflow unit tests passed there with no database reachable. - Not covered: the signed trigger has no dedicated test. It calls the same gate, which was confirmed by reading the code. ### Shared carry #8237, #8239, #8241 and #8242 each carry the same mesh-receive fix, boot and scraper regressions, and push-lease fixture correction. Whichever lands first owns them, and the others drop them on their next main merge. ### What got simpler The ban check now lives in the one gate every run-creation door already shares, so there is one fewer handler-local branch. Cron, event and signed triggers get the same protection without per-door code. The cron loop body is now `cron_tick(now)`, so a test can drive one tick without the 60-second sleep. buzz-workflow's infra-free unit tests ran in no CI lane. `just test-unit` and its cargo fallback in `scripts/run-tests.sh` now run them. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: seiler <seiler@squareup.com> Signed-off-by: Brad Seiler <seiler@squareup.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Codex <codex@openai.com> Co-authored-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f539ef4a87 |
docs(buzz-acp): fix stale prepare_reconnect doc comment (#8697)
## Summary Follow-up to #8653. The `prepare_reconnect` doc comment in `crates/buzz-acp/src/relay.rs` still said it runs `begin_reconnect` "with the real clock and RNG". `begin_reconnect` stopped taking a clock when `live_until` moved to #8688, so the comment now says "with the real RNG". Comment-only change. **What got simpler:** nothing in the code changes; the doc now matches it. ### Related issue N/A. This was a review note on #8653. ### Testing Comment-only change, so I ran nothing locally. CI covers fmt and clippy. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
76cd4294f6 |
fix(relay): close a REQ whose history read failed instead of sending EOSE (#8692)
## Summary **Merge order:** this lands and deploys **before** [#8688](https://github.com/block/buzz/pull/8688). That PR's buzz-acp resume cursor counts on a failed history read never ending in EOSE. Until #8688 ships, buzz-acp re-sends a REQ as soon as it gets an error CLOSED. The relay's REQ rate limit bounds that loop, and buzz-acp already parks on a rate-limited CLOSED. #8688 replaces this with a per-subscription backoff. When a historical query fails for a reason other than a statement timeout, the relay logs the error and still sends `EOSE`. EOSE means "you have every stored event" (`ARCHITECTURE.md` §5, NIP-01). A client that resumes from its last-seen point therefore treats the failed read as complete, advances past it, and never replays that history. This is RPR #1: a failure must not become an authoritative empty result. The history task now returns the CLOSED reason to send: - **Statement timeout:** `["CLOSED", sub_id, "error: query timed out"]`, unchanged. - **Any other DB error:** `["CLOSED", sub_id, "error: database error"]`. - **Cleanup:** CLOSED goes through the existing `close_if_owner` path, so the subscription, its sub-registry filters and its topic refcount are released, the same as on a timeout. - **Docs:** `ARCHITECTURE.md` §5 now states this contract. **Clients don't hot-loop on it.** Checked at `main` |
||
|
|
6d719c680a |
db: warm the relay read pool toward its ceiling at boot (#8651)
## Summary At boot the relay's read pool holds only its floor (`max/5`). A freshly started pod that meets a reconnect burst, such as the clients a rolling deploy just moved off an old pod, has to dial the rest of its reader connections inside the 150ms reader acquire budget. Cold dials (TCP + TLS + SCRAM) overrun that budget under load, so those reads fall back to the writer (`reader_acquire_timeout`). This PR warms the read pool toward its ceiling in the background, right after the existing boot ping succeeds: - **Off the startup path.** It runs in the boot-ping task that was already spawned and not awaited. Startup and readiness are unchanged. - **Bounded.** One pass, in waves of at most 5 concurrent dials. Each acquire, dial included, is bounded by the pool's own 150ms acquire timeout. A dial slower than that is dropped and counts as failed, just as it would on the request path. A wave that opens nothing ends the pass, and the pass runs at most twice the waves a clean pass needs. There is no sleep, backoff or second pass. - **Released idle, then reaped.** Warmed connections are dropped back into the pool, not closed. sqlx's existing `idle_timeout` reaps them back down to the floor, so there is no new reaping logic. - **Skipped if the reader is down.** If the boot ping finds the reader unreachable, the warm pass doesn't run. Today's all-writer warning is the only signal in that case. - **No alert noise.** Warm acquires use plain `acquire()`, not the instrumented reader acquire, so warm misses don't count toward the `buzz_db_reader_*` acquire-timeout metrics that fallback alerts watch. The existing `buzz_db_read_pool_*` gauges show the result. - **Logged.** The outcome is logged at info when the pool reaches its ceiling, and at warn when it falls short. Both lines carry `ceiling`, `held`, `failed` and `elapsed_ms`. The ceiling field isn't named `target`, because the relay's JSON formatter already emits the event target under that key. `held` counts the connections the pass dialed plus any idle floor connections it reused. - **Window.** Until the pass ends it holds every reader connection, the floor included. That is usually before the pod takes traffic. Boot-probe retries can delay the pass past the listener bind, though, so it can briefly overlap traffic, and a routed read that misses in that window falls back to the writer. **Cost.** Each pod holds up to `max_connections` reader connections, instead of the floor, for about `idle_timeout` after boot. Operators should size the reader's `max_connections` for pods × `BUZZ_DB_READ_POOL_SIZE` (falling back to `BUZZ_DB_POOL_SIZE`), counting the old and new pods that overlap during a rolling deploy. The configuration already permits that, and the `reader_min_connections` doc now gives the same advice. Booting pods can have up to the floor plus 5 dials in flight, because sqlx's floor maintenance dials alongside the first warm wave. **Simpler?** This adds one bounded helper and leaves the pool configuration alone: no new knob and no new metric. Separately, I identified some debt: a REQ with `limit: 0` still acquires a reader to run an empty query. A short-circuit straight to EOSE would remove that acquire, and a companion desktop change sends `limit: 0` live REQs. ### Related issue N/A. This is part of a set of reconnect-load reductions. The companion client changes (resume cursor, reconnect spreading) are in separate PRs. ### Testing - `cargo fmt --all -- --check`, plus `cargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warnings`. - `cargo test -p buzz-db`, including 6 new `reader_warm_tests`: - a table test for wave sizing - an exact target - the bound when dials keep missing - a top-up after a partial wave - a dead reader ending the pass after one wave - an unreachable reader skipping the warm - The new Postgres test `reader_pool_postgres_tests::reader_boot_ping_warms_pool_to_max_in_background`: after `spawn_read_pool_boot_ping()`, the pool settles at size = idle = max. It runs through `scripts/postgres-test-run.sh` together with the existing reader-pool Postgres tests, and `scripts/test-postgres-test-discovery.sh` passes. - The new Postgres test `reader_pool_postgres_tests::reader_warm_at_production_budget_ends_partial_when_dials_stall`. It builds the pool with the production constructor, so it has the real 150ms budget, and puts it behind a proxy that relays 3 dials and stalls the rest mid-handshake. The pass must end with 3 held and 7 failed after exactly 10 dials, finish in under 2s, and not dial again afterward. - Mutation checks. Each of these failed a new test: - zero waves - targeting the floor instead of the ceiling - `close_on_drop` instead of releasing - no early abort - no wave bound - no top-up - no ping gate - no concurrency cap buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f6d31d13e4 |
db: answer match-nothing event reads without taking a connection (#8663)
## Summary A NIP-01 `limit: 0` REQ asks for no stored events: it is a live-only subscription. The relay still acquired a pool connection for it, then ran a `LIMIT 0` query. Desktop is moving live subscriptions to `limit: 0`, so these acquires add up during a reconnect burst. The existing match-nothing shortcuts in `query_events_on` (an empty `kinds`, `authors`, `ids` or `#e` list) had the same cost, because they ran after the caller had already acquired a connection. This PR moves the query validation and the match-nothing checks into one function, `answer_without_db`. It adds a zero page (`limit: 0`, after the page clamp) to those checks, and every entry point now calls the function before it acquires a connection: - `query_events_with_operation`, which covers `Db::query_events` and its writer variants - `Db::query_events_routed` - `Db::query_events_routed_bounded` - `query_events_on`, for the session (aux) path that already holds a connection Invalid queries still return the same errors, in the same order, and match-nothing queries still return an empty page. What changes besides the connection: - A routed match-nothing read now returns before `route_read`, so it emits no `buzz_db_route_decision` and runs no replica proof. As desktop moves live subscriptions to `limit: 0`, writer/replica route-decision ratios will shift. Fewer reads are routed; no read changes destination. - During a pool outage, an invalid query now returns its `InvalidData` error instead of the pool error. - A zero page skips checks that only SQL applies, such as rejecting a negative `offset`. Current callers only build non-negative offsets. **Simpler?** Yes. The four separate early returns become one predicate, and the page-limit clamp is computed in one place (`page_limit`), shared by the shortcut and the SQL builder. ### Related issue N/A. This is a follow-up from https://github.com/block/buzz/pull/8651. ### Testing - New unit test, `runtime::postgres_tests::match_nothing_reads_take_no_connection`. Both pools point at a closed port, replica routing is on, and the fence is forced open, so a routed read that reached `route_read` would try the reader first. Each match-nothing shape (`limit: 0`, `max_limit: 0`, and an empty `kinds`, `authors`, `ids` or `#e` list) must return an empty page through the writer, routed and routed-bounded entry points. Invalid queries that would also match nothing must return `InvalidData` through each entry point. None of these may record a `buzz_db_pool_acquire_attempts_total`. A `limit: 1` routed control must fail after trying both the reader and the writer. The test runs in the `cargo nextest run -p buzz-db --lib` unit lane. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
cc28660c61 |
fix(buzz-acp): spread 1012 reconnects and keep replay gaps across a dropped socket (#8653)
## Summary During a relay rolling deploy, every agent harness connection gets a close 1012. `buzz-acp` reconnected immediately, so a pod's agents all arrived at once, and `jittered_duration` produced 0.80–0.89× rather than the intended ±20%. Separately, a replay that was cut off by a dropped socket could lose the gap it was replaying. This PR makes three changes: 1. **Reconnect spreading.** The Close frame's code is now read. After 1012 (Service Restart) or 1001 (Going Away), the first reconnect attempt waits a uniform random delay in [0, 10s], which matches the relay's server-side drain spread. Other closes keep the immediate first attempt. Both reconnect entry points go through one `prepare_reconnect`. 2. **Gaps survive a dropped replay.** A recorded dropped-event gap (`channel_dropped_since` / `membership_dropped_since`) is retired when the replay REQ is written, as before. On `main` it is then gone: if the socket drops before that replay finishes, the next `since` comes from `last_seen`. Relay history is sent newest first, so `last_seen` can already be past the gap, and the lost event is skipped. Now the retired record is kept per connection until that subscription reaches a counted EOSE. If the connection ends first, the record is restored (merged by `min` with any newer loss), so the next replay still starts at or before the gap. - This covers overflow-recovery and reconnect replays, and a socket that dies partway through a resubscribe (`RetryConnection`): every fresh-connection resubscribe closes out the previous socket first. - **Ambiguous EOSE is ignored:** EOSE carries only the subscription ID. If a REQ for the same ID is written a second time on one socket (an overflow-recovery replay, or an unsubscribe followed by a re-subscribe), that subscription's EOSE never releases its retired gap for the rest of that socket. - **Cost:** a gap whose replay EOSE can't be counted (a superseded REQ) is restored at close, so the next reconnect replays once more from it. State is one oldest gap per subscription, cleared on unsubscribe, and the first unambiguous EOSE for that subscription retires it. Until the relay sends CLOSED for a failed history read (#8692) and that relay is deployed, an EOSE sent after a failed read also retires the gap, even though nothing was replayed. That's no worse than `main`, which drops the gap as soon as the REQ is written. - **Removal paths:** unsubscribe clears the retired gap; the per-socket REQ tracking is cleared only when the connection closes. 3. **Jitter fix.** `jittered_duration` is now a symmetric ±20%, sampled from the OS CSPRNG. The replay cursor itself is unchanged: `channel_since` and the new `membership_since` share one pure `resume_since`, which is `main`'s `min(last_seen, dropped)`, else the baseline. **Moved out of this PR:** an earlier revision also resumed quiet subscriptions from an EOSE-derived `live_until`. The relay currently sends EOSE when a history read fails, so `live_until` could certify coverage that was never delivered. That term moves to #8688, alongside its `buzz-acp` backoff on that CLOSED. #8688 merges after the relay sends CLOSED for a failed history read (#8692) and that relay is deployed. **What got simpler:** `channel_since` and `membership_since` share `resume_since`, which replaces six inline copies of the membership `since` computation. `SubscribeMembership` now honours a recorded membership gap like every other membership REQ. **Identified debt (not changed here):** - Quiet channels and the membership subscription still replay from the process-start watermark on every reconnect, so a long-running agent pays more for each bounce. #8688 addresses this. - A non-rate-limited, non-auth CLOSED re-REQs the channel immediately, bounded only by the relay's rate limit (#8688). - `try_autonomous_reconnect` restarts at attempt 0, so a relay that accepts and immediately closes with a non-1012 code sees an immediate retry each time. - Per-REQ wire IDs would make every EOSE unambiguous. They'd remove the supersede tracking and let an overflow-recovery replay's EOSE release its gap. Today a superseded replay keeps the gap until close, and on a busy channel past the relay's 1000-event page limit that can repeat a bounded replay on every reconnect. The remaining per-connection sets would then collapse into one record. Proposed as a follow-up. ### Related issue N/A. This is part of a set of reconnect-load reductions. Companion PRs: desktop (#8652), mobile (#8659), relay read-pool warm (#8651), `buzz-acp` CLOSED backoff (#8688) and relay CLOSED on a failed history read (#8692). ### Testing - On a Linux workstation, at the head SHA: - `cargo fmt --all -- --check` and `cargo clippy -p buzz-acp --all-targets -- -D warnings` pass. - `cargo test -p buzz-acp -- --test-threads=1`, with the harness env cleared per `crates/buzz-acp/TESTING.md`: - lib: 1078 passed, 3 ignored, both serial and with default parallelism. - `pool_lifecycle_state` (9), `run_task` (9) and `stdio_contract` (1) all pass. - `git_bootstrap`: 2 of 4 fail with `could not read Username for 'https://relay.invalid/...'`. The same two fail identically at the merge base `b310fea0` on the same workstation, so they're environmental. - `cargo test -p buzz-acp --lib relay::resume_tests` with default parallelism: 10 runs in a row, 18/18 each time. - New `relay/resume_tests.rs`: - table tests for `resume_since`, `first_reconnect_delay` and the jitter math - Close-code capture, and the close code consumed by the next reconnect - a counting fake relay showing 1012/1001 delay the first connect while 1000/1006 don't - unfinished gap replays (channel and membership) keep their floor across repeated drops, with a newer event before or during the replay; a completed replay releases it - delayed EOSE of a superseded REQ (channel and membership overflow recovery), with `last_seen` past the gap - a redial after a failed resubscribe, a new loss during an unfinished replay (channel and membership, merged by `min`; a repeated close is a no-op), a second REQ on one socket retiring an older loss (the older one is restored), `wait_for_reconnect` restoring a retired gap, and unsubscribe clearing it - a connected `SubscribeMembership` asserting its wire `since` - 19 mutations of `relay.rs`, applied one at a time, each fail at least one `relay::` test: drop the loss restore on close, take `max` instead of `min` when merging a restored loss (channel or membership) or when a second REQ stashes one, keep the retired gap on EOSE, release it on a superseded EOSE, drop supersede tracking, drop the redial restore, keep the retired gap on unsubscribe, inline the membership `since`, skip `note_req_sent` (channel or membership), skip `prepare_reconnect` (either entry point), do not consume or record the close code, do not delay 1001, revert the jitter band, and let `last_seen` beat the gap. - Exercised against a real relay (Postgres 16, Redis) at an earlier revision whose reconnect-delay code is identical to this head (`prepare_reconnect` and `first_reconnect_delay` unchanged), comparing the merge base with that head: | | Base | Head | |---|---:|---:| | 20 agents after a 1012: reconnect min / median / max | 1 / 2 / 3 ms | 622 / 3,638 / 9,233 ms | | Reconnects per one-second bucket, 0–10s | `20,0,0,0,0,0,0,0,0,0,0` | `4,2,2,3,2,1,2,2,1,1,0` | | Mention sent during the bounce, answered | once | once | - The new tests run in CI via `just test-unit` (`cargo nextest run -p buzz-acp`). buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> |
||
|
|
aed4be8592 |
Add a scenario-driven relay load generator (#8636)
## Summary Buzz has no way to load-test the relay as a whole. `wamp_bench` sends kind:9 messages from one identity to one channel, and `perf/relay_bus_scaling.py` only measures Redis fan-out. Neither can show what happens to a relay with many more users, channels, or deep threads. This PR adds `buzz-loadgen`, a load generator in `crates/buzz-test-client`: - `buzz-loadgen seed <scenario.toml>` creates communities (through `POST /operator/communities`), channels, channel history, and deep threads. - `buzz-loadgen run <scenario.toml>` drives simulated humans and agents through ramp steps (1x, 2x, ...) and prints p50/p90/p99 latency, errors, and rate-limit rejections per operation for each step. - Humans hold a WebSocket with live subscriptions, fetch history on connect, and send presence, typing, messages, thread replies, and reactions. Agents authenticate with a NIP-OA tag, poll HTTP `/query`, post through HTTP `/events`, and send encrypted observer frames to their owner. - Per-client rates come from the scenario. Set them to model a deployment, and a step of `users = 10` means 10x that deployment's population. - Every identity, host, and channel id comes from the scenario's `run_id`, so `--shard i/n` splits a population across machines with no coordination. - Each step measures from ramp start to hold end, so connect failures during the ramp count. A step aborts the run when an operation's error rate or p99 (delivery included) crosses the scenario's `[abort]` limits, when a relay `CLOSED`s a live subscription often enough, or when a client exits unexpectedly. The report names the limit that tripped. - Each connect plus authentication has a deadline (`target.connect_timeout_secs`), and shutdown is bounded, so a relay that stalls connections still produces a report and a non-zero exit. - `seed` exits non-zero unless the relay accepted every planned post. - `NostrWsConnection::connect_to_addr` opens the TCP connection to a given address while the URL still names the host. This lets a client inside the relay's cluster reach a community host through the relay's service address. - `wamp_bench` is deleted. Its paced single-channel load is a subset of a scenario. What got simpler: one load tool instead of two partial ones. The change touches only test tooling, the unit-test lane, and one additive method in `buzz-ws-client`. Relay code is unchanged. ### Related issue None found. ### Testing Smoke run on a Linux workstation against a local relay built from this branch, with its own Postgres, Redis, and MinIO containers: ```bash cargo run --release -p buzz-test-client --bin buzz-loadgen -- seed perf/scenarios/local-smoke.toml cargo run --release -p buzz-test-client --bin buzz-loadgen -- run perf/scenarios/local-smoke.toml ``` - `seed`: 710 of 710 posts accepted (10 channels, 200 history messages, 10 thread roots, 500 thread replies). - `run`: both steps (1x = 50 humans + 5 agents, then 2x) completed with 0 errors and 0 rate-limit rejections. Every operation recorded traffic: 55/55 connect/auth, 205/205 live subscriptions, 150 history fetches, presence/typing/observer frames, messages and reactions, HTTP `/query`, HTTP `/events`, and publish→delivery. Delivery p99 was 45 ms at 1x and 42 ms at 2x. - Failure paths are covered by unit tests: a listener that accepts TCP but never answers (connect deadline, then `run` aborts and returns), a fake `/events` server that rejects posts (`seed` fails), `CLOSED` subscriptions, and invalid scenarios. `just test-unit` (and its `scripts/run-tests.sh` fallback) now runs the new `loadgen::` unit tests. Before this change, no CI lane ran `buzz-test-client` lib tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
8caf8351c1 |
Add standalone owned-community deletion handlers (#8645)
## Summary
User deletion needs to delegate owned communities to the existing
community deletion process and report what is still waiting. This adds
the standalone components for that flow; wiring them into the user
workflow is a later change.
The owned-community handler checks for an existing community deletion
request first. If none exists, it calls `submit_and_prepare`, the same
function used by the community CLI. The handler saves the child request
ID, last observed status, and operator instructions on the existing user
stage. Community approval and execution remain separate. Repeated or
concurrent calls reuse the existing child without resetting its approval
or execution state. The user stage follows the current request for its
approved community ID. If an operator aborts A and submits B for that
community, the next invocation saves B on the same stage row.
An unfinished child leaves the user stage waiting. A later handler
invocation refreshes its observation and completes the stage when the
child reaches logical completion (`retention_pending` with a completion
timestamp). Retained-copy obligations remain visible in the saved
result. Lease owner and generation checks protect stage updates.
The separate summary handler reads only the user request, approval, and
stage tables. It lists unfinished stages, saved external waits and
operator actions, completed results, and inventory communities not yet
reached. It never queries or refreshes community deletion state, so
child statuses are explicitly labeled as last observed.
### Scope and rollout
- No migrations, new HTTP endpoints, CLI commands, or worker dispatch
changes. Owned-community intake remains disabled.
- Waiting uses the existing stage status plus `metadata_json.waiting`.
This slice does not advance past waiting stages. Integration must add an
explicit resume or bounded revisit cadence before enabling automatic
scheduling.
- The CLI and user handler share the existing database submission,
inventory building, and inventory freezing operations. There is no API
wrapper class or second insertion path. Preparation failures leave a
durable child request and a retry record on the user stage; subsequent
lookup exposes instructions for the operator to finish preparation.
Guidance distinguishes operator-origin preparation, automatic
owner-origin preparation, blocked requests that need remediation and
`unblock`, and aborted requests that can be replaced.
- Existing submission accepts a host for the CLI or an immutable
community ID for the parent. A transaction protects target resolution
and admission. ID-pinned calls never replace an existing child, even an
aborted one. Inventory helpers take their store and media dependencies
directly. Successful stage persistence clears any saved waiting marker.
- These components become available to callers after release but are not
scheduled by the relay or user worker. No data migration is needed to
roll back this change.
See [the implementation and testing
guide](
|
||
|
|
713b864e2f |
fix(push-gateway): log which App Attest certificate check failed (#8671)
[#8630](https://github.com/block/buzz/pull/8630) made App Attest rejections log which check failed. In production, an internal iPhone's fresh enrollments now log `reason: certificate` with `detail: invalid certificate path or structure`. That detail still covers about eight distinct failures: a non-array or empty `x5c`, a bad configured root, a malformed or duplicate-extension certificate, any webpki path rejection (untrusted issuer, expiry, unsupported critical extension and so on), and a key-usage mismatch. So the logs still cannot say why this device's attestation is rejected while other devices enroll normally. Each certificate failure now carries a `CertificateCheck`, and the existing warning's `detail` names it: - `x5c is not a non-empty array` - `invalid configured root` - `invalid encoding at x5c[i]` - `invalid key usage at x5c[i]` - `path rejected: <webpki error>`, for example `CertExpired { time: .., not_after: .. }` or `UnknownIssuer` The webpki error includes only its variant and, for validity errors, the timestamps it compared. It includes no certificate bytes or device identifiers. The `reason` stays `certificate`, and the HTTP response stays a uniform `invalid_attestation`. ### Validation - Added tests: a table test runs each signed bad-chain case through the production `verify_chain` and asserts the exact check: wrong usage, expired, unknown critical extension, duplicate extension, trailing bytes, empty chain, and a bad root. Swapping the key-usage result for another check fails it. The shared certificate constraint corpus now builds its chains from the same case list. - The full push gateway suite passes locally. Clippy is clean. The local pre-push `rust-tests` lane failed only in `buzz-relay` tests, which do not depend on this crate, so I skipped it for the push and am relying on CI for it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@block.xyz> |
||
|
|
d6196a16c1 | fix(relay): revoke invitations and restrict claims (#8237) | ||
|
|
0a362eef15 |
perf(db,relay): bound thread participant scans and coalesce live summaries (#8635)
## Summary A thread summary's `participants` (up to 10 recent authors, used for the facepile) was computed by aggregating **every** reply in the thread. The live `kind:39005` emit runs that aggregate on the writer after every reply insert and every threaded delete. Each summary therefore cost O(thread), recomputed after every reply, so one very large thread could saturate the writer. ### Scale and impact A single long-running thread can grow to hundreds of thousands of replies and take several new replies per second. Each of those replies then pays a whole-thread aggregate on the writer, so writer CPU follows the reply rate of the largest active threads, and the read-side participant queries (channel window, thread root summaries) slow down the same way. Synthetic reproduction (PostgreSQL 17, the repo's `schema/schema.sql`, default `shared_buffers`): one channel with 200k background messages and three threads of 450k, 6k and 300 replies from a few authors with ~800-byte bodies. `EXPLAIN (ANALYZE, BUFFERS)` of the participant query, warm cache (runs 2–3 of 3): | Thread | Before (whole-thread aggregate) | After (newest 1000 replies) | |---|---|---| | 450k replies | ~7.6 s, ~117k buffers read, ~16k temp blocks spilled | ~6.8 ms, ~4k buffers | | 6k replies | ~25 ms, ~24k buffers | ~7 ms, ~4k buffers | | 300 replies | ~2.6 ms, ~1.2k buffers | ~2.6 ms, ~1.2k buffers | Cost now stops growing at 1000 replies, and small threads cost the same as before. With coalescing, a busy thread runs this query at most about once per second per pod instead of once per reply. ### Change 1. **Bound the participant scan to the newest 1000 replies.** All three participant readers now share one SQL fragment (`recent_thread_participants_sql!`): - `get_thread_summary` (the live 39005 emit) - the channel-window batch - `resolve_thread_root_summaries` The fragment walks `idx_thread_metadata_window` newest-first with `LIMIT 1000`, then joins `events` only for those rows, to get `pubkey` and drop deleted replies. Deleted replies keep their `thread_metadata` row, so they count toward the 1000 but contribute no author. It aggregates by author and returns the 10 most recently active. Cost no longer grows with thread size. Desktop and mobile show the 3 most recent participants and the forum view shows up to 10, so an author who hasn't posted in the last 1000 replies is not visible in any client today. `NIP-CW`, `docs/bridge-channel-window.md` and the `ThreadSummary::participants` doc now state the bound, deleted replies included. 2. **Coalesce live summary emits per thread root.** `emit_live_thread_summary` goes through a new `CoalescedRefresh` keyed by `(community, root)`: - A quiet thread emits immediately, as before. - On a busy thread, emits are spaced at least 1s apart per pod. A trailing emit covers the rest of the burst and reads state committed after its last mutation. - Because emits are at least 1s apart, consecutive overlays from one pod always get strictly increasing `created_at`, so the client's newest-wins replacement is never tied. - A panicking or cancelled refresh releases its key, including a task dropped before its first poll: the release guard is built before the spawn. Each run is bounded by a 30s timeout so a hung refresh can't wedge it. ### What got simpler - Three hand-written copies of the participant aggregate are now one fragment. - `get_thread_summary` is one statement instead of two round trips. - The channel-window batch dropped its `ROW_NUMBER()` window function and the `GROUP BY root, pubkey` over every reply of every root on the page. ### Related issue None found. ### Testing - `participant_summaries_read_only_the_newest_replies` (buzz-db, Postgres) seeds a thread with 1001 replies: - an early author, pushed just outside the window - a filler author - a deleted reply - a latest author All three readers must return `[latest, filler]`. Mutant check: removing the `LIMIT` (whole-thread aggregate) turns it red. - `coalesced_refresh` unit tests (paused tokio clock): - A 100-request burst runs once immediately and once trailing, then frees the key. - Separate keys run independently. - A panicking refresh doesn't wedge its key. - A hung refresh times out and releases its key. Mutant check: removing the timeout turns it red. - A request that arrives while a run is in flight gets one trailing run, and that run reads the state written mid-run. Mutant check: dropping the rerun flag turns it red. - A task dropped before its first poll (runtime shut down) still releases its key. Mutant check: building the guard inside the task turns it red. These run in `just test-unit` (added to the buzz-relay `--lib` filter and `scripts/run-tests.sh`). - Blox gate: `cargo fmt --check`, clippy `-D warnings` (buzz-db, buzz-relay), and the full buzz-db and buzz-relay Postgres nextest lanes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
2c28e07133 |
BUZZ-413: Add user deletion fence storage (#8632)
## Summary Add storage and lock helpers for the first user-deletion fencing PR. No serving handler or deletion worker calls these helpers yet, so this does not block users or enable real deletion. The operator-global `user_deletion_fences` table stores global user restrictions and community-scoped agent restrictions. Community and profile deletion retain these rows unchanged. `scope_community_id` records a restriction's scope; it does not make the row community-owned. The helpers use the caller's transaction, stable identity lock keys, ordered shared/exclusive locks, and indexed lookups. Inserts preserve the original request and owner on retry and reject conflicting provenance. Inserts validate the current worker lease and approved inventory through the existing entity-deletion guards. Callers must still verify that the target identities and agent ownership belong to the request. This is an additive schema expansion with a five-second migration lock timeout. It adds no database triggers, functions, foreign keys, or backfills. Both schema sources and operator-global registrations are updated. The unused request-ID index was removed. The existing community-deletion catalog and purge logic are unchanged. Tests cover live schema parity, concurrency, rollback, and retention through the existing purge and verification functions. ### Related issue Part of [BUZZ-413](https://linear.app/squareup/issue/BUZZ-413/gate-user-and-owned-agent-activity). This is the storage foundation; serving checks and real workflow handlers follow separately. No duplicate fence-storage PR found. ### Testing No manual app testing: these utilities have no application callers yet. Local PostgreSQL validation uses an isolated test container and the repository's per-test database runner. Passed 84 selected PostgreSQL tests covering fence authorization, concurrent inserts, schema parity, retention, community deletion, entity-deletion storage, and user discovery. The authorization regression also failed as expected when the insertion guard was temporarily removed; the guard was restored before the final run. Command: `scripts/postgres-test-run.sh -p buzz-db --lib -E 'test(/user_deletion_fences/) | test(/admin_schema_parity_between/) | test(/^store::deletion::postgres_tests::/) | test(/^store::entity_deletion::postgres_tests::/) | test(/^store::user_deletion::/)'` with the local test PostgreSQL connection and `PG_BIN_DIR` configured. The full `just test` run had one failure: `composition_tests::composition_log_does_not_leak_issuer_url` missed an expected log message. The same compiled test passed when rerun alone. No relay logging code is changed by this PR. The full local `just ci` run and all normal pre-push hooks passed on the updated branch. Generated with Codex --------- Signed-off-by: Ravneet Arora <rarora@squareup.com> |
||
|
|
646ef8abf9 |
Stop requiring the 0032 roster trigger at relay startup (#8611)
🤖 ## Summary When a relay starts, it checks that a database trigger from migration 0032 is installed and works. If the trigger is missing, the relay refuses to start. This PR removes that startup check, so a later migration can drop the trigger. The trigger blocks a stale channel member list from being saved. Since #8316, the relay runs the same check in Rust before every save of a member list. The trigger is now only a duplicate backstop. Removing it lets #8494 compute the `member`/`bot` label when it reads members, instead of storing `bot` in the database. This PR does not drop the trigger. The trigger stays installed and keeps working. Only the requirement that it exists at startup is removed. ### Related issue Prepares the trigger retirement that #8494 needs (review thread on #8494 about storing `bot`). No duplicate PRs found. ### Rollout `docs/database.md` requires this sequence for retiring a trigger: 1. Ship the replacement in the app. This was #8316. 2. Fully deploy a release in which no relay requires the trigger. **This PR.** 3. Drop the trigger in a separate migration, and name the step-2 release as a required stop in its release notes. A relay from before this PR refuses to start after the trigger is dropped. So every environment, staging included, must run this release before the drop ships. ## Details - Removed the startup step `channel_roster_fence`, which ran the catalog check and a behavior probe (a test insert that must be rejected, then rolled back). - Removed the catalog check that ran after migrations in `run_migrations_locked`. - The catalog check function stays, but only tests use it. - Removed four tests about the startup gate. Added `migrations_run_without_the_roster_trigger`: it drops the trigger in a scratch database and runs the migrations. If the post-migration check comes back, the test fails. - `observability_source` lists the event writers that may skip the guard. The probe was one of them, so it is removed from those lists, and the expected writer count goes from 9 to 8. - The Helm chart README now says that newer relays do not require the trigger. ### Testing - `cargo clippy -p buzz-db -p buzz-relay --all-targets -D warnings` and `cargo fmt --check` pass. - `observability_source` (21) and `startup_steps` (6) pass. - `migrations_run_without_the_roster_trigger` passes. With the post-migration catalog check restored, it fails with "channel roster fence trigger missing". 🤖 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> |
||
|
|
78b5dae1f6 |
fix(relay): store a kind:9000 and its membership writes in one transaction (#8606)
Follow-up to #8575. ## Problem Today a kind:9000 is stored first and the membership write runs afterwards, on its own. If that write fails, the relay logs it and still returns an accepted OK. A resend of the same event then returns `duplicate:`, so the person is never added. This is the catch-log-and-accept failure that Review-Proven Rules #1 and #5 describe. ## Change kind:9000 now uses the same atomic path as kind:9002 (`persisted_atomically` in ingest). - **`buzz-db`:** new `put_members_with_event`, which runs one transaction: 1. Take the channel's membership lock, before the event insert, the TTL lock and the channel row. This matches the order the other membership writers use, so it adds no new lock cycle. 2. Store the event. If it's already stored, roll back and return `Duplicate`. 3. Run the existing `put_members` decisions and upsert. These now live in a shared `put_members_on` step on the transaction. 4. Commit. - **Failure:** nothing is stored and the client gets an `error:` rejection, so it can resend the same event and have it applied. - **Everyone refused:** if the decisions under the lock refuse every person, nothing is stored and the event is rejected with the same text validation gives. Before, a single-person add refused at that point was accepted with an empty message. - **After commit:** the live effects stay as they were. They're best-effort and read the committed state: cache invalidation, `member_joined` notices, member-added notifications and 39002 discovery. - **`NOSTR.md`:** now says a failed kind:9000 can be resent as is. ## What got simpler - kind:9000 no longer has its own store-then-apply path in ingest. It shares the atomic branch with 9002 and workflow deletion. - `apply_put_user`'s "write failed, report it as a per-person rejection inside an accepted OK" branch is gone. - The `Db::put_members` wrapper is deleted. Its only caller was the kind:9000 path. ## Identified debt (not in this PR) kind:9001 (remove), 9021 (join) and 9022 (leave) have the same gap. Each stores the event, then runs the membership write in `handle_side_effects`, and a failure is only logged. kind:9008 and 9005 follow the same store-then-apply pattern. These can move onto the same atomic shape in a follow-up. ## Tests - `failed_put_user_stores_nothing` (relay, Postgres): a test trigger fails the second person's row in a two-person add, after the event row and the first person were already written in the same transaction. It checks: - Both the send and a resend are rejected with `error:`. - No kind:9000 is stored, nobody is added, and no `member_joined` notice is posted. - With the trigger dropped, the same signed event adds both people. - One more resend returns `duplicate:` and posts no second notice. - `put_members_with_event_stores_the_event_with_its_writes` (buzz-db): checks the three outcomes: - Refused: nothing is stored. - Applied: the event is stored and the person is written. - Duplicate: the roster is left alone. - `put_user_refused_under_the_lock_stores_nothing` (relay, Postgres): a test trigger removes the sender from a private channel inside the write transaction, so validation passes and the decision under the lock refuses. For a single add and a batch, it checks the `invalid:` rejection, that nothing is stored and no notice is posted, and that the same event applies once the trigger is gone. - Mutation checks, run in Blox gates: - At `d18e88fd`: committing the refused event fails the buzz-db test; applying a duplicate fails the first two tests; storing the event in its own transaction fails the first two tests. - At `3b106623`: turning the refusal under the lock into `error:` fails the new refusal test, and so does committing the refused event. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |