mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(quota): add redisQuotaStore (optional driver, gated by ioredis availability) (B/F6)
This commit is contained in:
308
src/lib/quota/redisQuotaStore.ts
Normal file
308
src/lib/quota/redisQuotaStore.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* redisQuotaStore.ts — Optional Redis-backed QuotaStore implementation.
|
||||
*
|
||||
* Counter keys follow the pattern:
|
||||
* omniroute:quota:<apiKeyId>:<dimensionKey>:<bucketIndex>
|
||||
*
|
||||
* Sliding window is maintained identically to the SQLite driver:
|
||||
* effective = prev × (1 − elapsed/window) + curr
|
||||
*
|
||||
* Pool/allocation metadata (listAllocationsForApiKey, getPool) still lives in
|
||||
* SQLite (F2) — only the rolling counters are stored in Redis.
|
||||
*
|
||||
* ioredis is a SOFT dependency. If not installed, constructing a RedisQuotaStore
|
||||
* throws a clear error message.
|
||||
*
|
||||
* Part of: Group B — Quota Sharing Engine (plan 22, frente F6).
|
||||
*/
|
||||
|
||||
import {
|
||||
getPool,
|
||||
listAllocationsForApiKey,
|
||||
} from "@/lib/localDb";
|
||||
import { WINDOW_MS, dimensionKeyToString } from "./dimensions";
|
||||
import type { DimensionKey } from "./dimensions";
|
||||
import type { QuotaStore, PoolUsageSnapshot } from "./types";
|
||||
import { computeBurnRate } from "./burnRate";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Redis connection singleton
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Lazy singleton — created on first use
|
||||
let _redisClient: unknown = null; // typed as unknown; cast via RedisLike below
|
||||
|
||||
interface RedisLike {
|
||||
incrbyfloat(key: string, value: number): Promise<string>;
|
||||
expire(key: string, seconds: number): Promise<number>;
|
||||
mget(...keys: string[]): Promise<Array<string | null>>;
|
||||
eval(script: string, numkeys: number, ...args: unknown[]): Promise<unknown>;
|
||||
del(...keys: string[]): Promise<number>;
|
||||
quit(): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the singleton Redis client. Throws if ioredis is not installed.
|
||||
* The url parameter is only used when creating the connection for the first time.
|
||||
*/
|
||||
export async function getRedisClient(url: string): Promise<RedisLike> {
|
||||
if (_redisClient) {
|
||||
return _redisClient as RedisLike;
|
||||
}
|
||||
|
||||
// Lazy dynamic require — ioredis is an optional dependency
|
||||
let Redis: new (url: string) => RedisLike;
|
||||
try {
|
||||
const mod = await import("ioredis");
|
||||
Redis = (mod.default ?? mod) as new (url: string) => RedisLike;
|
||||
} catch {
|
||||
throw new Error("Redis driver requires ioredis package. Run npm install ioredis.");
|
||||
}
|
||||
|
||||
_redisClient = new Redis(url);
|
||||
return _redisClient as RedisLike;
|
||||
}
|
||||
|
||||
/** Test-only: reset the Redis singleton. */
|
||||
export function resetRedisClient(): void {
|
||||
_redisClient = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KEY_PREFIX = "omniroute:quota";
|
||||
|
||||
function bucketKey(apiKeyId: string, dimensionKey: string, bucketIndex: number): string {
|
||||
return `${KEY_PREFIX}:${apiKeyId}:${dimensionKey}:${bucketIndex}`;
|
||||
}
|
||||
|
||||
function ttlSeconds(windowMs: number): number {
|
||||
// Keep both current + previous bucket alive → 2 × window
|
||||
return Math.ceil((2 * windowMs) / 1000);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RedisQuotaStore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class RedisQuotaStore implements QuotaStore {
|
||||
private readonly url: string;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
private async client(): Promise<RedisLike> {
|
||||
return getRedisClient(this.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment consumption by `cost` using INCRBYFLOAT (atomic) and refresh TTL.
|
||||
* Returns 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);
|
||||
|
||||
const client = await this.client();
|
||||
const currKey = bucketKey(apiKeyId, dimKey, currentBucket);
|
||||
const prevKey = bucketKey(apiKeyId, dimKey, currentBucket - 1);
|
||||
const ttl = ttlSeconds(windowMs);
|
||||
|
||||
// Atomic increment + refresh TTL
|
||||
const newCurrStr = await client.incrbyfloat(currKey, cost);
|
||||
await client.expire(currKey, ttl);
|
||||
// Also ensure prev key TTL is refreshed so it doesn't disappear prematurely
|
||||
await client.expire(prevKey, ttl);
|
||||
|
||||
const newCurr = parseFloat(newCurrStr) || 0;
|
||||
|
||||
// Read prev to compute sliding window
|
||||
const [prevStr] = await client.mget(prevKey);
|
||||
const prev = parseFloat(prevStr ?? "0") || 0;
|
||||
|
||||
return slidingWindowEffective(newCurr, prev, nowMs, windowMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the sliding-window effective value without modification.
|
||||
*/
|
||||
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 client = await this.client();
|
||||
const currKey = bucketKey(apiKeyId, dimKey, currentBucket);
|
||||
const prevKey = bucketKey(apiKeyId, dimKey, currentBucket - 1);
|
||||
|
||||
const [currStr, prevStr] = await client.mget(currKey, prevKey);
|
||||
const curr = parseFloat(currStr ?? "0") || 0;
|
||||
const prev = parseFloat(prevStr ?? "0") || 0;
|
||||
|
||||
return slidingWindowEffective(curr, prev, nowMs, windowMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate pool usage. Pool and allocation metadata come from SQLite (F2);
|
||||
* rolling counters come from Redis.
|
||||
*/
|
||||
async poolUsage(poolId: string): Promise<PoolUsageSnapshot> {
|
||||
const nowMs = Date.now();
|
||||
const pool = getPool(poolId);
|
||||
|
||||
if (!pool) {
|
||||
return {
|
||||
poolId,
|
||||
generatedAt: new Date(nowMs).toISOString(),
|
||||
dimensions: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Pool dimensions are not directly available here (they come from plan
|
||||
// resolver). Return empty for now — REST routes (F8) call poolUsageWithDimensions.
|
||||
return {
|
||||
poolId,
|
||||
generatedAt: new Date(nowMs).toISOString(),
|
||||
dimensions: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a PoolUsageSnapshot with explicit plan dimensions.
|
||||
* Mirrors SqliteQuotaStore.poolUsageWithDimensions().
|
||||
*/
|
||||
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 burnSamples: Array<{ ts: number; consumed: number }> = [];
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
burnSamples.push({ ts: nowMs, consumed: consumedTotal });
|
||||
dimensionSnapshots.push({
|
||||
unit: planDim.unit as PoolUsageSnapshot["dimensions"][number]["unit"],
|
||||
window: planDim.window as PoolUsageSnapshot["dimensions"][number]["window"],
|
||||
limit: planDim.limit,
|
||||
consumedTotal,
|
||||
perKey,
|
||||
});
|
||||
}
|
||||
|
||||
const tokenDim = dimensionSnapshots.find((d) => d.unit === "tokens");
|
||||
let burnRate: PoolUsageSnapshot["burnRate"];
|
||||
if (tokenDim && burnSamples.length >= 1) {
|
||||
const remaining = tokenDim.limit - tokenDim.consumedTotal;
|
||||
const rateResult = computeBurnRate(burnSamples, remaining);
|
||||
burnRate = {
|
||||
tokensPerSecond: rateResult.tokensPerSecond,
|
||||
timeToExhaustionMs: rateResult.timeToExhaustionMs,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
poolId,
|
||||
generatedAt: new Date(nowMs).toISOString(),
|
||||
dimensions: dimensionSnapshots,
|
||||
burnRate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear both current and previous bucket counters. Test-only.
|
||||
*/
|
||||
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 client = await this.client();
|
||||
const currKey = bucketKey(apiKeyId, dimKey, currentBucket);
|
||||
const prevKey = bucketKey(apiKeyId, dimKey, currentBucket - 1);
|
||||
|
||||
await client.del(currKey, prevKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton per URL
|
||||
let _storeInstance: RedisQuotaStore | null = null;
|
||||
let _storeUrl: string | null = null;
|
||||
|
||||
export function getRedisQuotaStore(url: string): RedisQuotaStore {
|
||||
if (!_storeInstance || _storeUrl !== url) {
|
||||
_storeInstance = new RedisQuotaStore(url);
|
||||
_storeUrl = url;
|
||||
}
|
||||
return _storeInstance;
|
||||
}
|
||||
|
||||
export function resetRedisQuotaStore(): void {
|
||||
_storeInstance = null;
|
||||
_storeUrl = null;
|
||||
}
|
||||
Reference in New Issue
Block a user