perf(desktop): de-block thread aux from first paint, page 500, cache reopens - #6446
perf(desktop): de-block thread aux from first paint, page 500, cache reopens#6446wpfleger96 wants to merge 6 commits into
Conversation
…reopens A 300-reply thread cold-open paid four serial relay legs before anything painted: two content pages (limit 200) followed by two aux waves for edits/deletions/reactions over all reply ids. staleTime:0 re-ran the whole pipeline on every reopen. Raise THREAD_PAGE_LIMIT to the server-clamped 500 so a <=500-reply thread fetches its content in one page. Resolve loadThreadReplies with content as soon as the page loop completes and hydrate aux into the same thread-replies cache via a functional setQueryData merge, off the critical path -- the exact pattern the channel timeline already ships. Set a 30s staleTime so reopening a recently-loaded thread is a cache hit; the live subscription keeps the subscribed channel's cache fresh while the bound guarantees an unsubscribed thread refetches to pick up edits/deletions it missed. The merge folds aux over whatever content is current, so a live append that lands mid-flight is preserved and a message a later refetch dropped is never resurrected. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The suite exercised backfillThreadAux() in isolation but never ran the loader through the de-block seam, so it stayed green if the fire-and-forget dispatch regressed to an await (recreating the first-paint latency this change removes) or was dropped entirely. Make loadThreadReplies injectable at the relay boundary and add behavioral tests proving content resolves while aux is pending, aux merges into the thread cache after content resolves, and aux rejection leaves the content query successful. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
ddfd716 to
0981d68
Compare
The 30s staleTime added for the reopen-storm win made an inactive channel's thread-replies cache authoritative on reopen. Switching channels disposes that channel's live subscription, so replies emitted while it is inactive never reach the cache; reopening a thread within the window rendered stale topology and unread state (unread divider, subtree badges, read-clear all computed against pre-switch data). Deterministically broke five thread-unread smoke cases. Invalidate the channel's thread-replies queries when its live subscription (re)establishes, mirroring the channel window's existing resubscribe refresh. This keeps the warm same-channel reopen win (that cache stays subscribed and never re-establishes) while a return-after-switch refetches the missed events. Cached rows still paint immediately via stale-while-revalidate. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Reviewed base 2edacde4d4c01490834725774aa878dbc373c41d through head beb5b5348b16eda96284c7f06bda2303e8019729.
No blocking correctness finding. I traced the cold thread read, structural/reaction aux hydration, live content and aux producers, query-key ownership, warm reopen, channel switch/reconnect invalidation, unread consumers, and the 500-row bridge/Tauri pagination boundary.
Two non-blocking follow-ups:
- Please add a seam test for the load-bearing warm-cache producer contract: a live threaded content event updates only its
(channel, root)key, and a live edit/reaction updates the channel's existing thread keys. The new 30-secondstaleTimeis safe only while those paths keep subscribed caches current; the new tests cover invalidation and cold hydration but not these producers. - The
THREAD_PAGE_LIMITcomment overstates the exact boundary. A page with exactly 500 replies receives a non-null cursor because Tauri treats every full page as potentially incomplete, so the loader makes a second empty content request. The one-round-trip claim is true for<500, not≤500. I reproducedreplyCount: 500, contentFetchCalls: 2at the reviewed head.
Focused verification: the two changed desktop test files passed 11/11 at the reviewed head. GitHub CI is green for all applicable checks. I also tested the suspected "aux finishes before React Query commits content" race against a real QueryObserver; React Query's notification batching commits content before the aux cache write, and the final cache retained both content and aux, so I did not treat that as a defect.
The 30s staleTime is safe only while the active channel's live subscription keeps subscribed thread-replies caches current via appendMessage. Two producer paths carry that contract with load-bearing key scoping: a threaded content event writes only its own (channel, root) key, while an aux edit/reaction fans out across every thread key in the channel. Prior tests covered invalidation and cold hydration but not these producers. Also correct the THREAD_PAGE_LIMIT comment: a full 500-reply page returns a non-null cursor and costs a second empty request, so the single-round-trip claim holds for <500 replies, not <=500. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Reviewed base 2edacde4d4c01490834725774aa878dbc373c41d through exact head 4224bba043f700947cfb060dc361ab8304ffd7ba.
High — do not create a fresh thread cache entry from a live reply.
appendMessage now unconditionally calls setQueryData(threadRepliesKey(channelId, rootId), ...) for every live threaded content event. When that thread has never been opened, this creates a query whose only row is the new live reply and whose dataUpdatedAt is now. useThreadReplies then mounts that key with a 30-second staleTime, treats it as complete/fresh, and skips loadThreadReplies. Opening the thread during that window therefore shows only the newest live reply and silently omits its older subtree.
The subscription-time invalidation cannot cover this case because the key does not exist when subscription starts. The producer test also misses it because mountSubscription pre-seeds both thread keys before emitting events.
Please avoid making an absent/unobserved thread key fresh (for example, append only to an existing/fetching key, or immediately invalidate a newly created key), and add a regression for no key -> live reply -> mount -> history fetch runs and unions the live reply.
Non-blocking test gap: add 499/500/501 page-loop coverage. The E2E bridge returns a cursor only when rows remain, while production returns a cursor for every full 500-row page, so exact-500 behavior is not currently modeled.
Validation: I traced the query producers/consumers and independently reproduced the cache freshness contract with a direct QueryClient probe: creating the absent key made it fresh and mounting it did not fetch. GitHub CI is green at this head; I did not duplicate the CI-equivalent suite locally.
`appendMessage` wrote every live threaded reply into its `(channel, root)` thread-replies key with `setQueryData`, which builds the key when absent. Under the PR's 30s `staleTime` that minted a "complete/fresh" cache whose only row was the live reply for a never-opened thread, so opening it within the window skipped `loadThreadReplies` and silently dropped the entire older subtree. Under the prior `staleTime: 0` the mount always refetched, so this is the PR's own regression. Write through `setQueriesData` with an exact-key filter instead: like the aux fan-out just below, it routes through `findAll` and updates only an already-observed key, so a warm or in-flight thread still appends live while an absent thread stays absent and its next mount fetches the full relay subtree (which includes the persisted reply). Regressions: the freshness contract at the real QueryClient (no key -> live reply -> open runs the history fetch and unions the reply exactly once) and the production exact-500 page-loop contract (a full page returns a cursor, so 500 and 501 replies page twice), both mutation-verified. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Reviewed exact base 2edacde4d4c01490834725774aa878dbc373c41d through head a8b701c96deab98c90ba285d45da18e1a6152f2e.
High — preserve resubscribe invalidation across live thread-cache writes.
A previously opened thread can miss reply B while its channel is inactive. Returning to the channel correctly invalidates that existing cache at desktop/src/features/messages/hooks.ts:307-312, but before the thread reopens, any live threaded content or aux event updates it through setQueriesData at hooks.ts:345-357. TanStack delegates that to setQueryData; its manual success transition clears isInvalidated and stamps dataUpdatedAt to now. The cache therefore becomes fresh A+C while still missing B, and opening it inside the new 30-second staleTime skips loadThreadReplies and renders incomplete history.
The new absent-key fix is correct, but it only prevents minting a never-observed key; it does not preserve stale state on an existing invalidated key. The added tests exercise invalidation and producer writes separately, so they miss this composition.
Please preserve invalidation, or otherwise force a history refetch, across live content and aux merges into an invalidated thread cache. Add regressions for invalidate -> live content -> mount and invalidate -> live aux -> mount, proving the mount still fetches the complete relay subtree.
Source-only review per channel policy; I did not run builds or tests. All applicable GitHub checks are green at the reviewed head. Separately, the branch currently conflicts with main in three changed files; that is mergeability state, not the reason for this changes-requested review.
|
🤖 Closing as superseded by #6572, which shipped a different answer to the same thread-open latency problem: aux now returns inline from the relay ( |
Opening a long thread (~200-300 replies) in the desktop app was slow and flaky, and reopening the same thread re-ran the entire fetch from scratch. This is PR-2 of the desktop thread-load arc — the fetch-pipeline quick wins.
What was slow
A 300-reply cold open paid four serial relay legs before anything painted:
THREAD_PAGE_LIMITwas 200, so 300 replies took two serialget_thread_repliescalls.withThreadAuxfetched edits/deletions and reactions over all ~301 ids and blocked the resolve on them.staleTime: 0meant closing and reopening the panel re-ran the whole pipeline every time.Changes
THREAD_PAGE_LIMIT200 → 500. The bridge clamps a thread page toBRIDGE_THREAD_MAX_LIMIT(500) and the Tauri command caps it with.min(500), so 500 is the largest page the server serves — a ≤500-reply thread now cold-opens its content in a single round trip.loadThreadRepliesresolves with content replies as soon as the page loop completes. Structural aux (edits/deletions) and reactions hydrate asynchronously into the samethread-repliescache via a functionalsetQueryDatamerge — the exact pattern the channel timeline already ships (backfillAuxForMessages). Accepted tradeoff: an edited reply may briefly render its original text until the merge lands.staleTime0 → 30s onuseThreadRepliesanduseThreadRepliesForRoots, so reopening a recently-loaded thread is a cache hit. While the panel's channel is subscribed, the live WS subscription writes every new content and aux event into the thread-replies key (hooks.tsappendMessage), keeping the warm cache current. The finite bound exists precisely because that self-healing only covers the subscribed channel: edits/deletions/reactions arriving for a thread whose channel is not subscribed never reach the cache, so a 30sstaleTimeguarantees the next mount refetches and corrects them.appendMessagewrites each live threaded reply throughsetQueriesDatawith anexact: truefilter (the same create-safe primitive as the aux fan-out just below it), not a rawsetQueryData. A raw write builds the key when absent, which under the 30sstaleTimewould mint a "complete/fresh" cache holding just that one reply for a never-opened thread — the next open would skip the history fetch and render only the newest reply. Routing throughfindAllmeans a warm or in-flight thread still appends live, while an absent thread stays absent and its next mount fetches the full relay subtree.Safety
The async aux merge uses a functional
setQueryDataupdater keyed on the thread's(channelId, rootId), so it can only touch that thread's cache. It folds aux over whatever content is current rather than a fetch-time snapshot, so a live reply that appends mid-flight is preserved, and aux referencing a message a later refetch dropped simply renders against nothing — content is never resurrected. Both aux fetches stay best-effort: a failure logs and degrades to bare replies. The live-reply producer never creates a thread cache, so a warm reopen is only ever trusted for a thread that was actually loaded.Tests
Extends
useThreadReplies.test.mjs: aux merges into the content cache; both-fetches-fail degrades to bare replies; the merge folds over a live mid-flight append rather than the stale snapshot; the page-limit/staleTimeinvariants are pinned; and the production exact-500 cursor contract pages correctly at 499/500/501 replies.threadReplyFreshness.test.mjsdrives the real producer and consumer against a liveQueryClientto prove a live reply to a never-opened thread does not skip the mount history fetch and unions the reply exactly once.useChannelSubscriptionProducers.test.mjspins the producer key scoping (a reply stays in its own root's cache; an aux overlay fans out to existing thread caches).