g1t/scripts/ops/fork-storage-test.mjs

227 lines11,010 bytesCodeBlame
1#!/usr/bin/env node
2// Do Artifacts forks copy their source's objects, or share them?
3//
4// Cloudflare does not document it (docs/ARTIFACTS.md, M2), and it decides
5// how much every pull request costs in storage. This makes a throwaway
6// repository holding ~100 MB that cannot be compressed, forks it five
7// times, and reads whatever storage figures Cloudflare reports before and
8// after, then deletes everything it made. Nothing of g1t's is touched: it
9// works in its own namespace (default `g1t-storage-test`), straight
10// against Cloudflare's API, never through g1t.sh.
11//
12// export CLOUDFLARE_API_TOKEN=<token: Artifacts edit, Account Analytics read>
13// (or a global API key: CLOUDFLARE_API_KEY with CLOUDFLARE_EMAIL)
14// node scripts/ops/fork-storage-test.mjs run # make, fork 5x, measure for 20 min, delete
15// node scripts/ops/fork-storage-test.mjs run --keep # ... and keep it, to measure again tomorrow
16// node scripts/ops/fork-storage-test.mjs measure # read the figures again (e.g. the next day)
17// node scripts/ops/fork-storage-test.mjs cleanup # delete the test repositories
18// node scripts/ops/fork-storage-test.mjs schema # list the analytics datasets Cloudflare offers for Artifacts
19//
20// Options: --namespace <name> (default g1t-storage-test), --mb <size> (100),
21// --forks <n> (5), --minutes <n> to keep measuring (20).
22//
23// How to read the result: docs/ARTIFACTS.md, "R2: the fork storage test".
24// Needs git on PATH. Costs a few cents of Artifacts storage and operations.
25
26import { execFileSync } from "node:child_process";
27import { randomBytes } from "node:crypto";
28import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
29import { tmpdir } from "node:os";
30import { join } from "node:path";
31
32import { cloudflareAuth } from "../deploy/cloudflare.mjs";
33
34const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID || "1e6f2cffa3f445920836e8ebe446bb58";
35const API = `https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}`;
36const args = process.argv.slice(2);
37const command = args[0] ?? "run";
38const option = (name, fallback) => {
39 const at = args.indexOf(name);
40 return at >= 0 && args[at + 1] ? args[at + 1] : fallback;
41};
42const NAMESPACE = option("--namespace", "g1t-storage-test");
43const MB = Number(option("--mb", "100"));
44const FORKS = Number(option("--forks", "5"));
45const MINUTES = Number(option("--minutes", "20"));
46const KEEP = args.includes("--keep");
47const SOURCE = "fork-test-source";
48const forkName = (n) => `fork-test-copy-${n}`;
49
50const auth = cloudflareAuth();
51if (!auth) {
52 console.error("Set CLOUDFLARE_API_TOKEN (Artifacts edit, Account Analytics read), or CLOUDFLARE_API_KEY and CLOUDFLARE_EMAIL.");
53 process.exit(2);
54}
55
56async function api(method, path, body) {
57 const response = await fetch(`${API}${path}`, {
58 method,
59 headers: { ...auth, "content-type": "application/json" },
60 body: body ? JSON.stringify(body) : undefined,
61 });
62 const json = await response.json().catch(() => ({}));
63 return { status: response.status, ok: response.ok && json.success !== false, json };
64}
65
66async function graphql(query, variables = {}) {
67 const response = await fetch("https://api.cloudflare.com/client/v4/graphql", {
68 method: "POST",
69 headers: { ...auth, "content-type": "application/json" },
70 body: JSON.stringify({ query, variables }),
71 });
72 const json = await response.json();
73 if (json.errors?.length) throw new Error(JSON.stringify(json.errors).slice(0, 500));
74 return json.data;
75}
76
77/** The account's analytics datasets about Artifacts, and their numeric fields. */
78async function datasets() {
79 const data = await graphql(`{ __type(name: "account") { fields { name type { name ofType { name ofType { name ofType { name } } } } } } }`);
80 const fields = data.__type?.fields ?? [];
81 const found = [];
82 for (const field of fields.filter((f) => /artifact/i.test(f.name))) {
83 let type = field.type;
84 while (type && !type.name) type = type.ofType;
85 while (type?.ofType) type = type.ofType;
86 const typeName = type?.name;
87 const shape = typeName ? await graphql(`{ __type(name: "${typeName}") { fields { name type { name kind ofType { name kind } } } } }`) : null;
88 found.push({ dataset: field.name, type: typeName, fields: (shape?.__type?.fields ?? []).map((f) => f.name) });
89 }
90 return found;
91}
92
93/** Every storage-looking figure Cloudflare reports for the test repositories. */
94async function storageFigures() {
95 const out = {};
96 const sets = await datasets();
97 const start = new Date(Date.now() - 2 * 24 * 3600 * 1000).toISOString();
98 const end = new Date().toISOString();
99 for (const set of sets) {
100 if (set.dataset === "artifactsEventsAdaptiveGroups") continue;
101 // A dataset other than events: ask for its sums, maxes and dimensions,
102 // filtered to this namespace where it can be.
103 for (const aggregate of ["max", "sum", "avg"]) {
104 if (!set.fields.includes(aggregate)) continue;
105 try {
106 const inner = await graphql(`{ __type(name: "${set.type}") { fields { name type { name ofType { name } } } } }`);
107 const aggregateType = inner.__type.fields.find((f) => f.name === aggregate)?.type;
108 const aggregateName = aggregateType?.name ?? aggregateType?.ofType?.name;
109 const numeric = aggregateName ? await graphql(`{ __type(name: "${aggregateName}") { fields { name } } }`) : null;
110 const names = (numeric?.__type?.fields ?? []).map((f) => f.name);
111 if (!names.length) continue;
112 const data = await graphql(
113 `query Q($account: String!, $start: Time!, $end: Time!) { viewer { accounts(filter: { accountTag: $account }) {
114 ${set.dataset}(limit: 1000, filter: { datetime_geq: $start, datetime_leq: $end }) {
115 ${aggregate} { ${names.join(" ")} } dimensions { repositoryNamespace repositoryName date }
116 } } } }`,
117 { account: ACCOUNT_ID, start, end },
118 );
119 const rows = data.viewer.accounts[0][set.dataset].filter((row) => !row.dimensions?.repositoryNamespace || row.dimensions.repositoryNamespace === NAMESPACE);
120 out[`${set.dataset}.${aggregate}`] = rows;
121 } catch (error) {
122 out[`${set.dataset}.${aggregate}`] = `not readable: ${String(error.message).slice(0, 200)}`;
123 }
124 }
125 }
126 // The events themselves: errors such as storageLimitReached, and pushes.
127 const events = await graphql(
128 `query Q($account: String!, $start: Time!, $end: Time!, $ns: String!) { viewer { accounts(filter: { accountTag: $account }) {
129 artifactsEventsAdaptiveGroups(limit: 1000, filter: { datetime_geq: $start, datetime_leq: $end, repositoryNamespace: $ns }) {
130 count sum { durationMs } dimensions { repositoryName eventKind eventType }
131 } } } }`,
132 { account: ACCOUNT_ID, start, end, ns: NAMESPACE },
133 );
134 out.events = events.viewer.accounts[0].artifactsEventsAdaptiveGroups;
135 return out;
136}
137
138async function repoInfo(name) {
139 const got = await api("GET", `/artifacts/namespaces/${NAMESPACE}/repos/${name}`);
140 return got.ok ? got.json.result : null;
141}
142
143async function setup() {
144 const made = await api("POST", "/artifacts/namespaces", { namespace: NAMESPACE });
145 if (!made.ok && made.status !== 409) console.log(`namespace: ${made.status} ${JSON.stringify(made.json.errors ?? "")}`);
146 const created = await api("POST", `/artifacts/namespaces/${NAMESPACE}/repos`, { name: SOURCE, description: "g1t fork storage test; safe to delete" });
147 if (!created.ok) throw new Error(`create: ${created.status} ${JSON.stringify(created.json.errors ?? created.json)}`);
148 const { remote, token: repoToken } = created.json.result;
149 console.log(`made ${NAMESPACE}/${SOURCE}`);
150 // ~MB of random bytes, in files under the 32 MB limit, so nothing compresses.
151 const dir = mkdtempSync(join(tmpdir(), "g1t-fork-test-"));
152 try {
153 const git = (...a) => execFileSync("git", a, { cwd: dir, stdio: ["ignore", "pipe", "pipe"] }).toString();
154 git("init", "-q", "-b", "main");
155 git("config", "user.email", "fork-test@g1t.invalid");
156 git("config", "user.name", "g1t fork test");
157 const files = Math.ceil(MB / 25);
158 for (let n = 0; n < files; n++) writeFileSync(join(dir, `random-${n}.bin`), randomBytes(Math.min(25, MB - n * 25) * 1024 * 1024));
159 git("add", ".");
160 git("commit", "-q", "-m", "incompressible data");
161 const started = Date.now();
162 execFileSync("git", ["-c", `http.extraHeader=Authorization: Bearer ${repoToken}`, "push", "-q", remote, "main"], { cwd: dir, stdio: "inherit" });
163 console.log(`pushed ${MB} MB in ${((Date.now() - started) / 1000).toFixed(1)} s`);
164 } finally {
165 rmSync(dir, { recursive: true, force: true });
166 }
167 const forks = [];
168 for (let n = 1; n <= FORKS; n++) {
169 const started = Date.now();
170 const forked = await api("POST", `/artifacts/namespaces/${NAMESPACE}/repos/${SOURCE}/fork`, { name: forkName(n), default_branch_only: true });
171 const ms = Date.now() - started;
172 forks.push({ name: forkName(n), status: forked.status, ms, result: forked.json.result ?? forked.json.errors });
173 console.log(`fork ${n}: ${forked.status} in ${ms} ms ${JSON.stringify(forked.json.result ?? forked.json.errors ?? {}).slice(0, 300)}`);
174 }
175 return forks;
176}
177
178async function cleanup() {
179 for (const name of [SOURCE, ...Array.from({ length: FORKS }, (_, n) => forkName(n + 1))]) {
180 const deleted = await api("DELETE", `/artifacts/namespaces/${NAMESPACE}/repos/${name}`);
181 console.log(`delete ${name}: ${deleted.status}`);
182 }
183 console.log(`The namespace ${NAMESPACE} is left, empty (the API has no call to delete one).`);
184}
185
186async function measure(label) {
187 const figures = await storageFigures();
188 const info = {};
189 for (const name of [SOURCE, ...Array.from({ length: FORKS }, (_, n) => forkName(n + 1))]) info[name] = await repoInfo(name);
190 console.log(`\n== ${label} (${new Date().toISOString()}) ==`);
191 console.log(JSON.stringify({ figures, repos: info }, null, 2));
192 return figures;
193}
194
195async function main() {
196 if (command === "schema") {
197 console.log(JSON.stringify(await datasets(), null, 2));
198 return;
199 }
200 if (command === "cleanup") return cleanup();
201 if (command === "measure") {
202 await measure("now");
203 return;
204 }
205 if (command !== "run") throw new Error(`unknown command ${command}`);
206 console.log("Artifacts analytics datasets:", JSON.stringify((await datasets()).map((d) => d.dataset)));
207 await measure("before");
208 let forks;
209 try {
210 forks = await setup();
211 await measure("right after");
212 const until = Date.now() + MINUTES * 60 * 1000;
213 while (Date.now() < until) {
214 await new Promise((resolve) => setTimeout(resolve, 5 * 60 * 1000));
215 await measure(`after, ${Math.round((MINUTES * 60 * 1000 - (until - Date.now())) / 60000)} min`);
216 }
217 } finally {
218 if (KEEP) console.log(`\nKept. Run \`measure\` tomorrow, then \`cleanup\`.`);
219 else await cleanup();
220 }
221 console.log("\nForks:", JSON.stringify(forks, null, 2));
222}
223
224main().catch((error) => {
225 console.error(error.message);
226 process.exit(1);
227});