Files
buzz/desktop/src/features/projects/assignmentOperationFetch.ts
Max Lampert b85d680fb1 perf(desktop): stop the Projects fan refetching on re-entry and running after leave (#6458)
Entering Projects fires a large fan: an exhaustive paginated relay
enumeration (projects/repos/tombstones), five 2,000-event work-item
queries plus assignment-operation scans, per-repo activity summaries,
and a local-repository filesystem scan. Measured on a large community
(101 issues / 258 PRs):

| query | measured cost |
|---|---|
| work-items (5 × 2,000-event REQs + assignment scans) | 3.5–3.9s |
| activity summaries | 4.1s |
| repository activity | 1.0–2.2s |
| local repository scan | 1.7s |

Two lifecycle bugs made the fan far more expensive than it needs to be:

- **Freshness windows guaranteed a full refetch on nearly every
re-entry** (60s enumeration, 30s work-items/activity, 10s local scan) —
i.e., the costs above were re-paid on almost every visit. Every local
write path already invalidates its keys explicitly (issue/PR mutations,
project creation, repo sync), so the short windows only served
remote-actor freshness. Raised to 5m/2m/2m with a 30m enumeration cache:
re-entries now paint from cache, and the fan re-runs at most every 2–5
minutes.
- **Leaving Projects left the whole fan running**, competing with the
next surface's channel fetches on the same relay connection. AbortSignal
is now threaded through the enumeration and assignment pagination loops
(optional params — behavior identical without a signal), and leaving the
surface cancels the work-items query. Deliberately NOT cancelled: the
enumeration (the always-mounted sidebar projects section observes it and
its 30m cache is valuable), repo snapshots and local scans (native work
that can't abort — cancelling would discard the finished result and
force the same clones again), and activity summaries (a single bounded
request).

Abort behavior is covered by red-first unit tests on both pagination
loops. Remaining follow-up (out of scope): the queries themselves want a
relay-side aggregate instead of shipping thousands of events to compute
counts client-side.

---------

Signed-off-by: Max Lampert <maxwell@squareup.com>
2026-08-21 17:40:35 -06:00

146 lines
5.6 KiB
TypeScript

import { relayClient } from "@/shared/api/relayClient";
import type { RelayEvent } from "@/shared/api/types";
import { KIND_TEXT_NOTE } from "@/shared/constants/kinds";
import {
ISSUE_ASSIGNMENT_LABEL,
ISSUE_UNASSIGNMENT_LABEL,
} from "./projectIssues.mjs";
type FetchEventsInput = Parameters<(typeof relayClient)["fetchEvents"]>[0];
const ASSIGNMENT_PAGE_LIMIT = 500;
/**
* The relay clamps every REQ page to this many rows regardless of the
* requested `limit` (`DEFAULT_MAX_PAGE_LIMIT` in `crates/buzz-db/src/event.rs`).
* A single second denser than this is unreachable through NIP-01 pagination,
* so the loop below reports it as an error instead of silently dropping
* operations.
*/
const RELAY_MAX_PAGE_LIMIT = 1_000;
/** Issue ids per relay query. Each id adds one JSONB containment clause to the
* relay's SQL, so batches are kept small enough to stay cheap while still
* collapsing typical projects into a single query. */
const ISSUE_ID_CHUNK_SIZE = 100;
function isAssignmentOperation(event: RelayEvent): boolean {
return event.tags.some(
(tag) =>
tag[0] === "t" &&
(tag[1] === ISSUE_ASSIGNMENT_LABEL ||
tag[1] === ISSUE_UNASSIGNMENT_LABEL),
);
}
/**
* Loads every assignment/unassignment operation for the given issues,
* paginating to exhaustion instead of trusting a bounded comment window.
*
* Why: assignment state is reduced from kind:1 operations (`t: assignment` /
* `t: unassignment`), but the general comment fetches are bounded (500 per
* repo in `hooks.ts`, 2,000 shared in `projectWorkItems.ts`). Once newer
* comments push an older operation out of that window, its assignee silently
* vanishes from the issue — and a later self-service operation can reduce
* against the wrong `prior` head.
*
* The filter deliberately carries ONLY constraints the relay pushes into SQL
* before applying `LIMIT`: kinds, `#e`, `until`, `limit` (see
* `filter_fully_pushable` in `crates/buzz-relay/src/handlers/req.rs`). Tag
* filters like `#t`/`#a` are post-filtered in Rust AFTER the SQL `LIMIT`, so
* including them would make a short page meaningless — the newest N candidate
* rows could all be post-filtered away while older matches remain, and the
* loop would declare exhaustion having seen nothing. Instead the query walks
* the full comment stream of the given issues (`#e` is pushed via JSONB
* containment) and the assignment labels are filtered locally.
*
* Pagination uses an inclusive `until` cursor with id-level dedupe. The relay
* orders `(created_at DESC, id ASC)`, so a full page whose oldest timestamp
* equals the cursor means a single second denser than the page: the loop
* escalates `limit` to the relay's hard page clamp once, and if the second is
* denser than even that, throws — the caller surfaces a failed assignments
* section instead of silently losing operations. NIP-01 filters cannot
* express the relay's composite `(created_at, id)` keyset cursor, so this is
* the strongest client-only guarantee available.
*/
export async function fetchAssignmentOperationEvents(
issueIds: string[],
fetchEvents: (
filter: FetchEventsInput,
) => Promise<RelayEvent[]> = relayClient.fetchEvents.bind(relayClient),
signal?: AbortSignal,
): Promise<RelayEvent[]> {
if (issueIds.length === 0) return [];
const chunks: string[][] = [];
for (let i = 0; i < issueIds.length; i += ISSUE_ID_CHUNK_SIZE) {
chunks.push(issueIds.slice(i, i + ISSUE_ID_CHUNK_SIZE));
}
const pages = await Promise.all(
chunks.map((chunk) =>
fetchIssueCommentsExhaustively(chunk, fetchEvents, signal),
),
);
const seen = new Map<string, RelayEvent>();
for (const page of pages) {
for (const event of page) {
if (isAssignmentOperation(event) && !seen.has(event.id)) {
seen.set(event.id, event);
}
}
}
return [...seen.values()];
}
async function fetchIssueCommentsExhaustively(
issueIds: string[],
fetchEvents: (filter: FetchEventsInput) => Promise<RelayEvent[]>,
signal?: AbortSignal,
): Promise<RelayEvent[]> {
const seen = new Map<string, RelayEvent>();
let limit = ASSIGNMENT_PAGE_LIMIT;
let until: number | undefined;
for (;;) {
// Leaving the Projects surface cancels its queries; stop queuing pages
// behind the next surface's fetches.
signal?.throwIfAborted();
const page = await fetchEvents({
kinds: [KIND_TEXT_NOTE],
"#e": issueIds,
limit,
...(until === undefined ? {} : { until }),
});
for (const event of page) {
if (!seen.has(event.id)) seen.set(event.id, event);
}
// Only SQL-pushed constraints are in the filter, so a short page is a
// true end-of-results signal.
if (page.length < limit) break;
const oldest = Math.min(...page.map((event) => event.created_at));
if (until === undefined || oldest < until) {
until = oldest;
continue;
}
// Full page and the inclusive cursor cannot advance: every row shares
// the cursor second. Widen to the relay's hard clamp so the whole second
// fits in one page; beyond that, no NIP-01 filter can reach the rest.
if (limit < RELAY_MAX_PAGE_LIMIT) {
limit = RELAY_MAX_PAGE_LIMIT;
continue;
}
throw new Error(
"Could not load assignment history: more than a full relay page of " +
"issue comments share one timestamp.",
);
}
return [...seen.values()];
}
/** Merge two event lists, dropping duplicates by event id. */
export function mergeEventsById(
base: RelayEvent[],
extra: RelayEvent[],
): RelayEvent[] {
const ids = new Set(base.map((event) => event.id));
return [...base, ...extra.filter((event) => !ids.has(event.id))];
}