|
| 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 | +} |
0 commit comments