diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index 6c86cd3948..ecfaa012c3 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -593,6 +593,13 @@ export { type ResultChangedFile, type ResultsPayload, } from "./results-payload.js"; +export { + evaluateTenantQuota, + type QuotaDimension, + type TenantQuota, + type TenantQuotaDecision, + type TenantUsage, +} from "./tenant-quota.js"; export { buildProgressSnapshot, progressChanged, diff --git a/packages/loopover-engine/src/tenant-quota.ts b/packages/loopover-engine/src/tenant-quota.ts new file mode 100644 index 0000000000..182760c32b --- /dev/null +++ b/packages/loopover-engine/src/tenant-quota.ts @@ -0,0 +1,101 @@ +// Per-tenant resource quota evaluation (pure) — #4796, part of the Rent-a-Loop path #4778. +// +// Deterministic and side-effect-free: given ONE tenant's already-metered usage and their allocation (the +// paid/staked quota that #4792's rental ledger resolves), it decides whether the tenant is still within quota +// and, when not, which resource dimension was exhausted plus a clear, user-facing reason. It reads only the +// tenant it is handed, so evaluating one tenant can never observe or affect another's state — the isolation the +// multi-tenant quota model requires. It computes a decision only: it does NOT store usage, meter compute, or +// stop a loop; that enforcement wiring is a separate, maintainer-owned concern. Every numeric input is +// normalized first, so a non-finite, fractional, or negative usage/quota can never make a decision NaN, +// fractional, or negative. Mirrors the governor's pure rate-limit calculator (governor/rate-limit.ts). + +/** A tenant's allocation — hard resource caps for the current billing period, from the rental ledger (#4792). */ +export type TenantQuota = { + /** Compute-unit ceiling for the period. */ + computeUnits: number; + /** Wall-clock-millisecond ceiling for the period. */ + wallClockMs: number; + /** Maximum loops the tenant may run at once. */ + maxConcurrentLoops: number; +}; + +/** A tenant's already-metered consumption this period — the input, never mutated. */ +export type TenantUsage = { + computeUnitsUsed: number; + wallClockMsUsed: number; + activeLoops: number; +}; + +/** The resource dimension a tenant exhausted, in the order they are checked. */ +export type QuotaDimension = "compute" | "time" | "concurrency"; + +export type TenantQuotaDecision = { + /** Whether the tenant is within quota and may consume more / start another loop. */ + allowed: boolean; + /** The first exhausted dimension when blocked, else null. */ + exceeded: QuotaDimension | null; + /** A clear, actionable, user-facing explanation when blocked, else null. */ + reason: string | null; + /** Headroom left in each dimension (0 when exhausted), echoed for callers that render the decision. */ + remaining: { computeUnits: number; wallClockMs: number; concurrentLoops: number }; +}; + +// Normalize any numeric input to a non-negative integer (a non-finite or negative value becomes 0), so usage +// and quota can never make a decision NaN, fractional, or negative. +function finiteNonNegativeInt(value: number): number { + return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; +} + +function quotaReason(dimension: QuotaDimension, cap: number): string { + switch (dimension) { + case "compute": + return `Quota exceeded: you have used all ${cap} compute units in your current allocation. Increase your allocation or wait for the next period before running more.`; + case "time": + return `Quota exceeded: you have used all ${cap} ms of wall-clock time in your current allocation. Increase your allocation or wait for the next period before running more.`; + case "concurrency": + return `Quota exceeded: you already have the maximum of ${cap} loops running. Wait for a running loop to finish before starting another.`; + } +} + +/** + * Decide whether a tenant is within quota. Pure: reads only the given tenant's usage and quota and returns a + * decision without mutating anything. Dimensions are checked in a fixed precedence — compute, then time, then + * concurrency — and the FIRST exhausted one is reported so the tenant gets a single, clear, actionable message. + * A dimension counts as exhausted when usage has reached (>=) its cap, so a tenant that has consumed its entire + * allocation is stopped rather than allowed one more over the line. Because it never reads shared or other-tenant + * state, one tenant hitting its quota has no effect on another tenant's decision. + */ +export function evaluateTenantQuota(usage: TenantUsage, quota: TenantQuota): TenantQuotaDecision { + const computeUsed = finiteNonNegativeInt(usage.computeUnitsUsed); + const timeUsed = finiteNonNegativeInt(usage.wallClockMsUsed); + const loops = finiteNonNegativeInt(usage.activeLoops); + const computeCap = finiteNonNegativeInt(quota.computeUnits); + const timeCap = finiteNonNegativeInt(quota.wallClockMs); + const loopCap = finiteNonNegativeInt(quota.maxConcurrentLoops); + + const remaining = { + computeUnits: Math.max(0, computeCap - computeUsed), + wallClockMs: Math.max(0, timeCap - timeUsed), + concurrentLoops: Math.max(0, loopCap - loops), + }; + + let exceeded: QuotaDimension | null = null; + let cap = 0; + if (computeUsed >= computeCap) { + exceeded = "compute"; + cap = computeCap; + } else if (timeUsed >= timeCap) { + exceeded = "time"; + cap = timeCap; + } else if (loops >= loopCap) { + exceeded = "concurrency"; + cap = loopCap; + } + + return { + allowed: exceeded === null, + exceeded, + reason: exceeded === null ? null : quotaReason(exceeded, cap), + remaining, + }; +} diff --git a/test/unit/tenant-quota.test.ts b/test/unit/tenant-quota.test.ts new file mode 100644 index 0000000000..217790b890 --- /dev/null +++ b/test/unit/tenant-quota.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { evaluateTenantQuota } from "../../packages/loopover-engine/src/tenant-quota"; + +const QUOTA = { computeUnits: 100, wallClockMs: 60_000, maxConcurrentLoops: 3 }; + +describe("evaluateTenantQuota (#4796)", () => { + it("allows a tenant within every dimension and reports headroom", () => { + const d = evaluateTenantQuota({ computeUnitsUsed: 40, wallClockMsUsed: 10_000, activeLoops: 1 }, QUOTA); + expect(d.allowed).toBe(true); + expect(d.exceeded).toBeNull(); + expect(d.reason).toBeNull(); + expect(d.remaining).toEqual({ computeUnits: 60, wallClockMs: 50_000, concurrentLoops: 2 }); + }); + + it("stops a tenant that has exhausted its compute allocation, with a user-facing reason (acceptance 1)", () => { + const d = evaluateTenantQuota({ computeUnitsUsed: 100, wallClockMsUsed: 0, activeLoops: 0 }, QUOTA); + expect(d.allowed).toBe(false); + expect(d.exceeded).toBe("compute"); + expect(d.reason).toContain("compute units"); + expect(d.reason).toContain("100"); + expect(d.remaining.computeUnits).toBe(0); + }); + + it("reports the time dimension when compute is fine but wall-clock is exhausted", () => { + const d = evaluateTenantQuota({ computeUnitsUsed: 10, wallClockMsUsed: 60_000, activeLoops: 0 }, QUOTA); + expect(d.exceeded).toBe("time"); + expect(d.reason).toContain("wall-clock"); + expect(d.remaining.wallClockMs).toBe(0); + }); + + it("reports the concurrency dimension when compute and time are fine but max loops are running", () => { + const d = evaluateTenantQuota({ computeUnitsUsed: 10, wallClockMsUsed: 1_000, activeLoops: 3 }, QUOTA); + expect(d.exceeded).toBe("concurrency"); + expect(d.reason).toContain("loops running"); + expect(d.remaining.concurrentLoops).toBe(0); + }); + + it("checks dimensions in a fixed precedence — compute is reported before time or concurrency", () => { + const d = evaluateTenantQuota({ computeUnitsUsed: 200, wallClockMsUsed: 99_999, activeLoops: 9 }, QUOTA); + expect(d.exceeded).toBe("compute"); + }); + + it("normalizes non-finite and negative inputs to 0 so a decision is never NaN or negative", () => { + const d = evaluateTenantQuota( + { computeUnitsUsed: Number.NaN, wallClockMsUsed: -5, activeLoops: Infinity }, + { computeUnits: 100, wallClockMs: 60_000, maxConcurrentLoops: Number.NaN }, + ); + // NaN compute → 0 used (within), -5 time → 0 used (within), Infinity loops → 0 used, NaN loop cap → 0. + // activeLoops(0) >= loopCap(0) → concurrency is the first exhausted dimension. + expect(d.exceeded).toBe("concurrency"); + expect(d.remaining.computeUnits).toBe(100); + expect(Number.isNaN(d.remaining.wallClockMs)).toBe(false); + expect(d.remaining.wallClockMs).toBe(60_000); + }); + + it("denies a tenant with zero allocation immediately", () => { + const d = evaluateTenantQuota( + { computeUnitsUsed: 0, wallClockMsUsed: 0, activeLoops: 0 }, + { computeUnits: 0, wallClockMs: 0, maxConcurrentLoops: 0 }, + ); + expect(d.allowed).toBe(false); + expect(d.exceeded).toBe("compute"); + }); + + it("isolates tenants — one over quota does not affect another's decision (acceptance 2)", () => { + const over = evaluateTenantQuota({ computeUnitsUsed: 100, wallClockMsUsed: 0, activeLoops: 0 }, QUOTA); + const under = evaluateTenantQuota({ computeUnitsUsed: 5, wallClockMsUsed: 5_000, activeLoops: 1 }, QUOTA); + expect(over.allowed).toBe(false); + expect(under.allowed).toBe(true); + // Re-evaluating the over-quota tenant does not change the under-quota tenant's independent decision. + expect(evaluateTenantQuota({ computeUnitsUsed: 5, wallClockMsUsed: 5_000, activeLoops: 1 }, QUOTA)).toEqual(under); + }); +});