Public: anyone can read this; the crew can change it.

A code grep holds its slot until git exits: strangers share 4 of 8, each member has 2, and an abandoned grep still counts #7123

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7111-a-code-grep-holds-its-slot-until-git-exi → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/7111-a-code-grep-holds-its-slot-until-git-exi was merged into main as ba0707e.

Review

Sign in to review this change.
8 files changed +305 −21
src/actions/search.ts +8 −2
@@ -94,6 +94,7 @@ export async function search(
9494 code: [],
9595 truncated: { repos: false, issues: false, code: false },
9696 moreCode: false,
97 codeSkipped: false,
9798 };
9899 if (q === "") return empty;
99100
@@ -144,8 +145,10 @@ export async function search(
144145 q,
145146 Math.min(limit * 5, MAX_CODE_HITS),
146147 input.codeBudgetMs ?? Infinity,
148 // The slot is keyed on the actor the session resolved, never on input.
149 { memberId: actor?.id },
147150 )
148 : { hits: [], truncated: false, more: false };
151 : { hits: [], truncated: false, more: false, skipped: false };
149152
150153 return {
151154 repos,
@@ -157,6 +160,7 @@ export async function search(
157160 code: code.truncated,
158161 },
159162 moreCode: code.more,
163 codeSkipped: code.skipped,
160164 };
161165 }
162166
@@ -232,6 +236,7 @@ export async function searchWithin(
232236 const hits = { repos: [] as RepoHit[], issues: [] as IssueHit[], code: [] as CodeHit[] };
233237 const truncated = { repos: false, issues: false, code: false };
234238 let moreCode = false;
239 let codeSkipped = false;
235240 for (const repo of repos) {
236241 if (!canRead(app.db, repo, actor)) continue;
237242 const one = await search(app, actor, { ...input, live: false, repo });
@@ -242,6 +247,7 @@ export async function searchWithin(
242247 truncated.issues ||= one.truncated.issues;
243248 truncated.code ||= one.truncated.code;
244249 moreCode ||= one.moreCode;
250 codeSkipped ||= one.codeSkipped;
245251 }
246 return { ...hits, truncated, moreCode };
252 return { ...hits, truncated, moreCode, codeSkipped };
247253 }
src/core/search.ts +56 −19
@@ -29,6 +29,7 @@ import { join } from "node:path";
2929 import { runGitCapture } from "../git/env.ts";
3030 import { isSafeRef } from "../git/guard.ts";
3131 import { repoDir } from "../git/repos.ts";
32 import { GREP_LIMITS, grepSlots, type GrepSlots } from "../search/grep_slots.ts";
3233 import { likeEscape } from "./issues.ts";
3334 import { listReposVisibleTo, type Repo } from "./repos.ts";
3435 import type { User } from "./users.ts";
@@ -129,6 +130,8 @@ export interface SearchResults {
129130 };
130131 /** True when the code budget ran out with repositories still ungrepped. */
131132 readonly moreCode: boolean;
133 /** True when a grep was refused a slot (`search/grep_slots.ts`): code results are partial. */
134 readonly codeSkipped: boolean;
132135 }
133136
134137 /**
@@ -462,12 +465,20 @@ async function grepCached(
462465 repo: Repo,
463466 dir: string,
464467 query: string,
465 ): Promise<RawHit[]> {
468 who: GrepWho,
469 ): Promise<RawHit[] | "skipped"> {
466470 const ref = repo.defaultBranch;
467471 const oid = await branchOid(dir, ref);
472 const slotted = async (at: string): Promise<RawHit[] | null | "skipped"> => {
473 const got = await (who.slots ?? grepSlots).run(who.memberId, () => {
474 grepsRun++;
475 return (who.grep ?? grepRun)(dir, at, query, MAX_HITS_PER_REPO);
476 });
477 return got.ran ? got.value : "skipped";
478 };
468479 if (oid === null) {
469 grepsRun++;
470 return (await grepRun(dir, ref, query, MAX_HITS_PER_REPO)) ?? [];
480 const hits = await slotted(ref);
481 return hits === "skipped" ? hits : (hits ?? []);
471482 }
472483 const key = `${repo.id}\0${oid}\0${cleanQuery(query)}`;
473484 const kept = grepCache.get(key);
@@ -476,8 +487,8 @@ async function grepCached(
476487 grepCache.set(key, kept);
477488 return kept.map((h) => ({ ...h }));
478489 }
479 grepsRun++;
480 const hits = await grepRun(dir, oid, query, MAX_HITS_PER_REPO);
490 const hits = await slotted(oid);
491 if (hits === "skipped") return hits;
481492 if (hits === null) return [];
482493 grepCache.set(key, hits.map((h) => ({ ...h })));
483494 if (grepCache.size > CACHE_MAX) {
@@ -486,6 +497,14 @@ async function grepCached(
486497 return hits;
487498 }
488499
500 /** Whose slot a grep takes; `grep` and `slots` are for tests to inject. */
501 export interface GrepWho {
502 /** The signed-in actor's id; undefined is a stranger. */
503 readonly memberId?: string | undefined;
504 readonly slots?: GrepSlots;
505 readonly grep?: typeof grepRun;
506 }
507
489508 /**
490509 * Grep across a bounded set of repos. Repos are searched a few at a time so
491510 * fifty small greps do not become fifty simultaneous processes, and the walk
@@ -499,28 +518,46 @@ export async function grepRepos(
499518 limit = MAX_CODE_HITS,
500519 /** No new batch starts after this many ms; the rest is reported as `more`. */
501520 budgetMs = Infinity,
502 ): Promise<{ hits: CodeHit[]; truncated: boolean; more: boolean }> {
521 who: GrepWho = {},
522 ): Promise<{ hits: CodeHit[]; truncated: boolean; more: boolean; skipped: boolean }> {
523 let skipped = false;
503524 const hits: CodeHit[] = [];
504525 const considered = repos.slice(0, MAX_SEARCH_REPOS);
505526 const batch = 4;
527 // Within a batch a member's greps run in pairs, so a search never
528 // starves itself of the member's own slots.
529 const width = who.memberId === undefined ? batch : GREP_LIMITS.perMember;
506530 const started = performance.now();
507531 for (let i = 0; i < considered.length; i += batch) {
508532 if (i > 0 && performance.now() - started >= budgetMs) {
509 return { hits, truncated: repos.length > MAX_SEARCH_REPOS, more: true };
533 return { hits, truncated: repos.length > MAX_SEARCH_REPOS, more: true, skipped };
510534 }
511535 const slice = considered.slice(i, i + batch);
512 const found = await Promise.all(
513 slice.map((repo) =>
514 grepCached(
515 repo,
516 repoDir(reposRoot, repo.ownerName, repo.name),
517 query,
518 ),
519 ),
520 );
536 const found: (RawHit[] | "skipped")[] = [];
537 for (let k = 0; k < slice.length; k += width) {
538 found.push(
539 ...(await Promise.all(
540 slice
541 .slice(k, k + width)
542 .map((repo) =>
543 grepCached(
544 repo,
545 repoDir(reposRoot, repo.ownerName, repo.name),
546 query,
547 who,
548 ),
549 ),
550 )),
551 );
552 }
521553 for (const [j, repo] of slice.entries()) {
522 for (const hit of found[j] ?? []) {
523 if (hits.length >= limit) return { hits, truncated: true, more: false };
554 const mine = found[j] ?? [];
555 if (mine === "skipped") {
556 skipped = true;
557 continue;
558 }
559 for (const hit of mine) {
560 if (hits.length >= limit) return { hits, truncated: true, more: false, skipped };
524561 hits.push({
525562 repo: { owner: repo.ownerName, name: repo.name },
526563 archived: repo.archivedAt !== null,
@@ -529,5 +566,5 @@ export async function grepRepos(
529566 }
530567 }
531568 }
532 return { hits, truncated: repos.length > MAX_SEARCH_REPOS, more: false };
569 return { hits, truncated: repos.length > MAX_SEARCH_REPOS, more: false, skipped };
533570 }
src/search/grep_slots.ts added +72
@@ -0,0 +1,72 @@
1 /**
2 * Slots for code greps. A `git grep` outlives the search that started it (a
3 * search that returns at its budget leaves its greps running for up to the
4 * grep timeout), so the slot is held until the grep settles, not until the
5 * search answers. Forge-wide there are `total`; signed-out callers together
6 * hold at most `strangers`; each member holds at most `perMember`, keyed on
7 * the actor's id and on nothing a client sends. Members together never take
8 * the strangers' share (`total - strangers`), so signed-in load cannot starve
9 * the strangers' promised slots.
10 */
11
12 export interface GrepLimits {
13 readonly total: number;
14 readonly strangers: number;
outdated

Four members at 2 each fill the total of 8 and leave strangers nothing, though they are promised 4. Reserve their share or document that they can be starved.

15 readonly perMember: number;
16 }
17
18 export const GREP_LIMITS: GrepLimits = { total: 8, strangers: 4, perMember: 2 };
19
20 export interface GrepSlots {
21 /**
22 * Run `grep` in a slot for `memberId` (undefined: a stranger). Resolves
23 * `{ ran: false }` without starting it when no slot is free; otherwise the
24 * slot is freed when `grep` settles, however it settles.
25 */
26 run<T>(
27 memberId: string | undefined,
28 grep: () => Promise<T>,
29 ): Promise<{ ran: true; value: T } | { ran: false }>;
30 /** Slots held now, for tests and diagnostics. */
31 held(): { total: number; strangers: number };
32 }
33
34 export function createGrepSlots(limits: GrepLimits = GREP_LIMITS): GrepSlots {
35 let total = 0;
36 let strangers = 0;
37 let membersHeld = 0;
38 const members = new Map<string, number>();
39
40 return {
41 async run(memberId, grep) {
42 if (total >= limits.total) return { ran: false };
43 if (memberId === undefined) {
44 if (strangers >= limits.strangers) return { ran: false };
45 strangers++;
46 } else {
47 const mine = members.get(memberId) ?? 0;
48 if (mine >= limits.perMember) return { ran: false };
49 if (membersHeld >= limits.total - limits.strangers) return { ran: false };
50 membersHeld++;
51 members.set(memberId, mine + 1);
52 }
53 total++;
54 try {
55 return { ran: true, value: await grep() };
56 } finally {
57 total--;
58 if (memberId === undefined) strangers--;
59 else {
60 membersHeld--;
61 const left = (members.get(memberId) ?? 1) - 1;
62 if (left <= 0) members.delete(memberId);
63 else members.set(memberId, left);
64 }
65 }
66 },
67 held: () => ({ total, strangers }),
68 };
69 }
70
71 /** The forge's one set of slots. */
72 export const grepSlots: GrepSlots = createGrepSlots();
src/tui/forge.ts +9
@@ -717,6 +717,15 @@ export function forgeSearch(session: Session): (ctx: Ctx) => PaletteSearch {
717717 hint: `${held.why}; retry in ${held.retryAfter}s`,
718718 run: () => ctx.toast("Sign in to search code without waiting.", "secondary"),
719719 });
720 if (found.codeSkipped)
721 items.push({
722 id: "code:skipped",
723 section: "Code",
724 mark: G.ring,
725 title: "Some code was not searched",
726 hint: "the forge is busy grepping; try again shortly",
727 run: () => ctx.toast("Code search is busy; try again shortly.", "secondary"),
728 });
720729 return items;
721730 };
722731 }
src/web/pages/search.ts +8
@@ -489,6 +489,14 @@ export async function searchPage(ctx: Ctx): Promise<Response> {
489489 ${String(busy)} s.
490490 </p>`,
491491 )}
492 ${when(
493 found.codeSkipped,
494 () =>
495 html`<p class="muted small" role="status" data-code-skipped>
496 Some code was not searched: the forge is busy grepping. Try again
497 shortly.
498 </p>`,
499 )}
492500 ${WORDS
493501 .filter(
494502 ([kind]) => kind !== "all" && (a.kind === "all" || a.kind === kind),
test/grep-slots.test.ts added +108
@@ -0,0 +1,108 @@
1 /** Code greps hold a slot until git settles, not until the search answers (#7123). */
2 import { expect, test } from "bun:test";
3 import { createGrepSlots } from "../src/search/grep_slots.ts";
4 import { grepRepos } from "../src/core/search.ts";
5 import type { Repo } from "../src/core/repos.ts";
6
7 const never = () => {
8 let settle!: () => void;
9 let began!: () => void;
10 const started = new Promise<void>((r) => (began = r));
11 const grep = () =>
12 new Promise<null>((done) => {
13 settle = () => done(null);
14 began();
15 });
16 return { grep, started, settle: () => settle() };
17 };
18 const repo = (i: number) =>
19 ({ id: `r${i}`, ownerName: "u", name: `r${i}`, defaultBranch: "main", archivedAt: null }) as Repo;
20
21 test("strangers hold 4, a member 2, and settling frees the slot", async () => {
22 const slots = createGrepSlots();
23 const hung = Array.from({ length: 4 }, never);
24 const strangers = hung.map((h) => slots.run(undefined, h.grep));
25 expect(slots.held()).toEqual({ total: 4, strangers: 4 });
26
27 // A fifth stranger is refused while a member's grep still runs.
28 const member = never();
29 const mine = slots.run("m1", member.grep);
30 expect(await slots.run(undefined, async () => 1)).toEqual({ ran: false });
31 expect(await slots.run("m1", async () => 1).then((r) => r.ran)).toBe(true);
32
33 // The member's second runs; the third is refused.
34 const second = never();
35 const again = slots.run("m1", second.grep);
36 expect(await slots.run("m1", async () => 1)).toEqual({ ran: false });
37 // Another member is not charged for m1's slots.
38 expect((await slots.run("m2", async () => 1)).ran).toBe(true);
39
40 // Settling frees exactly that slot.
41 second.settle();
42 await again;
43 expect((await slots.run("m1", async () => 1)).ran).toBe(true);
44 hung[0]!.settle();
45 await strangers[0];
46 expect((await slots.run(undefined, async () => 1)).ran).toBe(true);
47
48 for (const h of hung) h.settle();
49 member.settle();
50 await Promise.all([...strangers, mine]);
51 expect(slots.held()).toEqual({ total: 0, strangers: 0 });
52 });
53
54 test("the forge-wide total bounds members too, and a throwing grep frees its slot", async () => {
55 const slots = createGrepSlots({ total: 3, strangers: 1, perMember: 2 });
56 const a = never();
57 const b = never();
58 const pa = slots.run("a", a.grep);
59 const pb = slots.run("b", b.grep);
60 expect(await slots.run("c", async () => 1)).toEqual({ ran: false });
61 a.settle();
62 await pa;
63 await expect(slots.run("c", () => Promise.reject(new Error("x")))).rejects.toThrow("x");
64 expect(slots.held().total).toBe(1);
65 b.settle();
66 await pb;
67 });
68
69 test("a search refused a slot skips that repo and says so", async () => {
70 const slots = createGrepSlots({ total: 8, strangers: 4, perMember: 1 });
71 const hung = never();
72 // Settle only once the second repo has been refused, so the order is fixed.
73 let refused!: () => void;
outdated

This busy-wait loops forever if a regression means the grep never starts. Bound it or await an injected signal.

74 const wasRefused = new Promise<void>((r) => (refused = r));
75 const watched: typeof slots = {
76 held: slots.held,
77 run: async (id, grep) => {
78 const got = await slots.run(id, grep);
79 if (!got.ran) refused();
80 return got;
81 },
82 };
83 const search = grepRepos("/nowhere", [0, 1].map(repo), "q", 10, Infinity, {
84 memberId: "m1",
85 slots: watched,
86 grep: hung.grep as never,
87 });
88 await Promise.all([hung.started, wasRefused]);
89 hung.settle();
90 const out = await search;
91 expect(out.skipped).toBe(true);
92 expect(out.hits).toEqual([]);
93 expect(slots.held().total).toBe(0);
94 });
95
96 test("members together never take the strangers' share", async () => {
97 const slots = createGrepSlots();
98 const hung = ["a", "a", "b", "b"].map((id) => ({ id, ...never() }));
99 const held = hung.map((h) => slots.run(h.id, h.grep));
100 // Four member slots are all members may hold of 8, with 4 for strangers.
101 expect(await slots.run("c", async () => 1)).toEqual({ ran: false });
102 const strangers = Array.from({ length: 4 }, never);
103 const theirs = strangers.map((h) => slots.run(undefined, h.grep));
104 expect(slots.held()).toEqual({ total: 8, strangers: 4 });
105 for (const h of [...hung, ...strangers]) h.settle();
106 await Promise.all([...held, ...theirs]);
107 expect((await slots.run("c", async () => 1)).ran).toBe(true);
108 });
test/search.test.ts +2
@@ -30,6 +30,7 @@ const read = async <T>(response: Response): Promise<T> =>
3030
3131 interface Results {
3232 moreCode?: boolean;
33 codeSkipped?: boolean;
3334 repos: (
3435 | {
3536 source: "local";
@@ -395,6 +396,7 @@ describe("search: one query, four sources", () => {
395396 issues: [],
396397 code: [],
397398 moreCode: false,
399 codeSkipped: false,
398400 truncated: { repos: false, issues: false, code: false },
399401 });
400402 }
test/strangers.test.ts +42
@@ -6,6 +6,7 @@
66 * lands. Addresses are told apart by `X-Forwarded-For`, which the forge
77 * reads from this machine (`http/peer.ts`).
88 */
9 import { GREP_LIMITS, grepSlots } from "../src/search/grep_slots.ts";
910 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1011 import { mkdtemp, rm, writeFile } from "node:fs/promises";
1112 import { connect, type Socket } from "node:net";
@@ -347,6 +348,47 @@ describe("strangers at every door", () => {
347348 if (busy.allowed) busy.release();
348349 }
349350
351 // Every stranger grep slot taken elsewhere: the search answers, says code was partial.
352 const hung = Array.from({ length: GREP_LIMITS.strangers }, () => {
353 let settle!: () => void;
354 const done = grepSlots.run(undefined, () => new Promise<void>((r) => (settle = r)));
355 return { settle: () => settle(), done };
356 });
357 try {
358 // An uncached query, so the answer must come from a grep.
359 const items = await ask("trowel");
360 expect(code(items).filter((i) => i.id !== "code:skipped")).toEqual([]);
361 expect(items.find((i) => i.id === "code:skipped")?.title).toBe("Some code was not searched");
362 } finally {
363 for (const h of hung) h.settle();
364 await Promise.all(hung.map((h) => h.done));
365 }
366 expect((await ask("trowel")).some((i) => i.id === "code:skipped")).toBe(false);
367
368 // A signed-in member is held to its own two slots, and told the same way.
369 const member = new Session(app, {
370 sshUsername: "ada",
371 remoteAddress: address(),
372 fingerprint: "SHA256:ada",
373 user: ada,
374 });
375 const askAsMember = (q: string) =>
376 forgeSearch(member)(ctx)(q, false, new AbortController().signal);
377 const mine = Array.from({ length: GREP_LIMITS.perMember }, () => {
378 let settle!: () => void;
379 const done = grepSlots.run(ada.id, () => new Promise<void>((r) => (settle = r)));
380 return { settle: () => settle(), done };
381 });
382 try {
383 const items = await askAsMember("barrow");
384 expect(items.some((i) => i.id.startsWith("code:ada/"))).toBe(false);
385 expect(items.find((i) => i.id === "code:skipped")?.title).toBe("Some code was not searched");
386 } finally {
387 for (const h of mine) h.settle();
388 await Promise.all(mine.map((h) => h.done));
389 }
390 expect((await askAsMember("barrow")).some((i) => i.id.startsWith("code:ada/"))).toBe(true);
391
350392 // Its own minute, counted under its address.
351393 setStrangerLimits(app, { perAddress: { ...STRANGER_LIMITS.perAddress, code: 1 } });
352394 try {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs