import { beforeEach, describe, expect, mock, test } from "bun:test"; import { fakeD1 } from "../metering/fake-d1.test-helper"; import { memoryScopes } from "../scoped-db/testing"; import { seedJob, seedRun } from "./ci-fixtures.test-helper"; let d1 = fakeD1(); let scopes = memoryScopes(); const state = () => scopes.raw("repo", "r1"); const fakeEnv: Record = { get DB() { return d1.binding; }, PUBLIC_URL: "https://forge.test", CLOUD_MINUTES_PER_DAY: "600", }; mock.module("cloudflare:workers", () => ({ DurableObject: class {}, env: fakeEnv, waitUntil: () => undefined })); const realQuota = await import("../metering/quota"); mock.module("../metering/quota", () => ({ ...realQuota, assertWithinQuota: async () => undefined })); mock.module("../scoped-db", () => ({ repoDb: (id: string) => scopes.repoDb(id), accountDb: (id: string) => scopes.accountDb(id), stmt: scopes.module.stmt, })); const { budgetBusy, chargeDispatched, ownerQuota } = await import("./dispatch"); let seq = 0; function addJob(runId: string, status: string, timeoutMinutes: number): void { seq++; seedJob(state(), { id: `j${seq}`, run_id: runId, repo_id: "r1", spec: JSON.stringify({ timeoutMinutes }), status, started_at: status === "pending" ? null : 1, }); } beforeEach(() => { d1 = fakeD1(); seq = 0; scopes = memoryScopes(); d1.db.exec(` INSERT INTO accounts (id, name, kind) VALUES ('o1', 'acme', 'org'); INSERT INTO repos (id, owner_id, name) VALUES ('r1', 'o1', 'app'); INSERT INTO scope_locators VALUES ('workflow_run', 'w1', 'r1'), ('workflow_run', 'w2', 'r1'); `); seedRun(state(), { id: "w1", repo_id: "r1", created_at: 1 }); seedRun(state(), { id: "w2", repo_id: "r1", run_number: 2, created_at: 2 }); }); async function busy(runId: string): Promise { const quota = await ownerQuota(runId); if (!quota) throw new Error("missing owner"); return budgetBusy(quota); } describe("cloud budget deferral", () => { test("of two oversized jobs the first starts and the second waits for it", async () => { addJob("w1", "pending", 600); addJob("w2", "pending", 600); expect(await busy("w1")).toBe(false); state().exec("UPDATE workflow_jobs SET status = 'queued' WHERE id = 'j1'"); expect(await busy("w2")).toBe(true); }); test("a burst within one scheduling pass is charged as it dispatches", async () => { addJob("w1", "pending", 360); addJob("w1", "pending", 360); const quota = await ownerQuota("w1"); if (!quota) throw new Error("missing owner"); expect(budgetBusy(quota)).toBe(false); chargeDispatched(quota, ["ubuntu-latest"], 360); expect(budgetBusy(quota)).toBe(false); chargeDispatched(quota, ["ubuntu-latest"], 360); expect(budgetBusy(quota)).toBe(true); }); test("queued jobs filling the budget make new jobs wait", async () => { addJob("w1", "queued", 360); addJob("w1", "queued", 360); addJob("w2", "pending", 10); expect(await busy("w2")).toBe(true); }); test("a single job whose timeout is at least the budget starts when nothing else is queued or running", async () => { addJob("w1", "pending", 900); expect(await ownerQuota("w1")).toMatchObject({ quotaExceeded: false, heldMs: 0 }); expect(await busy("w1")).toBe(false); }); test("a job waits while a running job holds the rest of the budget, then starts once it finishes", async () => { addJob("w1", "in_progress", 600); addJob("w2", "pending", 10); expect(await busy("w2")).toBe(true); state().exec(`UPDATE workflow_jobs SET status = 'completed', completed_at = started_at + 60000 WHERE id = 'j1'`); expect(await busy("w2")).toBe(false); }); });