Skip to content

Commit db74aa3

Browse files
feat(engine): per-tenant resource quota evaluation (#5801)
Adds a pure, deterministic per-tenant quota evaluator for the Rent-a-Loop path (#4796, part of #4778): given one tenant's already-metered usage and their allocation (compute units, wall-clock time, concurrent loops), it decides whether the tenant is within quota and, when not, reports the first exhausted dimension plus a clear, user-facing reason. It reads only the tenant it is handed, so one tenant exhausting its quota can never observe or affect another tenant's decision. Computes a decision only — no usage storage, metering, or loop-stopping (that enforcement wiring is separate). Inputs are normalized so a non-finite/negative usage or quota can never make a decision NaN or negative. Mirrors the governor's pure rate-limit calculator. Full branch coverage. Closes #4796
1 parent 7ea18c3 commit db74aa3

3 files changed

Lines changed: 182 additions & 0 deletions

File tree

packages/loopover-engine/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,13 @@ export {
593593
type ResultChangedFile,
594594
type ResultsPayload,
595595
} from "./results-payload.js";
596+
export {
597+
evaluateTenantQuota,
598+
type QuotaDimension,
599+
type TenantQuota,
600+
type TenantQuotaDecision,
601+
type TenantUsage,
602+
} from "./tenant-quota.js";
596603
export {
597604
buildProgressSnapshot,
598605
progressChanged,
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Per-tenant resource quota evaluation (pure) — #4796, part of the Rent-a-Loop path #4778.
2+
//
3+
// Deterministic and side-effect-free: given ONE tenant's already-metered usage and their allocation (the
4+
// paid/staked quota that #4792's rental ledger resolves), it decides whether the tenant is still within quota
5+
// and, when not, which resource dimension was exhausted plus a clear, user-facing reason. It reads only the
6+
// tenant it is handed, so evaluating one tenant can never observe or affect another's state — the isolation the
7+
// multi-tenant quota model requires. It computes a decision only: it does NOT store usage, meter compute, or
8+
// stop a loop; that enforcement wiring is a separate, maintainer-owned concern. Every numeric input is
9+
// normalized first, so a non-finite, fractional, or negative usage/quota can never make a decision NaN,
10+
// fractional, or negative. Mirrors the governor's pure rate-limit calculator (governor/rate-limit.ts).
11+
12+
/** A tenant's allocation — hard resource caps for the current billing period, from the rental ledger (#4792). */
13+
export type TenantQuota = {
14+
/** Compute-unit ceiling for the period. */
15+
computeUnits: number;
16+
/** Wall-clock-millisecond ceiling for the period. */
17+
wallClockMs: number;
18+
/** Maximum loops the tenant may run at once. */
19+
maxConcurrentLoops: number;
20+
};
21+
22+
/** A tenant's already-metered consumption this period — the input, never mutated. */
23+
export type TenantUsage = {
24+
computeUnitsUsed: number;
25+
wallClockMsUsed: number;
26+
activeLoops: number;
27+
};
28+
29+
/** The resource dimension a tenant exhausted, in the order they are checked. */
30+
export type QuotaDimension = "compute" | "time" | "concurrency";
31+
32+
export type TenantQuotaDecision = {
33+
/** Whether the tenant is within quota and may consume more / start another loop. */
34+
allowed: boolean;
35+
/** The first exhausted dimension when blocked, else null. */
36+
exceeded: QuotaDimension | null;
37+
/** A clear, actionable, user-facing explanation when blocked, else null. */
38+
reason: string | null;
39+
/** Headroom left in each dimension (0 when exhausted), echoed for callers that render the decision. */
40+
remaining: { computeUnits: number; wallClockMs: number; concurrentLoops: number };
41+
};
42+
43+
// Normalize any numeric input to a non-negative integer (a non-finite or negative value becomes 0), so usage
44+
// and quota can never make a decision NaN, fractional, or negative.
45+
function finiteNonNegativeInt(value: number): number {
46+
return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
47+
}
48+
49+
function quotaReason(dimension: QuotaDimension, cap: number): string {
50+
switch (dimension) {
51+
case "compute":
52+
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.`;
53+
case "time":
54+
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.`;
55+
case "concurrency":
56+
return `Quota exceeded: you already have the maximum of ${cap} loops running. Wait for a running loop to finish before starting another.`;
57+
}
58+
}
59+
60+
/**
61+
* Decide whether a tenant is within quota. Pure: reads only the given tenant's usage and quota and returns a
62+
* decision without mutating anything. Dimensions are checked in a fixed precedence — compute, then time, then
63+
* concurrency — and the FIRST exhausted one is reported so the tenant gets a single, clear, actionable message.
64+
* A dimension counts as exhausted when usage has reached (>=) its cap, so a tenant that has consumed its entire
65+
* allocation is stopped rather than allowed one more over the line. Because it never reads shared or other-tenant
66+
* state, one tenant hitting its quota has no effect on another tenant's decision.
67+
*/
68+
export function evaluateTenantQuota(usage: TenantUsage, quota: TenantQuota): TenantQuotaDecision {
69+
const computeUsed = finiteNonNegativeInt(usage.computeUnitsUsed);
70+
const timeUsed = finiteNonNegativeInt(usage.wallClockMsUsed);
71+
const loops = finiteNonNegativeInt(usage.activeLoops);
72+
const computeCap = finiteNonNegativeInt(quota.computeUnits);
73+
const timeCap = finiteNonNegativeInt(quota.wallClockMs);
74+
const loopCap = finiteNonNegativeInt(quota.maxConcurrentLoops);
75+
76+
const remaining = {
77+
computeUnits: Math.max(0, computeCap - computeUsed),
78+
wallClockMs: Math.max(0, timeCap - timeUsed),
79+
concurrentLoops: Math.max(0, loopCap - loops),
80+
};
81+
82+
let exceeded: QuotaDimension | null = null;
83+
let cap = 0;
84+
if (computeUsed >= computeCap) {
85+
exceeded = "compute";
86+
cap = computeCap;
87+
} else if (timeUsed >= timeCap) {
88+
exceeded = "time";
89+
cap = timeCap;
90+
} else if (loops >= loopCap) {
91+
exceeded = "concurrency";
92+
cap = loopCap;
93+
}
94+
95+
return {
96+
allowed: exceeded === null,
97+
exceeded,
98+
reason: exceeded === null ? null : quotaReason(exceeded, cap),
99+
remaining,
100+
};
101+
}

test/unit/tenant-quota.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { evaluateTenantQuota } from "../../packages/loopover-engine/src/tenant-quota";
4+
5+
const QUOTA = { computeUnits: 100, wallClockMs: 60_000, maxConcurrentLoops: 3 };
6+
7+
describe("evaluateTenantQuota (#4796)", () => {
8+
it("allows a tenant within every dimension and reports headroom", () => {
9+
const d = evaluateTenantQuota({ computeUnitsUsed: 40, wallClockMsUsed: 10_000, activeLoops: 1 }, QUOTA);
10+
expect(d.allowed).toBe(true);
11+
expect(d.exceeded).toBeNull();
12+
expect(d.reason).toBeNull();
13+
expect(d.remaining).toEqual({ computeUnits: 60, wallClockMs: 50_000, concurrentLoops: 2 });
14+
});
15+
16+
it("stops a tenant that has exhausted its compute allocation, with a user-facing reason (acceptance 1)", () => {
17+
const d = evaluateTenantQuota({ computeUnitsUsed: 100, wallClockMsUsed: 0, activeLoops: 0 }, QUOTA);
18+
expect(d.allowed).toBe(false);
19+
expect(d.exceeded).toBe("compute");
20+
expect(d.reason).toContain("compute units");
21+
expect(d.reason).toContain("100");
22+
expect(d.remaining.computeUnits).toBe(0);
23+
});
24+
25+
it("reports the time dimension when compute is fine but wall-clock is exhausted", () => {
26+
const d = evaluateTenantQuota({ computeUnitsUsed: 10, wallClockMsUsed: 60_000, activeLoops: 0 }, QUOTA);
27+
expect(d.exceeded).toBe("time");
28+
expect(d.reason).toContain("wall-clock");
29+
expect(d.remaining.wallClockMs).toBe(0);
30+
});
31+
32+
it("reports the concurrency dimension when compute and time are fine but max loops are running", () => {
33+
const d = evaluateTenantQuota({ computeUnitsUsed: 10, wallClockMsUsed: 1_000, activeLoops: 3 }, QUOTA);
34+
expect(d.exceeded).toBe("concurrency");
35+
expect(d.reason).toContain("loops running");
36+
expect(d.remaining.concurrentLoops).toBe(0);
37+
});
38+
39+
it("checks dimensions in a fixed precedence — compute is reported before time or concurrency", () => {
40+
const d = evaluateTenantQuota({ computeUnitsUsed: 200, wallClockMsUsed: 99_999, activeLoops: 9 }, QUOTA);
41+
expect(d.exceeded).toBe("compute");
42+
});
43+
44+
it("normalizes non-finite and negative inputs to 0 so a decision is never NaN or negative", () => {
45+
const d = evaluateTenantQuota(
46+
{ computeUnitsUsed: Number.NaN, wallClockMsUsed: -5, activeLoops: Infinity },
47+
{ computeUnits: 100, wallClockMs: 60_000, maxConcurrentLoops: Number.NaN },
48+
);
49+
// NaN compute → 0 used (within), -5 time → 0 used (within), Infinity loops → 0 used, NaN loop cap → 0.
50+
// activeLoops(0) >= loopCap(0) → concurrency is the first exhausted dimension.
51+
expect(d.exceeded).toBe("concurrency");
52+
expect(d.remaining.computeUnits).toBe(100);
53+
expect(Number.isNaN(d.remaining.wallClockMs)).toBe(false);
54+
expect(d.remaining.wallClockMs).toBe(60_000);
55+
});
56+
57+
it("denies a tenant with zero allocation immediately", () => {
58+
const d = evaluateTenantQuota(
59+
{ computeUnitsUsed: 0, wallClockMsUsed: 0, activeLoops: 0 },
60+
{ computeUnits: 0, wallClockMs: 0, maxConcurrentLoops: 0 },
61+
);
62+
expect(d.allowed).toBe(false);
63+
expect(d.exceeded).toBe("compute");
64+
});
65+
66+
it("isolates tenants — one over quota does not affect another's decision (acceptance 2)", () => {
67+
const over = evaluateTenantQuota({ computeUnitsUsed: 100, wallClockMsUsed: 0, activeLoops: 0 }, QUOTA);
68+
const under = evaluateTenantQuota({ computeUnitsUsed: 5, wallClockMsUsed: 5_000, activeLoops: 1 }, QUOTA);
69+
expect(over.allowed).toBe(false);
70+
expect(under.allowed).toBe(true);
71+
// Re-evaluating the over-quota tenant does not change the under-quota tenant's independent decision.
72+
expect(evaluateTenantQuota({ computeUnitsUsed: 5, wallClockMsUsed: 5_000, activeLoops: 1 }, QUOTA)).toEqual(under);
73+
});
74+
});

0 commit comments

Comments
 (0)