mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 22:02:08 +03:00
Release v3.8.41 — 52 commits since v3.8.40 (19 CHANGELOG bullets, 11 contributors). All gating CI green: Unit×8, Coverage×8, Vitest, Package Artifact, Quality Ratchet, CodeQL, Lint, Docs Sync (Strict), Node 24/26 compat, E2E×9, Integration, Electron smoke. Advisory checks overridden (main unprotected): PR Test Policy = test-masking heuristic on the cumulative 52-commit assert delta (legitimate dead-code-sweep removals + consolidations, reviewed per-PR); SonarCloud/SonarQube = new-code maintainability/coverage quality gate (CodeQL/Semgrep/Security/npm-audit/Dependabot all clean — not a security finding).
283 lines
9.7 KiB
TypeScript
283 lines
9.7 KiB
TypeScript
/**
|
||
* sqliteQuotaStore.ts — SQLite-backed QuotaStore implementation.
|
||
*
|
||
* Uses a Sliding Window Counter with 2 buckets per (apiKeyId, dimensionKey):
|
||
* effective = prev × (1 − elapsed/window) + curr
|
||
* currentBucketIndex = Math.floor(nowMs / WINDOW_MS[window])
|
||
* currentBucketStartMs = currentBucketIndex × WINDOW_MS[window]
|
||
* elapsed = nowMs − currentBucketStartMs
|
||
*
|
||
* Concurrency: per-(apiKeyId|dimensionKey) in-memory mutex prevents races on
|
||
* the read-modify-write sequence (same anti-thundering-herd pattern used by
|
||
* auth.ts::markAccountUnavailable). UPSERT in incrementBucket is still atomic
|
||
* at the SQLite level.
|
||
*
|
||
* Part of: Group B — Quota Sharing Engine (plan 22, frente F6).
|
||
*/
|
||
|
||
import { getPool, getBucket, incrementBucket, getPair, sumPoolDimension } from "@/lib/localDb";
|
||
import { WINDOW_MS, dimensionKeyToString } from "./dimensions";
|
||
import type { DimensionKey } from "./dimensions";
|
||
import type { QuotaStore, PoolUsageSnapshot } from "./types";
|
||
import { computeBurnRateFromWindow } from "./burnRate";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// In-memory mutex (anti-thundering-herd, same pattern as auth.ts)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const _mutexes = new Map<string, Promise<void>>();
|
||
|
||
function mutexKey(apiKeyId: string, dimKey: string): string {
|
||
return `${apiKeyId}|${dimKey}`;
|
||
}
|
||
|
||
async function withMutex<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||
const current = _mutexes.get(key) ?? Promise.resolve();
|
||
let resolve!: () => void;
|
||
const next = new Promise<void>((res) => {
|
||
resolve = res;
|
||
});
|
||
_mutexes.set(key, next);
|
||
|
||
try {
|
||
await current;
|
||
return await fn();
|
||
} finally {
|
||
resolve();
|
||
// Clean up only if this promise is still the active one
|
||
if (_mutexes.get(key) === next) {
|
||
_mutexes.delete(key);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sliding window helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function slidingWindowEffective(
|
||
curr: number,
|
||
prev: number,
|
||
nowMs: number,
|
||
windowMs: number
|
||
): number {
|
||
const currentBucketIndex = Math.floor(nowMs / windowMs);
|
||
const currentBucketStartMs = currentBucketIndex * windowMs;
|
||
const elapsed = nowMs - currentBucketStartMs;
|
||
const weight = 1 - elapsed / windowMs;
|
||
return prev * weight + curr;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SqliteQuotaStore
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export class SqliteQuotaStore implements QuotaStore {
|
||
/**
|
||
* Increment consumption for (apiKeyId, dim) by `cost` and return the
|
||
* new sliding-window effective value.
|
||
*/
|
||
async consume(apiKeyId: string, dim: DimensionKey, cost: number): Promise<number> {
|
||
const nowMs = Date.now();
|
||
const dimKey = dimensionKeyToString(dim);
|
||
const windowMs = WINDOW_MS[dim.window];
|
||
const currentBucket = Math.floor(nowMs / windowMs);
|
||
|
||
return withMutex(mutexKey(apiKeyId, dimKey), async () => {
|
||
// UPSERT is atomic at the DB level
|
||
incrementBucket(apiKeyId, dimKey, currentBucket, cost, nowMs);
|
||
|
||
// Read fresh pair to compute effective
|
||
const { curr, prev } = getPair(apiKeyId, dimKey, currentBucket);
|
||
return slidingWindowEffective(curr, prev, nowMs, windowMs);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Peek at the current effective consumption without modifying any counters.
|
||
*/
|
||
async peek(apiKeyId: string, dim: DimensionKey): Promise<number> {
|
||
const nowMs = Date.now();
|
||
const dimKey = dimensionKeyToString(dim);
|
||
const windowMs = WINDOW_MS[dim.window];
|
||
const currentBucket = Math.floor(nowMs / windowMs);
|
||
|
||
const { curr, prev } = getPair(apiKeyId, dimKey, currentBucket);
|
||
return slidingWindowEffective(curr, prev, nowMs, windowMs);
|
||
}
|
||
|
||
/**
|
||
* Return the real pool-wide consumption for a dimension in the current
|
||
* sliding window, summed across ALL apiKeyIds that share the same
|
||
* dimensionKey (i.e. same poolId + unit + window).
|
||
*
|
||
* Uses the same 2-bucket sliding-window formula as peek(), applied once
|
||
* to the pool totals so the result is consistent with per-key semantics.
|
||
*/
|
||
async poolConsumedTotal(poolId: string, dim: DimensionKey): Promise<number> {
|
||
const nowMs = Date.now();
|
||
const dimKey = dimensionKeyToString(dim);
|
||
const windowMs = WINDOW_MS[dim.window];
|
||
const currentBucket = Math.floor(nowMs / windowMs);
|
||
|
||
const { currTotal, prevTotal } = sumPoolDimension(dimKey, currentBucket);
|
||
return slidingWindowEffective(currTotal, prevTotal, nowMs, windowMs);
|
||
}
|
||
|
||
/**
|
||
* Return a PoolUsageSnapshot for the given pool, aggregating per-key
|
||
* consumption across all dimensions and computing fairShare / deficit /
|
||
* borrowing flags.
|
||
*/
|
||
async poolUsage(poolId: string): Promise<PoolUsageSnapshot> {
|
||
const nowMs = Date.now();
|
||
const pool = getPool(poolId);
|
||
|
||
if (!pool) {
|
||
return {
|
||
poolId,
|
||
generatedAt: new Date(nowMs).toISOString(),
|
||
dimensions: [],
|
||
};
|
||
}
|
||
|
||
// QuotaPool does not carry dimension definitions — those live in the
|
||
// ProviderPlan, resolved separately. Without a plan we cannot enumerate
|
||
// dimension keys here, so this lightweight snapshot returns no dimensions.
|
||
// The REST route (F8) calls poolUsageWithDimensions() with the resolved
|
||
// plan to produce the full per-dimension response.
|
||
return {
|
||
poolId,
|
||
generatedAt: new Date(nowMs).toISOString(),
|
||
dimensions: [],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Build a PoolUsageSnapshot for a given pool with explicit dimensions from
|
||
* the provider plan. This is the richer version used by REST routes (F8)
|
||
* that already resolved the plan.
|
||
*
|
||
* This method is not part of the QuotaStore interface but is available on
|
||
* the concrete class for callers that have plan data.
|
||
*/
|
||
async poolUsageWithDimensions(
|
||
poolId: string,
|
||
planDimensions: Array<{ unit: string; window: string; limit: number }>
|
||
): Promise<PoolUsageSnapshot> {
|
||
const nowMs = Date.now();
|
||
const pool = getPool(poolId);
|
||
|
||
if (!pool) {
|
||
return {
|
||
poolId,
|
||
generatedAt: new Date(nowMs).toISOString(),
|
||
dimensions: [],
|
||
};
|
||
}
|
||
|
||
const { allocations } = pool;
|
||
const totalWeight = allocations.reduce((sum, a) => sum + a.weight, 0);
|
||
|
||
const dimensionSnapshots: PoolUsageSnapshot["dimensions"] = [];
|
||
|
||
for (const planDim of planDimensions) {
|
||
const windowMs = WINDOW_MS[planDim.window as keyof typeof WINDOW_MS];
|
||
if (!windowMs) continue;
|
||
|
||
let consumedTotal = 0;
|
||
const perKey: PoolUsageSnapshot["dimensions"][number]["perKey"] = [];
|
||
|
||
for (const alloc of allocations) {
|
||
const dim: DimensionKey = {
|
||
poolId,
|
||
unit: planDim.unit as DimensionKey["unit"],
|
||
window: planDim.window as DimensionKey["window"],
|
||
};
|
||
const consumed = await this.peek(alloc.apiKeyId, dim);
|
||
consumedTotal += consumed;
|
||
|
||
const effectiveWeight = totalWeight > 0 ? alloc.weight : 0;
|
||
const fairShare = (effectiveWeight / 100) * planDim.limit;
|
||
const deficit = consumed - fairShare;
|
||
const borrowing = consumed > fairShare;
|
||
|
||
perKey.push({
|
||
apiKeyId: alloc.apiKeyId,
|
||
consumed,
|
||
fairShare,
|
||
deficit,
|
||
borrowing,
|
||
});
|
||
}
|
||
|
||
dimensionSnapshots.push({
|
||
unit: planDim.unit as PoolUsageSnapshot["dimensions"][number]["unit"],
|
||
window: planDim.window as PoolUsageSnapshot["dimensions"][number]["window"],
|
||
limit: planDim.limit,
|
||
consumedTotal,
|
||
perKey,
|
||
});
|
||
}
|
||
|
||
// Burn rate: derive from the sliding window (single-snapshot, no history needed).
|
||
const tokenDim = dimensionSnapshots.find((d) => d.unit === "tokens");
|
||
let burnRate: PoolUsageSnapshot["burnRate"];
|
||
if (tokenDim && tokenDim.consumedTotal > 0) {
|
||
const windowMs = WINDOW_MS[tokenDim.window as keyof typeof WINDOW_MS];
|
||
const remaining = tokenDim.limit - tokenDim.consumedTotal;
|
||
const rateResult = computeBurnRateFromWindow(tokenDim.consumedTotal, windowMs, remaining);
|
||
burnRate = {
|
||
tokensPerSecond: rateResult.tokensPerSecond,
|
||
timeToExhaustionMs: rateResult.timeToExhaustionMs,
|
||
};
|
||
}
|
||
|
||
return {
|
||
poolId,
|
||
generatedAt: new Date(nowMs).toISOString(),
|
||
dimensions: dimensionSnapshots,
|
||
burnRate,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Clear consumption counters for (apiKeyId, dim). Test-only.
|
||
* Implemented by writing a large negative delta to bring curr + prev to 0,
|
||
* OR by directly zeroing out the bucket rows.
|
||
*
|
||
* We zero by reading current and then applying -curr as delta.
|
||
* The previous bucket is left as-is (its weight will decay naturally).
|
||
*/
|
||
async clear(apiKeyId: string, dim: DimensionKey): Promise<void> {
|
||
const nowMs = Date.now();
|
||
const dimKey = dimensionKeyToString(dim);
|
||
const windowMs = WINDOW_MS[dim.window];
|
||
const currentBucket = Math.floor(nowMs / windowMs);
|
||
const prevBucket = currentBucket - 1;
|
||
|
||
await withMutex(mutexKey(apiKeyId, dimKey), async () => {
|
||
// Zero current bucket
|
||
const currVal = getBucket(apiKeyId, dimKey, currentBucket);
|
||
if (currVal !== 0) {
|
||
incrementBucket(apiKeyId, dimKey, currentBucket, -currVal, nowMs);
|
||
}
|
||
// Zero previous bucket
|
||
const prevVal = getBucket(apiKeyId, dimKey, prevBucket);
|
||
if (prevVal !== 0) {
|
||
incrementBucket(apiKeyId, dimKey, prevBucket, -prevVal, nowMs);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Singleton per process
|
||
let _instance: SqliteQuotaStore | null = null;
|
||
|
||
export function getSqliteQuotaStore(): SqliteQuotaStore {
|
||
if (!_instance) {
|
||
_instance = new SqliteQuotaStore();
|
||
}
|
||
return _instance;
|
||
}
|