| 79 | 79 | | import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare"; |
| 80 | 80 | | import { CustomHostnames } from "./custom-hostnames"; |
| 81 | 81 | | import { Domains, NOT_ENABLED_NOTICE, toDomain } from "./domains"; |
| 82 | | − | import { monthCost } from "./metering"; |
| 82 | + | import { monthCost, movesMeter } from "./metering"; |
| 83 | 83 | | import { moveTargets, ownerOf, rebuildOutcome, type DroppedBuild, type MoveTarget } from "./moves"; |
| 84 | 84 | | import { commitMissing, MAX_IDENTICAL_FAILURES, missingCommitMessage, retryDecision, type PastBuild } from "./retries"; |
| 85 | 85 | | import { appHost, appUrl, label, uniqueLabel } from "./names"; |
| ⋯ |
| 2039 | 2039 | | * previews, the apps of workspaces whose plan ended, and scripts no app |
| 2040 | 2040 | | * holds come down; and a month that is over is charged past its |
| 2041 | 2041 | | * 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. |
| 2042 | 2047 | | */ |
| 2043 | 2048 | | 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()); |
| 2050 | 2060 | | |
| 2051 | 2061 | | let apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results; |
| 2052 | 2062 | | // Each app is its project's workspace's, as deployments has it now: an |
| ⋯ |
| 2066 | 2076 | | const billing = billingClient(this.env.BILLING); |
| 2067 | 2077 | | await this.holdToLimits(live, settings).catch((error) => console.error("could not apply limits", error)); |
| 2068 | 2078 | | 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 | + | }); |
| 2075 | 2087 | | } |
| 2076 | 2088 | | |
| 2077 | 2089 | | // Apps whose project moved and are not up under the new name yet: a |
| ⋯ |
| 2091 | 2103 | | |
| 2092 | 2104 | | await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error)); |
| 2093 | 2105 | | await this.count(apps).catch((error) => console.error("could not count usage", error)); |
| 2094 | | − | await this.takeDownIdle(); |
| 2095 | | − | await this.chargeMonths(); |
| 2096 | 2106 | | } |
| 2097 | 2107 | | |
| 2098 | 2108 | | /** |
| ⋯ |
| 2187 | 2197 | | perWorkspace.set(app.workspace, sum); |
| 2188 | 2198 | | } |
| 2189 | 2199 | | 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 | + | ); |
| 2190 | 2209 | | for (const [workspace, used] of perWorkspace) { |
| 2210 | + | if (!movesMeter(stored.get(workspace), used)) continue; |
| 2191 | 2211 | | await this.db |
| 2192 | 2212 | | .prepare( |
| 2193 | 2213 | | `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5) |
| ⋯ |
| 2203 | 2223 | | new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), |
| 2204 | 2224 | | at, |
| 2205 | 2225 | | ); |
| 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])); |
| 2206 | 2229 | | 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)) { |
| 2208 | 2232 | | await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run(); |
| 2209 | 2233 | | } |
| 2210 | 2234 | | } |