fiemwl / src /lib /spend /batchWriter.ts
automindy's picture
Upload 1980 files
6111b2b verified
Raw
History Blame Contribute Delete
6.2 kB
import { batchSaveCostEntries } from "@/lib/db/domainState";
export interface BufferedCostEntry {
apiKeyId: string;
cost: number;
timestamp: number;
}
interface SpendBatchWriterOptions {
flushIntervalMs?: number;
maxBufferSize?: number;
persistEntries?: (entries: BufferedCostEntry[]) => Promise<void> | void;
logger?: Pick<Console, "log" | "error">;
}
type FlushResult = {
flushedEntries: number;
uniqueKeys: number;
requeued: boolean;
};
const DEFAULT_FLUSH_INTERVAL_MS = 60_000;
const DEFAULT_MAX_BUFFER_SIZE = 1_000;
function getFlushIntervalMs() {
const parsed = Number.parseInt(process.env.OMNIROUTE_SPEND_FLUSH_INTERVAL_MS || "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_FLUSH_INTERVAL_MS;
}
function getMaxBufferSize() {
const parsed = Number.parseInt(process.env.OMNIROUTE_SPEND_MAX_BUFFER_SIZE || "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_BUFFER_SIZE;
}
function normalizeEntry(entry: BufferedCostEntry): BufferedCostEntry | null {
if (!entry?.apiKeyId || !Number.isFinite(entry.cost) || entry.cost <= 0) return null;
return {
apiKeyId: entry.apiKeyId,
cost: entry.cost,
timestamp: Number.isFinite(entry.timestamp) ? entry.timestamp : Date.now(),
};
}
export class SpendBatchWriter {
private buffer: BufferedCostEntry[] = [];
private inFlightEntries: BufferedCostEntry[] = [];
private discardedApiKeyIds = new Set<string>();
private timer: NodeJS.Timeout | null = null;
private started = false;
private flushPromise: Promise<FlushResult> | null = null;
private persistEntries: (entries: BufferedCostEntry[]) => Promise<void> | void;
private logger: Pick<Console, "log" | "error">;
private flushIntervalMs: number;
private maxBufferSize: number;
constructor(options: SpendBatchWriterOptions = {}) {
this.persistEntries = options.persistEntries || batchSaveCostEntries;
this.logger = options.logger || console;
this.flushIntervalMs = options.flushIntervalMs ?? getFlushIntervalMs();
this.maxBufferSize = options.maxBufferSize ?? getMaxBufferSize();
}
start() {
if (this.started) return;
this.started = true;
this.timer = setInterval(() => {
void this.flush();
}, this.flushIntervalMs);
this.timer.unref?.();
}
increment(apiKeyId: string, cost: number, timestamp = Date.now()) {
const entry = normalizeEntry({ apiKeyId, cost, timestamp });
if (!entry) return;
this.start();
this.discardedApiKeyIds.delete(entry.apiKeyId);
this.buffer.push(entry);
if (this.buffer.length >= this.maxBufferSize) {
void this.flush();
}
}
getBufferedEntries(
apiKeyId: string,
sinceTimestamp = 0,
untilTimestamp = Number.POSITIVE_INFINITY
) {
const matchesWindow = (entry: BufferedCostEntry) =>
entry.apiKeyId === apiKeyId &&
entry.timestamp >= sinceTimestamp &&
entry.timestamp < untilTimestamp;
return [...this.inFlightEntries, ...this.buffer].filter(matchesWindow);
}
getPendingCostTotal(
apiKeyId: string,
sinceTimestamp = 0,
untilTimestamp = Number.POSITIVE_INFINITY
) {
return this.getBufferedEntries(apiKeyId, sinceTimestamp, untilTimestamp).reduce(
(sum, entry) => sum + entry.cost,
0
);
}
discardEntries(apiKeyId: string) {
this.discardedApiKeyIds.add(apiKeyId);
this.buffer = this.buffer.filter((entry) => entry.apiKeyId !== apiKeyId);
this.inFlightEntries = this.inFlightEntries.filter((entry) => entry.apiKeyId !== apiKeyId);
}
async flush(): Promise<FlushResult> {
if (this.flushPromise) {
return this.flushPromise;
}
if (this.buffer.length === 0) {
return { flushedEntries: 0, uniqueKeys: 0, requeued: false };
}
const entriesToFlush = [...this.buffer];
this.buffer = [];
this.inFlightEntries = entriesToFlush;
this.flushPromise = (async () => {
const entriesToPersist = entriesToFlush.filter(
(entry) => !this.discardedApiKeyIds.has(entry.apiKeyId)
);
const uniqueKeys = new Set(entriesToPersist.map((entry) => entry.apiKeyId)).size;
try {
if (entriesToPersist.length > 0) {
await this.persistEntries(entriesToPersist);
}
this.logger.log(
`[SpendWriter] Flushed ${entriesToPersist.length} cost entr${
entriesToPersist.length === 1 ? "y" : "ies"
} across ${uniqueKeys} key(s)`
);
return {
flushedEntries: entriesToPersist.length,
uniqueKeys,
requeued: false,
};
} catch (error) {
this.buffer = [...entriesToPersist, ...this.buffer];
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`[SpendWriter] Flush error: ${message}`);
return {
flushedEntries: 0,
uniqueKeys,
requeued: true,
};
} finally {
this.inFlightEntries = [];
this.flushPromise = null;
}
})();
return this.flushPromise;
}
async stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
this.started = false;
return this.flush();
}
resetForTests() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
this.started = false;
this.buffer = [];
this.inFlightEntries = [];
this.discardedApiKeyIds.clear();
this.flushPromise = null;
}
}
export const spendBatchWriter = new SpendBatchWriter();
export function startSpendBatchWriter() {
spendBatchWriter.start();
}
export async function flushSpendBatchWriter() {
return spendBatchWriter.flush();
}
export async function stopSpendBatchWriter() {
return spendBatchWriter.stop();
}
export function resetSpendBatchWriterForTests() {
spendBatchWriter.resetForTests();
}
export function discardSpendBatchEntries(apiKeyId: string) {
spendBatchWriter.discardEntries(apiKeyId);
}