perf(feed-debug): pace persistence appends by time instead of per runtimes replacement - #750
perf(feed-debug): pace persistence appends by time instead of per runtimes replacement#750Juliusolsson05 wants to merge 5 commits into
Conversation
Refs #748 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa
…times replacement The persistence hook flushed pending feed-debug entries on every runtimes replacement and re-drained immediately on success, so the append cadence followed the streaming cadence: 8,649–10,159 debug:append-feed-log invokes in a 77-minute run, 20/s in bursts, each a writeFile(flag:'a') plus a retention-prune schedule on the main thread (p95 352 ms, max 1.2 s). A pure policy now decides when to flush: the first batch after a quiet period goes out at once so the entry explaining a bad paint is on disk quickly, anything within 1.5 s of the last attempt rides one per-session timer, and a 256-entry ceiling forces a flush so a burst cannot pile up an outsized batch. The persisted/in-flight cursors and the one-append-in- flight rule are unchanged; a failed attempt counts as an attempt so a rejecting main does not turn streaming into a retry storm. Fixes #748 Refs #722 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa
…ling The ring is byte-capped at 4 MiB and evicts from the head, so an entry waiting on the pacing timer can be evicted before it is persisted. A count ceiling does not bound that: the #722 shape is a few hundred KB per visible_rows entry, where twenty entries already exceed the ring. A byte ceiling at a quarter of the ring, computed from the ring's own cached per-entry estimate, forces the flush long before eviction can reach unpersisted entries. Refs #748, #722 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa
…f losing them to the timer Review of #750: a session leaves runtimes on replacement, pane close, tab kill and reload, and its last entries — the exit code, the kill reason — are written in the final second. With pacing alone they sat on a timer that found no runtime when it fired; the pre-pacing hook shipped them from the same effect pass that appended them. Removed sessions now get one final, unpaced flush from their last runtime snapshot (parked until an in-flight append resolves), then their bookkeeping is dropped. A failed append also re-runs the policy so an idle session retries after the interval, and post-unmount resolutions no longer re-arm timers. Comments that still described per-tick shipping are corrected; the plan records the byte ceiling and the removal flush. Refs #748, #770, #771 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa
|
Review applied in 6a6f84a:
|
Juliusolsson05
left a comment
There was a problem hiding this comment.
Adversarial review (re-run after the pause; worktree 6a6f84a, all commands run locally).
VERDICT: REQUEST-CHANGES
Core pacing design is sound — pure policy, one timer/session, one-in-flight preserved, cursors untouched — but the rework commit introduced the issues below.
- BLOCKER — Removed-session final flush has no pacing: persistent rejection spins an unbounded retry loop.
useFeedDebugPersist.ts:157-163— thefinalpath callssend()wheneverremaining > 0without consultinglastAttemptAtRefordecideFeedDebugFlush. On rejection,.catch(line 137) re-invokesconsider(sessionId, undefined)→ session is gone fromlatestRuntimesRef→finalpath again →send()again. No interval, ceiling, or attempt cap — a tight loop for as long as main keeps rejecting (disk full/EACCES/EIO at close time, shape validation, or #771's fail-closed branch after its own fix). This reintroduces exactly the retry storm the PR exists to kill, and contradicts the PR body claim "A rejected append counts as an attempt … retries after the interval" (true only for live sessions). Fix: apply the interval to the final path too. No test covers rejection on a removed session. - SHOULD-FIX —
unmountedRefnever reset on (re)mount; permanentlytrueafter StrictMode's dev double-mount.useFeedDebugPersist.ts:73-77sets the flag in cleanup but the effect body never sets it back tofalse. App runs under<React.StrictMode>(main.tsx:79); after the simulated unmount/remount every.then/.catchbails — resolve-path pacing and failure retry are dead in dev. One-line fix:unmountedRef.current = falsein the mount-effect body. New tests render without StrictMode so they cannot catch this. - SHOULD-FIX — Soft reload bypasses the removal flush; up to 1.5 s of trailing entries is a new loss window. The removal flush only fires when a session key leaves the map (
useFeedDebugPersist.ts:202-208).softReloadAgentView/softReloadRuntimereplaces the runtime under the same key (session.ts:120-192;reset = emptyRuntime()at :142 restartsfeedDebugNextId), so no final flush fires: pending entries on the armed timer are silently dropped, and #770 keeps the new epoch off disk. Hard paths (replaceSession,reloadAgentSessions,killSession) do remove the key and are covered — the PR body's blanket claim is wrong for soft reload. Fix: detect the epoch reset (incomingfeedDebugNextIdbelow the previous pass's) and final-flush the previous runtime; at minimum correct the body and document the window. - NIT —
forget()doesn't clear the session's feed-debug cursors inWorkspaceRefs(useFeedDebugPersist.ts:141-149); entries leak for the app lifetime. - NIT — per-replacement allocation at
useFeedDebugPersist.ts:175:slice(-pendingCount)feeds the byte estimator dozens of times/second/session; an index-based tail sum avoids it. - NIT — tests:
installparam shadows the importedappendFeedDebugLog; missing coverage for findings 1-3.
Verified non-issues: byte ceiling at ¼ of the ring correctly bounds eviction risk while not in flight; countPendingFeedDebug tail scan consistent with selectFeedDebugAppendBatch under monotone ids including the #770 restart shape; armed-timer-at-removal race resolves benignly; #770/#771 behaviors unchanged (correctly separate).
Commands: renderer persistence suite 22/22 PASS; unit persistence+feedDebug 13/13 PASS; npm run typecheck PASS.
…unts The removal path's rejection handler re-enters the final branch with the session gone, so an unpaced final flush retried once per microtask for as long as main kept rejecting — the retry storm this PR exists to kill, resurrected on the removal path. The first final flush stays immediate (removal is one append, and delaying it would drop exactly the exit-code entries it exists to persist); only retries the final branch itself made inside the interval wait, on a dedicated per-session attempt stamp the live cadence cannot contaminate. The unmount-only effect also re-arms its flag in the effect body: React 18 StrictMode's simulated unmount left it permanently true, deadening every later resolve/reject callback in dev. Addressses the blocker and StrictMode finding from the round-2 review.
|
Round-3 rework pushed (f622997):
Verification: renderer persistence suite 10/10 (incl. 2 new), unit persistence + feedDebug 13/13, |
Problem
useFeedDebugPersistruns in auseEffectkeyed on theruntimesmap, which is replaced dozens of times per second while a turn streams. Every replacement that found new feed-debug entries sent them to main immediately and the success path re-drained at once, so the append cadence followed the streaming cadence. Perf journal evidence (#748): 8,649–10,159debug:append-feed-loginvokes in a 77-minute run (111/min average, 20/s in bursts); 44 calls ≥ 50 ms, max 206 ms; on the Sep 1 run p50 87 ms, p95 352 ms, max 1,159 ms. Each invoke is awriteFile(flag:'a')and a retention-prune schedule on the main thread.Behaviour
feedDebugFlushPolicy.ts,decideFeedDebugFlush) decides when a session's pending entries go out: the first batch after a quiet period flushes immediately; anything that arrives withinFEED_DEBUG_FLUSH_INTERVAL_MS(1.5 s) of the last attempt waits for one per-session timer;FEED_DEBUG_FLUSH_MAX_PENDING(256 entries) orFEED_DEBUG_FLUSH_MAX_PENDING_BYTES(1 MiB, from the ring's cached per-entry estimate) forces an immediate flush.(sessionId, id)on the main side still hold.runtimes(replacement, pane close, tab kill, hard reload — soft reload replaces the runtime under the same key and does not fire the removal flush; its pending entries ride the armed timer into the next epoch, tracked in bug(feed-debug): soft reload restarts entry ids below the persisted cursor so persistence silently stops #770) gets one final, unpaced flush of its trailing entries from its last snapshot — parked until an in-flight append resolves if there is one — then its timers/cursors are dropped. Those entries (exit code, kill reason) are what debug bundles read for closed panes.Design decisions
pagehideflush: aninvokecannot be awaited past teardown, so up to 1.5 s of feed-debug can be lost at window close or on a hard renderer crash. Session removal is NOT in that category — it flushes synchronously with the removal (see Behaviour).Linked issues
Fixes #748. Refs #722, #103.
Verification
feedDebugFlushPolicy.test.ts(6): immediate on first batch and after the interval; timer for the remainder inside it; forced at the count ceiling; forced at the byte ceiling before the count is reached; nothing while in flight or with nothing pending; tail-scan pending count.useFeedDebugPersist.renderer.test.tsx(8, fake timers, stubbedwindow.api.appendFeedDebugLog): 20 replacements inside the interval produce one immediate append and exactly one paced append carrying all 20 entries in order; entries arriving while an append is in flight are paced on resolve (not drained); the count ceiling forces a flush inside the interval; three ~512 KB entries force a flush via the byte ceiling; a removed session's trailing entries are flushed at once, and once its in-flight append resolves; a rejected append keeps entries pending and retries no sooner than the interval with the full range; unmount clears the timer.--project renderer src/renderer/src/workspace/hook/persistence/— 22 passed;--project unitpersistence +feedDebug.test.ts— 13 passed;npx tsc -b— clean.Limitations / follow-ups
runtimesreplacement per frame that drives this effect (and many others) is a structural item being audited separately; this PR only stops it from turning into disk writes.feedDebugLog.tsis unchanged; with ~1 append/1.5 s/session its per-batch stat/prune scheduling is no longer hot.🤖 Generated with Claude Code
https://claude.ai/code/session_01Kk16MNVJtWAnCeGxGRuHqa