Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/loopover-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
108 changes: 108 additions & 0 deletions packages/loopover-engine/src/loop-consumption.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
138 changes: 138 additions & 0 deletions test/unit/loop-consumption.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): LoopRunFacts => ({
tenantId: "acme",
loopId: "loop-1",
startedAtMs: 1_000,
endedAtMs: 61_000,
outcome: "completed",
computeUnitsMetered: 42,
...over,
});

const entry = (over: Partial<LoopConsumptionEntry> = {}): 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" });
});
});