Skip to content

Commit

Old artifacts in KV are asked for only for a finished run made before the move to R2, after the run is shown, and not at all from 2026-10-22

syntaqxcommitted Parent2c551c7Browse files
5 files+94−150/5 viewed
+33−4
5656 name: String,
5757 }
5858
59+/// Artifacts older runners kept in KV expire 14 days after they were made,
60+/// and none has been made there since artifacts moved to R2 on 2026-10-08:
61+/// from 2026-10-22T00:00Z every one is gone, and KV is not asked (a list is
62+/// the dearest thing KV does). Delete the KV artifact code after that date,
63+/// with its twin in apps/web/app/lib/artifacts.server.ts.
64+const LEGACY_KV_UNTIL_MS: u64 = 1_792_627_200_000;
65+
66+/// Whether artifacts kept in KV may still be there at `now`.
67+fn legacy_kv(now: u64) -> bool {
68+ now < LEGACY_KV_UNTIL_MS
69+}
70+
5971 fn store(env: &Env) -> Result<KvStore> {
6072 env.kv("BLOBS")
6173 }
404416 // Artifacts older runners kept in KV, for the days they
405417 // are still there.
406418 let legacy_run = run_id.as_deref().unwrap_or(run);
407− for (_, meta) in list(kv, &format!("a/{legacy_run}/")).await? {
408− if !listed.iter().any(|a| a["name"] == meta.name.as_str()) {
409− listed.push(json!({ "name": meta.name, "size": meta.size, "format": "tgz" }));
419+ if legacy_kv(g1t_kit::now_ms()) {
420+ for (_, meta) in list(kv, &format!("a/{legacy_run}/")).await? {
421+ if !listed.iter().any(|a| a["name"] == meta.name.as_str()) {
422+ listed.push(json!({ "name": meta.name, "size": meta.size, "format": "tgz" }));
423+ }
410424 }
411425 }
412426 crate::reply(&listed)
547561 match found {
548562 Outcome::Ok(found) => stream(bucket, &found.object, &found.artifact.format).await,
549563 // One an older runner kept in KV.
550− Outcome::Fail(_) => match get(kv, &format!("a/{run}/{name}")).await? {
564+ Outcome::Fail(_) if legacy_kv(g1t_kit::now_ms()) => match get(kv, &format!("a/{run}/{name}")).await? {
551565 Some(bytes) => Response::from_bytes(bytes),
552566 None => error(404, "No such artifact."),
553567 },
568+ Outcome::Fail(_) => error(404, "No such artifact."),
554569 }
555570 }
556571 _ => error(404, "No such endpoint."),
612627 return Response::redirect_with_status(Url::parse(&crate::artifacts::blob_url(&services.addresses.api, &found.blob))?, 302);
613628 }
614629 // Kept in KV by an older runner.
630+ if !legacy_kv(g1t_kit::now_ms()) {
631+ return error(404, "No such artifact, or it has expired.");
632+ }
615633 match get(&store(env)?, &format!("a/{run}/{name}")).await? {
616634 Some(bytes) => {
617635 let mut response = Response::from_bytes(bytes)?;
624642 }
625643 }
626644
645+#[cfg(test)]
646+mod tests {
647+ use super::*;
648+
649+ #[test]
650+ fn kv_artifacts_are_not_asked_for_after_they_have_all_expired() {
651+ assert_eq!(g1t_contracts::time::rfc3339(LEGACY_KV_UNTIL_MS), "2026-10-22T00:00:00.000Z");
652+ assert!(legacy_kv(LEGACY_KV_UNTIL_MS - 1));
653+ assert!(!legacy_kv(LEGACY_KV_UNTIL_MS));
654+ }
655+}
+21−7
22
33 import type { RepoPath, Viewer } from "@g1t/contracts";
44
5+import { LEGACY_KV_UNTIL } from "./artifacts";
56 import { actions } from "./services.server";
67
78 /**
89 * A run's artifacts, for its page. The actions service lists them (their
910 * bytes are in R2, downloaded through the API's signed links); artifacts an
1011 * older runner kept in KV (`a/{run}/{name}`, its bytes in chunks `…#0`,
11− * `…#1`) are listed too until KV expires them.
12+ * `…#1`) are listed too until KV expires them, for runs the caller has
13+ * already been shown (`legacyArtifactsWorthAsking` in artifacts.ts).
1214 */
1315 export type ArtifactRow = {
1416 /** The artifact's number; null for one kept in KV. */
3537 }));
3638 }
3739
40+/** A run's artifacts the actions service keeps, which checks who may see them. */
3841 export async function listArtifacts(repo: RepoPath, viewer: Viewer, run: string): Promise<ArtifactRow[]> {
39− const [kept, legacy] = await Promise.all([
40− actions.artifacts(repo, viewer, { run, per_page: 100 }),
41− kvArtifacts(run).catch(() => []),
42− ]);
42+ const kept = await actions.artifacts(repo, viewer, { run, per_page: 100 });
4343 const rows: ArtifactRow[] = kept.ok
4444 ? kept.value.artifacts.map((a) => ({ id: a.id, name: a.name, size: a.size, expiresAt: a.expires_at, createdAt: a.created_at }))
4545 : [];
46+ return rows.sort((a, b) => a.name.localeCompare(b.name));
47+}
48+
49+/**
50+ * `rows` with the artifacts an older runner kept in KV for `run` added.
51+ * Only for a run the viewer was allowed to see. Delete after
52+ * 2026-10-22T00:00Z (`LEGACY_KV_UNTIL`).
53+ */
54+export async function withLegacyArtifacts(rows: ArtifactRow[], run: string): Promise<ArtifactRow[]> {
55+ if (Date.now() >= LEGACY_KV_UNTIL) return rows;
56+ const legacy = await kvArtifacts(run).catch(() => []);
57+ const all = [...rows];
4658 for (const row of legacy) {
47− if (!rows.some((r) => r.name === row.name)) rows.push(row);
59+ if (!all.some((r) => r.name === row.name)) all.push(row);
4860 }
49− return rows.sort((a, b) => a.name.localeCompare(b.name));
61+ return all.sort((a, b) => a.name.localeCompare(b.name));
5062 }
5163
5264 export async function readArtifact(run: string, name: string): Promise<Uint8Array | null> {
65+ // Every artifact kept in KV has expired (artifacts.ts).
66+ if (Date.now() >= LEGACY_KV_UNTIL) return null;
5367 const base = `a/${run}/${name}`;
5468 const meta = await env.BLOBS.get<Meta>(base, "json");
5569 if (!meta) return null;
+13−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { expiresIn, formatBytes } from "./artifacts.ts";
4+import { expiresIn, formatBytes, legacyArtifactsWorthAsking } from "./artifacts.ts";
55
66 test("sizes read as KB, MB or GB", () => {
77 assert.equal(formatBytes(10), "1 KB");
1818 assert.equal(expiresIn("2026-10-08T11:00:00Z", now), "expired");
1919 assert.equal(expiresIn(null, now), "");
2020 });
21+
22+test("KV is asked for an old, finished run's artifacts only until they have expired", () => {
23+ const old = { createdAt: "2026-10-07T12:00:00Z", status: "completed" };
24+ const now = Date.parse("2026-10-10T00:00:00Z");
25+ assert.ok(legacyArtifactsWorthAsking(old, now));
26+ // Still going: its artifacts are in R2, and its page refreshes.
27+ assert.ok(!legacyArtifactsWorthAsking({ ...old, status: "in_progress" }, now));
28+ // Made after the move to R2.
29+ assert.ok(!legacyArtifactsWorthAsking({ ...old, createdAt: "2026-10-09T08:00:00Z" }, now));
30+ // Every KV artifact has expired.
31+ assert.ok(!legacyArtifactsWorthAsking(old, Date.parse("2026-10-22T00:00:00Z")));
32+});
+21−0
1616 if (days === 1) return "expires tomorrow";
1717 return `expires in ${days} days`;
1818 }
19+
20+/**
21+ * Artifacts older runners kept in Workers KV. None has been made there
22+ * since artifacts moved to R2 on 2026-10-08, and KV expires each 14 days
23+ * after it was made: from 2026-10-22T00:00Z every one is gone. Delete the
24+ * KV artifact code (here, artifacts.server.ts, and apps/api/src/blobs.rs)
25+ * after that date.
26+ */
27+export const LEGACY_KV_UNTIL = Date.parse("2026-10-22T00:00:00Z");
28+/** Runs made from this time on kept their artifacts in R2 only. */
29+export const LEGACY_KV_BEFORE = Date.parse("2026-10-09T00:00:00Z");
30+
31+/**
32+ * Whether a run's page asks KV for artifacts an older runner kept there:
33+ * only for a finished run made before the move, and only until they have
34+ * all expired. A KV list is the dearest thing KV does, and a run still
35+ * going refreshes its page every few seconds.
36+ */
37+export function legacyArtifactsWorthAsking(run: { createdAt: string; status: string }, now: number = Date.now()): boolean {
38+ return now < LEGACY_KV_UNTIL && run.status === "completed" && Date.parse(run.createdAt) < LEGACY_KV_BEFORE;
39+}
+6−3
3939 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu";
4040 import { Hint } from "../../components/ui/hint";
4141 import { searchLog } from "../../lib/log-lines";
42−import { listArtifacts } from "../../lib/artifacts.server";
43−import { expiresIn, formatBytes } from "../../lib/artifacts";
42+import { listArtifacts, withLegacyArtifacts } from "../../lib/artifacts.server";
43+import { expiresIn, formatBytes, legacyArtifactsWorthAsking } from "../../lib/artifacts";
4444 import { actions } from "../../lib/services.server";
4545 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
4646 import { accessTo, refusal } from "../../lib/access.server";
5757 // An earlier attempt, when one is asked for.
5858 const asked = Number(new URL(request.url).searchParams.get("attempt") ?? "");
5959 const attempt = Number.isInteger(asked) && asked > 0 ? asked : undefined;
60− const [detail, artifacts, summaries] = await Promise.all([
60+ const [detail, kept, summaries] = await Promise.all([
6161 actions.run(repo, viewer, params.id, attempt).then(unwrap),
6262 listArtifacts(repo, viewer, params.id).catch(() => []),
6363 actions
6565 .then((found) => (found.ok ? found.value : []))
6666 .catch((): JobSummary[] => []),
6767 ]);
68+ // Artifacts an older runner kept in KV: asked for only once the service
69+ // has shown the viewer the run, and never while it is still going.
70+ const artifacts = legacyArtifactsWorthAsking(detail.run) ? await withLegacyArtifacts(kept, params.id) : kept;
6871 // Cancelling and re-running need Write.
6972 return {
7073 detail,