Public: anyone can read this; the crew can change it.
Meter awake time and storage: per app and environment, the two quantities a forge charges for #527
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/505-meter-awake-time-and-storage-per-app-and → main 4 commits fork
10 files
changed +764
−4
docs/APPS.md +16
| @@ -778,6 +778,22 @@ it was kept awake. Busy buys one crew run's length past the idle window | ||
| 778 | 778 | (`FORGE_CREW_TIMEOUT_MIN`, 45 minutes by default) and no more, so an app |
| 779 | 779 | that keeps a child forever still rests. |
| 780 | 780 | |
| 781 | ### The meter | |
| 782 | ||
| 783 | Awake and asleep are also what an app costs, so the runtime writes them | |
| 784 | down (`core/meter.ts`). Every stretch an environment serves is one row in | |
| 785 | `meter` — repository, environment, placement (`owned` or `cloud`), started, | |
| 786 | ended — opened when a deploy or a wake starts serving (a redeploy keeps the | |
| 787 | same row open) and closed when it sleeps, stops, crashes, is torn down or | |
| 788 | the forge shuts down. Each sweep is a heartbeat on the open rows; a row the | |
| 789 | last forge left open is closed at boot at its last heartbeat, never left | |
| 790 | running. Once an hour the runtime samples each app's bytes — every | |
| 791 | environment's `FORGE_APP_DATA` and its bare repository, files only, links | |
| 792 | not followed (`apps/bytes.ts`) — into `storage`. `usageBetween` reads both | |
| 793 | for a `[from, to)` range: awake seconds per app (by placement too) and | |
| 794 | GB-hours, bytes × hours / 1e9, each sample standing for the hour after it | |
| 795 | or until the next one. | |
| 796 | ||
| 781 | 797 | ### Subscriptions |
| 782 | 798 | |
| 783 | 799 | One token in the environment is one account for the whole forge. An admin |
forge.json +1 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | "ready": 90 |
| 7 | 7 | }, |
| 8 | 8 | "check": { |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/apps-token.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts test/offline.test.ts test/share.test.ts test/cloud.test.ts test/run-secrets.test.ts test/forge-cloudflare.test.ts test/usage.test.ts test/standing.test.ts test/board-windows.test.ts test/spawn-plan.test.ts test/spawn.test.ts test/web-apps.test.ts test/fleet-ops.test.ts", | |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 -- crew crew-wrapup crew-limits crew-sandbox steer landing decide ledger checks dependencies autonomy studio studio-words forks fork-flow cross-repo-pull pulls apps apps-adversarial apps-busy apps-token web git-trust helpers shards migrate vitals asks feed feed-page offline share cloud run-secrets forge-cloudflare usage standing board-windows spawn-plan spawn web-apps fleet-ops meter", | |
| 10 | 10 | "timeout": 1800 |
| 11 | 11 | } |
| 12 | 12 | } |
scripts/shards.ts +28
| @@ -79,6 +79,34 @@ export function splitFiles( | ||
| 79 | 79 | /** How a test file is named: the same rule `scripts/test-shards.ts` uses. */ |
| 80 | 80 | export const TEST_FILE = /\.test\.[cm]?[jt]sx?$/; |
| 81 | 81 | |
| 82 | /** A test's short name after `--`: `crew-wrapup` is `test/crew-wrapup.test.ts`. */ | |
| 83 | const TEST_NAME = /^[a-z0-9][a-z0-9-]{0,99}$/; | |
| 84 | ||
| 85 | /** | |
| 86 | * Splits `scripts/test-shards.ts`'s arguments into `bun test` flags and | |
| 87 | * test files. Before `--`, an argument naming a test file is a file and | |
| 88 | * anything else a flag; after it, every argument is a short name under | |
| 89 | * `test/` (`crew` is `test/crew.test.ts`), which keeps `forge.json`'s | |
| 90 | * `check.run` under the manifest's 1000-character bound. | |
| 91 | */ | |
| 92 | export function splitArgs( | |
| 93 | args: readonly string[], | |
| 94 | ): | |
| 95 | | { ok: true; flags: string[]; files: string[] } | |
| 96 | | { ok: false; error: string } { | |
| 97 | const at = args.indexOf("--"); | |
| 98 | const head = at === -1 ? args : args.slice(0, at); | |
| 99 | const names = at === -1 ? [] : args.slice(at + 1); | |
| 100 | const flags = head.filter((arg) => !TEST_FILE.test(arg)); | |
| 101 | const files = head.filter((arg) => TEST_FILE.test(arg)); | |
| 102 | for (const name of names) { | |
| 103 | if (!TEST_NAME.test(name)) | |
| 104 | return { ok: false, error: `${JSON.stringify(name)} is not a test name` }; | |
| 105 | files.push(`test/${name}.test.ts`); | |
| 106 | } | |
| 107 | return { ok: true, flags, files }; | |
| 108 | } | |
| 109 | ||
| 82 | 110 | /** A sane bound on one entry of the times table: a day, in seconds. */ |
| 83 | 111 | const TIME_MAX = 86_400; |
| 84 | 112 |
scripts/test-shards.ts +8 −3
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | * |
| 4 | 4 | * bun scripts/test-shards.ts [bun test flags…] test/a.test.ts test/b.test.ts … |
| 5 | 5 | * bun scripts/test-shards.ts --record [bun test flags…] test/a.test.ts … |
| 6 | * bun scripts/test-shards.ts [bun test flags…] -- a b … (test/a.test.ts …) | |
| 6 | 7 | * |
| 7 | 8 | * The files come from the command line, so `forge.json`'s `check.run` |
| 8 | 9 | * stays the one place that names them. Each shard is its own `bun test` |
| @@ -21,10 +22,10 @@ | ||
| 21 | 22 | import { availableParallelism } from "node:os"; |
| 22 | 23 | import { readFileSync, statSync, writeFileSync } from "node:fs"; |
| 23 | 24 | import { |
| 24 | TEST_FILE, | |
| 25 | 25 | formatTimes, |
| 26 | 26 | parseTimes, |
| 27 | 27 | shardCount, |
| 28 | splitArgs, | |
| 28 | 29 | splitFiles, |
| 29 | 30 | tally, |
| 30 | 31 | weigher, |
| @@ -36,8 +37,12 @@ const TIMES = new URL("./test-times.json", import.meta.url).pathname; | ||
| 36 | 37 | const argv = process.argv.slice(2); |
| 37 | 38 | const record = argv.includes("--record"); |
| 38 | 39 | const args = argv.filter((arg) => arg !== "--record"); |
| 39 | const files = args.filter((arg) => TEST_FILE.test(arg)); | |
| 40 | const flags = args.filter((arg) => !TEST_FILE.test(arg)); | |
| 40 | const split = splitArgs(args); | |
| 41 | if (!split.ok) { | |
| 42 | console.error(`test-shards: ${split.error}`); | |
| 43 | process.exit(2); | |
| 44 | } | |
| 45 | const { files, flags } = split; | |
| 41 | 46 | |
| 42 | 47 | if (files.length === 0) { |
| 43 | 48 | console.error("test-shards: name the test files to run"); |
src/apps/bytes.ts added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | /** | |
| 2 | * How many bytes a directory holds: the apparent size of every regular | |
| 3 | * file under it, summed. Symlinks are counted as themselves and never | |
| 4 | * followed, so a link out of the tree cannot bill for what it points at; | |
| 5 | * a path that is missing (an environment never deployed) holds 0 bytes. | |
| 6 | * Asynchronous, so walking a large repository does not hold up a request. | |
| 7 | */ | |
| 8 | ||
| 9 | import { lstat, readdir } from "node:fs/promises"; | |
| 10 | import { join } from "node:path"; | |
| 11 | ||
| 12 | export async function dirBytes(path: string): Promise<number> { | |
| 13 | let total = 0; | |
| 14 | const pending = [path]; | |
| 15 | while (pending.length > 0) { | |
| 16 | const dir = pending.pop()!; | |
| 17 | let names: string[]; | |
| 18 | try { | |
| 19 | const info = await lstat(dir); | |
| 20 | if (!info.isDirectory()) { | |
| 21 | if (info.isFile()) total += info.size; | |
| 22 | continue; | |
| 23 | } | |
| 24 | names = await readdir(dir); | |
| 25 | } catch { | |
| 26 | continue; // gone between the listing and the look: nothing to count | |
| 27 | } | |
| 28 | for (const name of names) { | |
| 29 | const child = join(dir, name); | |
| 30 | try { | |
| 31 | const info = await lstat(child); | |
| 32 | if (info.isDirectory()) pending.push(child); | |
| 33 | else if (info.isFile()) total += info.size; | |
| 34 | } catch { | |
| 35 | // removed while walking | |
| 36 | } | |
| 37 | } | |
| 38 | } | |
| 39 | return total; | |
| 40 | } |
src/apps/runtime.ts +110
| @@ -45,6 +45,18 @@ import type { Result } from "../kernel/result.ts"; | ||
| 45 | 45 | import { holdLease, STALE_AFTER_MS } from "../runs/lease.ts"; |
| 46 | 46 | import { appendRunLog } from "../runs/log.ts"; |
| 47 | 47 | import { primaryDomain } from "../core/domains.ts"; |
| 48 | import { | |
| 49 | beatAwake, | |
| 50 | closeAllAwake, | |
| 51 | closeAwake, | |
| 52 | closeOrphans, | |
| 53 | lastStorageAt, | |
| 54 | openAwake, | |
| 55 | recordStorage, | |
| 56 | SAMPLE_SPAN_MS, | |
| 57 | type Placement, | |
| 58 | } from "../core/meter.ts"; | |
| 59 | import { dirBytes } from "./bytes.ts"; | |
| 48 | 60 | import { BUSY_WORDS, busyReason, type BusyReason } from "./busy.ts"; |
| 49 | 61 | import { exportTree, readFileAt } from "./checkout.ts"; |
| 50 | 62 | import type { DomainResolver } from "./domains.ts"; |
| @@ -110,6 +122,10 @@ export interface RuntimeOptions { | ||
| 110 | 122 | * its own, so proving `app.example.com` needs no network in a test. |
| 111 | 123 | */ |
| 112 | 124 | readonly dns?: DomainResolver | undefined; |
| 125 | /** Where this forge's processes run, as the meter records it. @default "owned" */ | |
| 126 | readonly placement?: Placement | undefined; | |
| 127 | /** How often storage is sampled for the meter. @default one hour */ | |
| 128 | readonly storageEveryMs?: number | undefined; | |
| 113 | 129 | } |
| 114 | 130 | |
| 115 | 131 | export type EnvState = |
| @@ -522,6 +538,9 @@ export class AppsRuntime { | ||
| 522 | 538 | failAbandonedRuns(this.app.db, STALE_AFTER_MS, ["deploy"]) + |
| 523 | 539 | requeueAbandonedChecks(this.app, STALE_AFTER_MS); |
| 524 | 540 | if (abandoned > 0) log.info("settled abandoned runs", { count: abandoned }); |
| 541 | // An awake interval the last forge left open ends at its last heartbeat. | |
| 542 | const orphans = this.metered(() => closeOrphans(this.app.db)); | |
| 543 | if (orphans) log.info("closed open meter intervals", { count: orphans }); | |
| 525 | 544 | this.unsubscribe = this.app.bus.subscribe((event) => { |
| 526 | 545 | // Never let an event handler's rejection reach the bus. |
| 527 | 546 | this.onEvent(event).catch((error) => |
| @@ -539,6 +558,7 @@ export class AppsRuntime { | ||
| 539 | 558 | await this.reconcileApp(record, repo, "boot"); |
| 540 | 559 | } |
| 541 | 560 | this.pruneDirs(known); |
| 561 | await this.sampleStorage(); | |
| 542 | 562 | this.sweeper = setInterval( |
| 543 | 563 | () => void this.sweep(), |
| 544 | 564 | this.options.sweepEveryMs ?? SWEEP_EVERY_MS, |
| @@ -566,6 +586,7 @@ export class AppsRuntime { | ||
| 566 | 586 | stops.push(state.chain.catch(() => {})); |
| 567 | 587 | } |
| 568 | 588 | await Promise.all(stops); |
| 589 | this.metered(() => closeAllAwake(this.app.db, new Date().toISOString())); | |
| 569 | 590 | runtimes.delete(this.app); |
| 570 | 591 | } |
| 571 | 592 | |
| @@ -921,6 +942,7 @@ export class AppsRuntime { | ||
| 921 | 942 | deployStartedAt, |
| 922 | 943 | }; |
| 923 | 944 | state.live = live; |
| 945 | this.awake(state, servingAt); | |
| 924 | 946 | state.asleep = undefined; |
| 925 | 947 | state.lastRequestAt = Date.now(); |
| 926 | 948 | state.lastError = undefined; |
| @@ -1153,6 +1175,7 @@ export class AppsRuntime { | ||
| 1153 | 1175 | .then((code) => { |
| 1154 | 1176 | if (this.stopped || state.live !== live) return; // replaced or stopped on purpose |
| 1155 | 1177 | state.live = undefined; |
| 1178 | this.resting(state); | |
| 1156 | 1179 | revokeAppToken(this.app.db, live.tokenId); |
| 1157 | 1180 | state.lastError = `exited with code ${code}`; |
| 1158 | 1181 | appendRunLog( |
| @@ -1286,6 +1309,7 @@ export class AppsRuntime { | ||
| 1286 | 1309 | }; |
| 1287 | 1310 | state.asleep = undefined; |
| 1288 | 1311 | state.live = live; |
| 1312 | this.awake(state, now); | |
| 1289 | 1313 | state.lastRequestAt = Date.now(); |
| 1290 | 1314 | this.watch(state, repo, live); |
| 1291 | 1315 | write("awake"); |
| @@ -1451,6 +1475,7 @@ export class AppsRuntime { | ||
| 1451 | 1475 | const after = this.options.sleepAfterMs ?? SLEEP_AFTER_MS; |
| 1452 | 1476 | const grace = this.options.busyGraceMs ?? BUSY_GRACE_MS; |
| 1453 | 1477 | const now = Date.now(); |
| 1478 | this.metered(() => beatAwake(this.app.db, new Date(now).toISOString())); | |
| 1454 | 1479 | for (const state of this.envs.values()) { |
| 1455 | 1480 | const live = state.live; |
| 1456 | 1481 | // Still running: its credential is still wanted for another window. |
| @@ -1484,6 +1509,7 @@ export class AppsRuntime { | ||
| 1484 | 1509 | continue; |
| 1485 | 1510 | } |
| 1486 | 1511 | state.live = undefined; |
| 1512 | this.resting(state, now); | |
| 1487 | 1513 | state.asleep = { |
| 1488 | 1514 | commitOid: live.commitOid, |
| 1489 | 1515 | dir: live.dir, |
| @@ -1503,6 +1529,88 @@ export class AppsRuntime { | ||
| 1503 | 1529 | revokeAppToken(this.app.db, live.tokenId); |
| 1504 | 1530 | await live.handle.stop(); |
| 1505 | 1531 | } |
| 1532 | await this.sampleStorage(); | |
| 1533 | } | |
| 1534 | ||
| 1535 | // ---- the meter ---- | |
| 1536 | ||
| 1537 | /** | |
| 1538 | * Run one write to the meter. What an app used is worth recording, never | |
| 1539 | * worth taking a deploy, a sleep or a shutdown down with it. | |
| 1540 | */ | |
| 1541 | private metered<T>(write: () => T): T | undefined { | |
| 1542 | try { | |
| 1543 | return write(); | |
| 1544 | } catch (error) { | |
| 1545 | log.error("meter write failed", { error: String(error) }); | |
| 1546 | return undefined; | |
| 1547 | } | |
| 1548 | } | |
| 1549 | ||
| 1550 | /** The environment began serving: its awake interval opens, if not open. */ | |
| 1551 | private awake(state: Environment, at: string): void { | |
| 1552 | this.metered(() => | |
| 1553 | openAwake(this.app.db, { | |
| 1554 | repoId: state.app.repoId, | |
| 1555 | env: state.env, | |
| 1556 | placement: this.options.placement ?? "owned", | |
| 1557 | at, | |
| 1558 | }), | |
| 1559 | ); | |
| 1560 | } | |
| 1561 | ||
| 1562 | /** The environment stopped serving: its awake interval closes. */ | |
| 1563 | private resting(state: Environment, at: number = Date.now()): void { | |
| 1564 | this.metered(() => | |
| 1565 | closeAwake(this.app.db, { | |
| 1566 | repoId: state.app.repoId, | |
| 1567 | env: state.env, | |
| 1568 | at: new Date(at).toISOString(), | |
| 1569 | }), | |
| 1570 | ); | |
| 1571 | } | |
| 1572 | ||
| 1573 | private sampling = false; | |
| 1574 | ||
| 1575 | /** | |
| 1576 | * Once per `storageEveryMs`: the bytes of each app's `FORGE_APP_DATA`, | |
| 1577 | * every environment summed, and of its bare repository. | |
| 1578 | */ | |
| 1579 | private async sampleStorage(): Promise<void> { | |
| 1580 | if (this.stopped || this.sampling) return; | |
| 1581 | const every = this.options.storageEveryMs ?? SAMPLE_SPAN_MS; | |
| 1582 | const last = this.metered(() => lastStorageAt(this.app.db)); | |
| 1583 | if (last !== undefined && Date.now() - Date.parse(last) < every) return; | |
| 1584 | this.sampling = true; | |
| 1585 | try { | |
| 1586 | const at = new Date().toISOString(); | |
| 1587 | for (const record of listApps(this.app.db)) { | |
| 1588 | const repo = getRepoById(this.app.db, record.repoId); | |
| 1589 | if (!repo) continue; | |
| 1590 | let dataBytes = 0; | |
| 1591 | const root = join(appsRoot(this.app.config.dataDir), record.id); | |
| 1592 | if (existsSync(root)) | |
| 1593 | for (const name of readdirSync(root)) | |
| 1594 | if (isEnvName(name)) | |
| 1595 | dataBytes += await dirBytes( | |
| 1596 | envDataDir(this.app.config.dataDir, record.id, name), | |
| 1597 | ); | |
| 1598 | const repoBytes = await dirBytes( | |
| 1599 | repoDir(this.app.reposRoot, repo.ownerName, repo.name), | |
| 1600 | ); | |
| 1601 | if (this.stopped) return; | |
| 1602 | this.metered(() => | |
| 1603 | recordStorage(this.app.db, { | |
| 1604 | repoId: record.repoId, | |
| 1605 | dataBytes, | |
| 1606 | repoBytes, | |
| 1607 | at, | |
| 1608 | }), | |
| 1609 | ); | |
| 1610 | } | |
| 1611 | } finally { | |
| 1612 | this.sampling = false; | |
| 1613 | } | |
| 1506 | 1614 | } |
| 1507 | 1615 | |
| 1508 | 1616 | async teardown(record: AppRecord, env: EnvName): Promise<void> { |
| @@ -1522,6 +1630,7 @@ export class AppsRuntime { | ||
| 1522 | 1630 | // after its pull closed, checkout, cloned data and all. |
| 1523 | 1631 | const live = state.live; |
| 1524 | 1632 | state.live = undefined; |
| 1633 | this.resting(state); | |
| 1525 | 1634 | revokeAppToken(this.app.db, live?.tokenId); |
| 1526 | 1635 | if (live) await live.handle.stop(); |
| 1527 | 1636 | } |
| @@ -1546,6 +1655,7 @@ export class AppsRuntime { | ||
| 1546 | 1655 | await state.chain.catch(() => {}); |
| 1547 | 1656 | const live = state.live; |
| 1548 | 1657 | state.live = undefined; |
| 1658 | this.resting(state); | |
| 1549 | 1659 | state.asleep = undefined; |
| 1550 | 1660 | revokeAppToken(this.app.db, live?.tokenId); |
| 1551 | 1661 | if (live) await live.handle.stop(); |
src/core/meter.ts added +229
| @@ -0,0 +1,229 @@ | ||
| 1 | /** | |
| 2 | * The meter — what each app used, in the two quantities a forge charges | |
| 3 | * for: time awake and bytes kept. The runtime (`apps/runtime.ts`) writes | |
| 4 | * it as environments start, sleep, wake and stop, and samples storage | |
| 5 | * hourly; the profit slider (#392) reads it. | |
| 6 | * | |
| 7 | * Two ledgers: | |
| 8 | * - `meter`: one row per awake interval of an environment — from the | |
| 9 | * instant a process starts serving to the instant it sleeps, stops, | |
| 10 | * crashes or the forge shuts down. An open interval carries `beat_at`, | |
| 11 | * the last instant it was seen running; one still open at boot was cut | |
| 12 | * short by a crash and is closed there, never left running. | |
| 13 | * - `storage`: one row per app per sample — the bytes of all its | |
| 14 | * environments' `FORGE_APP_DATA` and of its bare repository. | |
| 15 | * | |
| 16 | * Units, stated once: | |
| 17 | * - awake time is seconds (wall clock, not CPU), summed over environments; | |
| 18 | * - storage is GB-hours: bytes × hours / 1e9 (decimal GB). A sample stands | |
| 19 | * for the hour after it, or until the next sample if that comes sooner. | |
| 20 | * | |
| 21 | * Pure data: no authz, no events. `repo_id` is text, not a reference, so | |
| 22 | * removing an app does not erase what it used. | |
| 23 | */ | |
| 24 | ||
| 25 | import type { Database } from "bun:sqlite"; | |
| 26 | import { defect } from "../kernel/error.ts"; | |
| 27 | ||
| 28 | /** Where an environment ran: hardware the operator owns, or a cloud's. */ | |
| 29 | export type Placement = "owned" | "cloud"; | |
| 30 | export const PLACEMENTS: readonly Placement[] = ["owned", "cloud"]; | |
| 31 | ||
| 32 | /** How long one storage sample stands for, in milliseconds. */ | |
| 33 | export const SAMPLE_SPAN_MS = 3_600_000; | |
| 34 | ||
| 35 | const iso = (ms: number): string => new Date(ms).toISOString(); | |
| 36 | ||
| 37 | /** An instant as milliseconds, or a defect: callers pass `toISOString()`. */ | |
| 38 | function instant(at: string): number { | |
| 39 | const ms = Date.parse(at); | |
| 40 | if (!Number.isFinite(ms)) defect("meter.bad_instant", `not an instant: ${at}`); | |
| 41 | return ms; | |
| 42 | } | |
| 43 | ||
| 44 | // ---- writing ---- | |
| 45 | ||
| 46 | /** | |
| 47 | * An environment began serving. Idempotent: while an interval is open for | |
| 48 | * the environment (a redeploy swapping one process for the next) nothing | |
| 49 | * new is opened — the app never stopped being awake. | |
| 50 | */ | |
| 51 | export function openAwake( | |
| 52 | db: Database, | |
| 53 | input: { repoId: string; env: string; placement: Placement; at: string }, | |
| 54 | ): void { | |
| 55 | const at = iso(instant(input.at)); | |
| 56 | db.query( | |
| 57 | `INSERT OR IGNORE INTO meter (repo_id, env, placement, started_at, beat_at) | |
| 58 | VALUES (?1, ?2, ?3, ?4, ?4)`, | |
| 59 | ).run(input.repoId, input.env, input.placement, at); | |
| 60 | } | |
| 61 | ||
| 62 | /** An environment stopped serving: close its open interval, if any. */ | |
| 63 | export function closeAwake( | |
| 64 | db: Database, | |
| 65 | input: { repoId: string; env: string; at: string }, | |
| 66 | ): void { | |
| 67 | const at = iso(instant(input.at)); | |
| 68 | db.query( | |
| 69 | `UPDATE meter SET ended_at = max(?3, started_at), beat_at = max(?3, started_at) | |
| 70 | WHERE repo_id = ?1 AND env = ?2 AND ended_at IS NULL`, | |
| 71 | ).run(input.repoId, input.env, at); | |
| 72 | } | |
| 73 | ||
| 74 | /** The forge is shutting down: every open interval ends now. */ | |
| 75 | export function closeAllAwake(db: Database, at: string): void { | |
| 76 | const now = iso(instant(at)); | |
| 77 | db.query( | |
| 78 | `UPDATE meter SET ended_at = max(?1, started_at), beat_at = max(?1, started_at) | |
| 79 | WHERE ended_at IS NULL`, | |
| 80 | ).run(now); | |
| 81 | } | |
| 82 | ||
| 83 | /** Every open interval was seen running at `at`. */ | |
| 84 | export function beatAwake(db: Database, at: string): void { | |
| 85 | const now = iso(instant(at)); | |
| 86 | db.query( | |
| 87 | `UPDATE meter SET beat_at = max(?1, beat_at) WHERE ended_at IS NULL`, | |
| 88 | ).run(now); | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * At boot: an interval still open belongs to a forge that died without | |
| 93 | * shutting down. It ends at its last heartbeat — the last instant anyone | |
| 94 | * knew it ran. Returns how many were closed. | |
| 95 | */ | |
| 96 | export function closeOrphans(db: Database): number { | |
| 97 | return db | |
| 98 | .query(`UPDATE meter SET ended_at = beat_at WHERE ended_at IS NULL`) | |
| 99 | .run().changes; | |
| 100 | } | |
| 101 | ||
| 102 | /** One storage sample of one app, in bytes. */ | |
| 103 | export function recordStorage( | |
| 104 | db: Database, | |
| 105 | input: { repoId: string; dataBytes: number; repoBytes: number; at: string }, | |
| 106 | ): void { | |
| 107 | const bytes = (n: number): number => | |
| 108 | Number.isFinite(n) && n > 0 ? Math.round(n) : 0; | |
| 109 | db.query( | |
| 110 | `INSERT INTO storage (repo_id, data_bytes, repo_bytes, at) | |
| 111 | VALUES (?1, ?2, ?3, ?4)`, | |
| 112 | ).run( | |
| 113 | input.repoId, | |
| 114 | bytes(input.dataBytes), | |
| 115 | bytes(input.repoBytes), | |
| 116 | iso(instant(input.at)), | |
| 117 | ); | |
| 118 | } | |
| 119 | ||
| 120 | /** When storage was last sampled, if ever. */ | |
| 121 | export function lastStorageAt(db: Database): string | undefined { | |
| 122 | const row = db | |
| 123 | .query<{ at: string | null }, []>(`SELECT max(at) AS at FROM storage`) | |
| 124 | .get(); | |
| 125 | return row?.at ?? undefined; | |
| 126 | } | |
| 127 | ||
| 128 | // ---- reading ---- | |
| 129 | ||
| 130 | export interface AppUsage { | |
| 131 | readonly repoId: string; | |
| 132 | /** Seconds awake in the range, every environment summed. */ | |
| 133 | readonly awakeSeconds: number; | |
| 134 | /** The same seconds, by where they ran. */ | |
| 135 | readonly awakeByPlacement: Readonly<Record<Placement, number>>; | |
| 136 | /** (data + repository bytes) × hours in the range / 1e9. */ | |
| 137 | readonly gbHours: number; | |
| 138 | } | |
| 139 | ||
| 140 | interface IntervalRow { | |
| 141 | repo_id: string; | |
| 142 | placement: Placement; | |
| 143 | started_at: string; | |
| 144 | ended_at: string | null; | |
| 145 | } | |
| 146 | ||
| 147 | interface SampleRow { | |
| 148 | repo_id: string; | |
| 149 | data_bytes: number; | |
| 150 | repo_bytes: number; | |
| 151 | at: string; | |
| 152 | } | |
| 153 | ||
| 154 | /** | |
| 155 | * What each app used in [from, to): awake seconds and GB-hours. An | |
| 156 | * interval still open counts up to `now` (default: the clock), never past | |
| 157 | * it. Apps with nothing in the range are absent. Ordered by repo id. | |
| 158 | */ | |
| 159 | export function usageBetween( | |
| 160 | db: Database, | |
| 161 | from: string, | |
| 162 | to: string, | |
| 163 | now: string = new Date().toISOString(), | |
| 164 | ): AppUsage[] { | |
| 165 | const lo = instant(from); | |
| 166 | const hi = instant(to); | |
| 167 | const clock = instant(now); | |
| 168 | if (!(hi > lo)) return []; | |
| 169 | const apps = new Map< | |
| 170 | string, | |
| 171 | { owned: number; cloud: number; gbHours: number } | |
| 172 | >(); | |
| 173 | const entry = (repoId: string) => { | |
| 174 | let e = apps.get(repoId); | |
| 175 | if (e === undefined) { | |
| 176 | e = { owned: 0, cloud: 0, gbHours: 0 }; | |
| 177 | apps.set(repoId, e); | |
| 178 | } | |
| 179 | return e; | |
| 180 | }; | |
| 181 | ||
| 182 | const intervals = db | |
| 183 | .query<IntervalRow, [string, string]>( | |
| 184 | `SELECT repo_id, placement, started_at, ended_at FROM meter | |
| 185 | WHERE started_at < ?2 AND (ended_at IS NULL OR ended_at > ?1)`, | |
| 186 | ) | |
| 187 | .all(iso(lo), iso(hi)); | |
| 188 | for (const row of intervals) { | |
| 189 | const start = Math.max(lo, instant(row.started_at)); | |
| 190 | const end = Math.min( | |
| 191 | hi, | |
| 192 | row.ended_at === null ? clock : instant(row.ended_at), | |
| 193 | ); | |
| 194 | if (end > start) entry(row.repo_id)[row.placement] += (end - start) / 1000; | |
| 195 | } | |
| 196 | ||
| 197 | // A sample before `from` may still stand for part of the range. | |
| 198 | const samples = db | |
| 199 | .query<SampleRow, [string, string]>( | |
| 200 | `SELECT repo_id, data_bytes, repo_bytes, at FROM storage | |
| 201 | WHERE at >= ?1 AND at < ?2 ORDER BY repo_id, at`, | |
| 202 | ) | |
| 203 | .all(iso(lo - SAMPLE_SPAN_MS), iso(hi)); | |
| 204 | samples.forEach((row, i) => { | |
| 205 | const at = instant(row.at); | |
| 206 | const next = samples[i + 1]; | |
| 207 | const until = Math.min( | |
| 208 | at + SAMPLE_SPAN_MS, | |
| 209 | next !== undefined && next.repo_id === row.repo_id | |
| 210 | ? instant(next.at) | |
| 211 | : Infinity, | |
| 212 | ); | |
| 213 | const start = Math.max(lo, at); | |
| 214 | const end = Math.min(hi, until); | |
| 215 | if (end <= start) return; | |
| 216 | const hours = (end - start) / SAMPLE_SPAN_MS; | |
| 217 | entry(row.repo_id).gbHours += | |
| 218 | ((row.data_bytes + row.repo_bytes) * hours) / 1e9; | |
| 219 | }); | |
| 220 | ||
| 221 | return [...apps.entries()] | |
| 222 | .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) | |
| 223 | .map(([repoId, e]) => ({ | |
| 224 | repoId, | |
| 225 | awakeSeconds: e.owned + e.cloud, | |
| 226 | awakeByPlacement: { owned: e.owned, cloud: e.cloud }, | |
| 227 | gbHours: e.gbHours, | |
| 228 | })); | |
| 229 | } |
src/core/schema.ts +30
| @@ -1081,4 +1081,34 @@ export const migrations: readonly string[] = [ | ||
| 1081 | 1081 | PRIMARY KEY (operation_id, position) |
| 1082 | 1082 | ); |
| 1083 | 1083 | `, |
| 1084 | ||
| 1085 | // 41 — the meter (`core/meter.ts`): what an app used, in the two | |
| 1086 | // quantities a forge charges for. `meter` is one row per awake interval | |
| 1087 | // of an environment, `ended` NULL while it runs and `beat_at` the last | |
| 1088 | // instant it was seen running, so a crash is closed there at boot. | |
| 1089 | // `storage` is one row per hourly sample of an app's bytes. `repo_id` is | |
| 1090 | // text, not a reference: removing an app does not erase what it used. | |
| 1091 | ` | |
| 1092 | CREATE TABLE meter ( | |
| 1093 | id INTEGER PRIMARY KEY, | |
| 1094 | repo_id TEXT NOT NULL, | |
| 1095 | env TEXT NOT NULL, | |
| 1096 | placement TEXT NOT NULL CHECK (placement IN ('owned','cloud')), | |
| 1097 | started_at TEXT NOT NULL, | |
| 1098 | beat_at TEXT NOT NULL, | |
| 1099 | ended_at TEXT | |
| 1100 | ); | |
| 1101 | CREATE INDEX meter_repo ON meter(repo_id, started_at); | |
| 1102 | CREATE UNIQUE INDEX meter_open ON meter(repo_id, env) WHERE ended_at IS NULL; | |
| 1103 | ||
| 1104 | CREATE TABLE storage ( | |
| 1105 | id INTEGER PRIMARY KEY, | |
| 1106 | repo_id TEXT NOT NULL, | |
| 1107 | data_bytes INTEGER NOT NULL CHECK (data_bytes >= 0), | |
| 1108 | repo_bytes INTEGER NOT NULL CHECK (repo_bytes >= 0), | |
| 1109 | at TEXT NOT NULL | |
| 1110 | ); | |
| 1111 | CREATE INDEX storage_repo ON storage(repo_id, at); | |
| 1112 | CREATE INDEX storage_at ON storage(at); | |
| 1113 | `, | |
| 1084 | 1114 | ]; |
test/meter.test.ts added +262
| @@ -0,0 +1,262 @@ | ||
| 1 | /** | |
| 2 | * The meter: awake intervals and storage samples, and the two sums read | |
| 3 | * from them — awake seconds and GB-hours — checked against a hand count. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { newApp } from "../src/actions/apps.ts"; | |
| 11 | import { dirBytes } from "../src/apps/bytes.ts"; | |
| 12 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 13 | import type { AppRecord } from "../src/core/apps.ts"; | |
| 14 | import { | |
| 15 | beatAwake, | |
| 16 | closeAllAwake, | |
| 17 | closeAwake, | |
| 18 | openAwake, | |
| 19 | recordStorage, | |
| 20 | usageBetween, | |
| 21 | } from "../src/core/meter.ts"; | |
| 22 | import { getRepoById } from "../src/core/repos.ts"; | |
| 23 | import { listRuns } from "../src/core/runs.ts"; | |
| 24 | import { createUser } from "../src/core/users.ts"; | |
| 25 | import { repoDir } from "../src/git/repos.ts"; | |
| 26 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 27 | ||
| 28 | const T = (hhmm: string): string => `2026-10-01T${hhmm}:00.000Z`; | |
| 29 | ||
| 30 | interface MeterRow { | |
| 31 | repo_id: string; | |
| 32 | env: string; | |
| 33 | placement: string; | |
| 34 | started_at: string; | |
| 35 | beat_at: string; | |
| 36 | ended_at: string | null; | |
| 37 | } | |
| 38 | const usageOf = ( | |
| 39 | app: App, | |
| 40 | repoId: string, | |
| 41 | from: string, | |
| 42 | to: string, | |
| 43 | now?: string, | |
| 44 | ) => usageBetween(app.db, from, to, now).find((u) => u.repoId === repoId); | |
| 45 | const rows = (app: App, repoId: string): MeterRow[] => | |
| 46 | app.db | |
| 47 | .query<MeterRow, [string]>( | |
| 48 | "SELECT * FROM meter WHERE repo_id = ? ORDER BY id", | |
| 49 | ) | |
| 50 | .all(repoId); | |
| 51 | ||
| 52 | describe("the meter ledger", () => { | |
| 53 | let dataDir: string; | |
| 54 | let app: App; | |
| 55 | ||
| 56 | beforeAll(async () => { | |
| 57 | dataDir = await mkdtemp(join(tmpdir(), "forge-meter-")); | |
| 58 | app = createApp({ dataDir, port: 0 }); | |
| 59 | }); | |
| 60 | afterAll(async () => { | |
| 61 | app.close(); | |
| 62 | await rm(dataDir, { recursive: true, force: true }); | |
| 63 | }); | |
| 64 | ||
| 65 | test("start, sleep, wake, stop: the intervals sum to a hand count", () => { | |
| 66 | const at = (env: string, placement: "owned" | "cloud", t: string) => | |
| 67 | openAwake(app.db, { repoId: "r1", env, placement, at: T(t) }); | |
| 68 | at("production", "owned", "10:00"); // start | |
| 69 | at("production", "owned", "10:05"); // a redeploy: still the same interval | |
| 70 | closeAwake(app.db, { repoId: "r1", env: "production", at: T("10:30") }); // sleep | |
| 71 | at("production", "owned", "11:00"); // wake | |
| 72 | closeAwake(app.db, { repoId: "r1", env: "production", at: T("11:15") }); // stop | |
| 73 | at("pull-1", "cloud", "10:10"); | |
| 74 | closeAwake(app.db, { repoId: "r1", env: "pull-1", at: T("10:20") }); | |
| 75 | closeAwake(app.db, { repoId: "r1", env: "pull-1", at: T("10:40") }); // nothing open | |
| 76 | ||
| 77 | expect(rows(app, "r1").map((r) => [r.env, r.started_at, r.ended_at])).toEqual([ | |
| 78 | ["production", T("10:00"), T("10:30")], | |
| 79 | ["production", T("11:00"), T("11:15")], | |
| 80 | ["pull-1", T("10:10"), T("10:20")], | |
| 81 | ]); | |
| 82 | // By hand: 30 + 15 minutes owned, 10 minutes cloud. | |
| 83 | const whole = usageOf(app, "r1", T("09:00"), T("12:00"), T("12:00")); | |
| 84 | expect(whole!.awakeByPlacement).toEqual({ owned: 45 * 60, cloud: 10 * 60 }); | |
| 85 | expect(whole!.awakeSeconds).toBe(55 * 60); | |
| 86 | // [10:15, 11:05): 15 + 5 owned, 5 cloud. | |
| 87 | const part = usageOf(app, "r1", T("10:15"), T("11:05"), T("12:00")); | |
| 88 | expect(part!.awakeByPlacement).toEqual({ owned: 20 * 60, cloud: 5 * 60 }); | |
| 89 | // An empty or backwards range is nothing. | |
| 90 | expect(usageBetween(app.db, T("12:00"), T("12:00"))).toEqual([]); | |
| 91 | }); | |
| 92 | ||
| 93 | test("an open interval counts up to now, never past it, and shutdown closes it", () => { | |
| 94 | openAwake(app.db, { repoId: "r2", env: "production", placement: "owned", at: T("08:00") }); | |
| 95 | const open = usageOf(app, "r2", T("00:00"), T("23:00"), T("08:20")); | |
| 96 | expect(open!.awakeSeconds).toBe(20 * 60); | |
| 97 | closeAllAwake(app.db, T("08:45")); | |
| 98 | expect(rows(app, "r2")[0]!.ended_at).toBe(T("08:45")); | |
| 99 | const closed = usageOf(app, "r2", T("00:00"), T("23:00"), T("23:00")); | |
| 100 | expect(closed!.awakeSeconds).toBe(45 * 60); | |
| 101 | }); | |
| 102 | ||
| 103 | test("storage: bytes × hours / 1e9, a sample standing for its hour", () => { | |
| 104 | // 2 GB for an hour, then 1 GB until the next sample 30 minutes later, | |
| 105 | // then 4 GB for its full hour. | |
| 106 | recordStorage(app.db, { repoId: "r3", dataBytes: 1.5e9, repoBytes: 0.5e9, at: T("10:00") }); | |
| 107 | recordStorage(app.db, { repoId: "r3", dataBytes: 1e9, repoBytes: 0, at: T("11:00") }); | |
| 108 | recordStorage(app.db, { repoId: "r3", dataBytes: 3e9, repoBytes: 1e9, at: T("11:30") }); | |
| 109 | const all = usageOf(app, "r3", T("00:00"), T("23:00"), T("23:00")); | |
| 110 | expect(all!.gbHours).toBeCloseTo(2 + 0.5 + 4, 9); | |
| 111 | // [10:30, 11:45): 2 GB × 0.5h + 1 GB × 0.5h + 4 GB × 0.25h. | |
| 112 | const part = usageOf(app, "r3", T("10:30"), T("11:45"), T("23:00")); | |
| 113 | expect(part!.gbHours).toBeCloseTo(1 + 0.5 + 1, 9); | |
| 114 | // Nonsense in, zero bytes recorded. | |
| 115 | recordStorage(app.db, { repoId: "r4", dataBytes: -5, repoBytes: Number.NaN, at: T("10:00") }); | |
| 116 | const none = usageOf(app, "r4", T("00:00"), T("23:00"), T("23:00")); | |
| 117 | expect(none?.gbHours ?? 0).toBe(0); | |
| 118 | }); | |
| 119 | ||
| 120 | test("a restart closes the interval the last forge left open, at its last heartbeat", async () => { | |
| 121 | openAwake(app.db, { repoId: "r5", env: "production", placement: "owned", at: T("09:00") }); | |
| 122 | beatAwake(app.db, T("09:07")); | |
| 123 | // The forge dies here: no close. A new runtime boots over the same data. | |
| 124 | const runtime = new AppsRuntime(app, { | |
| 125 | domain: "apps.test", | |
| 126 | scheme: "http", | |
| 127 | forgeUrl: "http://localhost", | |
| 128 | }); | |
| 129 | await runtime.start(); | |
| 130 | await runtime.stop(); | |
| 131 | const [row] = rows(app, "r5"); | |
| 132 | expect(row!.ended_at).toBe(T("09:07")); | |
| 133 | const used = usageOf(app, "r5", T("00:00"), T("23:00")); | |
| 134 | expect(used!.awakeSeconds).toBe(7 * 60); | |
| 135 | }); | |
| 136 | ||
| 137 | test("a fixture directory's bytes are its files' bytes, links not followed", async () => { | |
| 138 | const root = join(dataDir, "fixture"); | |
| 139 | await mkdir(join(root, "a", "b"), { recursive: true }); | |
| 140 | await writeFile(join(root, "one.txt"), "x".repeat(1000)); | |
| 141 | await writeFile(join(root, "a", "two.bin"), new Uint8Array(4096)); | |
| 142 | await writeFile(join(root, "a", "b", "three"), ""); | |
| 143 | await writeFile(join(root, "a", "b", "four"), "four"); | |
| 144 | const outside = join(dataDir, "outside"); | |
| 145 | await writeFile(outside, new Uint8Array(1_000_000)); | |
| 146 | await symlink(outside, join(root, "link")); | |
| 147 | expect(await dirBytes(root)).toBe(1000 + 4096 + 0 + 4); | |
| 148 | expect(await dirBytes(join(dataDir, "missing"))).toBe(0); | |
| 149 | }); | |
| 150 | }); | |
| 151 | ||
| 152 | describe("the runtime meters what it runs", () => { | |
| 153 | let dataDir: string; | |
| 154 | let app: App; | |
| 155 | let servers: Servers; | |
| 156 | let runtime: AppsRuntime; | |
| 157 | let record: AppRecord; | |
| 158 | const DOMAIN = "apps.test"; | |
| 159 | ||
| 160 | async function until<T>(read: () => T | undefined, ms = 60_000): Promise<T> { | |
| 161 | const deadline = Date.now() + ms; | |
| 162 | while (Date.now() < deadline) { | |
| 163 | const value = read(); | |
| 164 | if (value !== undefined) return value; | |
| 165 | await Bun.sleep(50); | |
| 166 | } | |
| 167 | throw new Error("did not happen in time"); | |
| 168 | } | |
| 169 | const production = () => | |
| 170 | runtime.status(record).find((e) => e.env === "production"); | |
| 171 | ||
| 172 | beforeAll(async () => { | |
| 173 | dataDir = await mkdtemp(join(tmpdir(), "forge-meter-rt-")); | |
| 174 | app = createApp({ dataDir, port: 0 }); | |
| 175 | servers = await serve(app); | |
| 176 | runtime = new AppsRuntime(app, { | |
| 177 | domain: DOMAIN, | |
| 178 | scheme: "http", | |
| 179 | publicPort: servers.port, | |
| 180 | forgeUrl: servers.url, | |
| 181 | sleepAfterMs: { production: 600, preview: 600 }, | |
| 182 | sweepEveryMs: 100, | |
| 183 | storageEveryMs: 200, | |
| 184 | }); | |
| 185 | await runtime.start(); | |
| 186 | const created = await createUser(app.db, { | |
| 187 | username: "alice", | |
| 188 | email: "[email protected]", | |
| 189 | password: "a-good-passphrase", | |
| 190 | adminIfFirst: true, | |
| 191 | }); | |
| 192 | if (!created.ok) throw new Error(created.error); | |
| 193 | const made = await newApp(app, created.value, { | |
| 194 | name: "metered", | |
| 195 | description: "Counted", | |
| 196 | visibility: "public", | |
| 197 | }); | |
| 198 | if (!made.ok) throw new Error(String(made.error)); | |
| 199 | record = made.value.app; | |
| 200 | }, 120_000); | |
| 201 | ||
| 202 | afterAll(async () => { | |
| 203 | await runtime.stop(); | |
| 204 | servers.stop(); | |
| 205 | app.close(); | |
| 206 | await rm(dataDir, { recursive: true, force: true }); | |
| 207 | }); | |
| 208 | ||
| 209 | test("deploy, sleep, wake and stop each open or close an interval", async () => { | |
| 210 | const run = await until(() => | |
| 211 | listRuns(app.db, record.repoId, { kind: "deploy", limit: 5 }).find( | |
| 212 | (r) => r.state === "ok", | |
| 213 | ), | |
| 214 | ); | |
| 215 | // Asleep: the first interval runs from serving to the sweep that slept it. | |
| 216 | const asleep = await until(() => | |
| 217 | production()?.state === "asleep" ? production() : undefined, | |
| 218 | ); | |
| 219 | let ledger = rows(app, record.repoId); | |
| 220 | expect(ledger.length).toBe(1); | |
| 221 | expect(ledger[0]!.placement).toBe("owned"); | |
| 222 | expect(ledger[0]!.started_at).toBe(run.servingAt!); | |
| 223 | expect(ledger[0]!.ended_at).toBe(asleep.since!); | |
| 224 | ||
| 225 | // Woken, then stopped: a second interval, opened and closed. | |
| 226 | const woke = await runtime.wake(record, "production"); | |
| 227 | expect(woke).toBeDefined(); | |
| 228 | const since = production()!.since!; | |
| 229 | expect(rows(app, record.repoId)[1]!.ended_at).toBeNull(); | |
| 230 | await runtime.stopEnv(record, "production"); | |
| 231 | ledger = rows(app, record.repoId); | |
| 232 | expect(ledger.length).toBe(2); | |
| 233 | expect(ledger[1]!.started_at).toBe(since); | |
| 234 | expect(ledger[1]!.ended_at).not.toBeNull(); | |
| 235 | ||
| 236 | // The sum read back is the sum of the intervals, by hand. | |
| 237 | const hand = ledger.reduce( | |
| 238 | (s, r) => s + (Date.parse(r.ended_at!) - Date.parse(r.started_at)) / 1000, | |
| 239 | 0, | |
| 240 | ); | |
| 241 | const used = usageOf(app, record.repoId, "2000-01-01T00:00:00.000Z", "2100-01-01T00:00:00.000Z"); | |
| 242 | expect(used!.repoId).toBe(record.repoId); | |
| 243 | expect(used!.awakeSeconds).toBeCloseTo(hand, 6); | |
| 244 | expect(used!.awakeSeconds).toBeGreaterThan(0); | |
| 245 | }, 90_000); | |
| 246 | ||
| 247 | test("storage is sampled: the repository's bytes, as counted on disk", async () => { | |
| 248 | const sample = await until(() => | |
| 249 | app.db | |
| 250 | .query<{ repo_bytes: number; data_bytes: number }, [string]>( | |
| 251 | "SELECT repo_bytes, data_bytes FROM storage WHERE repo_id = ? ORDER BY id DESC", | |
| 252 | ) | |
| 253 | .get(record.repoId) ?? undefined, | |
| 254 | ); | |
| 255 | expect(sample.repo_bytes).toBeGreaterThan(0); | |
| 256 | const repo = getRepoById(app.db, record.repoId)!; | |
| 257 | // The repository does not change between the sample and this count. | |
| 258 | expect(sample.repo_bytes).toBe( | |
| 259 | await dirBytes(repoDir(app.reposRoot, repo.ownerName, repo.name)), | |
| 260 | ); | |
| 261 | }, 30_000); | |
| 262 | }); |
test/shards.test.ts +40
| @@ -11,11 +11,13 @@ import { | ||
| 11 | 11 | formatTimes, |
| 12 | 12 | parseTimes, |
| 13 | 13 | shardCount, |
| 14 | splitArgs, | |
| 14 | 15 | splitFiles, |
| 15 | 16 | tally, |
| 16 | 17 | weigher, |
| 17 | 18 | type ShardResult, |
| 18 | 19 | } from "../scripts/shards.ts"; |
| 20 | import { parseCheck } from "../src/apps/manifest.ts"; | |
| 19 | 21 | |
| 20 | 22 | const files = Array.from({ length: 23 }, (_, i) => `test/f${i}.test.ts`); |
| 21 | 23 | |
| @@ -271,3 +273,41 @@ describe("tally", () => { | ||
| 271 | 273 | ); |
| 272 | 274 | }); |
| 273 | 275 | }); |
| 276 | ||
| 277 | describe("splitArgs", () => { | |
| 278 | test("after --, a bare name is a file under test/", () => { | |
| 279 | expect( | |
| 280 | splitArgs(["--timeout", "60000", "test/x.test.ts", "--", "crew", "fork-flow"]), | |
| 281 | ).toEqual({ | |
| 282 | ok: true, | |
| 283 | flags: ["--timeout", "60000"], | |
| 284 | files: ["test/x.test.ts", "test/crew.test.ts", "test/fork-flow.test.ts"], | |
| 285 | }); | |
| 286 | expect(splitArgs(["--bail", "test/a.test.ts"])).toEqual({ | |
| 287 | ok: true, | |
| 288 | flags: ["--bail"], | |
| 289 | files: ["test/a.test.ts"], | |
| 290 | }); | |
| 291 | }); | |
| 292 | ||
| 293 | test("a name that could leave test/ or is not a name is refused", () => { | |
| 294 | for (const name of ["../src/x", "a/b", "", "-x", "Crew", "a.test.ts"]) | |
| 295 | expect(splitArgs(["--", name]).ok).toBe(false); | |
| 296 | }); | |
| 297 | ||
| 298 | test("forge.json's check fits the manifest and names only files that exist", async () => { | |
| 299 | const text = readFileSync(new URL("../forge.json", import.meta.url), "utf8"); | |
| 300 | // The forge's own reading of the manifest, which is what refused it. | |
| 301 | const check = parseCheck(text, undefined); | |
| 302 | expect(check.ok).toBe(true); | |
| 303 | if (!check.ok || check.value === undefined) throw new Error("no check"); | |
| 304 | const run = check.value.run; | |
| 305 | const args = run.slice(run.indexOf("scripts/test-shards.ts ") + 23).split(" "); | |
| 306 | const split = splitArgs(args); | |
| 307 | expect(split.ok).toBe(true); | |
| 308 | if (!split.ok) return; | |
| 309 | expect(split.files.length).toBeGreaterThan(0); | |
| 310 | for (const file of split.files) | |
| 311 | expect(await Bun.file(new URL(`../${file}`, import.meta.url)).exists()).toBe(true); | |
| 312 | }); | |
| 313 | }); |
new above
offline