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

The flow panel's figures come from one short-lived cache, so a second /boards load inside 30 s runs none of its reads #7000

Merged opened this

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

Merged

scout/forge:scout/6974-the-flow-panel-s-figures-come-from-one-s → main 1 commit fork

  • Merged. scout/6974-the-flow-panel-s-figures-come-from-one-s was merged into main as 4c3dbaf.

Review

Sign in to review this change.
3 files changed +183 −25
src/flow/panel_cache.ts added +79
@@ -0,0 +1,79 @@
1 /**
2 * The flow panel's figures, kept per database so a second `/boards` load reads none
3 * of them again. A value holds while the write clock (`core/write_clock.ts`) stands
4 * still, and for 30 s at most (the figures are windows of time, so they age too).
5 * Kept apart by who asks: an agent's public-only answer is never an admin's.
6 * Read it only after the viewer's rights are known.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { writeStamp } from "../core/write_clock.ts";
11 import { conflictedPulls, rebaseLane, type ConflictedPull, type RebaseLane } from "./conflicted.ts";
12 import { readPile, type Pile } from "./pile.ts";
13 import { reviewersBusy, type ReviewerLoad } from "./reviewing.ts";
14 import { requestedWaits, type RequestedWaits } from "./requested.ts";
15 import { idlePullsCached, type IdlePull } from "./idle_pulls.ts";
16 import { readParked, type Parked } from "./parked.ts";
17 import { readLeadTime, type LeadTime } from "./lead_time.ts";
18 import { readBuildYield, type BuildYield } from "./build_yield.ts";
19 import { readReviewLatency, type ReviewLatency } from "./review_latency.ts";
20 import { readOpeners, type Opener } from "./openers.ts";
21
22 export const PANEL_TTL_MS = 30_000;
23
24 /** Who is asking: an `agent` sees public repositories only; the `owner` sees all. */
25 export type PanelViewer = "owner" | "agent";
26
27 export interface PanelFigures {
28 readonly pile: Pile;
29 readonly conflicted: ConflictedPull[];
30 readonly lane: RebaseLane;
31 readonly requested: RequestedWaits;
32 readonly reviewing: ReviewerLoad[];
33 readonly idle: IdlePull[];
34 readonly parked: Parked;
35 readonly lead: LeadTime;
36 readonly yielded: BuildYield;
37 readonly latency: ReviewLatency | null;
38 readonly openers: Opener[];
39 }
40
41 /** Every reader the panel's figures come from, run once. */
42 export function readPanelFigures(db: Database, now: Date, publicOnly: boolean): PanelFigures {
43 const conflicted = conflictedPulls(db, publicOnly);
44 return {
45 pile: readPile(db, publicOnly),
46 conflicted,
47 lane: rebaseLane(db, conflicted),
48 requested: requestedWaits(db, publicOnly),
49 reviewing: reviewersBusy(db, publicOnly),
50 idle: idlePullsCached(db, now, publicOnly),
51 parked: readParked(db, now, publicOnly),
52 lead: readLeadTime(db, now, publicOnly),
53 yielded: readBuildYield(db, now, publicOnly),
54 latency: readReviewLatency(db, now, publicOnly),
55 openers: readOpeners(db, now, publicOnly),
56 };
57 }
58
59 interface Entry {
60 readonly at: number;
61 readonly stamp: string;
62 readonly figures: PanelFigures;
63 }
64
65 const kept = new WeakMap<Database, Map<PanelViewer, Entry>>();
66
67 /** The panel's figures for `viewer`: the kept ones while nothing was written and under 30 s old. */
68 export function panelFigures(db: Database, viewer: PanelViewer, now = new Date()): PanelFigures {
69 const mine = kept.get(db) ?? new Map<PanelViewer, Entry>();
70 kept.set(db, mine);
71 const stamp = writeStamp(db);
72 const t = now.getTime();
73 const hit = mine.get(viewer);
74 if (stamp !== undefined && hit && hit.stamp === stamp && t >= hit.at && t - hit.at < PANEL_TTL_MS) return hit.figures;
75 const figures = readPanelFigures(db, now, viewer !== "owner");
76 if (stamp === undefined) mine.delete(viewer);
77 else mine.set(viewer, { at: t, stamp, figures });
78 return figures;
79 }
src/web/pages/flow-panel.ts +18 −25
@@ -9,10 +9,10 @@
99
1010 import type { Reader } from "../../actions/telemetry.ts";
1111 import { queuesFor, systemFor } from "../../system/telemetry.ts";
12 import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../../flow/conflicted.ts";
13 import { pileSays, readPile } from "../../flow/pile.ts";
14 import { reviewersBusy, reviewingSays } from "../../flow/reviewing.ts";
15 import { requestedSays, requestedWaits } from "../../flow/requested.ts";
12 import { conflictedSays, rebaseLaneSays } from "../../flow/conflicted.ts";
13 import { pileSays } from "../../flow/pile.ts";
14 import { reviewingSays } from "../../flow/reviewing.ts";
15 import { requestedSays } from "../../flow/requested.ts";
1616 import {
1717 bottleneck,
1818 readPipeline,
@@ -36,12 +36,12 @@ import { type QueueRow, redBatchWords } from "../../flow/queue.ts";
3636 import { HOT_PULLS, pullCounts, warmFiles } from "../../crew/hot_files.ts";
3737 import { warmingLine } from "../../crew/hot_history.ts";
3838 import { getRepoById } from "../../core/repos.ts";
39 import { IDLE_LIMIT, idlePullsCached, type IdlePull } from "../../flow/idle_pulls.ts";
40 import { readParked } from "../../flow/parked.ts";
41 import { hoursWords, readLeadTime } from "../../flow/lead_time.ts";
42 import { buildYieldSays, readBuildYield } from "../../flow/build_yield.ts";
43 import { readReviewLatency, reviewLatencySays } from "../../flow/review_latency.ts";
44 import { openerCounts, readOpeners } from "../../flow/openers.ts";
39 import { IDLE_LIMIT, type IdlePull } from "../../flow/idle_pulls.ts";
40 import { panelFigures } from "../../flow/panel_cache.ts";
41 import { hoursWords } from "../../flow/lead_time.ts";
42 import { buildYieldSays } from "../../flow/build_yield.ts";
43 import { reviewLatencySays } from "../../flow/review_latency.ts";
44 import { openerCounts } from "../../flow/openers.ts";
4545 import { readHeld } from "../../flow/held.ts";
4646 import type { App } from "../../app.ts";
4747 import { html, type Html } from "../html.ts";
@@ -100,21 +100,14 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
100100 const out = new Map(flow.stages.map((t) => [t.key, t]));
101101 const landed = out.get("approved");
102102 const dups = systemFor(app, reader).duplicates;
103 const conflictedList = conflictedPulls(app.db, reader.kind === "agent");
104 const conflicted = conflictedSays(conflictedList, now);
105 const lane = rebaseLaneSays(rebaseLane(app.db, conflictedList));
106 const requested = requestedWaits(app.db, reader.kind === "agent");
107 const onBuilder = requestedSays(requested.builder, "builder", now);
108 const onReviewer = requestedSays(requested.reviewer, "reviewer", now);
109 const reviewing = reviewingSays(reviewersBusy(app.db, reader.kind === "agent"));
110 const agent = reader.kind === "agent";
111 const lead = readLeadTime(app.db, new Date(now), agent);
112 const yielded = readBuildYield(app.db, new Date(now), agent);
113 const latency = readReviewLatency(app.db, new Date(now), agent);
114 const openers = readOpeners(app.db, new Date(now), agent);
115 const pile = pileSays(readPile(app.db, reader.kind === "agent"));
116 const idle = idlePullsCached(app.db, new Date(now), reader.kind === "agent");
117 const parked = readParked(app.db, new Date(now), reader.kind === "agent");
103 const fig = panelFigures(app.db, reader.kind, new Date(now));
104 const conflicted = conflictedSays(fig.conflicted, now);
105 const lane = rebaseLaneSays(fig.lane);
106 const onBuilder = requestedSays(fig.requested.builder, "builder", now);
107 const onReviewer = requestedSays(fig.requested.reviewer, "reviewer", now);
108 const reviewing = reviewingSays(fig.reviewing);
109 const { lead, yielded, latency, openers, idle, parked } = fig;
110 const pile = pileSays(fig.pile);
118111 const newest = parked.newest[0];
119112 const listed = asked(url);
120113 const shown = listed === null ? undefined : out.get(listed.key);
test/flow-panel-cache.test.ts added +86
@@ -0,0 +1,86 @@
1 /** The flow panel's figures are kept (#6974): a second load inside the window runs no reader, a write or 30 s ends it, and an agent never gets the admin's. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtempSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createUser } from "../src/core/users.ts";
8 import { PANEL_TTL_MS, panelFigures } from "../src/flow/panel_cache.ts";
9 import { flowPanel } from "../src/web/pages/flow-panel.ts";
10
11 let app: App;
12 let alice: string;
13
14 beforeAll(async () => {
15 app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "panel-cache-")), port: 0 });
16 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
17 if (!u.ok) throw new Error(u.error);
18 alice = u.value.id;
19 app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('pub', ?1, 'pub', 'public')", [alice]);
20 app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('priv', ?1, 'priv', 'private')", [alice]);
21 });
22 afterAll(() => app.close?.());
23
24 /** Statements run against the db, counted. */
25 function counting(): { n: () => number; stop: () => void } {
26 let n = 0;
27 const q = app.db.query.bind(app.db);
28 const orig = app.db.query;
29 (app.db as unknown as { query: unknown }).query = (sql: string) => {
30 if (!/total_changes|data_version/.test(sql)) n++;
31 return q(sql as never);
32 };
33 return { n: () => n, stop: () => ((app.db as unknown as { query: unknown }).query = orig) };
34 }
35
36 describe("flow panel cache", () => {
37 test("a second load inside the window runs none of the readers", () => {
38 const t = new Date();
39 panelFigures(app.db, "owner", t);
40 const c = counting();
41 const again = panelFigures(app.db, "owner", new Date(t.getTime() + 1000));
42 const ran = c.n();
43 c.stop();
44 expect(ran).toBe(0);
45 expect(again).toBe(panelFigures(app.db, "owner", t));
46 });
47
48 test("a write makes the next load read again", () => {
49 const t = new Date();
50 const first = panelFigures(app.db, "owner", t);
51 app.db.run("UPDATE repo SET description = 'x' WHERE id = 'pub'");
52 expect(panelFigures(app.db, "owner", t)).not.toBe(first);
53 });
54
55 test("it reads again after 30 s with nothing written", () => {
56 const t = new Date(Date.now() + 10 * PANEL_TTL_MS);
57 const first = panelFigures(app.db, "owner", t);
58 expect(panelFigures(app.db, "owner", new Date(t.getTime() + PANEL_TTL_MS - 1))).toBe(first);
59 expect(panelFigures(app.db, "owner", new Date(t.getTime() + PANEL_TTL_MS))).not.toBe(first);
60 });
61
62 test("an agent never sees an admin's figures", async () => {
63 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "scout", email: "[email protected]", password: "a-good-passphrase" });
64 if (!b.ok) throw new Error(b.error);
65 app.db.run("UPDATE user SET is_agent = 1 WHERE id = ?1", [b.value.id]);
66 const ago = new Date(Date.now() - 3_600_000).toISOString();
67 for (const [id, n, repo] of [["a1", 1, "pub"], ["a2", 2, "priv"]] as const) {
68 app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', 't', '', ?5, 'open', ?4)", [id, repo, n, ago, b.value.id]);
69 app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', 'b', '', 'h')", [id]);
70 }
71 const t = new Date();
72 const owner = panelFigures(app.db, "owner", t);
73 const agent = panelFigures(app.db, "agent", t);
74 expect(agent).not.toBe(owner);
75 expect(owner.openers.reduce((n, o) => n + o.opened, 0)).toBe(2);
76 expect(agent.openers.reduce((n, o) => n + o.opened, 0)).toBe(1);
77 // asked the other way round, still apart
78 expect(panelFigures(app.db, "owner", t)).toBe(owner);
79 expect(panelFigures(app.db, "agent", t)).toBe(agent);
80 });
81
82 test("the panel draws from it", () => {
83 const page = String(flowPanel(app, { kind: "owner" }, new URL("http://x/boards"), "/boards"));
84 expect(page).toContain('id="openers"');
85 });
86 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs