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

A deploy that never reached its preview is counted, not quietly dropped #141

Merged opened this

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

Merged

scout/forge:scout/123-a-deploy-that-never-reached-its-preview → main 1 commit fork

  • Merged. scout/123-a-deploy-that-never-reached-its-preview was merged into main as f9748ea.

Review

Sign in to review this change.
3 files changed +168 −22
src/core/runs.ts +36 −17
@@ -621,15 +621,21 @@ export function latestAgentRunsForRepos(
621621 * and the count it is drawn from, plus the same walk split at `started_at`
622622 * — the wait to be picked up, and the boot once it was. The split is its
623623 * own two medians, not half of the total's; they will not sum to it.
624 * Beside them, how many of this window's deploys finished without ever
625 * serving — counted, never folded into the median a dead deploy says
626 * nothing about.
624627 */
625628 export interface PreviewLatency {
626 readonly medianSeconds: number;
629 /** Undefined only when nothing served in the window — `n` is then 0. */
630 readonly medianSeconds: number | undefined;
627631 readonly n: number;
628632 /** Undefined when no served deploy in the window also has a `started_at`. */
629633 readonly queueMedianSeconds: number | undefined;
630634 readonly bootMedianSeconds: number | undefined;
631635 /** How many of `n` carried a `started_at` and so fed the split. */
632636 readonly splitN: number;
637 /** Deploys in the window that finished (`ok`, `failed` or `cancelled`) without ever serving. A deploy still queued or running is neither served nor counted here. */
638 readonly failedN: number;
633639 }
634640
635641 const median = (xs: readonly number[]): number => {
@@ -641,11 +647,14 @@ const median = (xs: readonly number[]): number => {
641647 * The median seconds from push (`created_at`) to serving (`serving_at`)
642648 * over this repo's deploys since `since` — the same reference instant
643649 * `servedPhrase` (`web/ui.ts`) words a single deploy from. A deploy that
644 * never served says nothing about speed and is left out, never a zero;
645 * one that served but never records a `started_at` feeds the total but
646 * not the split. One query, bounded by `limit` and served by the
647 * `run_repo` index (it walks newest-first and stops at the cap), never a
648 * scan of the table.
650 * never served says nothing about speed and is left out of the median,
651 * never a zero; one that served but never records a `started_at` feeds
652 * the total but not the split. Beside the median, the same walk counts
653 * deploys that finished — `ok`, `failed` or `cancelled` — without ever
654 * serving; one still `queued` or `running` is neither, and counts as
655 * neither. One query, bounded by `limit` and served by the `run_repo`
656 * index (it walks newest-first and stops at the cap), never a scan of
657 * the table.
649658 */
650659 export function previewLatency(
651660 db: Database,
@@ -655,36 +664,46 @@ export function previewLatency(
655664 ): PreviewLatency | undefined {
656665 const rows = db
657666 .query<
658 { secs: number; queueSecs: number | null; bootSecs: number | null },
667 {
668 secs: number | null;
669 queueSecs: number | null;
670 bootSecs: number | null;
671 finished: 0 | 1;
672 },
659673 [string, string, number]
660674 >(
661 `SELECT (julianday(serving_at) - julianday(created_at)) * 86400 AS secs,
662 CASE WHEN started_at IS NOT NULL
675 `SELECT CASE WHEN serving_at IS NOT NULL
676 THEN (julianday(serving_at) - julianday(created_at)) * 86400 END AS secs,
677 CASE WHEN serving_at IS NOT NULL AND started_at IS NOT NULL
663678 THEN (julianday(started_at) - julianday(created_at)) * 86400 END AS queueSecs,
664 CASE WHEN started_at IS NOT NULL
665 THEN (julianday(serving_at) - julianday(started_at)) * 86400 END AS bootSecs
679 CASE WHEN serving_at IS NOT NULL AND started_at IS NOT NULL
680 THEN (julianday(serving_at) - julianday(started_at)) * 86400 END AS bootSecs,
681 state IN ('ok','failed','cancelled') AS finished
666682 FROM run
667 WHERE repo_id = ?1 AND kind = 'deploy' AND serving_at IS NOT NULL
683 WHERE repo_id = ?1 AND kind = 'deploy'
668684 AND created_at >= ?2
669685 ORDER BY id DESC LIMIT ?3`,
670686 )
671687 .all(repoId, since, limit);
672 if (rows.length === 0) return undefined;
673 const secs = rows.map((r) => r.secs).sort((a, b) => a - b);
674 const queueSecs = rows
688 const served = rows.filter((r) => r.secs !== null);
689 const failedN = rows.filter((r) => r.secs === null && r.finished === 1).length;
690 if (served.length === 0 && failedN === 0) return undefined;
691 const secs = served.map((r) => r.secs!).sort((a, b) => a - b);
692 const queueSecs = served
675693 .map((r) => r.queueSecs)
676694 .filter((s): s is number => s !== null)
677695 .sort((a, b) => a - b);
678 const bootSecs = rows
696 const bootSecs = served
679697 .map((r) => r.bootSecs)
680698 .filter((s): s is number => s !== null)
681699 .sort((a, b) => a - b);
682700 return {
683 medianSeconds: median(secs),
701 medianSeconds: secs.length === 0 ? undefined : median(secs),
684702 n: secs.length,
685703 queueMedianSeconds: queueSecs.length === 0 ? undefined : median(queueSecs),
686704 bootMedianSeconds: bootSecs.length === 0 ? undefined : median(bootSecs),
687705 splitN: queueSecs.length,
706 failedN,
688707 };
689708 }
690709
src/web/studio/app.ts +23 −5
@@ -81,11 +81,29 @@ const PREVIEW_LATENCY_MIN = 3;
8181 * rather than printing one from a run or two. Below the total, the same
8282 * walk split at `started_at` — waiting to be picked up, then coming up —
8383 * each its own median, not a piece of the total that ought to add up.
84 * When some of the week's deploys finished without ever serving, the line
85 * says how many of how many and links to them — a clean week reads exactly
86 * as it always has, no `0 failed`, no empty clause.
8487 */
85 function previewLatencyWords(latency: PreviewLatency | undefined): Html {
86 if (latency === undefined || latency.n < PREVIEW_LATENCY_MIN)
88 function previewLatencyWords(
89 latency: PreviewLatency | undefined,
90 failedHref: string,
91 ): Html {
92 const failedClause =
93 latency !== undefined && latency.failedN > 0
94 ? html`<a href="${failedHref}">${latency.failedN} of ${
95 latency.n + latency.failedN
96 } never reached a preview</a>`
97 : null;
98 const failedSuffix =
99 failedClause === null ? "" : html` · ${failedClause}`;
100 if (
101 latency === undefined ||
102 latency.n < PREVIEW_LATENCY_MIN ||
103 latency.medianSeconds === undefined
104 )
87105 return html`<span class="muted small"
88 >not enough deploys this week to say</span
106 >not enough deploys this week to say${failedSuffix}</span
89107 >`;
90108 const split =
91109 latency.queueMedianSeconds === undefined ||
@@ -97,7 +115,7 @@ function previewLatencyWords(latency: PreviewLatency | undefined): Html {
97115 ? ` (${latency.splitN} of ${latency.n})`
98116 : ""} · `;
99117 return html`reaches its preview in ${latency.medianSeconds.toFixed(1)}s
100 <span class="muted">· ${split}${latency.n} deploys</span>`;
118 <span class="muted">· ${split}${latency.n} deploys${failedSuffix}</span>`;
101119 }
102120
103121 export async function studioApp(
@@ -343,7 +361,7 @@ export async function studioApp(
343361 <dt>Runs</dt>
344362 <dd>${weekTotals.runs}</dd>
345363 <dt>Preview</dt>
346 <dd>${previewLatencyWords(weekLatency)}</dd>
364 <dd>${previewLatencyWords(weekLatency, `${base}/runs?kind=deploy`)}</dd>
347365 <dt>Spent</dt>
348366 <dd>
349367 $${spent.toFixed(2)}${shipped > 0
test/studio.test.ts +109
@@ -3364,6 +3364,80 @@ describe("previewLatency: the median seconds from push to serving", () => {
33643364 expect(withGap.queueMedianSeconds).toBeCloseTo(2, 2);
33653365 expect(withGap.bootMedianSeconds).toBeCloseTo(6, 2);
33663366 });
3367
3368 test("a deploy that finished without ever serving is counted apart, never in the median; one still running is counted as neither", () => {
3369 const now = Date.now() + 20_000_000; // clear of the earlier tests' windows
3370 const since = new Date(now - 60_000).toISOString();
3371
3372 // Two served deploys, 4s and 6s.
3373 [4, 6].forEach((d, i) => {
3374 const createdAt = new Date(now + i * 1_000).toISOString();
3375 seedDeploy(
3376 createdAt,
3377 null,
3378 new Date(Date.parse(createdAt) + d * 1_000).toISOString(),
3379 );
3380 });
3381 const clean = previewLatency(app.db, repo.id, since)!;
3382 expect(clean.n).toBe(2);
3383 expect(clean.failedN).toBe(0);
3384 expect(clean.medianSeconds).toBeCloseTo(5, 2);
3385
3386 // A deploy still queued: neither served nor finished. Moves neither number.
3387 const inFlight = createRun(app.db, { repoId: repo.id, kind: "deploy" });
3388 app.db
3389 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
3390 .run(inFlight.id, new Date(now + 2_000).toISOString());
3391 const withInFlight = previewLatency(app.db, repo.id, since)!;
3392 expect(withInFlight.n).toBe(2);
3393 expect(withInFlight.failedN).toBe(0);
3394 expect(withInFlight.medianSeconds).toBeCloseTo(5, 2);
3395
3396 // A deploy that finished but never served — counted as failed, never a
3397 // zero-second deploy pulling the median down.
3398 const failed = createRun(app.db, { repoId: repo.id, kind: "deploy" });
3399 app.db
3400 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
3401 .run(failed.id, new Date(now + 3_000).toISOString());
3402 claimRun(app.db, failed.id);
3403 finishRun(app.db, failed.id, { state: "failed", summary: "build failed" });
3404 const withFailed = previewLatency(app.db, repo.id, since)!;
3405 expect(withFailed.n).toBe(2);
3406 expect(withFailed.failedN).toBe(1);
3407 expect(withFailed.medianSeconds).toBeCloseTo(5, 2);
3408
3409 // A cancelled deploy that never served also counts as failed.
3410 const cancelled = createRun(app.db, { repoId: repo.id, kind: "deploy" });
3411 app.db
3412 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
3413 .run(cancelled.id, new Date(now + 4_000).toISOString());
3414 claimRun(app.db, cancelled.id);
3415 app.db
3416 .query("UPDATE run SET state = 'cancelled', finished_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1")
3417 .run(cancelled.id);
3418 const withCancelled = previewLatency(app.db, repo.id, since)!;
3419 expect(withCancelled.n).toBe(2);
3420 expect(withCancelled.failedN).toBe(2);
3421 expect(withCancelled.medianSeconds).toBeCloseTo(5, 2);
3422 });
3423
3424 test("a week with only never-served deploys still reports the failed count, with no median to give", () => {
3425 const now = Date.now() + 30_000_000;
3426 const since = new Date(now - 60_000).toISOString();
3427
3428 const failed = createRun(app.db, { repoId: repo.id, kind: "deploy" });
3429 app.db
3430 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
3431 .run(failed.id, new Date(now).toISOString());
3432 claimRun(app.db, failed.id);
3433 finishRun(app.db, failed.id, { state: "failed", summary: "build failed" });
3434
3435 const latency = previewLatency(app.db, repo.id, since)!;
3436 expect(latency).toBeDefined();
3437 expect(latency.n).toBe(0);
3438 expect(latency.failedN).toBe(1);
3439 expect(latency.medianSeconds).toBeUndefined();
3440 });
33673441 });
33683442
33693443 describe("the studio's own words for how fast a change reaches its preview", () => {
@@ -3493,6 +3567,41 @@ describe("the studio's own words for how fast a change reaches its preview", ()
34933567 expect(page).toContain("2.0s waiting");
34943568 // Only 3 of the 4 served deploys carried a `started_at`: the split says so.
34953569 expect(page).toContain("6.0s coming up (3 of 4)");
3570
3571 // A clean week — every deploy served — reads exactly as it always has:
3572 // no mention of failures, no empty clause left dangling.
3573 expect(page).not.toContain("never reached a preview");
3574 expect(page).not.toContain("0 failed");
3575 });
3576
3577 test("a deploy that finished without ever serving is counted and worded, and links to the runs list", async () => {
3578 const before = await (await get("/alice/trickle")).text();
3579
3580 const failed = createRun(app.db, {
3581 repoId: repo.id,
3582 kind: "deploy",
3583 env: "production",
3584 reason: "push",
3585 });
3586 claimRun(app.db, failed.id);
3587 finishRun(app.db, failed.id, { state: "failed", summary: "build failed" });
3588
3589 const after = await (await get("/alice/trickle")).text();
3590 expect(after).toContain("1 of 5 never reached a preview");
3591 expect(after).toContain(`href="/alice/trickle/runs?kind=deploy"`);
3592
3593 // A deploy still running moves neither the served count nor the failed
3594 // count: it is neither served nor finished.
3595 const inFlight = createRun(app.db, {
3596 repoId: repo.id,
3597 kind: "deploy",
3598 env: "production",
3599 reason: "push",
3600 });
3601 claimRun(app.db, inFlight.id);
3602 const withInFlight = await (await get("/alice/trickle")).text();
3603 expect(withInFlight).toContain("1 of 5 never reached a preview");
3604 expect(withInFlight).not.toBe(before);
34963605 });
34973606 });
34983607

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs