Files
PaperClipAI/ui/src/lib/query-invalidation-batcher.ts
T
DottaandClaude Opus 5.5 2a2d3f6e23 fix(ui): stop refetching the @task mention pool on every task event (#19931)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The board UI keeps its caches fresh from live events in
`LiveUpdatesProvider`
> - Each task activity event invalidates the whole `["issues",
companyId]` query prefix
> - That prefix also holds the `@task` mention pool: the 100 most
recently updated tasks, about 315 KB per response
> - A chat turn emits many comment, status and run events, so an open
task page downloads that list again for almost every event
> - This pull request keeps the pool fresh from push events without a
refetch per event: a closed server flag marks real membership or title
changes, progress on pooled tasks reorders the cache in place, and other
progress refetches at most once per 30 seconds
> - The benefit is less bandwidth and CPU for users with an open task or
chat, and smaller eval traces

## Linked Issues or Issue Description

**What happened**
While a task chat page is open, the browser repeats `GET
/api/companies/:id/issues?limit=100&sortField=updated&sortDir=desc`
(about 315 KB each). One 10-minute chat-eval attempt recorded about 194
of these requests. The trigger is `invalidateActivityQueries` in
`ui/src/context/LiveUpdatesProvider.tsx`. For every `entityType:
"issue"` activity event, it invalidates
`queryKeys.issues.list(companyId)`. That prefix matches
`queryKeys.issues.mentionPool(companyId)`, which `IssueDetail` (and
`NewIssueDialog`) keep active for the `@task` picker. Invalidation
refetches active queries and ignores `staleTime`.

**Expected behavior**
Comments, run progress and status changes on any task do not download
the full mention pool again. The pool still updates when a task that the
picker can show is created, deleted or renamed.

**Steps to reproduce**
1. Run `pnpm dev` and create a company with about 140 tasks.
2. Open a task page, for example `/REF/issues/REF-1`.
3. Every 4 seconds for 15 rounds, post comments on the open task, on a
task in the top 100, and on a task outside the top 100, and change the
open task's priority (60 events).
4. Count requests to `issues?limit=100&sortField=updated`. Before this
change there are 15 (about 3.3 MB). After this change there are 2 (about
480 KB).

## What Changed

- New shared helper `issueActivityChangesTaskIdentity`
(`packages/shared/src/issue-activity-identity.ts`). It is true for a
created, deleted, hidden, unhidden or renamed task.
- `server/src/realtime/live-events-ws.ts`: task `activity.logged` events
now carry the closed boolean `taskIdentityChanged`. Authenticated
sockets still get no `details`; the flag carries no task content.
- `ui/src/context/LiveUpdatesProvider.tsx` refreshes the mention pool
this way:
- The task identity flag is true (or full details show a create, delete,
hide, unhide or rename): the pool refetches at once.
- Other events on a task already in the pool: the task moves to the
front of the cached list. There is no request. The freshness clock does
not change, and a pending invalidation is never overwritten.
- Other events on a task outside the pool: one trailing refetch at most
every 30 seconds, because the task can now be in the top 100.
  - The picker is closed: the pool is only marked stale.
- `ui/src/lib/query-invalidation-batcher.ts`: new `excludingQueryKeys()`
keyed exclusion predicate. The batcher coalesces it and uses it for
prefix coverage. The broad `["issues", companyId]` invalidation uses it
to skip the pool, so a burst is again one broad refresh per batch.
- Tests: `ui/src/context/LiveUpdatesProvider.mention-pool.test.ts`
(authenticated event shape, hide and unhide, the 30-second window,
reorder safety, a burst through the batcher), new batcher tests,
`packages/shared/src/issue-activity-identity.test.ts`, and socket
assertions in `server/src/__tests__/live-events-toast.test.ts`.

## Verification

- `cd ui && npx vitest run src/context/LiveUpdatesProvider
src/lib/query-invalidation-batcher`: 231 tests pass.
- `cd server && npx vitest run src/__tests__/live-events-toast.test.ts`:
11 tests pass. In authenticated mode the socket keeps `details` off and
sends `taskIdentityChanged` (false for routine updates, true for a
create, rename or hide).
- `cd packages/shared && npx vitest run
src/issue-activity-identity.test.ts`: 14 tests pass.
- The new UI tests fail against the first commit of this PR: the
authenticated burst, hide, unhide, the 30-second window and the batcher
burst.
- `tsc --noEmit` for ui, server and shared: no new errors.
- Browser measurement: a local `pnpm dev` instance (local trusted),
headless Chromium on a task page, 141 tasks, 60 events over about 63
seconds (the reproduction steps above):
  - Before (master UI): **15** mention-pool requests, about 3.3 MB.
  - After: **2** requests, about 480 KB (one per 30-second window).
- Freshness after the fix: one rename, hide, unhide or create each
caused **1** refetch. A priority change caused **0**.
- I did not run a browser session in authenticated mode. The
authenticated event shape is covered by the socket test and the UI unit
tests.

## Risks

- A task that moves into the top 100 can be missing from the `@task`
picker for up to 30 seconds.
- The cached order is approximate between refetches: a moved task goes
to the front, but its `updatedAt` value is not changed.
- Other queries under `["issues", companyId]` (descendants,
created-from, summaries, onboarding first task) still refresh for each
event batch. They are small, but they can get the same treatment later.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code
execution in Claude Code.

## 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
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [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 updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] 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 5.5 <noreply@anthropic.com>
2026-11-07 20:55:39 -06:00

229 lines
9.3 KiB
TypeScript

import { getPageVisibility } from "./page-visibility";
import type { InvalidateQueryFilters, QueryClient } from "@tanstack/react-query";
/**
* Coalesces React Query invalidations triggered by the live-events stream.
*
* `LiveUpdatesProvider` used to call `queryClient.invalidateQueries(...)`
* synchronously for every websocket event. During an active agent run these
* fire many times per second, and each invalidation cascades into refetches
* and re-renders — the dominant source of steady-state CPU churn (and, over a
* long-lived tab, off-heap allocation growth).
*
* The batcher collects invalidation filters over a short window, de-duplicates
* identical ones, and flushes them in a single pass at most once per interval.
* A trailing-throttle (not a pure debounce) is used deliberately: during a
* continuous event stream a pure debounce would never flush, so UI updates
* would stall; throttling guarantees the buffered invalidations flush every
* `intervalMs`.
*/
export interface InvalidationBatcher {
/** Schedule an invalidation; resolves once the batched flush has run. */
schedule: (filters: InvalidateQueryFilters) => Promise<void>;
/** Flush any buffered invalidations immediately (e.g. before teardown). */
flush: () => Promise<void>;
dispose: () => void;
}
export const DEFAULT_INVALIDATION_INTERVAL_MS = 300;
type Deferred = { promise: Promise<void>; resolve: () => void };
function createDeferred(): Deferred {
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
return { promise, resolve };
}
export function createInvalidationBatcher(
queryClient: Pick<QueryClient, "invalidateQueries">,
intervalMs: number = DEFAULT_INVALIDATION_INTERVAL_MS,
): InvalidationBatcher {
// Keyed by a stable serialization of the filters so repeated invalidations of
// the same key (the common case) collapse to one entry.
const pending = new Map<string, InvalidateQueryFilters>();
let timer: ReturnType<typeof setTimeout> | null = null;
// Resolves when the currently-buffered window has been flushed. `schedule`
// hands this back so callers that await it observe the real invalidation.
let windowDeferred: Deferred | null = null;
const flush = async (): Promise<void> => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
const deferred = windowDeferred;
windowDeferred = null;
if (pending.size === 0) {
deferred?.resolve();
return;
}
const filtersList = removeCoveredFilters([...pending.values()]);
pending.clear();
try {
await Promise.all(filtersList.map((filters) => queryClient.invalidateQueries(
getPageVisibility().visible ? filters : { ...filters, refetchType: "none" },
)));
} finally {
deferred?.resolve();
}
};
const schedule = (filters: InvalidateQueryFilters): Promise<void> => {
pending.set(serializeFilters(filters), filters);
if (windowDeferred === null) {
windowDeferred = createDeferred();
}
if (timer === null) {
timer = setTimeout(() => void flush(), intervalMs);
}
return windowDeferred.promise;
};
const dispose = () => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
pending.clear();
// Release any awaiters so they don't hang forever after teardown.
windowDeferred?.resolve();
windowDeferred = null;
};
return { schedule, flush, dispose };
}
let uniqueFilterCounter = 0;
const EXCLUDED_QUERY_KEYS = Symbol("excludedQueryKeys");
type QueryKeyPrefix = readonly unknown[];
type ExclusionPredicate = ((query: { queryKey: readonly unknown[] }) => boolean) & {
[EXCLUDED_QUERY_KEYS]: readonly QueryKeyPrefix[];
};
function startsWithKey(key: readonly unknown[], prefix: QueryKeyPrefix): boolean {
if (prefix.length > key.length) return false;
return prefix.every((part, index) => JSON.stringify(part) === JSON.stringify(key[index]));
}
/**
* A `predicate` that skips queries under the given key prefixes. Unlike an
* arbitrary predicate, the batcher can read its exclusions, so repeated
* invalidations with the same key and exclusions still coalesce into one, and
* the filter still covers its non-excluded children in the same batch.
*/
export function excludingQueryKeys(...excluded: QueryKeyPrefix[]): ExclusionPredicate {
const predicate = ((query: { queryKey: readonly unknown[] }) =>
!excluded.some((prefix) => startsWithKey(query.queryKey, prefix))) as ExclusionPredicate;
predicate[EXCLUDED_QUERY_KEYS] = excluded;
return predicate;
}
function readExclusions(filters: InvalidateQueryFilters): readonly QueryKeyPrefix[] | null {
const predicate = filters.predicate as Partial<ExclusionPredicate> | undefined;
if (typeof predicate !== "function") return [];
return predicate[EXCLUDED_QUERY_KEYS] ?? null;
}
function filterBehaviorKey(filters: InvalidateQueryFilters): string {
return JSON.stringify([
filters.type ?? "all",
filters.refetchType ?? filters.type ?? "active",
filters.stale ?? null,
filters.fetchStatus ?? null,
]);
}
/** A collection prefix already refreshes its children in the same batch. */
function removeCoveredFilters(filters: InvalidateQueryFilters[]): InvalidateQueryFilters[] {
// Each prefix keeps the exclusions of every filter that names it; a plain
// prefix has none.
const prefixesByBehavior = new Map<string, Map<string, Array<readonly QueryKeyPrefix[]>>>();
const isScalar = (value: unknown) => value === null || typeof value === "string"
|| typeof value === "boolean" || typeof value === "number" && Number.isFinite(value);
for (const filter of filters) {
const exclusions = readExclusions(filter);
if (!exclusions || filter.exact || !filter.queryKey?.every(isScalar)) continue;
const behavior = filterBehaviorKey(filter);
const prefixes = prefixesByBehavior.get(behavior) ?? new Map<string, Array<readonly QueryKeyPrefix[]>>();
const keyJson = JSON.stringify(filter.queryKey);
prefixes.set(keyJson, [...(prefixes.get(keyJson) ?? []), exclusions]);
prefixesByBehavior.set(behavior, prefixes);
}
return filters.filter(filter => {
const ownExclusions = readExclusions(filter);
if (!ownExclusions || !filter.queryKey) return true;
const prefixes = prefixesByBehavior.get(filterBehaviorKey(filter));
if (!prefixes) return true;
const key = filter.queryKey;
// An excluded subtree overlapping this filter's subtree is not refreshed by
// the covering prefix, so that prefix does not cover this filter.
const coveredBy = (exclusions: readonly QueryKeyPrefix[]) =>
!exclusions.some((excluded) => startsWithKey(key, excluded) || (!filter.exact && startsWithKey(excluded, key)));
// A non-exact filter covers itself, so only check its shorter prefixes.
// A filter with exclusions is also covered by a plain filter on its own key.
const hasExclusions = ownExclusions.length > 0;
const last = filter.exact || hasExclusions ? key.length : key.length - 1;
for (let length = 0; length <= last; length += 1) {
if (length > 0 && !isScalar(key[length - 1])) break;
const entries = prefixes.get(JSON.stringify(key.slice(0, length)));
const sameKeyNonExact = length === key.length && !filter.exact;
if (entries?.some((exclusions) => sameKeyNonExact ? exclusions.length === 0 : coveredBy(exclusions))) return false;
}
return true;
});
}
function serializeFilters(filters: InvalidateQueryFilters): string {
// A predicate is an opaque function — two predicate-based filters can never be
// proven equal, so never coalesce them (that would silently drop one). Give
// each its own key.
const exclusions = readExclusions(filters);
if (exclusions === null) {
return `__predicate__:${(uniqueFilterCounter += 1)}`;
}
// Query selection and refetch behavior both belong to the identity. In
// particular, dropping an idle/fresh filter in favor of a paused/stale one
// can lose the only refresh that actually matches a cached query.
try {
return JSON.stringify([
filters.queryKey ?? null,
filterBehaviorKey(filters),
filters.exact ?? false,
...(exclusions.length > 0 ? [{ excluding: exclusions }] : []),
]);
} catch {
// Non-serializable filter (shouldn't happen for our query keys) — fall back
// to a unique key so it is never dropped.
return `__nonserializable__:${(uniqueFilterCounter += 1)}`;
}
}
/**
* Wrap a QueryClient so `invalidateQueries` is routed through `batcher` while
* every other method (reads, `setQueryData`, …) passes straight through to the
* real client. Returned value is a `QueryClient` and can be used anywhere one
* is expected. Private class fields keep working because methods are bound to
* the real client via `Reflect.get(target, prop, target)`.
*
* The batched `invalidateQueries` returns a promise that resolves only after the
* flush actually runs, so callers that await it still observe completion.
*/
export function createCoalescingQueryClient(
queryClient: QueryClient,
batcher: InvalidationBatcher,
): QueryClient {
return new Proxy(queryClient, {
get(target, prop) {
if (prop === "invalidateQueries") {
return (filters?: InvalidateQueryFilters) => batcher.schedule(filters ?? {});
}
const value = Reflect.get(target, prop, target);
return typeof value === "function" ? value.bind(target) : value;
},
});
}