perf(desktop): persist channel heads, collapse thread reads and reply sends - #6572
perf(desktop): persist channel heads, collapse thread reads and reply sends#6572tlongwell-block wants to merge 14 commits into
Conversation
Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Signed-off-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Signed-off-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
The service-restart backoff test waited for the channel list to paint before firing the 1012 close. The channel list is Tauri-backed and renders before the websocket is up, so with three rejected dials the session was still in its backoff loop when the test looked for a socket to close and found none. Now that preconnect dials immediately instead of waiting for requestIdleCallback, the first rejected attempt happens early enough to expose the gap deterministically. Wait for the connected state instead. The test still fails with the 1012 backoff reset disabled, so it guards the same behavior. Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
The B6 change raised the per-pubkey entry check and query staleTime in useUsersBatchQuery from 60s to 10 minutes; two comments still described the 60s window. Comments only. Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Requesting changes for four correctness/lifecycle regressions at 0c492366d61de62dbc01aeae32d5f83390b243b8:
-
Do not hide the entire app behind cache hydration.
CommunityQueryProviderrenders no children untilchannel_head_cache_loadsettles (desktop/src/app/App.tsx:217-269), but both the boot splash andAppReadylive inside those children (App.tsx:643-660). A busy SQLite cache can therefore produce a completely blank window for up to the configured 5-second busy timeout and also delays the supposedly immediate relay preconnect (channel_head_cache.rs:79-85). Keep the loading surface visible and start connection work independently of this optional paint cache. -
Guarantee authoritative revalidation when initial subscription setup fails. A hydrated channel consumes persisted data and intentionally skips
get_channel_window(desktop/src/features/messages/hooks.ts:286-306). Its only authoritative refresh is launched aftersubscribeToChannelLivesucceeds (hooks.ts:421-447); the rejection path only logs (hooks.ts:448-450). If initial subscription setup fails while the window endpoint is healthy, the channel remains stale for the mounted session. Trigger the refresh on failure too, or make authoritative fetch independent of subscription establishment. -
Do not treat an empty persisted head as painted content. Hydration marks every valid entry, including a bounds-only page with zero rows, as persisted (
channelHeadCache.ts:47-79).ChannelScreenthen treats that channel as settled regardless ofdataLength(ChannelScreen.tsx:600-623), overriding the existing contract that an empty placeholder holds the skeleton (timelineLoadingState.test.mjs:101-109). On restart this can flash the empty-channel/intro UI while real server messages are still revalidating. Only bypass loading when persisted rows provide something to paint. -
Preserve complete thread aux closure beyond 1,000 events. The old client path chunked references 100 at a time and fetched each chunk independently (
relayGateBoundary.ts:115-131,relayChannelFilters.ts:15-20). The replacement runs one unpagedlimit=1000query over the root plus the whole reply page (crates/buzz-relay/src/api/bridge.rs:396-405,1235-1265). Because DB results are newest-first, a reaction-heavy page silently loses the oldest reactions, edits, or deletions once the combined first hop exceeds 1,000. Missing edits/deletions can render original or deleted content, not merely lose decoration. Page this closure or otherwise provide explicit complete/truncated semantics.
The native cache’s scope/isolation, LRU/caps, schema recovery, and shutdown handling otherwise look sound. Thread aux authorization/kind coverage and cached-root send fallback/relay ancestry validation also look sound. Exact-head CI is green; I did not duplicate CI-equivalent suites locally.
Three lifecycle regressions in the persisted channel-head cache, from review at 0c49236: 1. CommunityQueryProvider rendered no children until channel_head_cache_load settled, so the boot splash, AppReady, and relay preconnect all waited on an optional paint cache (blank window up to the 5s SQLite busy timeout). Hydration now starts in the query client's initializer and the app mounts immediately; only useChannelMessagesQuery awaits channelHeadHydration() before choosing the hydrated or cold path, so a channel opened mid-load still paints persisted rows instead of racing a relay fetch. The seed merges into the existing window store rather than replacing it, since the live subscription can overlay events before the load returns. 2. A hydrated channel skips get_channel_window on mount, leaving the post-subscribe refresh as its only authoritative fetch. A rejected subscribeToChannelLive only logged, so the channel stayed stale for the session. The refresh now runs on both settle branches; the reconnect listener re-syncs when the socket recovers. 3. A bounds-only persisted head (zero rows) was marked hydrated, and the ChannelScreen bypass then settled onto an empty placeholder, flashing the empty-channel intro while the relay revalidated. Rowless heads now take the cold path so the skeleton holds, matching the existing timelineLoadingState contract. Tests: three new cases in channelHeadCache.test.mjs (slow-load race, bounds-only head, subscribe failure); each fails with its fix reverted. Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Both aux-closure call sites (the window path in handle_channel_window_filter and the thread path in query_events_authed) issued each hop as a one-shot `limit: 1000` query. query_events returns newest-first, so a reaction-heavy page past the clamp silently dropped the *oldest* edits and deletions — rendering original or deleted content, not merely losing decoration. build_aux_query no longer sets a limit. A new query_all_pages walks the (created_at, id) keyset the DB already orders by, advancing `until`/`before_id` from the last row of each full page until a short page. Page size is AUX_PAGE_LIMIT (= buzz_db::DEFAULT_MAX_PAGE_LIMIT, one full DB query per page); AUX_MAX_PAGES = 64 bounds a pathological write pattern with a warn + truncated closure instead of a loop. AuxReader is a small enum over the two read paths (the window path pins the request's proved ReadSession; the thread path keeps the routed "bridge_thread_aux" display-read) plus a cfg(test) Fake. An enum rather than an async closure because the closure form tripped a higher-ranked Send bound in the axum handler future. Tests: query_all_pages_drains_past_the_page_clamp emulates the DB's `created_at < until OR (created_at = until AND id > before_id)` cursor with a tied timestamp and asserts every event returned exactly once across 2 full pages + 1 short page (fails with the cursor advance removed); query_all_pages_stops_at_one_short_page; the thread aux query test now asserts limit/until/before_id are unset. docs/bridge-channel-window.md notes each hop is drained server-side. Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Re-review at 35834cb313c01edb39f5260a18552e341625b96a: three prior blockers are resolved, but one deterministic ordering still leaves the authoritative-revalidation defect open.
Slow hydration can still swallow the post-subscription refresh. useChannelMessagesQuery starts by awaiting channelHeadHydration() (desktop/src/features/messages/hooks.ts:287-300) while useChannelSubscription starts concurrently (ChannelScreen.tsx:195-201). If subscribeToChannelLive resolves before the SQLite load, refreshAfterSubscribe() calls invalidateQueries() while the initial query is already fetching and has no data (hooks.ts:407-464). TanStack Query deduplicates that invalidation onto the existing fetch. When hydration then seeds rows, consumeHydratedChannel() returns the persisted snapshot and the query exits without calling get_channel_window. The channel can therefore remain stale until reconnect, remount, or another invalidation.
I reproduced this at the exact head with a 150 ms channel_head_cache_load delay and an immediately successful live subscription: the post-subscribe path produced 0 get_channel_window calls, expected 1. This affects Stream, DM, and Huddle through the shared ChannelScreen query/subscription pair. The added tests miss the ordering: the slow-hydration case does not mount the subscription, while the subscription-failure case completes hydration before mounting.
Please sequence the post-subscription refresh after hydration and after the initial query settles, or otherwise guarantee a distinct authoritative window fetch, and add the success-before-hydration regression case.
Verified fixed: app mounting/preconnect no longer waits behind the optional cache; bounds-only heads take the cold loading path; both relay aux call sites now drain the composite (created_at, id) keyset correctly. The two focused relay pagination tests pass locally at this head. Current CI failures are unrelated: Unit Tests lacks the sherpa-onnx-c-api native library; Smoke (3) has an unrelated project-review row-count failure plus a messaging timing flake.
Brings in #6558 (mobile Huddles audio protocol v2) and #6312 (Huddle participant interaction polish). No overlap with this PR's files; merge-tree was clean. Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> * origin/main: Polish Huddle participant interactions (#6312) Downgrade mobile Huddles to audio protocol v2 (#6558) Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
…ration Carl/#6572 re-review: useChannelMessagesQuery parks on channelHeadHydration() while useChannelSubscription starts concurrently. When subscribeToChannelLive settles before the SQLite head load, refreshAfterSubscribe() invalidates a query that is fetching with no data yet. TanStack dedupes that onto the in-flight fetch (query.fetch only cancels when state.data exists), the seed lands, consumeHydratedChannel() returns the snapshot, and get_channel_window is never called — the channel stays stale until reconnect or remount. refreshChannelWindowMessages now awaits channelHeadHydration() first, and when the query holds seeded data (dataUpdatedAt 0 — only the hydration seed writes that) awaits the snapshot fetch's promise so the mount gate is consumed before invalidating. The refetch is then a distinct authoritative window fetch. Cold and warm channels carry no such marker and dedupe or cancel exactly as before; one call site covers the subscribe-success, subscribe-failure and reconnect paths. Tests (channelHeadCache.test.mjs): - subscribe resolves before a 150 ms cache load → 1 get_channel_window call and the relay row replaces the persisted one. Fails at the previous head with 0 calls. - cold channel with an immediate subscription → still exactly 1 call. Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
|
Re-review 5002596542 addressed at Fix — Mechanism confirmed in Tests —
Verification at |
…w fetch Max and Wren (#6572 review of 5a5566c): when subscribe settlement and a reconnect both call refreshChannelWindowMessages while the channel query is parked on its hydration-seeded snapshot, both await the same query.promise and both invalidate on wake. The first invalidation starts the authoritative fetch; the second, with TanStack's default cancelRefetch: true, cancels and replaces it — 3 queryFn calls (snapshot + 2 authoritative) where 2 suffice, and the cancelled Tauri invoke still reaches the relay. On the seeded branch only, invalidate with cancelRefetch: false so a second waker joins the in-flight authoritative fetch. Cold and warm channels keep the default: test_canceled_stale_fetch_cannot_overwrite_catch_up_window relies on a catch-up refresh replacing a stale active fetch. Test (projectChannelWindow.test.mjs): seed at updatedAt 0, park the snapshot fetch, call the helper twice, release — exactly 2 requests, the second not aborted, projection shows the relay gap row. Fails at 5a5566c with 3. Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
|
Follow-up at Fix: on the seeded branch only, Test At |
Summary
Lands the build-now items from the desktop latency plan (#ui-performance-deep-dive) as one change. Every perceived-latency hot path a user hits on launch, channel open, thread open, and reply send drops one or more round trips.
A1 — persisted channel heads (the big one). Native WAL SQLite cache (
desktop/src-tauri/src/channel_head_cache.rs) keyed by{pubkey, relayUrl}scope, 32 rows/scope LRU, 1 MiB per-row drop cap, schema-version reset, corrupt-row tolerance, checkpointed on shutdown. Three blocking-pool commands:channel_head_cache_load/_store/_clear. On the renderer side,CommunityQueryProviderkicks off hydration of up to 12 heads when it constructs the query client — the app, splash and relay preconnect mount immediately; onlyuseChannelMessagesQueryawaits the seed (channelHeadHydration), then consumes a one-shot hydrated gate so a hydrated channel pays zeroget_channel_windowcalls on mount and exactly one on the post-subscription refresh, whose response replaces page zero wholesale. That refresh fires whether live-subscription setup succeeds or fails, and is sequenced behind hydration so it is always a distinct authoritative fetch (see Review follow-ups). Bounds-only persisted heads (zero rows) are not hydrated and take the cold loading path. The timeline loading latch recognizes native-hydrated rows as restart-safe so they paint immediately instead of holding a skeleton. The cache is a paint accelerator only — the relay response is always authoritative. Replaces the legacy localStoragemessageSnapshot.ts(removed, -401 lines).Kill switch:
VITE_BUZZ_CHANNEL_HEAD_CACHE=offat build time orlocalStorage["buzz-channel-head-cache"] = "off"at runtime. Cache is cleared on community removal and scoped per identity, so a replaced signer never sees the previous identity's rows.B1 — thread aux in one response. Relay thread filters accept
include_aux; the bridge appends the same authorized two-hop reactions/edits/deletions closure a channel window gets (build_aux_queryshared with the window path). RendereruseThreadRepliesdrops its two follow-up aux fetches.next_cursoris computed from reply-kind rows only since aux rows are unpaged. Documented indocs/bridge-channel-window.md. Thread queries keepstaleTime: 0(bcfe04e2f): an earlier revision raised it to 30s, which CI'sthread-unread.spec.tscaught — once the user leaves a channel, the live subscription stops feeding that thread's cache, so a reopen must always take the (now single) authoritative read.B2 — cached root on reply send.
send_channel_messagegainsroot_event_id; when the renderer already holds the parent (channel or thread cache) it passes the NIP-10 root, and native signs without the relay round trip thatresolve_thread_refused to make. Strict hex parse;root_event_idrequiresparent_event_id; absent root falls back to the existing relay resolution. The renderer never sends a guessed root.B4 general HTTP pool idle 10s→300s, max idle per host 1→2. B5 relay preconnect fires as soon as identity is ready instead of waiting for
requestIdleCallback. One e2e test (relay-reconnect.spec.ts"service restart close resets accumulated backoff") had been relying on the idle-callback batching to skip past its own seeded dial failures before the channel list painted;8133d70bbmakes it wait for the connected state instead (test-only, still fails with the 1012 backoff reset disabled). B6 profile freshness 60s→10 min (both the in-memory entry check and the querystaleTime). Tradeoff: another user's display-name/avatar edit can take up to 10 min to propagate to a client that already holds their profile (relay reconnect refetchesusers-batchbut resolves from the still-fresh per-pubkey entry); your own edits still evict the entry immediately (evictUsersBatchEntriesinuseUpdateProfileMutation).Related issue
Follows #6456/#6457/#6459/#6460 (already merged). #6455 is the measurement instrument and is intentionally not folded in. No duplicate PR found.
Review follow-ups
Addressing Carl's reviews 5001114109 and 5002596542, each pushed as new commits (no rebase):
4f06b7770fix(desktop): mount app while channel heads hydrate; always revalidate — provider no longer gates children on the cache load;refreshAfterSubscriberuns on subscribe failure too; bounds-only heads skipped at seed; seed merges into an existing window store. +3 tests.35834cb31fix(relay): drain aux closure hops across the page clamp —query_all_pageswalks the(created_at, id)keyset viauntil/before_iduntil a short page (AUX_PAGE_LIMIT=DEFAULT_MAX_PAGE_LIMIT,AUX_MAX_PAGES= 64 warn+truncate) so one-shotlimit: 1000newest-first no longer drops the oldest edits/deletions. +3 tests;docs/bridge-channel-window.mdupdated.db21b0531merge oforigin/maine23632941(Downgrade mobile Huddles to audio protocol v2 #6558, Polish Huddle participant interactions #6312 — no overlap).5a5566c0ffix(desktop): sequence post-subscribe refresh behind channel head hydration —refreshChannelWindowMessagesawaitschannelHeadHydration()and, for a hydration-seeded query (data !== undefined && dataUpdatedAt === 0), the in-flight snapshot fetch before invalidating. Without this, a subscription that settles before the SQLite load invalidated a data-less in-flight query; TanStack dedupes that onto the existing fetch (query-corefetch()only cancels whenstate.dataexists), which returned the seeded snapshot — 0 authoritative fetches. Regression test reproduces Carl's exact ordering (fails at35834cb31with 0 calls), plus a cold-channel guard that the fix does not double-fetch.b129231c8fix(desktop): let concurrent post-hydration refreshes share one window fetch — found independently by Max and Wren reviewing5a5566c0f: subscribe settlement + reconnect both wake on the same snapshot promise and both invalidate; the second (defaultcancelRefetch: true) cancelled and replaced the first authoritative fetch (3 queryFn calls, not 2, and the cancelled Tauri invoke still hits the relay). The seeded branch now invalidates withcancelRefetch: falseso a second waker joins the in-flight fetch; cold/warm keep the default (test_canceled_stale_fetch_cannot_overwrite_catch_up_windowrelies on it). Concurrent regression test fails at5a5566c0fwith 3.Testing
At
b129231c8(PR head; verified in one shell withgit rev-parse HEAD=b129231c8):pnpm check,tsc --noEmit, desktop unit 5,393 / 0, Playwrightboot-splash+channel-head-restart+relay-reconnect+relay-reconnect-affordance+thread-unread34 / 34 on a freshbuild:e2e, pre-push hooks green.At
5a5566c0f:pnpm check,tsc --noEmit, desktop unit 5,392 / 0, Playwrightboot-splash+channel-head-restart+relay-reconnect+relay-reconnect-affordance+thread-unread34 / 34 on a freshbuild:e2e, pre-push hooks green.At
35834cb31: desktop unit 5,390 / 0;cargo test -p buzz-relay --lib910 / 0; fmt + clippy-D warningsclean; Playwright 32 / 32 (same specs minus affordance); GitHub CI green on every job except Smoke (3) (unrelated project-review row-count + messaging timing flake, per Carl) and Unit Tests (sherpa cache skeleton, below).Earlier, all at
8133d70bb(this PR head is0c492366d= 8133d70 + a comments-only commit correcting twoprofile/hooks.tsfreshness comments from 60s to 10 min; pre-push desktop check/typecheck/test 5,387/0 re-ran at 0c49236) in one shell;origin/main=040b203f7at PR open, since moved to4baccd539(#6558, mobile only — zero file overlap,git merge-treeclean):just desktop-test— 5,387 passed / 0 failed (includes new hook-level call-count test: cold = 1, stale-prefetched = 1, hydrated = 0 on mount then 1 on invalidate with wholesale replacement)relay-reconnect.spec.ts+thread-unread.spec.ts+channel-head-restart.spec.ts— 30/30 (thread-unread was 8/13 at7acbf951b; relay-reconnect was 15/16 atbcfe04e2f). The restart spec persists a head, reloads into a fresh mock relay with the head fetch held 5s, asserts the persisted row paints within 2s, exactly oneget_channel_windowafter open, and the stale row is removed when the authoritative page lands.pnpm typecheck,pnpm check— cleanAt
7acbf951b(everything except the two-lineuseThreadReplies.tsstaleTime revert and the test-onlyrelay-reconnect.spec.tschange), also green in one shell:just desktop-tauri-test— 2,859 passed / 0 failed across the workspace (channel_head_cache: wire shape, LRU+caps, schema reset, corrupt-row skip)just test-unit— 632 passed (buzz-core/auth);cargo test -p buzz-relay --lib— 908 passed / 0 failedjust checkcomponents: fmt-check, clippy, desktop-check, desktop-typecheck, desktop-tauri-fmt-check, desktop-tauri-clippy, web-check, mobile-check, file-size-check — all greenjust desktop-build,web-build,desktop-tauri-check,mobile-test(1,661 passed) — all greenCI note: the "Unit Tests" job goes red on this PR and on
mainwhenever it hits a poisonedrust-cacheentry (an empty-directory skeleton oftarget/sherpa-onnx-prebuiltthatsherpa-onnx-sysbuild.rs trusts), surfacing ascould not find native static library sherpa-onnx-c-apiinbuzz-voice— a crate this PR doesn't touch. Deleting the cache entry and rerunning turned the job green at0c492366d(28/28); it re-poisons on the nextmainpush until the workflow clears that directory after cache restore.Reviewed in-channel by Wren (9 / 9 / 9.5) and Eva (9 / 9 / 9), and line-by-line by me before opening; the staleTime fix re-verified by Wren and me independently; the relay-reconnect test fix bisected and verified by me.