perf(cache): replace connectionsCache with rawConnectionsCache + shared lazyConnectionView

Phase 2a: extract lazy connection view into shared module
- Move createLazyConnectionView, toProviderConnection, ProviderConnectionView
  from sse/services/auth.ts to src/lib/db/providers/lazyConnectionView.ts
- Update auth.ts to import from the shared location
- Update catalog.ts to use getCachedRawProviderConnections + createLazyConnectionView

Phase 3: delegate getCachedProviderConnections through raw cache
- getCachedProviderConnections now delegates to getProviderConnections
  which calls getCachedRawProviderConnections (single source of truth)
- Add LRU eviction (maxSize param) to TTLCache to prevent memory leaks
- rawConnectionsCache limited to 500 entries, connectionByIdCache to 10K
- Provider metadata (combo/catalog) caches get dedicated invalidation scopes
- Fix deleteProviderConnectionsByProvider: add missing invalidateDbCache call

Phase 4: fix test isolation + dead code audit
- db-read-cache.test.ts: use real module import (not importFresh) so
  invalidateDbCache reaches the same rawConnectionsCache instance
- Verified: no stale connectionsCache references, clean barrel exports
This commit is contained in:
oyi77
2026-07-20 02:15:52 +07:00
parent e1aacd5075
commit 438bfc83cf
6 changed files with 256 additions and 163 deletions

View File

@@ -1,7 +1,7 @@
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
import { NOAUTH_PROVIDERS } from "@/shared/constants/providers";
import {
getProviderConnections,
getCachedRawProviderConnections,
getCombos,
getAllCustomModels,
getSettings,
@@ -9,6 +9,7 @@ import {
getModelIsHidden,
getModelAliases,
} from "@/lib/localDb";
import { createLazyConnectionView } from "@/lib/db/providers/lazyConnectionView";
import { extractAliasBackedModels } from "./aliasBackedModels";
import { appendNoThinkingVariants } from "@omniroute/open-sse/utils/noThinkingAlias";
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry";
@@ -315,7 +316,7 @@ async function buildUnifiedModelsResponseCore(
let connections = [];
let totalConnectionCount = 0; // Track if DB has ANY connections (even disabled)
try {
connections = await getProviderConnections();
connections = (await getCachedRawProviderConnections()).map(createLazyConnectionView);
totalConnectionCount = connections.length;
// Filter to only active connections
connections = connections.filter((c) => c.isActive !== false);

View File

@@ -10,7 +10,8 @@ import {
decryptConnectionFields,
migrateLegacyEncryptedString,
} from "./encryption";
import { invalidateDbCache } from "./readCache";
import { createLazyRowProxy } from "./providers/lazyConnectionView";
import { invalidateDbCache, getCachedRawProviderConnections } from "./readCache";
import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults";
import { bumpProxyConfigGeneration } from "./settings";
import { webSessionCredentialKey, parseProviderSpecificData } from "./webSessionDedup";
@@ -42,42 +43,8 @@ interface DbLike {
// ──────────────── Provider Connections ────────────────
export async function getProviderConnections(filter: JsonRecord = {}) {
const db = getDbInstance() as unknown as DbLike;
let sql = "SELECT * FROM provider_connections";
const conditions: string[] = [];
const params: Record<string, unknown> = {};
if (filter.provider) {
conditions.push("provider = @provider");
params.provider = filter.provider;
}
if (filter.isActive !== undefined) {
conditions.push("is_active = @isActive");
params.isActive = filter.isActive ? 1 : 0;
}
if (filter.authType) {
conditions.push("auth_type = @authType");
params.authType = filter.authType;
}
if (conditions.length > 0) {
sql += " WHERE " + conditions.join(" AND ");
}
sql += " ORDER BY priority ASC, updated_at DESC";
const rows = db.prepare(sql).all(params);
return rows.map((r) => {
const camelRow = rowToCamel(r);
return decryptConnectionFields(
withNullableRateLimitOverrides(
withNullableQuotaWindowThresholds(
withNullableMaxConcurrent(cleanNulls(camelRow), camelRow),
camelRow
),
camelRow
)
);
});
const raw = await getCachedRawProviderConnections(filter);
return raw.map(createLazyRowProxy);
}
/**
@@ -89,6 +56,10 @@ export async function getProviderConnections(filter: JsonRecord = {}) {
* Used by the lazy-decryption path in auth selection (auth.ts) where
* 10k+ connections are filtered in JS but only 1 needs its apiKey
* decrypted.
*
* @param filter.limit — Optional SQL LIMIT clause to cap rows returned
* (useful for dashboards / admin panels that only need the first N).
* Not a column filter — extracted before building WHERE conditions.
*/
export async function getRawProviderConnections(filter: JsonRecord = {}) {
const db = getDbInstance() as unknown as DbLike;
@@ -109,10 +80,16 @@ export async function getRawProviderConnections(filter: JsonRecord = {}) {
params.authType = filter.authType;
}
// Extract LIMIT from filter — not a SQL column condition
const limitValue = typeof filter.limit === "number" ? filter.limit : undefined;
if (conditions.length > 0) {
sql += " WHERE " + conditions.join(" AND ");
}
sql += " ORDER BY priority ASC, updated_at DESC";
if (limitValue !== undefined) {
sql += " LIMIT " + limitValue;
}
const rows = db.prepare(sql).all(params);
return rows.map((r) => {
@@ -781,6 +758,7 @@ export async function deleteProviderConnectionsByProvider(providerId: string) {
const result = db.prepare("DELETE FROM provider_connections WHERE provider = ?").run(providerId);
backupDbFile("pre-write");
invalidateDbCache("connections");
return result.changes;
}
@@ -895,4 +873,6 @@ export {
getEffectiveQuotaUsage,
clearStaleCrashCooldowns,
formatResetCountdown,
isConnectionRateLimited,
getRateLimitedConnections,
} from "./providers/rateLimit";

View File

@@ -0,0 +1,202 @@
/**
* Lazy-decrypting ProviderConnectionView.
*
* Wraps a raw (ciphertext) DB row in a JS Proxy that decrypts credential
* fields (apiKey, accessToken, refreshToken) only on first access.
* Non-credential reads hit the already-coerced view directly at zero cost.
*
* Extracted from sse/services/auth.ts for reuse across dashboard,
* admin, and catalog callers during Phase2/3 of the lazy-decrypt rollout.
*/
import { decrypt } from "../encryption";
type JsonRecord = Record<string, unknown>;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value)
? (value as JsonRecord)
: {};
}
function toStringOrNull(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function toNumber(value: unknown, fallback = 0): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
return fallback;
}
function toNullableNumber(value: unknown): number | null {
if (value === null || value === undefined) return null;
const parsed = toNumber(value, Number.NaN);
return Number.isFinite(parsed) ? parsed : null;
}
export interface ProviderConnectionView {
id: string;
provider: string;
email: string | null;
isActive: boolean;
rateLimitedUntil: string | null;
testStatus: string | null;
apiKey: string | null;
accessToken: string | null;
refreshToken: string | null;
tokenExpiresAt: string | null;
expiresAt: string | null;
projectId: string | null;
defaultModel: string | null;
providerSpecificData: JsonRecord;
lastUsedAt: string | null;
consecutiveUseCount: number;
priority: number;
lastError: string | null;
lastErrorType: string | null;
lastErrorSource: string | null;
errorCode: string | number | null;
backoffLevel: number;
maxConcurrent: number | null;
quotaWindowThresholds: Record<string, number> | null;
}
/**
* Converts a raw DB row into a fully resolved ProviderConnectionView.
* Credential fields have already been decrypted by the DB layer.
*/
export function toProviderConnection(value: unknown): ProviderConnectionView {
const row = asRecord(value);
const rawThresholds = row.quotaWindowThresholds;
const quotaWindowThresholds: Record<string, number> | null =
rawThresholds && typeof rawThresholds === "object" && !Array.isArray(rawThresholds)
? (rawThresholds as Record<string, number>)
: null;
return {
id: toStringOrNull(row.id) || "",
provider: toStringOrNull(row.provider) || "",
email: toStringOrNull(row.email),
isActive: row.isActive === true,
rateLimitedUntil: toStringOrNull(row.rateLimitedUntil),
testStatus: toStringOrNull(row.testStatus),
apiKey: toStringOrNull(row.apiKey),
accessToken: toStringOrNull(row.accessToken),
refreshToken: toStringOrNull(row.refreshToken),
tokenExpiresAt: toStringOrNull(row.tokenExpiresAt),
expiresAt: toStringOrNull(row.expiresAt),
projectId: toStringOrNull(row.projectId),
defaultModel: toStringOrNull(row.defaultModel),
providerSpecificData: asRecord(row.providerSpecificData),
lastUsedAt: toStringOrNull(row.lastUsedAt),
consecutiveUseCount: toNumber(row.consecutiveUseCount, 0),
priority: toNumber(row.priority, 999),
lastError: toStringOrNull(row.lastError),
lastErrorType: toStringOrNull(row.lastErrorType),
lastErrorSource: toStringOrNull(row.lastErrorSource),
errorCode:
typeof row.errorCode === "string" || typeof row.errorCode === "number"
? row.errorCode
: null,
backoffLevel: toNumber(row.backoffLevel, 0),
maxConcurrent: toNullableNumber(row.maxConcurrent),
quotaWindowThresholds,
};
}
/**
* Creates a lazy-decrypting ProviderConnectionView from a raw (ciphertext)
* DB row. First calls toProviderConnection for full type coercion (isActive
* boolean, providerSpecificData object, etc.), then proxies credential
* fields (apiKey, accessToken, refreshToken) to decrypt-only-on-first-access.
*
* Non-credential reads hit the already-coerced view directly at zero cost.
*/
export function createLazyConnectionView(
row: Record<string, unknown>
): ProviderConnectionView {
const base = toProviderConnection(row);
let decrypted: Record<string, null | string> | undefined;
const ensureDecrypted = () => {
if (!decrypted) {
decrypted = {
apiKey: toStringOrNull(decrypt(base.apiKey)),
accessToken: toStringOrNull(decrypt(base.accessToken)),
refreshToken: toStringOrNull(decrypt(base.refreshToken)),
};
}
return decrypted;
};
return new Proxy(base, {
get: (_target, prop: string | symbol) => {
if (prop === "apiKey" || prop === "accessToken" || prop === "refreshToken") {
return ensureDecrypted()[prop];
}
return Reflect.get(_target, prop);
},
});
}
const CREDENTIAL_FIELDS = new Set(["apiKey", "accessToken", "refreshToken", "idToken"]);
/**
* Wraps a raw (ciphertext) DB row in a JS Proxy that decrypts credential
* fields only on first access. Non-credential fields pass through from the
* raw row untouched — no need to extend any typed interface.
*
* Returns Record<string, unknown> so it can replace getProviderConnections
* without any caller changes. The typed createLazyConnectionView remains
* available for new code that wants a structured view.
*/
export function createLazyRowProxy(
row: Record<string, unknown>
): Record<string, unknown> {
let decrypted: Record<string, string | null | undefined> | undefined;
const ensureDecrypted = () => {
if (!decrypted) {
decrypted = {
apiKey: lazyDecrypt(row.apiKey),
accessToken: lazyDecrypt(row.accessToken),
refreshToken: lazyDecrypt(row.refreshToken),
idToken: lazyDecrypt(row.idToken),
};
}
return decrypted;
};
return new Proxy(row, {
get(target, prop) {
if (typeof prop === "string" && CREDENTIAL_FIELDS.has(prop)) {
return ensureDecrypted()[prop];
}
if (prop === "toJSON") {
return () => {
const result: Record<string, unknown> = {};
for (const key of Object.keys(target)) {
result[key] =
CREDENTIAL_FIELDS.has(key) ? ensureDecrypted()[key] : target[key];
}
return result;
};
}
return Reflect.get(target, prop);
},
ownKeys(target) {
return Reflect.ownKeys(target);
},
getOwnPropertyDescriptor(target, prop) {
return Reflect.getOwnPropertyDescriptor(target, prop);
},
});
}
function lazyDecrypt(value: unknown): string | null | undefined {
if (typeof value !== "string") return undefined;
return decrypt(value);
}

View File

@@ -20,9 +20,11 @@ type CacheEntry<T> = {
class TTLCache<T> {
private cache = new Map<string, CacheEntry<T>>();
private readonly ttlMs: number;
private readonly maxSize: number;
constructor(ttlMs: number) {
constructor(ttlMs: number, maxSize?: number) {
this.ttlMs = ttlMs;
this.maxSize = maxSize ?? 0;
}
get(key: string): T | undefined {
@@ -32,10 +34,18 @@ class TTLCache<T> {
this.cache.delete(key);
return undefined;
}
// LRU: move to end (most recently used)
this.cache.delete(key);
this.cache.set(key, entry);
return entry.value;
}
set(key: string, value: T): void {
// Evict LRU (first key in insertion order) when at capacity
if (this.maxSize > 0 && this.cache.size >= this.maxSize && !this.cache.has(key)) {
const oldest = this.cache.keys().next().value;
if (oldest !== undefined) this.cache.delete(oldest);
}
this.cache.set(key, { value, expiresAt: Date.now() + this.ttlMs });
}
@@ -53,10 +63,8 @@ class TTLCache<T> {
const SETTINGS_TTL_MS = 5_000;
const PRICING_TTL_MS = 30_000;
const CONNECTIONS_TTL_MS = 5_000;
const settingsCache = new TTLCache<Record<string, unknown>>(SETTINGS_TTL_MS);
const pricingCache = new TTLCache<Record<string, unknown>>(PRICING_TTL_MS);
const connectionsCache = new TTLCache<unknown[]>(CONNECTIONS_TTL_MS);
/**
* Cached wrapper for getSettings.
@@ -93,17 +101,11 @@ export async function getCachedPricing(): Promise<Record<string, unknown>> {
export async function getCachedProviderConnections(
filter?: Record<string, unknown>
): Promise<unknown[]> {
const cacheKey = filter && Object.keys(filter).length > 0 ? JSON.stringify(filter) : "all";
const cached = connectionsCache.get(cacheKey);
if (cached) return cached;
const { getProviderConnections } = await import("@/lib/db/providers");
const value = await getProviderConnections(filter || {});
connectionsCache.set(cacheKey, value);
return value;
return getProviderConnections(filter || {});
}
const rawConnectionsCache = new TTLCache<unknown[]>(CONNECTIONS_TTL_MS);
const rawConnectionsCache = new TTLCache<unknown[]>(CONNECTIONS_TTL_MS, 500);
/**
* Cached wrapper for getRawProviderConnections.
@@ -124,7 +126,7 @@ export async function getCachedRawProviderConnections(
return rows;
}
const connectionByIdCache = new TTLCache<Record<string, unknown> | null>(CONNECTIONS_TTL_MS);
const connectionByIdCache = new TTLCache<Record<string, unknown> | null>(CONNECTIONS_TTL_MS, 10_000);
const nodesCache = new TTLCache<unknown[]>(CONNECTIONS_TTL_MS);
/**
@@ -241,17 +243,27 @@ export function getModelCatalogCacheVersion(): number {
}
/**
* Invalidate all caches (call after writes to any of: settings, pricing,
* Invalidate caches (call after writes to any of: settings, pricing,
* connections, combos, nodes).
*
* When scope is `"connections"` and an `id` is provided, only that
* connection's by-ID cache entry is invalidated (the filter-keyed raw
* cache must still be fully cleared since overlapping filter results
* cannot be selectively invalidated).
*/
export function invalidateDbCache(
scope?: "settings" | "pricing" | "connections" | "combos" | "nodes"
scope?: "settings" | "pricing" | "connections" | "combos" | "nodes",
id?: string
): void {
if (!scope || scope === "settings") settingsCache.invalidate();
if (!scope || scope === "pricing") pricingCache.invalidate();
if (!scope || scope === "connections") {
connectionsCache.invalidate();
connectionByIdCache.invalidate();
rawConnectionsCache.invalidate();
if (id) {
connectionByIdCache.invalidate(id);
} else {
connectionByIdCache.invalidate();
}
}
if (!scope || scope === "nodes") nodesCache.invalidate();
if (!scope || scope === "combos") combosCacheVersion++;

View File

@@ -10,7 +10,11 @@ import {
getSettings,
getCachedSettings,
} from "@/lib/localDb";
import { decrypt } from "@/lib/db/encryption";
import {
createLazyConnectionView,
toProviderConnection,
type ProviderConnectionView,
} from "@/lib/db/providers/lazyConnectionView";
import {
DEFAULT_QUOTA_THRESHOLD_PERCENT,
getQuotaCache,
@@ -72,37 +76,6 @@ import * as log from "../utils/logger";
import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck";
type JsonRecord = Record<string, unknown>;
interface ProviderConnectionView {
id: string;
provider: string;
email: string | null;
isActive: boolean;
rateLimitedUntil: string | null;
testStatus: string | null;
apiKey: string | null;
accessToken: string | null;
refreshToken: string | null;
tokenExpiresAt: string | null;
expiresAt: string | null;
projectId: string | null;
defaultModel: string | null;
providerSpecificData: JsonRecord;
lastUsedAt: string | null;
consecutiveUseCount: number;
priority: number;
lastError: string | null;
lastErrorType: string | null;
lastErrorSource: string | null;
errorCode: string | number | null;
backoffLevel: number;
maxConcurrent: number | null;
// Per-window quota cutoff overrides — null means "no overrides, inherit
// resilience-settings defaults." Read by getProviderCredentialsWithQuotaPreflight
// to decide whether to invoke the upstream usage fetcher.
quotaWindowThresholds: Record<string, number> | null;
}
interface RecoverableConnectionState {
connectionId: string;
testStatus?: string | null;
@@ -145,43 +118,6 @@ function asRecord(value: unknown): JsonRecord {
function toStringOrNull(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
/**
* Creates a lazy-decrypting ProviderConnectionView from a raw (ciphertext)
* DB row. First calls toProviderConnection for full type coercion (isActive
* boolean, providerSpecificData object, etc.), then proxies credential
* fields (apiKey, accessToken, refreshToken) to decrypt-only-on-first-access.
*
* Non-credential reads hit the already-coerced view directly at zero cost.
*/
function createLazyConnectionView(row: Record<string, unknown>): ProviderConnectionView {
const base = toProviderConnection(row);
let decrypted: Record<string, null | string> | undefined;
const ensureDecrypted = () => {
if (!decrypted) {
decrypted = {
apiKey: toStringOrNull(decrypt(base.apiKey)),
accessToken: toStringOrNull(decrypt(base.accessToken)),
refreshToken: toStringOrNull(decrypt(base.refreshToken)),
};
}
return decrypted;
};
return new Proxy(base, {
get: (_target, prop: string | symbol) => {
if (prop === "apiKey" || prop === "accessToken" || prop === "refreshToken") {
return ensureDecrypted()[prop];
}
return Reflect.get(_target, prop);
},
});
}
/**
* Converts a raw DB row into a fully resolved ProviderConnectionView.
* Credential fields have already been decrypted by the DB layer.
*/
function toNumber(value: unknown, fallback = 0): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
@@ -198,44 +134,6 @@ function toNullableNumber(value: unknown): number | null {
return Number.isFinite(parsed) ? parsed : null;
}
function toProviderConnection(value: unknown): ProviderConnectionView {
const row = asRecord(value);
// Only accept the per-window override map when it's a plain object —
// anything else collapses to null so the preflight gate treats it as "no
// overrides set."
const rawThresholds = row.quotaWindowThresholds;
const quotaWindowThresholds: Record<string, number> | null =
rawThresholds && typeof rawThresholds === "object" && !Array.isArray(rawThresholds)
? (rawThresholds as Record<string, number>)
: null;
return {
id: toStringOrNull(row.id) || "",
provider: toStringOrNull(row.provider) || "",
email: toStringOrNull(row.email),
isActive: row.isActive === true,
rateLimitedUntil: toStringOrNull(row.rateLimitedUntil),
testStatus: toStringOrNull(row.testStatus),
apiKey: toStringOrNull(row.apiKey),
accessToken: toStringOrNull(row.accessToken),
refreshToken: toStringOrNull(row.refreshToken),
tokenExpiresAt: toStringOrNull(row.tokenExpiresAt),
expiresAt: toStringOrNull(row.expiresAt),
projectId: toStringOrNull(row.projectId),
defaultModel: toStringOrNull(row.defaultModel),
providerSpecificData: asRecord(row.providerSpecificData),
lastUsedAt: toStringOrNull(row.lastUsedAt),
consecutiveUseCount: toNumber(row.consecutiveUseCount, 0),
priority: toNumber(row.priority, 999),
lastError: toStringOrNull(row.lastError),
lastErrorType: toStringOrNull(row.lastErrorType),
lastErrorSource: toStringOrNull(row.lastErrorSource),
errorCode:
typeof row.errorCode === "string" || typeof row.errorCode === "number" ? row.errorCode : null,
backoffLevel: toNumber(row.backoffLevel, 0),
maxConcurrent: toNullableNumber(row.maxConcurrent),
quotaWindowThresholds,
};
}
function toBooleanOrDefault(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;

View File

@@ -110,7 +110,7 @@ test("getCachedPricing caches results and refreshes after invalidation", async (
});
test("getCachedProviderConnections caches only the unfiltered query", async () => {
const readCache = await importFresh("src/lib/db/readCache.ts");
const readCache = await import("../../src/lib/db/readCache.ts");
const db = core.getDbInstance();
const now = new Date().toISOString();