Repository navigation
fix(db): seek replaceable heads by channel - #9147
Conversation
Drop idx_events_community_deleted (migration 0084). No relay query reads by (community_id, deleted_at), yet every events write maintains it and every read that plans against events locks it on each partition. A partitioned index cannot be dropped CONCURRENTLY, so the drop is a plain DROP INDEX under a 1s lock budget that fails rather than queue writers behind a long read; the next boot retries it. replace_addressable_event read and superseded the coordinate's head with `channel_id IS NOT DISTINCT FROM $4`, which a btree cannot use as an index condition. Every relay channel-state write (39000-39002) filtered the relay's live rows of that kind across the whole community. Spell the predicate per case, `channel_id = $4` or `channel_id IS NULL`. Signed-off-by: Luke Tornquist <[email protected]> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]>
The planner picks idx_events_community_deleted for community deletion's row count and purge, as the smallest community_id-led index. Every other events index is also community_id-led and serves them after the drop. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Luke Tornquist <[email protected]>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Luke Tornquist <[email protected]>
On an empty table the planner prefers the newest-first (pubkey, kind) index for LIMIT 1 and filters the channel, so the probe needs the shape it guards: one relay head per channel with fresh statistics. The 0083 upgrade tests now check that 0083 is applied rather than that it is the newest migration. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Luke Tornquist <[email protected]>
lock_timeout applies to each lock wait, and DROP INDEX holds `events` while it waits on each partition, so the whole stall is bounded by the 5s statement_timeout, not the 1s lock budget. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Luke Tornquist <[email protected]>
🔐 Codex Security Review
Review SummaryOverall Risk: NONE
FindingsNo concrete security, correctness, or reliability findings were identified. Notes
Generated by Codex Security Review | |
The per-case statements lived in a helper fn, which the guarded-write source scan reads as a write seam outside the tenant chokepoint. Share only the WHERE scope and head read as macros; the supersede is spelled in replace_addressable_event, which opens the chokepoint. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Luke Tornquist <[email protected]>
…-head Main moved the addressable replacement body into replace_addressable_event_under_lock; the per-case head and supersede statements now live there. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Luke Tornquist <[email protected]>
Production reader stats show the index scanned continuously, so the drop comes out of this PR. The replaceable head and supersede fix stays. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Luke Tornquist <[email protected]>
|
@buzz-security-review a948a43 |
|
@buzz-security-review a948a43 |
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Automated review of a948a4337c (base cec918ae): no blocking findings.
The two new spellings, channel_id = $4 for a channel write and channel_id IS NULL for a channel-less one, select exactly the rows the old IS NOT DISTINCT FROM $4 did, for both the head read and the supersede. The coordinate lock, the newest-first/lowest-id ordering, the stale-write check, the roster guard and the rollback path are unchanged, including through LockedGroupStateSnapshot::replace. Each branch binds exactly the parameters its SQL uses. With all four equality columns in the scope, idx_events_addressable (community_id, kind, pubkey, channel_id, deleted_at) takes the channel as an index condition. The UPDATE events literals stay inside the &mut AdmittedTx function, so the guarded-write source scan still covers them.
Run against an isolated PostgreSQL 17 at this head: both new tests pass, along with all 18 replaceable-module tests, the 2 channel replacement-lock tests and the 222 default buzz-db tests and source-policy checks. With the new tests moved onto the base commit, the scope-behavior test passes and the plan test fails in all four cases (head read and supersede, channel and channel-less), with channel_id only in Filter. So the plan test catches the regression it is meant to catch.
Nonblocking:
- The plan assertion accepts a
channel_idindex condition from any node in the text plan.eventsis partitioned, so a match on one node doesn't show that every populated partition scan seeks by channel. Checking the JSON plan and requiring the condition on the populated partition scans would make the test exact. - The plan test builds its own copy of the supersede string (
"UPDATE events SET deleted_at = NOW() "+ scope) rather than sharing production's, so the two prefixes could drift apart. That copy is what keeps theUPDATE eventsliteral inside the admitted function for the source scan, so it's a fair trade.
The plan check matched an Index Cond on any node of the text plan. Read the JSON plan instead and require each scan of a populated events partition to seek by channel_id, and every populated partition to be scanned. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Luke Tornquist <[email protected]>
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Automated review (Auto PR Review v1) of head 14576a0610: no blocking findings.
This head adds one commit on top of the previously reviewed a948a4337c, with the same base. That commit only changes the test replacement_channel_predicate_is_an_index_condition. It now reads the plan as EXPLAIN (FORMAT JSON), finds the partitions that actually hold the seeded community's rows, and requires every one of those partitions to be scanned exactly once, with a channel_id index condition either on the scan itself or on the bitmap index scan beneath it. This closes the earlier nonblocking note that the plan check passed when any single node matched. A sequential scan on a populated partition, a missing partition, or a duplicated partition now fails the test.
Checked:
- The partition names come from
pg_class.relnameviatableoid, which matches the unqualifiedRelation Namein the JSON plan. Partitions with no seeded rows are ignored on purpose. - The bitmap path recurses through
Plans, so aBitmap Heap Scanpasses only when an index child seeks bychannel_id. - The supersede statement still comes from the shared
superseded_with_mentions_sql!/replacement_scope!macros, so the test plans the same SQL the store runs. - CI's PostgreSQL Tests job at this exact head ran the test, and it passed (
store::replaceable::postgres_tests::replacement_channel_predicate_is_an_index_condition).
kazemek21
left a comment
There was a problem hiding this comment.
Kyle reviewed the PR and the Nexus review-tracker notes and approved with no comments.
Posted by Codex (gpt-6.1-sol) on behalf of kylez
…small * origin/main: fix(db): seek replaceable heads by channel (#9147) buzz-db: check events created_at floor in app writers (phase 1) (#8325) fix(mobile): keep a thread tail behind the keyboard unread (#9295) fix(mobile): restore header cues and per-channel identity check for #9369 (#9370) fix(relay): treat empty filter sets as match-nothing in SQL (#9342) buzz-cli: add --before and --before-id to messages search (#9382) feat(relay): agents cannot write in DMs except with their owner (#9371) fix(relay): bound each cross-node delivery attempt (#9305) feat(mobile): add iOS Home Screen shortcuts for New chat, New DM and the Bestie (#9360) perf(mobile): stop downloading whole videos for posterless stills (#9367) Enforce single-community relay mode (#9149) fix(relay): match #a tag filters in SQL before LIMIT (#9339) Add participant avatars to mobile conversation headers (#9369) fix(mobile): stop asking for an API token when the channel list is refused (#9327) chore(release): release Buzz Relay version 0.3.0 (#9362) Fix gallery video selection, thumbnails, and swipe dismissal (#9356) Signed-off-by: dessert <7d616e7e143e5d408159614dccff1371350a1c1bd36c8de838a45ece5aca0aca@buzz.block.builderlab.xyz>
Summary
replace_addressable_event_under_lockread and superseded a replaceable coordinate's head withchannel_id IS NOT DISTINCT FROM $4. A btree can't use that as an index condition, so the read filtered every live row of the(kind, pubkey)across all channels. The relay signs every channel's state events (39000–39002) with one key, so each channel-state write walked the whole community's relay state for that kind. BothDb::replace_addressable_eventand the relay's locked group-state snapshots go through this function.The predicate is now spelled per case, as
channel_id = $4orchannel_id IS NULL, and$4is bound only for the first. The head read becomes an index seek to one channel instead of a filtered walk. It reads the same rows as before.The
WHEREscope and the head read are shared as macros (replacement_scope!,replacement_head_sql!), so the plan test checks the same predicate production runs. The supersede statement is spelled inside the function itself, which takes&mut AdmittedTx, as the guarded-write source scan requires.superseded_with_mentions_sql!now takes anexpr, so it accepts aconcat!.Tests
replacement_channel_predicate_is_an_index_conditionseeds one relay head per channel plus one channel-less head and refreshes the statistics. Then, for both cases, it checks that the head read and the supersede each havechannel_idin anIndex Cond. WithIS NOT DISTINCT FROM, the planner walks the newest-first(pubkey, kind)index and applies the channel as aFilter. On an empty table it does that even with the per-case spelling, which is why the test needs the fixture.addressable_replacement_supersedes_only_its_own_channel_scope: a channel write supersedes only that channel's head, and a channel-less write is ordered against the global head only.IS NOT DISTINCT FROM;What got simpler
🤖 Generated with Claude Code