Files
PaperClipAI/ui/src/lib/query-invalidation-batcher.test.ts
T
DottaandClaude Opus 4.8 3e348b96b9 perf(ui): cut live-updates churn that inflates tab memory (#9624)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Its web UI keeps live views (issue threads, run transcripts,
dashboard) fresh via React Query polling plus a live-events websocket,
coordinated across tabs with a `BroadcastChannel` layer
> - Long-lived tabs viewing live agent runs grew to multi-GB memory
footprints while their JS heap stayed ~60–200 MB — so the memory is
off-heap (Blink/native + committed allocator arenas), not a classic JS
leak
> - Live profiling of a reproduced 2.7 GB / 66 MB tab showed 15–30% idle
CPU, ~3 fetches/sec across overlapping poll loops, and ~8 `setInterval`
create/clear cycles per second whose rate grew ~7× as the tab aged —
relentless allocation churn that inflates committed memory the OS never
reclaims, amplified across tabs by the cross-tab fan-out
> - This pull request cuts that churn at its four largest sources
(invalidation storm, per-instance 1 s timers, redundant polling,
unbounded streamed-run set)
> - The benefit is that idle tabs do far less periodic work, so their
off-heap footprint stops ballooning over a long session

## Linked Issues or Issue Description

No public GitHub issue exists; describing inline per CONTRIBUTING.md →
"Link Issues or Describe Them In-PR", following the bug report template.

**What happened?**

Browser tabs viewing live agent runs grew to 8–16 GB memory footprint
over a long session (multiple tabs open), while each tab's "live" JS
heap stayed only ~150–250 MB. Every idle tab also burned 15–30% CPU.
Tabs eventually approached the ~4 GB V8 heap ceiling / OS pressure and
could crash.

**Expected behavior**

Tabs viewing live runs should hold a bounded footprint and do minimal
work while idle, regardless of how long they stay open or how many tabs
are open.

**Steps to reproduce**

Open several issue/run tabs that have agents actively streaming and
leave them open for a while. Watch Chrome's Task Manager: Memory
Footprint climbs into the GBs while "JavaScript Memory" stays small, and
CPU stays high on idle tabs. Reproduced in ~90 minutes: a tab reached
2.7 GB footprint on a 66 MB JS heap, and the per-second timer-churn rate
was ~7× higher on a 90-minute-old tab than a fresh one.

**Paperclip version or commit**

Branch `fix/live-updates-churn`, off `master`.

**Deployment mode**

Local dev (`pnpm dev`), web UI. Not adapter-specific — core UI
live-updates plumbing (observed with `claude_local` / `codex_local`
runs).

## What Changed

- **`ui/src/lib/query-invalidation-batcher.ts` (new)** —
`createInvalidationBatcher` throttles + de-dupes React Query
invalidations into one flush per ~300 ms, and
`createCoalescingQueryClient` wraps the client via a `Proxy` so only
`invalidateQueries` is batched (optimistic `setQueryData` writes stay
immediate). Wired into `LiveUpdatesProvider`, which previously
invalidated synchronously on every websocket event.
- **`ui/src/hooks/useSecondTick.ts` (new)** — one shared, ref-counted,
page-wide 1 s ticker. `useLiveElapsed` in `IssueChatThread` now uses it
instead of a per-instance `setInterval` that forced a full-thread
re-render every second per live element.
- **`ui/src/components/transcript/useLiveRunTranscripts.ts`** — when the
realtime websocket is enabled, the recurring log poll backs off to a 30
s safety-net cadence instead of polling every 2 s on top of the live
stream. Added a marker for the durable poll→push rearchitecture.
- **`ui/src/lib/issueChatTranscriptRuns.ts`** —
`resolveIssueChatTranscriptRuns` now caps the streamed run set
(live/active runs always kept; most-recent linked runs fill up to 20) so
a large run history can't open a live-transcript poll per historical
run.
- **`ui/src/main.tsx`** — explicit `gcTime` so cross-tab-published cache
entries for unobserved resources are collected promptly.
- Tests for the batcher, shared ticker, and run cap.

## Verification

- `vitest`: new suites `query-invalidation-batcher.test.ts` (batcher
collapses 20 invalidations → 1 flush; keeps distinct keys/variants;
dispose cancels; proxy passes non-invalidate methods through),
`useSecondTick.test.tsx` (single ref-counted timer, stops when idle),
`issueChatTranscriptRuns.test.ts` (cap keeps newest + live). All pass.
- Existing affected suites pass: `LiveUpdatesProvider` (23),
`IssueChatThread` (), `useLiveRunTranscripts`,
`AgentDetail.instructions` — 109 tests across affected files.
- `tsc -b` clean.
- Behavior confirmed by live profiling before the change (2.7 GB / 66 MB
tab, ~8 interval churns/sec growing 7× with age). Runtime churn
reduction should be re-measured against a rebuilt bundle with the same
instrumentation.

## Risks

Low-to-moderate; all changes reduce work rather than add features.
- **Invalidation batching** delays live-driven refetches by up to ~300
ms. Optimistic `setQueryData` writes (e.g. the visible issue's new
comment) remain immediate, so foreground updates still feel instant;
only the safety-net refetch is throttled. Non-live invalidations (user
actions, mutations) are unaffected — they use the real client.
- **Poll back-off** relies on the websocket as the live source when
realtime is enabled; a 30 s fallback poll still covers gaps/reconnects
(both the transcript hook and `LiveUpdatesProvider` also
auto-reconnect).
- **Run cap (20)** means an issue with a very large run history streams
live transcripts only for its live/active + 20 most-recent runs; older
runs still open normally via their run pages.
- Downstream test fallout (timing-sensitive tests around
invalidation/polling) may need adjustment — flagged intentionally for
follow-up.

Durable follow-up (out of scope, marked in code): replace transcript/run
polling with server push (SSE/websocket deltas) so idle tabs do no
periodic work at all.

## Model Used

- **Provider:** Anthropic, via the Claude Code CLI.
- **Model:** Claude Opus 4.8 (`claude-opus-4-8`).
- **Reasoning mode:** Extended thinking enabled.
- **Capabilities used:** tool use (shell, file editing), sub-agent
fan-out for codebase analysis, and the Chrome DevTools MCP to reproduce
and profile the memory/CPU churn on a live instance.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work (a bug/perf fix, not planned core feature work)
- [x] I have searched GitHub for duplicate or related PRs and linked
them above (none found)
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have considered and documented any risks above
- [ ] I have updated relevant documentation to reflect my changes (N/A —
no user-facing docs; rationale documented inline)
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 14:17:37 -05:00

142 lines
4.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { QueryClient } from "@tanstack/react-query";
import {
createCoalescingQueryClient,
createInvalidationBatcher,
} from "./query-invalidation-batcher";
function fakeClient() {
const calls: unknown[] = [];
const client = {
invalidateQueries: vi.fn((filters?: unknown) => {
calls.push(filters);
return Promise.resolve();
}),
};
return { client, calls };
}
describe("createInvalidationBatcher", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("coalesces repeated invalidations of the same key into one call per window", () => {
const { client } = fakeClient();
const batcher = createInvalidationBatcher(client, 300);
for (let i = 0; i < 20; i++) batcher.schedule({ queryKey: ["dashboard", "c1"] });
expect(client.invalidateQueries).not.toHaveBeenCalled(); // nothing until flush
vi.advanceTimersByTime(300);
expect(client.invalidateQueries).toHaveBeenCalledTimes(1);
expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: ["dashboard", "c1"] });
});
it("keeps distinct keys and distinct refetchType variants separate", () => {
const { client } = fakeClient();
const batcher = createInvalidationBatcher(client, 300);
batcher.schedule({ queryKey: ["a"] });
batcher.schedule({ queryKey: ["b"] });
batcher.schedule({ queryKey: ["a"] }); // dup of first
batcher.schedule({ queryKey: ["a"], refetchType: "inactive" }); // distinct variant
vi.advanceTimersByTime(300);
expect(client.invalidateQueries).toHaveBeenCalledTimes(3);
});
it("never coalesces predicate-based invalidations", () => {
const { client } = fakeClient();
const batcher = createInvalidationBatcher(client, 300);
// Two distinct predicate filters must both run — they can't be proven equal.
batcher.schedule({ predicate: () => true });
batcher.schedule({ predicate: () => false });
vi.advanceTimersByTime(300);
expect(client.invalidateQueries).toHaveBeenCalledTimes(2);
});
it("schedule() resolves only after the flush has invalidated", async () => {
const { client } = fakeClient();
const batcher = createInvalidationBatcher(client, 300);
let resolved = false;
const p = batcher.schedule({ queryKey: ["a"] }).then(() => {
resolved = true;
});
await Promise.resolve();
expect(resolved).toBe(false); // not yet — window still open
expect(client.invalidateQueries).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
await p;
expect(resolved).toBe(true);
expect(client.invalidateQueries).toHaveBeenCalledTimes(1);
});
it("starts a fresh window after flushing", () => {
const { client } = fakeClient();
const batcher = createInvalidationBatcher(client, 300);
batcher.schedule({ queryKey: ["a"] });
vi.advanceTimersByTime(300);
expect(client.invalidateQueries).toHaveBeenCalledTimes(1);
batcher.schedule({ queryKey: ["a"] });
vi.advanceTimersByTime(300);
expect(client.invalidateQueries).toHaveBeenCalledTimes(2);
});
it("dispose cancels a pending flush", () => {
const { client } = fakeClient();
const batcher = createInvalidationBatcher(client, 300);
batcher.schedule({ queryKey: ["a"] });
batcher.dispose();
vi.advanceTimersByTime(1000);
expect(client.invalidateQueries).not.toHaveBeenCalled();
});
it("flush() invalidates immediately", () => {
const { client } = fakeClient();
const batcher = createInvalidationBatcher(client, 300);
batcher.schedule({ queryKey: ["a"] });
batcher.flush();
expect(client.invalidateQueries).toHaveBeenCalledTimes(1);
});
});
describe("createCoalescingQueryClient", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("batches invalidateQueries but passes other methods straight through", () => {
const setQueryData = vi.fn();
const getQueryData = vi.fn(() => ({ some: "data" }));
const realInvalidate = vi.fn(() => Promise.resolve());
const real = { invalidateQueries: realInvalidate, setQueryData, getQueryData } as unknown as QueryClient;
const batcher = createInvalidationBatcher(
{ invalidateQueries: realInvalidate } as unknown as QueryClient,
300,
);
const proxied = createCoalescingQueryClient(real, batcher);
// setQueryData / getQueryData pass through immediately.
proxied.setQueryData(["k"], 1);
expect(setQueryData).toHaveBeenCalledWith(["k"], 1);
expect(proxied.getQueryData(["k"])).toEqual({ some: "data" });
// invalidateQueries is deferred through the batcher.
void proxied.invalidateQueries({ queryKey: ["k"] });
void proxied.invalidateQueries({ queryKey: ["k"] });
expect(realInvalidate).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(realInvalidate).toHaveBeenCalledTimes(1);
});
});