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
7 changes: 7 additions & 0 deletions packages/loopover-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
101 changes: 101 additions & 0 deletions packages/loopover-engine/src/tenant-quota.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
74 changes: 74 additions & 0 deletions test/unit/tenant-quota.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});