Skip to content

Commit

Deployments' sweep takes idle previews down and charges months first, each step on its own, and writes meters only when they move

syntaqxcommitted Parent5436729Browse files
4 files+60−180/4 viewed
+1−1
121121 buildSeconds: number;
122122 /** Charged so far this month for builds, in millionths of a dollar. */
123123 buildMicros: number;
124− /** RFC 3339: when requests and CPU time were last counted. */
124+ /** RFC 3339: when requests and CPU time last went up. */
125125 countedAt: string | null;
126126 };
127127
+40−16
7979 import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
8080 import { CustomHostnames } from "./custom-hostnames";
8181 import { Domains, NOT_ENABLED_NOTICE, toDomain } from "./domains";
82−import { monthCost } from "./metering";
82+import { monthCost, movesMeter } from "./metering";
8383 import { moveTargets, ownerOf, rebuildOutcome, type DroppedBuild, type MoveTarget } from "./moves";
8484 import { commitMissing, MAX_IDENTICAL_FAILURES, missingCommitMessage, retryDecision, type PastBuild } from "./retries";
8585 import { appHost, appUrl, label, uniqueLabel } from "./names";
20392039 * previews, the apps of workspaces whose plan ended, and scripts no app
20402040 * holds come down; and a month that is over is charged past its
20412041 * allowance.
2042+ *
2043+ * Each step stands alone: one that fails is logged and the rest still
2044+ * run. Taking idle previews down and charging months, which only read
2045+ * g1t's own tables, go before the steps that wait on Cloudflare's API,
2046+ * so a slow or failing API never holds them up.
20422047 */
20432048 async sweep(): Promise<void> {
2044− const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
2045− const stuck = await this.db
2046− .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
2047− .bind(cutoff)
2048− .all<{ id: string }>();
2049− for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
2049+ const step = (name: string, work: () => Promise<unknown>) => work().catch((error) => console.error(`sweep: ${name} failed`, error));
2050+ await step("failing stuck builds", async () => {
2051+ const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
2052+ const stuck = await this.db
2053+ .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
2054+ .bind(cutoff)
2055+ .all<{ id: string }>();
2056+ for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
2057+ });
2058+ await step("taking down idle previews", () => this.takeDownIdle());
2059+ await step("charging months", () => this.chargeMonths());
20502060
20512061 let apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
20522062 // Each app is its project's workspace's, as deployments has it now: an
20662076 const billing = billingClient(this.env.BILLING);
20672077 await this.holdToLimits(live, settings).catch((error) => console.error("could not apply limits", error));
20682078 for (const workspace of workspaces) {
2069− const plan = await billing.hasFeature(workspace, "deployments");
2070− if (!plan.ok && plan.error.code === "payment_required") {
2071− for (const app of live.filter((a) => ownerOf(a, owners) === workspace)) await this.removeApp(app.script);
2072− // Custom domains cost g1t by the month: they go with the plan.
2073− await this.domains.removeWhere("workspace", workspace).catch((error) => console.error("could not remove domains", error));
2074− }
2079+ await step(`ending ${workspace}'s apps`, async () => {
2080+ const plan = await billing.hasFeature(workspace, "deployments");
2081+ if (!plan.ok && plan.error.code === "payment_required") {
2082+ for (const app of live.filter((a) => ownerOf(a, owners) === workspace)) await this.removeApp(app.script);
2083+ // Custom domains cost g1t by the month: they go with the plan.
2084+ await this.domains.removeWhere("workspace", workspace).catch((error) => console.error("could not remove domains", error));
2085+ }
2086+ });
20752087 }
20762088
20772089 // Apps whose project moved and are not up under the new name yet: a
20912103
20922104 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
20932105 await this.count(apps).catch((error) => console.error("could not count usage", error));
2094− await this.takeDownIdle();
2095− await this.chargeMonths();
20962106 }
20972107
20982108 /**
21872197 perWorkspace.set(app.workspace, sum);
21882198 }
21892199 const at = now();
2200+ // Written only when the count moves the meter: most sweeps it does not.
2201+ const stored = new Map(
2202+ (
2203+ await this.db
2204+ .prepare("SELECT namespace, requests, cpu_ms FROM meters WHERE month = ?")
2205+ .bind(month())
2206+ .all<{ namespace: string; requests: number; cpu_ms: number }>()
2207+ ).results.map((row) => [row.namespace, row]),
2208+ );
21902209 for (const [workspace, used] of perWorkspace) {
2210+ if (!movesMeter(stored.get(workspace), used)) continue;
21912211 await this.db
21922212 .prepare(
21932213 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
22032223 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
22042224 at,
22052225 );
2226+ // At an hour's resolution: previews idle for days are what it finds.
2227+ const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
2228+ const lastRequest = new Map(apps.map((app) => [app.script, app.last_request_at]));
22062229 for (const [script, used] of recent) {
2207− if (used.requests > 0) {
2230+ const last = lastRequest.get(script);
2231+ if (used.requests > 0 && (!last || last < hourAgo)) {
22082232 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
22092233 }
22102234 }
+10−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { amount, monthCost } from "./metering.ts";
4+import { amount, monthCost, movesMeter } from "./metering.ts";
55
66 /** Cloudflare's prices, at cost: $0.30 per million requests, $0.02 per million CPU ms, $0.10 a custom domain a month. */
77 const COSTS = { millionRequests: 300_000, millionCpuMs: 20_000, domainMonth: 100_000 };
4444 assert.equal(amount(1_240_000), "1.2 million");
4545 assert.equal(amount(0), "0");
4646 });
47+
48+test("a count that does not move the meter is not written", () => {
49+ assert.ok(movesMeter(undefined, { requests: 0, cpuMs: 0 }));
50+ assert.ok(movesMeter({ requests: 10, cpu_ms: 5 }, { requests: 11, cpuMs: 5 }));
51+ assert.ok(movesMeter({ requests: 10, cpu_ms: 5 }, { requests: 10, cpuMs: 6 }));
52+ assert.ok(!movesMeter({ requests: 10, cpu_ms: 5 }, { requests: 10, cpuMs: 5 }));
53+ // Analytics forgot an app that came down: the meter keeps what it had.
54+ assert.ok(!movesMeter({ requests: 10, cpu_ms: 5 }, { requests: 4, cpuMs: 2 }));
55+});
+9−0
6262 description: parts.join(", "),
6363 };
6464 }
65+
66+/**
67+ * Whether a count from analytics moves a workspace's meter for the month.
68+ * The meter keeps the most it has seen (analytics forgets apps that came
69+ * down), so a count at or below it changes nothing and is not written.
70+ */
71+export function movesMeter(stored: { requests: number; cpu_ms: number } | undefined, used: { requests: number; cpuMs: number }): boolean {
72+ return !stored || used.requests > stored.requests || used.cpuMs > stored.cpu_ms;
73+}