diff --git a/packages/loopover-engine/src/tenant-quota.ts b/packages/loopover-engine/src/tenant-quota.ts index 182760c32b..aa0111be51 100644 --- a/packages/loopover-engine/src/tenant-quota.ts +++ b/packages/loopover-engine/src/tenant-quota.ts @@ -99,3 +99,32 @@ export function evaluateTenantQuota(usage: TenantUsage, quota: TenantQuota): Ten remaining, }; } + +/** + * #7662: the soft-warning counterpart to {@link evaluateTenantQuota}. Returns the FIRST dimension — same + * compute→time→concurrency precedence as the hard check — whose remaining headroom has fallen to or below + * `warnThreshold` of its cap (default 0.2 ⇒ ≤20% left / ≥80% consumed), the pre-exhaustion band a soft + * "running low" notification fires in — or null when every bounded dimension still has comfortable headroom. + * A dimension already exhausted (remaining 0) is owned by the hard check and is NOT warned on here; a + * zero/undefined cap yields no meaningful fraction and is skipped. Pure: the caller decides whether/when to + * actually fire a notification from the result (the delivery wiring rides #7647's admission-check point). + */ +export function evaluateTenantQuotaWarning( + usage: TenantUsage, + quota: TenantQuota, + warnThreshold = 0.2, +): { warned: boolean; dimension: QuotaDimension | null; remaining: TenantQuotaDecision["remaining"] } { + const { remaining } = evaluateTenantQuota(usage, quota); + const byDimension: ReadonlyArray<[QuotaDimension, number, number]> = [ + ["compute", remaining.computeUnits, finiteNonNegativeInt(quota.computeUnits)], + ["time", remaining.wallClockMs, finiteNonNegativeInt(quota.wallClockMs)], + ["concurrency", remaining.concurrentLoops, finiteNonNegativeInt(quota.maxConcurrentLoops)], + ]; + for (const [dimension, left, cap] of byDimension) { + if (cap <= 0) continue; + if (left > 0 && left / cap <= warnThreshold) { + return { warned: true, dimension, remaining }; + } + } + return { warned: false, dimension: null, remaining }; +} diff --git a/test/unit/tenant-quota.test.ts b/test/unit/tenant-quota.test.ts index 217790b890..808f77ac9b 100644 --- a/test/unit/tenant-quota.test.ts +++ b/test/unit/tenant-quota.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { evaluateTenantQuota } from "../../packages/loopover-engine/src/tenant-quota"; +import { evaluateTenantQuota, evaluateTenantQuotaWarning } from "../../packages/loopover-engine/src/tenant-quota"; const QUOTA = { computeUnits: 100, wallClockMs: 60_000, maxConcurrentLoops: 3 }; @@ -72,3 +72,40 @@ describe("evaluateTenantQuota (#4796)", () => { expect(evaluateTenantQuota({ computeUnitsUsed: 5, wallClockMsUsed: 5_000, activeLoops: 1 }, QUOTA)).toEqual(under); }); }); + +describe("evaluateTenantQuotaWarning (#7662)", () => { + it("does not warn when every dimension has comfortable headroom", () => { + const w = evaluateTenantQuotaWarning({ computeUnitsUsed: 40, wallClockMsUsed: 10_000, activeLoops: 1 }, QUOTA); + expect(w).toEqual({ warned: false, dimension: null, remaining: { computeUnits: 60, wallClockMs: 50_000, concurrentLoops: 2 } }); + }); + + it("warns on the first low dimension at/below the soft threshold (85/100 compute ⇒ 15% left ≤ 20%)", () => { + const w = evaluateTenantQuotaWarning({ computeUnitsUsed: 85, wallClockMsUsed: 0, activeLoops: 0 }, QUOTA); + expect(w.warned).toBe(true); + expect(w.dimension).toBe("compute"); + }); + + it("reports the time dimension when compute is healthy but wall-clock is low (precedence respected)", () => { + const w = evaluateTenantQuotaWarning({ computeUnitsUsed: 10, wallClockMsUsed: 50_000, activeLoops: 0 }, QUOTA); + expect(w.dimension).toBe("time"); + }); + + it("does not warn a dimension already exhausted — the hard check owns remaining 0", () => { + const w = evaluateTenantQuotaWarning({ computeUnitsUsed: 100, wallClockMsUsed: 0, activeLoops: 0 }, QUOTA); + expect(w.warned).toBe(false); + expect(w.dimension).toBeNull(); + }); + + it("skips a dimension whose cap is zero/undefined (no meaningful fraction)", () => { + const w = evaluateTenantQuotaWarning( + { computeUnitsUsed: 0, wallClockMsUsed: 1_000, activeLoops: 0 }, + { computeUnits: 0, wallClockMs: 60_000, maxConcurrentLoops: 3 }, + ); + expect(w.warned).toBe(false); + }); + + it("honors a custom warn threshold (30% left: no warn at the 20% default, warns at 40%)", () => { + expect(evaluateTenantQuotaWarning({ computeUnitsUsed: 70, wallClockMsUsed: 0, activeLoops: 0 }, QUOTA).warned).toBe(false); + expect(evaluateTenantQuotaWarning({ computeUnitsUsed: 70, wallClockMsUsed: 0, activeLoops: 0 }, QUOTA, 0.4).dimension).toBe("compute"); + }); +});