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

/apps and an owner's page draw their app shelf under 40 ms with 40 apps #6138

Merged opened this

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

Merged

scout/forge:scout/6123-apps-and-an-owner-s-page-draw-their-app → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/6123-apps-and-an-owner-s-page-draw-their-app was merged into main as 5837896.

Review

Sign in to review this change.
5 files changed +1043 −77
src/apps/runtime.ts +40 −13
@@ -57,7 +57,7 @@ import { createLog } from "../kernel/log.ts";
5757 import type { Result } from "../kernel/result.ts";
5858 import { holdLease, STALE_AFTER_MS } from "../runs/lease.ts";
5959 import { appendRunLog } from "../runs/log.ts";
60 import { primaryDomain } from "../core/domains.ts";
60 import { primaryDomain, primaryHosts } from "../core/domains.ts";
6161 import {
6262 beatAwake,
6363 closeAllAwake,
@@ -394,6 +394,14 @@ export function envsFor(
394394 return runtime.status(record);
395395 }
396396
397 /** Production first, then previews by pull number — a number, so `pull-10` follows `pull-9`. */
398 const byEnv = (a: EnvStatus, b: EnvStatus): number =>
399 a.env === "production"
400 ? -1
401 : b.env === "production"
402 ? 1
403 : (envPull(a.env) ?? 0) - (envPull(b.env) ?? 0);
404
397405 export class AppsRuntime {
398406 private readonly envs = new Map<string, Environment>();
399407 /** Whether this host can hold an app in a cell (`apps/contain.ts`), asked once at start. */
@@ -449,10 +457,16 @@ export class AppsRuntime {
449457 * domain carries no port: it reaches the forge through whatever fronts
450458 * it, which holds its own.
451459 */
452 urlFor(record: AppRecord, env: EnvName): string {
460 urlFor(
461 record: AppRecord,
462 env: EnvName,
463 hosts?: ReadonlyMap<string, string>,
464 ): string {
453465 if (env === "production") {
454 const own = primaryDomain(this.app.db, record.id);
455 if (own !== undefined) return `${this.options.scheme}://${own.host}`;
466 const host = hosts
467 ? hosts.get(record.id)
468 : primaryDomain(this.app.db, record.id)?.host;
469 if (host !== undefined) return `${this.options.scheme}://${host}`;
456470 }
457471 const port =
458472 this.options.publicPort === undefined ||
@@ -471,6 +485,22 @@ export class AppsRuntime {
471485 return this.describe(state);
472486 }
473487
488 /**
489 * `status` for many apps in one pass over the environments and one read
490 * of their domains; nothing per app touches the database. The caller has
491 * already decided which apps the viewer may see (`envsFor`'s rule).
492 */
493 statusMany(records: readonly AppRecord[]): Map<string, EnvStatus[]> {
494 const out = new Map<string, EnvStatus[]>();
495 const wanted = new Set(records.map((r) => r.id));
496 const hosts = primaryHosts(this.app.db, [...wanted]);
497 for (const id of wanted) out.set(id, []);
498 for (const state of this.envs.values())
499 out.get(state.app.id)?.push(this.describe(state, hosts));
500 for (const list of out.values()) list.sort(byEnv);
501 return out;
502 }
503
474504 /** Every environment of an app, production first. */
475505 status(record: AppRecord): EnvStatus[] {
476506 const out: EnvStatus[] = [];
@@ -479,17 +509,14 @@ export class AppsRuntime {
479509 }
480510 // Production first, then previews by pull number — a number, not a
481511 // string, so `pull-10` follows `pull-9`.
482 return out.sort((a, b) =>
483 a.env === "production"
484 ? -1
485 : b.env === "production"
486 ? 1
487 : (envPull(a.env) ?? 0) - (envPull(b.env) ?? 0),
488 );
512 return out.sort(byEnv);
489513 }
490514
491 private describe(state: Environment): EnvStatus {
492 const url = this.urlFor(state.app, state.env);
515 private describe(
516 state: Environment,
517 hosts?: ReadonlyMap<string, string>,
518 ): EnvStatus {
519 const url = this.urlFor(state.app, state.env, hosts);
493520 // Once a deploy's own commit has answered and swapped in, it is
494521 // "running" from here — even though its run stays open, checking its
495522 // tests, until they finish. Only a build still short of that point (no
src/core/domains.ts +14
@@ -177,6 +177,20 @@ export function primaryDomain(
177177 return row ? toDomain(row) : undefined;
178178 }
179179
180 /** `primaryDomain` for many apps in one read: app id to host, apps with none left out. */
181 export function primaryHosts(db: Database, appIds: readonly string[]): Map<string, string> {
182 const out = new Map<string, string>();
183 if (appIds.length === 0) return out;
184 const marks = appIds.map(() => "?").join(",");
185 const rows = db
186 .query<{ app_id: string; host: string }, string[]>(
187 `SELECT app_id, host FROM app_domain WHERE app_id IN (${marks}) AND verified_at IS NOT NULL ORDER BY verified_at, host`,
188 )
189 .all(...appIds);
190 for (const r of rows) if (!out.has(r.app_id)) out.set(r.app_id, r.host);
191 return out;
192 }
193
180194 export function removeDomain(db: Database, host: string): boolean {
181195 return (
182196 db.query("DELETE FROM app_domain WHERE host = ?1").run(normalizeHost(host))
src/web/studio/shelf.ts +106 −64
@@ -25,11 +25,12 @@
2525 */
2626
2727 import { spanWords } from "../../apps/golden_path.ts";
28 import { appsRuntime, type EnvStatus, envsFor } from "../../apps/runtime.ts";
29 import { appsForRepos } from "../../core/apps.ts";
28 import { appsRuntime, type EnvStatus } from "../../apps/runtime.ts";
29 import { appsForRepos, type AppRecord } from "../../core/apps.ts";
3030 import { reposWithBuilder } from "../../core/agents.ts";
3131 import { reposWithUnclaimedTell } from "../../core/issues.ts";
3232 import { openPullsForRepos } from "../../core/pulls.ts";
33 import { notePhase } from "../../core/route_timing.ts";
3334 import { enterableApps, ownerFactRepoIds, type Repo } from "../../core/repos.ts";
3435 import {
3536 getRunsByIds,
@@ -67,38 +68,51 @@ export interface ShelfReads {
6768 }
6869
6970 /**
70 * Production's own fact for every one of these repositories, in one batch:
71 * `runtime.status` is in-memory (no query), so the only read is the run rows
72 * a fact points to — one `IN` for the whole shelf, never one per row.
71 * Production's status for each of these apps, in one batch: the runtime's
72 * environments are in memory and their domains are one read, so nothing per
73 * app touches the database. `enterable` is who the viewer may see into
74 * (`enterableApps`, the rule `envsFor` holds); the rest read as no status.
7375 */
74 export function productionFactsForRepos(
76 function productionStatuses(
7577 ctx: Ctx,
7678 repos: readonly Repo[],
77 ): Map<string, ProductionFact> {
78 const db = ctx.app.db;
79 records: ReadonlyMap<string, AppRecord>,
80 enterable: ReadonlySet<string>,
81 ): Map<string, EnvStatus | undefined> {
82 const out = new Map<string, EnvStatus | undefined>();
7983 const runtime = appsRuntime(ctx.app);
80 const out = new Map<string, ProductionFact>();
8184 if (runtime === undefined) return out;
82 const records = appsForRepos(
83 db,
84 repos.map((r) => r.id),
85 );
86 const productions = new Map<string, EnvStatus | undefined>();
87 const runIds = new Set<string>();
85 const seen = repos.flatMap((r) => {
86 const record = records.get(r.id);
87 return record !== undefined && enterable.has(record.id) ? [record] : [];
88 });
89 const statuses = runtime.statusMany(seen);
8890 for (const repo of repos) {
8991 const record = records.get(repo.id);
90 const production = record
91 ? envsFor(ctx.app, ctx.auth.user, record).find(
92 (e) => e.env === "production",
93 )
94 : undefined;
95 productions.set(repo.id, production);
92 out.set(
93 repo.id,
94 record === undefined
95 ? undefined
96 : statuses.get(record.id)?.find((e) => e.env === "production"),
97 );
98 }
99 return out;
100 }
101
102 /** Production's own fact for each status, reading the run rows it points to in one `IN`. */
103 function factsOf(
104 ctx: Ctx,
105 productions: ReadonlyMap<string, EnvStatus | undefined>,
106 ): Map<string, ProductionFact> {
107 const out = new Map<string, ProductionFact>();
108 const runIds = new Set<string>();
109 for (const production of productions.values()) {
96110 if (production?.runId !== undefined && production.createdAt !== undefined)
97111 runIds.add(production.runId);
98112 if (production?.failedDeploy !== undefined)
99113 runIds.add(production.failedDeploy.runId);
100114 }
101 const runs = getRunsByIds(db, [...runIds]);
115 const runs = getRunsByIds(ctx.app.db, [...runIds]);
102116 for (const [repoId, production] of productions) {
103117 const fact = productionFact(production, runs);
104118 if (fact !== undefined) out.set(repoId, fact);
@@ -106,6 +120,33 @@ export function productionFactsForRepos(
106120 return out;
107121 }
108122
123 /**
124 * Production's own fact for every one of these repositories, in one batch:
125 * `runtime.status` is in-memory (no query), so the only reads are the app
126 * records, who may enter them, their domains and the run rows a fact points
127 * to — one each for the whole shelf, never one per row.
128 */
129 export function productionFactsForRepos(
130 ctx: Ctx,
131 repos: readonly Repo[],
132 ): Map<string, ProductionFact> {
133 if (appsRuntime(ctx.app) === undefined) return new Map();
134 const records = appsForRepos(
135 ctx.app.db,
136 repos.map((r) => r.id),
137 );
138 const enterable = enterableApps(
139 ctx.app.db,
140 repos.flatMap((repo) => {
141 const app = records.get(repo.id);
142 return app === undefined ? [] : [{ repo, app }];
143 }),
144 ctx.auth.user,
145 { named: false },
146 );
147 return factsOf(ctx, productionStatuses(ctx, repos, records, enterable));
148 }
149
109150 /** The row of a private app the viewer may not enter: its name, nothing it says. */
110151 function sealedRow(repo: Repo, user: User | undefined): Html {
111152 const mine = user !== undefined && repo.ownerId === user.id;
@@ -136,48 +177,19 @@ export async function shelfRows(
136177 const user = ctx.auth.user;
137178 const runtime = appsRuntime(ctx.app);
138179 const history = reads.history ?? servingHistory();
139 const records = appsForRepos(
140 db,
141 repos.map((r) => r.id),
142 );
180 const repoIds = repos.map((r) => r.id);
181 // Each stretch is named for Server-Timing, so a slow shelf says where.
182 let mark = performance.now();
183 const lap = (name: string): void => {
184 const now = performance.now();
185 notePhase(ctx.request, name, now - mark);
186 mark = now;
187 };
188 const records = appsForRepos(db, repoIds);
143189 // A public app you can only read appears here too, but who is on its
144190 // crew is its owner's fact, not yours to see — same predicate as the
145191 // app's own page.
146192 const ownerFacts = ownerFactRepoIds(db, repos, user);
147 // One read for the newest run of every app, one for who ran them: the
148 // shelf must not get slower as the apps grow.
149 const latestRuns = latestAgentRunsForRepos(
150 db,
151 repos.map((r) => r.id),
152 );
153 // Production's own fact, per app, one batch — a deploy that just landed or
154 // just failed, the other half of what "the last thing that happened" can
155 // mean now that it is not only an agent run.
156 const productionFacts =
157 reads.productionFacts ?? productionFactsForRepos(ctx, repos);
158 const actors = getUsersByIds(
159 db,
160 [
161 ...[...latestRuns.values()].map((r) => r.actorId),
162 ...[...productionFacts.values()].map((f) => f.actorId),
163 ].filter((id): id is string => id !== null),
164 );
165 // The newest open change of every app, one read — a person's own change
166 // counts here too, not only one an agent run produced.
167 const openPulls = openPullsForRepos(
168 db,
169 repos.map((r) => r.id),
170 );
171 // A tell nobody can pick up (the crew has no builder) is stuck, not quiet —
172 // both reads batched across the whole shelf, never once per row.
173 const repoIds = repos.map((r) => r.id);
174 const withBuilder = reposWithBuilder(db, repoIds);
175 const stuck = new Set(
176 [...reposWithUnclaimedTell(db, repoIds)].filter(
177 (id) => !withBuilder.has(id),
178 ),
179 );
180
181193 // Who may enter which private app, for the whole shelf in one batch.
182194 const enterable = enterableApps(
183195 db,
@@ -188,8 +200,38 @@ export async function shelfRows(
188200 user,
189201 { named: false },
190202 );
203 lap("records");
204 // One read for the newest run of every app, one for who ran them: the
205 // shelf must not get slower as the apps grow.
206 const latestRuns = latestAgentRunsForRepos(db, repoIds);
207 lap("runs");
208 // Production's own fact, per app, one batch — a deploy that just landed or
209 // just failed, the other half of what "the last thing that happened" can
210 // mean now that it is not only an agent run.
211 const productions = productionStatuses(ctx, repos, records, enterable);
212 const productionFacts = reads.productionFacts ?? factsOf(ctx, productions);
213 lap("production");
214 const actors = getUsersByIds(
215 db,
216 [
217 ...[...latestRuns.values()].map((r) => r.actorId),
218 ...[...productionFacts.values()].map((f) => f.actorId),
219 ].filter((id): id is string => id !== null),
220 );
221 // The newest open change of every app, one read — a person's own change
222 // counts here too, not only one an agent run produced.
223 const openPulls = openPullsForRepos(db, repoIds);
224 // A tell nobody can pick up (the crew has no builder) is stuck, not quiet —
225 // both reads batched across the whole shelf, never once per row.
226 const withBuilder = reposWithBuilder(db, repoIds);
227 const stuck = new Set(
228 [...reposWithUnclaimedTell(db, repoIds)].filter(
229 (id) => !withBuilder.has(id),
230 ),
231 );
232 lap("pulls");
191233
192 return await Promise.all(
234 const rows = await Promise.all(
193235 repos.map(async (repo) => {
194236 const record = records.get(repo.id);
195237 // A private app the viewer may not enter is a name on a shelf: no
@@ -197,9 +239,7 @@ export async function shelfRows(
197239 // and crew's (`envsFor`, `mayReadRuns`).
198240 if (record !== undefined && !enterable.has(record.id))
199241 return sealedRow(repo, user);
200 const envs =
201 record === undefined ? [] : envsFor(ctx.app, ctx.auth.user, record);
202 const production = envs.find((e) => e.env === "production");
242 const production = productions.get(repo.id);
203243 const latest = latestRuns.get(repo.id);
204244 // A run still in flight is the thing that is moving, and outranks a
205245 // change sitting open; once nothing is moving, an open change — an
@@ -315,4 +355,6 @@ export async function shelfRows(
315355 });
316356 }),
317357 );
358 lap("rows");
359 return rows;
318360 }
test/fixtures/shelf-speed.html added +759
@@ -0,0 +1,759 @@
1 <li>
2 <a href="/alice/app-00" class="row"
3
4 data-nav tabindex="-1"><span class="row-lead"><span
5 class="dot"
6
7 ></span></span>
8 <span class="row-main">
9 <span
10 class="row-title"
11 title="app number 0"
12 >app-00</span
13 >
14 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 0</span>
15 </span>
16 <span class="row-trail">
17 <span class="badge" data-tone="default">live</span></span></a>
18 </li>
19 ---
20 <li>
21 <a href="/alice/app-01" class="row"
22
23 data-nav tabindex="-1"><span class="row-lead"><span
24 class="dot"
25
26 ></span></span>
27 <span class="row-main">
28 <span
29 class="row-title"
30 title="app number 1"
31 >app-01</span
32 >
33 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 1</span>
34 </span>
35 <span class="row-trail">
36 <span class="badge" data-tone="default">not running</span></span></a>
37 </li>
38 ---
39 <li>
40 <a href="/alice/app-02" class="row"
41
42 data-nav tabindex="-1"><span class="row-lead"><span
43 class="dot"
44
45 ></span></span>
46 <span class="row-main">
47 <span
48 class="row-title"
49 title="app number 2"
50 >app-02</span
51 >
52 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 2</span>
53 </span>
54 <span class="row-trail">
55 <span class="badge" data-tone="default">live</span></span></a>
56 </li>
57 ---
58 <li>
59 <a href="/alice/app-03" class="row"
60
61 data-nav tabindex="-1"><span class="row-lead"><span
62 class="dot"
63
64 ></span></span>
65 <span class="row-main">
66 <span
67 class="row-title"
68 title="app number 3"
69 >app-03</span
70 >
71 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 3</span>
72 </span>
73 <span class="row-trail">
74 <span class="badge" data-tone="default">not running</span></span></a>
75 </li>
76 ---
77 <li>
78 <a href="/alice/app-04" class="row"
79
80 data-nav tabindex="-1"><span class="row-lead"><span
81 class="dot"
82
83 ></span></span>
84 <span class="row-main">
85 <span
86 class="row-title"
87 title="app number 4"
88 >app-04</span
89 >
90 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 4</span>
91 </span>
92 <span class="row-trail">
93 <span class="badge" data-tone="default">live</span></span></a>
94 </li>
95 ---
96 <li>
97 <a href="/alice/app-05" class="row"
98
99 data-nav tabindex="-1"><span class="row-lead"><span
100 class="dot"
101
102 ></span></span>
103 <span class="row-main">
104 <span
105 class="row-title"
106 title="app number 5"
107 >app-05</span
108 >
109 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 5</span>
110 </span>
111 <span class="row-trail">
112 <span class="badge" data-tone="default">not running</span></span></a>
113 </li>
114 ---
115 <li>
116 <a href="/alice/app-06" class="row"
117
118 data-nav tabindex="-1"><span class="row-lead"><span
119 class="dot"
120
121 ></span></span>
122 <span class="row-main">
123 <span
124 class="row-title"
125 title="app number 6"
126 >app-06</span
127 >
128 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 6</span>
129 </span>
130 <span class="row-trail">
131 <span class="badge" data-tone="default">live</span></span></a>
132 </li>
133 ---
134 <li>
135 <a href="/alice/app-07" class="row"
136
137 data-nav tabindex="-1"><span class="row-lead"><span
138 class="dot"
139
140 ></span></span>
141 <span class="row-main">
142 <span
143 class="row-title"
144 title="app number 7"
145 >app-07</span
146 >
147 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 7</span>
148 </span>
149 <span class="row-trail">
150 <span class="badge" data-tone="default">not running</span></span></a>
151 </li>
152 ---
153 <li>
154 <a href="/alice/app-08" class="row"
155
156 data-nav tabindex="-1"><span class="row-lead"><span
157 class="dot"
158
159 ></span></span>
160 <span class="row-main">
161 <span
162 class="row-title"
163 title="app number 8"
164 >app-08</span
165 >
166 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 8</span>
167 </span>
168 <span class="row-trail">
169 <span class="badge" data-tone="default">live</span></span></a>
170 </li>
171 ---
172 <li>
173 <a href="/alice/app-09" class="row"
174
175 data-nav tabindex="-1"><span class="row-lead"><span
176 class="dot"
177
178 ></span></span>
179 <span class="row-main">
180 <span
181 class="row-title"
182 title="app number 9"
183 >app-09</span
184 >
185 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 9</span>
186 </span>
187 <span class="row-trail">
188 <span class="badge" data-tone="default">not running</span></span></a>
189 </li>
190 ---
191 <li>
192 <a href="/alice/app-10" class="row"
193
194 data-nav tabindex="-1"><span class="row-lead"><span
195 class="dot"
196
197 ></span></span>
198 <span class="row-main">
199 <span
200 class="row-title"
201 title="app number 10"
202 >app-10</span
203 >
204 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 10</span>
205 </span>
206 <span class="row-trail">
207 <span class="badge" data-tone="default">live</span></span></a>
208 </li>
209 ---
210 <li>
211 <a href="/alice/app-11" class="row"
212
213 data-nav tabindex="-1"><span class="row-lead"><span
214 class="dot"
215
216 ></span></span>
217 <span class="row-main">
218 <span
219 class="row-title"
220 title="app number 11"
221 >app-11</span
222 >
223 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 11</span>
224 </span>
225 <span class="row-trail">
226 <span class="badge" data-tone="default">not running</span></span></a>
227 </li>
228 ---
229 <li>
230 <a href="/alice/app-12" class="row"
231
232 data-nav tabindex="-1"><span class="row-lead"><span
233 class="dot"
234
235 ></span></span>
236 <span class="row-main">
237 <span
238 class="row-title"
239 title="app number 12"
240 >app-12</span
241 >
242 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 12</span>
243 </span>
244 <span class="row-trail">
245 <span class="badge" data-tone="default">live</span></span></a>
246 </li>
247 ---
248 <li>
249 <a href="/alice/app-13" class="row"
250
251 data-nav tabindex="-1"><span class="row-lead"><span
252 class="dot"
253
254 ></span></span>
255 <span class="row-main">
256 <span
257 class="row-title"
258 title="app number 13"
259 >app-13</span
260 >
261 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 13</span>
262 </span>
263 <span class="row-trail">
264 <span class="badge" data-tone="default">not running</span></span></a>
265 </li>
266 ---
267 <li>
268 <a href="/alice/app-14" class="row"
269
270 data-nav tabindex="-1"><span class="row-lead"><span
271 class="dot"
272
273 ></span></span>
274 <span class="row-main">
275 <span
276 class="row-title"
277 title="app number 14"
278 >app-14</span
279 >
280 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 14</span>
281 </span>
282 <span class="row-trail">
283 <span class="badge" data-tone="default">live</span></span></a>
284 </li>
285 ---
286 <li>
287 <a href="/alice/app-15" class="row"
288
289 data-nav tabindex="-1"><span class="row-lead"><span
290 class="dot"
291
292 ></span></span>
293 <span class="row-main">
294 <span
295 class="row-title"
296 title="app number 15"
297 >app-15</span
298 >
299 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 15</span>
300 </span>
301 <span class="row-trail">
302 <span class="badge" data-tone="default">not running</span></span></a>
303 </li>
304 ---
305 <li>
306 <a href="/alice/app-16" class="row"
307
308 data-nav tabindex="-1"><span class="row-lead"><span
309 class="dot"
310
311 ></span></span>
312 <span class="row-main">
313 <span
314 class="row-title"
315 title="app number 16"
316 >app-16</span
317 >
318 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 16</span>
319 </span>
320 <span class="row-trail">
321 <span class="badge" data-tone="default">live</span></span></a>
322 </li>
323 ---
324 <li>
325 <a href="/alice/app-17" class="row"
326
327 data-nav tabindex="-1"><span class="row-lead"><span
328 class="dot"
329
330 ></span></span>
331 <span class="row-main">
332 <span
333 class="row-title"
334 title="app number 17"
335 >app-17</span
336 >
337 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 17</span>
338 </span>
339 <span class="row-trail">
340 <span class="badge" data-tone="default">not running</span></span></a>
341 </li>
342 ---
343 <li>
344 <a href="/alice/app-18" class="row"
345
346 data-nav tabindex="-1"><span class="row-lead"><span
347 class="dot"
348
349 ></span></span>
350 <span class="row-main">
351 <span
352 class="row-title"
353 title="app number 18"
354 >app-18</span
355 >
356 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 18</span>
357 </span>
358 <span class="row-trail">
359 <span class="badge" data-tone="default">live</span></span></a>
360 </li>
361 ---
362 <li>
363 <a href="/alice/app-19" class="row"
364
365 data-nav tabindex="-1"><span class="row-lead"><span
366 class="dot"
367
368 ></span></span>
369 <span class="row-main">
370 <span
371 class="row-title"
372 title="app number 19"
373 >app-19</span
374 >
375 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 19</span>
376 </span>
377 <span class="row-trail">
378 <span class="badge" data-tone="default">not running</span></span></a>
379 </li>
380 ---
381 <li>
382 <a href="/alice/app-20" class="row"
383
384 data-nav tabindex="-1"><span class="row-lead"><span
385 class="dot"
386
387 ></span></span>
388 <span class="row-main">
389 <span
390 class="row-title"
391 title="app number 20"
392 >app-20</span
393 >
394 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 20</span>
395 </span>
396 <span class="row-trail">
397 <span class="badge" data-tone="default">live</span></span></a>
398 </li>
399 ---
400 <li>
401 <a href="/alice/app-21" class="row"
402
403 data-nav tabindex="-1"><span class="row-lead"><span
404 class="dot"
405
406 ></span></span>
407 <span class="row-main">
408 <span
409 class="row-title"
410 title="app number 21"
411 >app-21</span
412 >
413 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 21</span>
414 </span>
415 <span class="row-trail">
416 <span class="badge" data-tone="default">not running</span></span></a>
417 </li>
418 ---
419 <li>
420 <a href="/alice/app-22" class="row"
421
422 data-nav tabindex="-1"><span class="row-lead"><span
423 class="dot"
424
425 ></span></span>
426 <span class="row-main">
427 <span
428 class="row-title"
429 title="app number 22"
430 >app-22</span
431 >
432 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 22</span>
433 </span>
434 <span class="row-trail">
435 <span class="badge" data-tone="default">live</span></span></a>
436 </li>
437 ---
438 <li>
439 <a href="/alice/app-23" class="row"
440
441 data-nav tabindex="-1"><span class="row-lead"><span
442 class="dot"
443
444 ></span></span>
445 <span class="row-main">
446 <span
447 class="row-title"
448 title="app number 23"
449 >app-23</span
450 >
451 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 23</span>
452 </span>
453 <span class="row-trail">
454 <span class="badge" data-tone="default">not running</span></span></a>
455 </li>
456 ---
457 <li>
458 <a href="/alice/app-24" class="row"
459
460 data-nav tabindex="-1"><span class="row-lead"><span
461 class="dot"
462
463 ></span></span>
464 <span class="row-main">
465 <span
466 class="row-title"
467 title="app number 24"
468 >app-24</span
469 >
470 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 24</span>
471 </span>
472 <span class="row-trail">
473 <span class="badge" data-tone="default">live</span></span></a>
474 </li>
475 ---
476 <li>
477 <a href="/alice/app-25" class="row"
478
479 data-nav tabindex="-1"><span class="row-lead"><span
480 class="dot"
481
482 ></span></span>
483 <span class="row-main">
484 <span
485 class="row-title"
486 title="app number 25"
487 >app-25</span
488 >
489 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 25</span>
490 </span>
491 <span class="row-trail">
492 <span class="badge" data-tone="default">not running</span></span></a>
493 </li>
494 ---
495 <li>
496 <a href="/alice/app-26" class="row"
497
498 data-nav tabindex="-1"><span class="row-lead"><span
499 class="dot"
500
501 ></span></span>
502 <span class="row-main">
503 <span
504 class="row-title"
505 title="app number 26"
506 >app-26</span
507 >
508 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 26</span>
509 </span>
510 <span class="row-trail">
511 <span class="badge" data-tone="default">live</span></span></a>
512 </li>
513 ---
514 <li>
515 <a href="/alice/app-27" class="row"
516
517 data-nav tabindex="-1"><span class="row-lead"><span
518 class="dot"
519
520 ></span></span>
521 <span class="row-main">
522 <span
523 class="row-title"
524 title="app number 27"
525 >app-27</span
526 >
527 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 27</span>
528 </span>
529 <span class="row-trail">
530 <span class="badge" data-tone="default">not running</span></span></a>
531 </li>
532 ---
533 <li>
534 <a href="/alice/app-28" class="row"
535
536 data-nav tabindex="-1"><span class="row-lead"><span
537 class="dot"
538
539 ></span></span>
540 <span class="row-main">
541 <span
542 class="row-title"
543 title="app number 28"
544 >app-28</span
545 >
546 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 28</span>
547 </span>
548 <span class="row-trail">
549 <span class="badge" data-tone="default">live</span></span></a>
550 </li>
551 ---
552 <li>
553 <a href="/alice/app-29" class="row"
554
555 data-nav tabindex="-1"><span class="row-lead"><span
556 class="dot"
557
558 ></span></span>
559 <span class="row-main">
560 <span
561 class="row-title"
562 title="app number 29"
563 >app-29</span
564 >
565 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 29</span>
566 </span>
567 <span class="row-trail">
568 <span class="badge" data-tone="default">not running</span></span></a>
569 </li>
570 ---
571 <li>
572 <a href="/alice/app-30" class="row"
573
574 data-nav tabindex="-1"><span class="row-lead"><span
575 class="dot"
576
577 ></span></span>
578 <span class="row-main">
579 <span
580 class="row-title"
581 title="app number 30"
582 >app-30</span
583 >
584 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 30</span>
585 </span>
586 <span class="row-trail">
587 <span class="badge" data-tone="default">live</span></span></a>
588 </li>
589 ---
590 <li>
591 <a href="/alice/app-31" class="row"
592
593 data-nav tabindex="-1"><span class="row-lead"><span
594 class="dot"
595
596 ></span></span>
597 <span class="row-main">
598 <span
599 class="row-title"
600 title="app number 31"
601 >app-31</span
602 >
603 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 31</span>
604 </span>
605 <span class="row-trail">
606 <span class="badge" data-tone="default">not running</span></span></a>
607 </li>
608 ---
609 <li>
610 <a href="/alice/app-32" class="row"
611
612 data-nav tabindex="-1"><span class="row-lead"><span
613 class="dot"
614
615 ></span></span>
616 <span class="row-main">
617 <span
618 class="row-title"
619 title="app number 32"
620 >app-32</span
621 >
622 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 32</span>
623 </span>
624 <span class="row-trail">
625 <span class="badge" data-tone="default">live</span></span></a>
626 </li>
627 ---
628 <li>
629 <a href="/alice/app-33" class="row"
630
631 data-nav tabindex="-1"><span class="row-lead"><span
632 class="dot"
633
634 ></span></span>
635 <span class="row-main">
636 <span
637 class="row-title"
638 title="app number 33"
639 >app-33</span
640 >
641 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 33</span>
642 </span>
643 <span class="row-trail">
644 <span class="badge" data-tone="default">not running</span></span></a>
645 </li>
646 ---
647 <li>
648 <a href="/alice/app-34" class="row"
649
650 data-nav tabindex="-1"><span class="row-lead"><span
651 class="dot"
652
653 ></span></span>
654 <span class="row-main">
655 <span
656 class="row-title"
657 title="app number 34"
658 >app-34</span
659 >
660 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 34</span>
661 </span>
662 <span class="row-trail">
663 <span class="badge" data-tone="default">live</span></span></a>
664 </li>
665 ---
666 <li>
667 <a href="/alice/app-35" class="row"
668
669 data-nav tabindex="-1"><span class="row-lead"><span
670 class="dot"
671
672 ></span></span>
673 <span class="row-main">
674 <span
675 class="row-title"
676 title="app number 35"
677 >app-35</span
678 >
679 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 35</span>
680 </span>
681 <span class="row-trail">
682 <span class="badge" data-tone="default">not running</span></span></a>
683 </li>
684 ---
685 <li>
686 <a href="/alice/app-36" class="row"
687
688 data-nav tabindex="-1"><span class="row-lead"><span
689 class="dot"
690
691 ></span></span>
692 <span class="row-main">
693 <span
694 class="row-title"
695 title="app number 36"
696 >app-36</span
697 >
698 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 36</span>
699 </span>
700 <span class="row-trail">
701 <span class="badge" data-tone="default">live</span></span></a>
702 </li>
703 ---
704 <li>
705 <a href="/alice/app-37" class="row"
706
707 data-nav tabindex="-1"><span class="row-lead"><span
708 class="dot"
709
710 ></span></span>
711 <span class="row-main">
712 <span
713 class="row-title"
714 title="app number 37"
715 >app-37</span
716 >
717 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 37</span>
718 </span>
719 <span class="row-trail">
720 <span class="badge" data-tone="default">not running</span></span></a>
721 </li>
722 ---
723 <li>
724 <a href="/alice/app-38" class="row"
725
726 data-nav tabindex="-1"><span class="row-lead"><span
727 class="dot"
728
729 ></span></span>
730 <span class="row-main">
731 <span
732 class="row-title"
733 title="app number 38"
734 >app-38</span
735 >
736 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 38</span>
737 </span>
738 <span class="row-trail">
739 <span class="badge" data-tone="default">live</span></span></a>
740 </li>
741 ---
742 <li>
743 <a href="/alice/app-39" class="row"
744
745 data-nav tabindex="-1"><span class="row-lead"><span
746 class="dot"
747
748 ></span></span>
749 <span class="row-main">
750 <span
751 class="row-title"
752 title="app number 39"
753 >app-39</span
754 >
755 <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 39</span>
756 </span>
757 <span class="row-trail">
758 <span class="badge" data-tone="default">not running</span></span></a>
759 </li>↵
test/shelf-speed.test.ts added +124
@@ -0,0 +1,124 @@
1 /** The shelf of 40 apps is drawn in a fixed number of reads, under 40 ms, and says what it said before (#6123). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtempSync, readFileSync, rmSync, writeFileSync } 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 { createRepo } from "../src/actions/repos.ts";
8 import { enableApp } from "../src/actions/apps.ts";
9 import { AppsRuntime } from "../src/apps/runtime.ts";
10 import { createRun, claimRun, finishRun } from "../src/core/runs.ts";
11 import { createUser, type User } from "../src/core/users.ts";
12 import type { Repo } from "../src/core/repos.ts";
13 import type { Ctx } from "../src/http/context.ts";
14 import { takePhases } from "../src/core/route_timing.ts";
15 import { shelfRows } from "../src/web/studio/shelf.ts";
16 import { expectBudget } from "./helpers/budget.ts";
17 import { appsForRepos } from "../src/core/apps.ts";
18 import { primaryHosts } from "../src/core/domains.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20 import { proveAll } from "./helpers/verified.ts";
21
22 quietLogs();
23
24 const GOLDEN = join(import.meta.dir, "fixtures", "shelf-speed.html");
25 let root = "";
26 let app: App;
27 let alice: User;
28 let repos: Repo[] = [];
29
30 const ctx = (): Ctx => ({
31 app,
32 request: new Request("http://forge.test/apps"),
33 url: new URL("http://forge.test/apps"),
34 params: {},
35 auth: { user: alice, scope: undefined, via: "session" },
36 theme: undefined,
37 });
38
39 /** The relative times and ids a run leaves are the clock's, not the shelf's. */
40 const steady = (s: string): string =>
41 s.replace(/datetime="[^"]*"/g, 'datetime="T"').replace(/>[^<>]*\bago\b[^<>]*</g, ">T<").replace(/>(now|just now|\d+[smhd])</g, ">T<");
42
43 beforeAll(async () => {
44 root = mkdtempSync(join(tmpdir(), "forge-shelf-speed-"));
45 app = createApp({ dataDir: root, port: 0 });
46 const u = await createUser(app.db, {
47 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
48 password: "a-good-passphrase", adminIfFirst: true,
49 });
50 if (!u.ok) throw new Error(u.error);
51 alice = u.value;
52 proveAll(app);
53 const runtime = new AppsRuntime(app, { domain: "apps.test", scheme: "http", publicPort: 80, forgeUrl: "http://forge.test", concurrency: 1 });
54 for (let i = 0; i < 40; i++) {
55 const made = await createRepo(app, alice, { name: `app-${String(i).padStart(2, "0")}`, visibility: i % 4 === 0 ? "private" : "public", description: `app number ${i}` });
56 if (!made.ok) throw new Error(String(made.error));
57 const enabled = enableApp(app, alice, made.value, { slug: `app-${i}` });
58 if (!enabled.ok) throw new Error(String(enabled.error));
59 repos.push(made.value);
60 // Every other app has served: an environment asleep on a deploy run.
61 if (i % 2 === 0) {
62 const run = createRun(app.db, { repoId: made.value.id, kind: "deploy", env: "production", commitOid: "a".repeat(40), reason: "push" });
63 claimRun(app.db, run.id);
64 finishRun(app.db, run.id, { state: i % 6 === 0 ? "failed" : "ok", summary: "deployed" });
65 // biome-ignore lint: reaching the runtime's own table is how a test seeds an environment
66 const state = (runtime as any).envFor(enabled.value, "production");
67 state.asleep = { commitOid: "a".repeat(40), dir: root, manifest: {}, runId: run.id, since: new Date().toISOString() };
68 }
69 }
70 });
71 afterAll(() => {
72 app.close();
73 rmSync(root, { recursive: true, force: true });
74 });
75
76 test("40 apps are drawn within budget, in the phases Server-Timing names", async () => {
77 await shelfRows(ctx(), repos); // warm
78 let best = Infinity;
79 let phases: string[] = [];
80 for (let i = 0; i < 5; i++) {
81 const c = ctx();
82 const started = performance.now();
83 await shelfRows(c, repos);
84 best = Math.min(best, performance.now() - started);
85 phases = takePhases(c.request).map((p) => p.name);
outdated

This is a wall-clock bound in CI. Best-of-5 makes a flake unlikely, but if it flakes on a loaded check, assert the read count instead (the claim the batch actually makes).

86 }
87 expect(phases).toEqual(["records", "runs", "production", "pulls", "rows"]);
88 // The yardstick is one batched read of the same shelf, taken in this run.
89 let against = Infinity;
90 for (let i = 0; i < 5; i++) {
91 const t = performance.now();
92 appsForRepos(app.db, repos.map((r) => r.id));
93 against = Math.min(against, performance.now() - t);
94 }
95 expectBudget(best, { ms: 40, against });
96 });
97
98 test("its rows are byte-identical to the ones drawn before the batch", async () => {
99 const rows = (await shelfRows(ctx(), repos)).map((r) => steady(String(r))).join("\n---\n");
100 if (process.env.WRITE_GOLDEN === "1") writeFileSync(GOLDEN, rows);
101 expect(rows).toBe(readFileSync(GOLDEN, "utf8"));
102 });
103
104 test("primaryHosts names only verified hosts, the earliest verified first", () => {
105 const [a, b, c] = repos.slice(1, 4).map((r) => appsForRepos(app.db, [r.id]).get(r.id)!.id);
106 const add = (host: string, appId: string, at: string | null) =>
107 app.db.query("INSERT INTO app_domain (host, app_id, env, token, created_at, verified_at) VALUES (?1, ?2, 'production', ?1, '2026-01-01T00:00:00Z', ?3)").run(host, appId, at);
108 add("late.test", a!, "2026-03-01T00:00:00Z");
109 add("early.test", a!, "2026-02-01T00:00:00Z");
110 add("unproven.test", b!, null);
111 expect(primaryHosts(app.db, [a!, b!, c!])).toEqual(new Map([[a!, "early.test"]]));
112 expect(primaryHosts(app.db, [])).toEqual(new Map());
113 });
114
115 test("a viewer who may not enter a private app sees a sealed row for it", async () => {
116 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
117 if (!b.ok) throw new Error(b.error);
118 const c = { ...ctx(), auth: { user: b.value, scope: undefined, via: "session" as const } };
119 const rows = (await shelfRows(c, repos.slice(0, 2))).map(String);
120 expect(rows[0]).toContain("private app");
121 expect(rows[0]).not.toContain(">live<");
122 expect(rows[0]).not.toContain("not running");
123 expect(rows[1]).not.toContain("private app");
124 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs