diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index 6de93bd8bd..3d31fb9c87 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -594,6 +594,16 @@ export { type ResultChangedFile, type ResultsPayload, } from "./results-payload.js"; +// `LoopConsumptionOutcome` is deliberately its own name, not loop-escalation.ts's `LoopRunOutcome` re-exported +// below: that one is a loop's HEALTH state (running/converged/abandoned/error), whereas a consumption entry +// only exists for a run that already stopped and only distinguishes finished work from work cut short. +export { + buildLoopConsumptionEntry, + totalConsumptionForTenant, + type LoopConsumptionEntry, + type LoopConsumptionOutcome, + type LoopRunFacts, +} from "./loop-consumption.js"; export { evaluateTenantQuota, type QuotaDimension, diff --git a/packages/loopover-engine/src/loop-consumption.ts b/packages/loopover-engine/src/loop-consumption.ts new file mode 100644 index 0000000000..44daf1aefd --- /dev/null +++ b/packages/loopover-engine/src/loop-consumption.ts @@ -0,0 +1,108 @@ +// Per-loop compute consumption ledger entry (pure) — #4792, part of the Rent-a-Loop path #4778. +// +// Deterministic and side-effect-free: given ONE finished loop run's already-metered raw facts, it produces the +// consumption entry a rental ledger records for that run — the tenant it belongs to, the elapsed wall-clock it +// occupied, and the compute units it burned. It is the upstream counterpart to tenant-quota.ts's +// evaluateTenantQuota: summing these entries over a period yields exactly that function's TenantUsage +// (computeUnitsUsed / wallClockMsUsed), so allocation can be reconciled against real consumption. +// +// It computes an entry only: it does NOT write to a ledger, meter a running loop, or price anything. Persisting +// the entry is the separate, blocked-on-#4789/#4790 integration (and per #5669 must target whatever storage +// abstraction #4940/#5216 lands on, not raw SQLite a second time) — the decision core below has no storage +// opinion at all, so it stays correct whichever datastore that turns out to be. +// +// A KILLED run is a first-class case, not an error path: a loop stopped mid-run still consumed real compute and +// real wall-clock, so it MUST still bill accurately (#4792's second acceptance criterion). It produces the same +// shape as a completed run, flagged so a caller can tell a full run from a truncated one without inferring it. +// Every numeric input is normalized first, so clock skew, a non-finite reading, or an end-before-start timestamp +// can never make an entry negative, fractional, or NaN — a ledger that bills a tenant for -1 ms, or for NaN +// units, is worse than one that bills 0. Mirrors tenant-quota.ts's own normalization discipline. + +/** How a loop run ended, for billing. Distinct from loop-escalation.ts's LoopRunOutcome, which describes a + * loop's health state (running/converged/abandoned/error); a consumption entry only exists for a run that has + * already stopped, and only cares whether it finished its work or was cut short. */ +export type LoopConsumptionOutcome = "completed" | "killed"; + +/** One finished loop run's raw, already-metered facts — the input, never mutated. */ +export type LoopRunFacts = { + /** The tenant the run is billed to. */ + tenantId: string; + /** The run's own identifier, carried through so an entry is traceable back to its loop. */ + loopId: string; + /** Epoch-ms the loop started occupying compute. */ + startedAtMs: number; + /** Epoch-ms it stopped — its own completion, or the moment it was killed. */ + endedAtMs: number; + outcome: LoopConsumptionOutcome; + /** Compute units the run actually burned, as metered by the caller. 0 when nothing metered it — never fabricated. */ + computeUnitsMetered: number; +}; + +/** One rental-ledger row: what a single loop run consumed, ready to sum into a period's TenantUsage. */ +export type LoopConsumptionEntry = { + tenantId: string; + loopId: string; + outcome: LoopConsumptionOutcome; + /** Wall-clock ms the run occupied. Never negative, whatever the input timestamps say. */ + wallClockMs: number; + /** Compute units consumed. Never negative/fractional/NaN. */ + computeUnits: number; + /** False for a run killed mid-work — the entry is still accurate, just not a full run. */ + complete: boolean; +}; + +// Normalize any numeric input to a non-negative integer (a non-finite or negative value becomes 0), so no +// reading can make an entry NaN, fractional, or negative. Same rule as tenant-quota.ts's own inputs. +function finiteNonNegativeInt(value: number): number { + return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; +} + +/** + * Build the rental-ledger consumption entry for one finished loop run. Pure: reads only the run it is handed + * and returns an entry without mutating or storing anything. + * + * Elapsed wall-clock is `endedAtMs - startedAtMs`, floored at 0: a non-finite timestamp, or an end that + * precedes its start (clock skew, or a kill recorded against a stale start), yields 0 rather than a negative + * charge. Compute units are taken as metered and normalized the same way — never inferred from elapsed time, + * because a loop that idled and one that saturated a core for the same duration did not consume the same + * compute, and guessing would bill a tenant for work that never happened. + * + * A `killed` run yields the same shape as a `completed` one, with `complete: false`: it really did consume the + * compute and time it occupied before being stopped, so it bills exactly like any other run (#4792) — the flag + * only records that the work was truncated. + */ +export function buildLoopConsumptionEntry(facts: LoopRunFacts): LoopConsumptionEntry { + const startedAtMs = finiteNonNegativeInt(facts.startedAtMs); + const endedAtMs = finiteNonNegativeInt(facts.endedAtMs); + + return { + tenantId: facts.tenantId, + loopId: facts.loopId, + outcome: facts.outcome, + wallClockMs: Math.max(0, endedAtMs - startedAtMs), + computeUnits: finiteNonNegativeInt(facts.computeUnitsMetered), + complete: facts.outcome === "completed", + }; +} + +/** + * Sum a period's consumption entries into the shape tenant-quota.ts's evaluateTenantQuota reads, so an + * allocation can be reconciled against what was really consumed (#4792's "queryable against allocation"). + * Pure. Entries for other tenants are ignored rather than silently mixed in: billing one tenant for another's + * compute is the one mistake a rental ledger must never make, so the caller's filtering is not trusted here. + * `activeLoops` is NOT derived — a finished run's entry says nothing about what is running right now, and + * inventing a count would make evaluateTenantQuota's concurrency dimension decide on a fabricated number. + */ +export function totalConsumptionForTenant( + entries: readonly LoopConsumptionEntry[], + tenantId: string, +): { computeUnitsUsed: number; wallClockMsUsed: number } { + let computeUnitsUsed = 0; + let wallClockMsUsed = 0; + for (const entry of entries) { + if (entry.tenantId !== tenantId) continue; + computeUnitsUsed += finiteNonNegativeInt(entry.computeUnits); + wallClockMsUsed += finiteNonNegativeInt(entry.wallClockMs); + } + return { computeUnitsUsed, wallClockMsUsed }; +} diff --git a/test/unit/loop-consumption.test.ts b/test/unit/loop-consumption.test.ts new file mode 100644 index 0000000000..b9235dcf19 --- /dev/null +++ b/test/unit/loop-consumption.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; + +import { + buildLoopConsumptionEntry, + totalConsumptionForTenant, + type LoopConsumptionEntry, + type LoopRunFacts, +} from "../../packages/loopover-engine/src/loop-consumption"; +import { evaluateTenantQuota } from "../../packages/loopover-engine/src/tenant-quota"; + +const facts = (over: Partial = {}): LoopRunFacts => ({ + tenantId: "acme", + loopId: "loop-1", + startedAtMs: 1_000, + endedAtMs: 61_000, + outcome: "completed", + computeUnitsMetered: 42, + ...over, +}); + +const entry = (over: Partial = {}): LoopConsumptionEntry => ({ + tenantId: "acme", + loopId: "loop-1", + outcome: "completed", + wallClockMs: 60_000, + computeUnits: 42, + complete: true, + ...over, +}); + +describe("buildLoopConsumptionEntry (#4792)", () => { + // Acceptance criterion 1: a completed loop produces an entry with accurate elapsed compute/time. + it("a completed run bills its real elapsed wall-clock and metered compute", () => { + expect(buildLoopConsumptionEntry(facts())).toEqual({ + tenantId: "acme", + loopId: "loop-1", + outcome: "completed", + wallClockMs: 60_000, + computeUnits: 42, + complete: true, + }); + }); + + // Acceptance criterion 2: a killed-mid-run loop ALSO produces an accurate, consistent entry. + it("a killed run bills identically for what it consumed, flagged incomplete rather than dropped", () => { + const killed = buildLoopConsumptionEntry(facts({ outcome: "killed", endedAtMs: 31_000, computeUnitsMetered: 20 })); + expect(killed).toEqual({ + tenantId: "acme", + loopId: "loop-1", + outcome: "killed", + wallClockMs: 30_000, + computeUnits: 20, + complete: false, + }); + // Same shape as a completed run — a killed run is billed, never silently zeroed or discarded. + expect(Object.keys(killed).sort()).toEqual(Object.keys(buildLoopConsumptionEntry(facts())).sort()); + }); + + it("carries tenant/loop identity through unchanged, so an entry stays traceable to its run", () => { + const out = buildLoopConsumptionEntry(facts({ tenantId: "globex", loopId: "loop-9" })); + expect(out.tenantId).toBe("globex"); + expect(out.loopId).toBe("loop-9"); + }); + + describe("never emits a nonsensical charge", () => { + it("an end before its start (clock skew, stale start on a kill) floors at 0, never a negative charge", () => { + expect(buildLoopConsumptionEntry(facts({ startedAtMs: 61_000, endedAtMs: 1_000 })).wallClockMs).toBe(0); + }); + + it("non-finite timestamps and compute normalize to 0 rather than NaN", () => { + const out = buildLoopConsumptionEntry( + facts({ startedAtMs: Number.NaN, endedAtMs: Number.POSITIVE_INFINITY, computeUnitsMetered: Number.NaN }), + ); + expect(out.wallClockMs).toBe(0); + expect(out.computeUnits).toBe(0); + }); + + it("negative and fractional metered compute normalize to a non-negative integer", () => { + expect(buildLoopConsumptionEntry(facts({ computeUnitsMetered: -5 })).computeUnits).toBe(0); + expect(buildLoopConsumptionEntry(facts({ computeUnitsMetered: 7.9 })).computeUnits).toBe(7); + }); + + it("negative timestamps normalize before subtracting, so elapsed stays sane", () => { + expect(buildLoopConsumptionEntry(facts({ startedAtMs: -1_000, endedAtMs: 5_000 })).wallClockMs).toBe(5_000); + }); + + it("an unmetered run bills 0 compute — never inferred from elapsed time", () => { + const out = buildLoopConsumptionEntry(facts({ computeUnitsMetered: 0 })); + expect(out.computeUnits).toBe(0); + expect(out.wallClockMs).toBe(60_000); // time still real; compute is not guessed from it + }); + }); +}); + +describe("totalConsumptionForTenant (#4792)", () => { + it("sums a tenant's entries into evaluateTenantQuota's TenantUsage shape", () => { + const usage = totalConsumptionForTenant([entry(), entry({ loopId: "loop-2", wallClockMs: 10_000, computeUnits: 8 })], "acme"); + expect(usage).toEqual({ computeUnitsUsed: 50, wallClockMsUsed: 70_000 }); + }); + + it("INVARIANT: never bills a tenant for another tenant's compute", () => { + const usage = totalConsumptionForTenant( + [entry(), entry({ tenantId: "globex", loopId: "loop-3", wallClockMs: 999_000, computeUnits: 999 })], + "acme", + ); + expect(usage).toEqual({ computeUnitsUsed: 42, wallClockMsUsed: 60_000 }); + }); + + it("an empty period, and a tenant with no entries, total to zero rather than undefined", () => { + expect(totalConsumptionForTenant([], "acme")).toEqual({ computeUnitsUsed: 0, wallClockMsUsed: 0 }); + expect(totalConsumptionForTenant([entry()], "nobody")).toEqual({ computeUnitsUsed: 0, wallClockMsUsed: 0 }); + }); + + it("normalizes a corrupt stored entry instead of propagating NaN into the total", () => { + const usage = totalConsumptionForTenant([entry({ computeUnits: Number.NaN, wallClockMs: -5 })], "acme"); + expect(usage).toEqual({ computeUnitsUsed: 0, wallClockMsUsed: 0 }); + }); + + it("counts a killed run's consumption toward the period like any other", () => { + expect(totalConsumptionForTenant([entry({ outcome: "killed", complete: false })], "acme")).toEqual({ + computeUnitsUsed: 42, + wallClockMsUsed: 60_000, + }); + }); + + // The reason this primitive exists: its output is exactly what the sibling quota evaluator reads, so a + // period's real consumption can be reconciled against the tenant's allocation (#4792 ↔ #4796). + it("composes with evaluateTenantQuota: summed consumption drives the allocation decision", () => { + const entries = [entry({ computeUnits: 90, wallClockMs: 30_000 })]; + const usage = totalConsumptionForTenant(entries, "acme"); + const quota = { computeUnits: 100, wallClockMs: 60_000, maxConcurrentLoops: 2 }; + + expect(evaluateTenantQuota({ ...usage, activeLoops: 0 }, quota)).toMatchObject({ allowed: true, exceeded: null }); + + const overspent = totalConsumptionForTenant([...entries, entry({ loopId: "loop-2", computeUnits: 10, wallClockMs: 0 })], "acme"); + expect(evaluateTenantQuota({ ...overspent, activeLoops: 0 }, quota)).toMatchObject({ allowed: false, exceeded: "compute" }); + }); +});