Skip to content

perf(desktop): persist channel heads, collapse thread reads and reply sends - #6572

Open
tlongwell-block wants to merge 14 commits into
mainfrom
meli/gui-10x-latency
Open

perf(desktop): persist channel heads, collapse thread reads and reply sends#6572
tlongwell-block wants to merge 14 commits into
mainfrom
meli/gui-10x-latency

Conversation

@tlongwell-block

@tlongwell-block tlongwell-block commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

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, CommunityQueryProvider kicks off hydration of up to 12 heads when it constructs the query client — the app, splash and relay preconnect mount immediately; only useChannelMessagesQuery awaits the seed (channelHeadHydration), then consumes a one-shot hydrated gate so a hydrated channel pays zero get_channel_window calls 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 localStorage messageSnapshot.ts (removed, -401 lines).

Kill switch: VITE_BUZZ_CHANNEL_HEAD_CACHE=off at build time or localStorage["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_query shared with the window path). Renderer useThreadReplies drops its two follow-up aux fetches. next_cursor is computed from reply-kind rows only since aux rows are unpaged. Documented in docs/bridge-channel-window.md. Thread queries keep staleTime: 0 (bcfe04e2f): an earlier revision raised it to 30s, which CI's thread-unread.spec.ts caught — 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_message gains root_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 that resolve_thread_ref used to make. Strict hex parse; root_event_id requires parent_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; 8133d70bb makes 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 query staleTime). 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 refetches users-batch but resolves from the still-fresh per-pubkey entry); your own edits still evict the entry immediately (evictUsersBatchEntries in useUpdateProfileMutation).

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):

  • 4f06b7770 fix(desktop): mount app while channel heads hydrate; always revalidate — provider no longer gates children on the cache load; refreshAfterSubscribe runs on subscribe failure too; bounds-only heads skipped at seed; seed merges into an existing window store. +3 tests.
  • 35834cb31 fix(relay): drain aux closure hops across the page clamp — query_all_pages walks the (created_at, id) keyset via until/before_id until a short page (AUX_PAGE_LIMIT = DEFAULT_MAX_PAGE_LIMIT, AUX_MAX_PAGES = 64 warn+truncate) so one-shot limit: 1000 newest-first no longer drops the oldest edits/deletions. +3 tests; docs/bridge-channel-window.md updated.
  • db21b0531 merge of origin/main e23632941 (Downgrade mobile Huddles to audio protocol v2 #6558, Polish Huddle participant interactions #6312 — no overlap).
  • 5a5566c0f fix(desktop): sequence post-subscribe refresh behind channel head hydration — refreshChannelWindowMessages awaits channelHeadHydration() 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-core fetch() only cancels when state.data exists), which returned the seeded snapshot — 0 authoritative fetches. Regression test reproduces Carl's exact ordering (fails at 35834cb31 with 0 calls), plus a cold-channel guard that the fix does not double-fetch.
  • b129231c8 fix(desktop): let concurrent post-hydration refreshes share one window fetch — found independently by Max and Wren reviewing 5a5566c0f: subscribe settlement + reconnect both wake on the same snapshot promise and both invalidate; the second (default cancelRefetch: 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 with cancelRefetch: false so a second waker joins the in-flight fetch; cold/warm keep the default (test_canceled_stale_fetch_cannot_overwrite_catch_up_window relies on it). Concurrent regression test fails at 5a5566c0f with 3.

Testing

At b129231c8 (PR head; verified in one shell with git rev-parse HEAD = b129231c8): pnpm check, tsc --noEmit, desktop unit 5,393 / 0, Playwright boot-splash + channel-head-restart + relay-reconnect + relay-reconnect-affordance + thread-unread 34 / 34 on a fresh build:e2e, pre-push hooks green.

At 5a5566c0f: pnpm check, tsc --noEmit, desktop unit 5,392 / 0, Playwright boot-splash + channel-head-restart + relay-reconnect + relay-reconnect-affordance + thread-unread 34 / 34 on a fresh build:e2e, pre-push hooks green.

At 35834cb31: desktop unit 5,390 / 0; cargo test -p buzz-relay --lib 910 / 0; fmt + clippy -D warnings clean; 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 is 0c492366d = 8133d70 + a comments-only commit correcting two profile/hooks.ts freshness comments from 60s to 10 min; pre-push desktop check/typecheck/test 5,387/0 re-ran at 0c49236) in one shell; origin/main = 040b203f7 at PR open, since moved to 4baccd539 (#6558, mobile only — zero file overlap, git merge-tree clean):

  • 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)
  • Playwright smoke relay-reconnect.spec.ts + thread-unread.spec.ts + channel-head-restart.spec.ts — 30/30 (thread-unread was 8/13 at 7acbf951b; relay-reconnect was 15/16 at bcfe04e2f). 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 one get_channel_window after open, and the stale row is removed when the authoritative page lands.
  • pnpm typecheck, pnpm check — clean

At 7acbf951b (everything except the two-line useThreadReplies.ts staleTime revert and the test-only relay-reconnect.spec.ts change), 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 failed
  • just check components: fmt-check, clippy, desktop-check, desktop-typecheck, desktop-tauri-fmt-check, desktop-tauri-clippy, web-check, mobile-check, file-size-check — all green
  • just desktop-build, web-build, desktop-tauri-check, mobile-test (1,661 passed) — all green

CI note: the "Unit Tests" job goes red on this PR and on main whenever it hits a poisoned rust-cache entry (an empty-directory skeleton of target/sherpa-onnx-prebuilt that sherpa-onnx-sys build.rs trusts), surfacing as could not find native static library sherpa-onnx-c-api in buzz-voice — a crate this PR doesn't touch. Deleting the cache entry and rerunning turned the job green at 0c492366d (28/28); it re-poisons on the next main push 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.

Perci and others added 6 commits August 22, 2026 11:36
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>
@tlongwell-block
tlongwell-block requested a review from a team as a code owner August 22, 2026 17:15
Wren and others added 3 commits August 22, 2026 14:00
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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Requesting changes for four correctness/lifecycle regressions at 0c492366d61de62dbc01aeae32d5f83390b243b8:

  1. Do not hide the entire app behind cache hydration. CommunityQueryProvider renders no children until channel_head_cache_load settles (desktop/src/app/App.tsx:217-269), but both the boot splash and AppReady live 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.

  2. 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 after subscribeToChannelLive succeeds (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.

  3. 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). ChannelScreen then treats that channel as settled regardless of dataLength (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.

  4. 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 unpaged limit=1000 query 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.

Meli added 2 commits August 22, 2026 19:13
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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Meli added 2 commits August 23, 2026 11:22
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>
@tlongwell-block

Copy link
Copy Markdown
Collaborator Author

Re-review 5002596542 addressed at 5a5566c0f (merge of origin/main e23632941 as db21b0531 first; no rebase, no force).

Fix — desktop/src/features/messages/lib/projectChannelWindow.ts, refreshChannelWindowMessages: await channelHeadHydration() before invalidating, and when the query holds a hydration-seeded snapshot (data !== undefined && dataUpdatedAt === 0, which only the seed writes) await the in-flight snapshot fetch's promise so the mount gate is consumed first. The invalidation then cancels nothing and starts a distinct get_channel_window fetch. One helper covers the subscribe-success, subscribe-failure, and reconnect paths. Cold/warm channels carry no marker and dedupe/cancel exactly as before.

Mechanism confirmed in @tanstack/query-core@5.100.14 query.js:184-193: fetch() only cancels an in-flight fetch when state.data !== undefined; otherwise it returns the existing retryer promise.

Tests — channelHeadCache.test.mjs:

  • a live subscription that settles before a slow cache load still revalidates — your exact ordering: 150 ms channel_head_cache_load, immediately-resolved subscribeToChannelLive, subscription and query mounted together. Asserts 1 get_channel_window call and the relay row replacing the persisted one. At 35834cb31 this fails with actual: 0, expected: 1.
  • a cold channel with an immediate live subscription fetches the window once — guards against the fix regressing dedupe into a double fetch.

Verification at 5a5566c0f: pnpm check, tsc --noEmit, desktop unit 5392/5392, Playwright boot-splash + channel-head-restart + relay-reconnect + relay-reconnect-affordance + thread-unread 34/34 on a fresh build:e2e.

…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>
@tlongwell-block

Copy link
Copy Markdown
Collaborator Author

Follow-up at b129231c8 (new commit on top, no rebase): reviewing 5a5566c0f, Max and Wren independently found that two concurrent callers of refreshChannelWindowMessages (subscribe settlement + reconnect) wake on the same snapshot promise and both invalidate; the second invalidation's default cancelRefetch: true cancelled and replaced the first authoritative fetch — 3 queryFn calls where 2 suffice, and the cancelled Tauri invoke still reaches the relay.

Fix: on the seeded branch only, invalidateQueries(..., { cancelRefetch: false }) so a second waker joins the in-flight authoritative fetch. Cold/warm channels keep the default (test_canceled_stale_fetch_cannot_overwrite_catch_up_window depends on replacing a stale active fetch).

Test test_concurrent_refreshes_after_seeded_snapshot_share_one_authoritative_fetch (projectChannelWindow.test.mjs): fails at 5a5566c0f with actual: 3, expected: 2; passes at head with the second request un-aborted and the relay gap row projected.

At b129231c8: pnpm check, tsc --noEmit, desktop unit 5,393/0, Playwright boot-splash + channel-head-restart + relay-reconnect ×2 + thread-unread 34/34 on a fresh build:e2e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants