From 75f34ff1273bf8694b16a9308e4593d54ff6ee7b Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 25 Feb 2026 16:17:06 -0800 Subject: [PATCH] Stub cache --- server/lib/billing/usageService.ts | 2 +- server/lib/cache.ts | 107 +------- server/private/lib/cache.ts | 266 +++++++++++++++++++ server/private/lib/certificates.ts | 2 +- server/private/lib/logAccessAudit.ts | 2 +- server/private/middlewares/logActionAudit.ts | 2 +- server/routers/badger/logRequestAudit.ts | 2 +- server/routers/badger/verifySession.ts | 2 +- server/routers/newt/handleSocketMessages.ts | 2 +- server/routers/org/updateOrg.ts | 2 +- server/routers/site/listSites.ts | 2 +- server/routers/site/socketIntegration.ts | 2 +- server/routers/user/inviteUser.ts | 2 +- 13 files changed, 278 insertions(+), 117 deletions(-) create mode 100644 server/private/lib/cache.ts diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index 74241a4c..9cb24bbe 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -12,7 +12,7 @@ import { import { FeatureId, getFeatureMeterId } from "./features"; import logger from "@server/logger"; import { build } from "@server/build"; -import cache from "@server/lib/cache"; +import cache from "#dynamic/lib/cache"; export function noop() { if (build !== "saas") { diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 1d8c2453..f089a638 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -1,6 +1,5 @@ import NodeCache from "node-cache"; import logger from "@server/logger"; -import { redisManager } from "@server/private/lib/redis"; // Create local cache with maxKeys limit to prevent memory leaks // With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient @@ -23,10 +22,6 @@ setInterval(() => { * otherwise falls back to local memory cache for single-node deployments. */ class AdaptiveCache { - private useRedis(): boolean { - return redisManager.isRedisEnabled() && redisManager.getHealthStatus().isHealthy; - } - /** * Set a value in the cache * @param key - Cache key @@ -37,24 +32,6 @@ class AdaptiveCache { async set(key: string, value: any, ttl?: number): Promise { const effectiveTtl = ttl === 0 ? undefined : ttl; - if (this.useRedis()) { - try { - const serialized = JSON.stringify(value); - const success = await redisManager.set(key, serialized, effectiveTtl); - - if (success) { - logger.debug(`Set key in Redis: ${key}`); - return true; - } - - // Redis failed, fall through to local cache - logger.debug(`Redis set failed for key ${key}, falling back to local cache`); - } catch (error) { - logger.error(`Redis set error for key ${key}:`, error); - // Fall through to local cache - } - } - // Use local cache as fallback or primary const success = localCache.set(key, value, effectiveTtl || 0); if (success) { @@ -69,23 +46,6 @@ class AdaptiveCache { * @returns The cached value or undefined if not found */ async get(key: string): Promise { - if (this.useRedis()) { - try { - const value = await redisManager.get(key); - - if (value !== null) { - logger.debug(`Cache hit in Redis: ${key}`); - return JSON.parse(value) as T; - } - - logger.debug(`Cache miss in Redis: ${key}`); - return undefined; - } catch (error) { - logger.error(`Redis get error for key ${key}:`, error); - // Fall through to local cache - } - } - // Use local cache as fallback or primary const value = localCache.get(key); if (value !== undefined) { @@ -105,29 +65,6 @@ class AdaptiveCache { const keys = Array.isArray(key) ? key : [key]; let deletedCount = 0; - if (this.useRedis()) { - try { - for (const k of keys) { - const success = await redisManager.del(k); - if (success) { - deletedCount++; - logger.debug(`Deleted key from Redis: ${k}`); - } - } - - if (deletedCount === keys.length) { - return deletedCount; - } - - // Some Redis deletes failed, fall through to local cache - logger.debug(`Some Redis deletes failed, falling back to local cache`); - } catch (error) { - logger.error(`Redis del error for keys ${keys.join(", ")}:`, error); - // Fall through to local cache - deletedCount = 0; - } - } - // Use local cache as fallback or primary for (const k of keys) { const success = localCache.del(k); @@ -146,16 +83,6 @@ class AdaptiveCache { * @returns boolean indicating if key exists */ async has(key: string): Promise { - if (this.useRedis()) { - try { - const value = await redisManager.get(key); - return value !== null; - } catch (error) { - logger.error(`Redis has error for key ${key}:`, error); - // Fall through to local cache - } - } - // Use local cache as fallback or primary return localCache.has(key); } @@ -166,26 +93,6 @@ class AdaptiveCache { * @returns Array of values (undefined for missing keys) */ async mget(keys: string[]): Promise<(T | undefined)[]> { - if (this.useRedis()) { - try { - const results: (T | undefined)[] = []; - - for (const key of keys) { - const value = await redisManager.get(key); - if (value !== null) { - results.push(JSON.parse(value) as T); - } else { - results.push(undefined); - } - } - - return results; - } catch (error) { - logger.error(`Redis mget error:`, error); - // Fall through to local cache - } - } - // Use local cache as fallback or primary return keys.map((key) => localCache.get(key)); } @@ -194,10 +101,6 @@ class AdaptiveCache { * Flush all keys from the cache */ async flushAll(): Promise { - if (this.useRedis()) { - logger.warn("Adaptive cache flushAll called - Redis flush not implemented, only local cache will be flushed"); - } - localCache.flushAll(); logger.debug("Flushed local cache"); } @@ -215,7 +118,7 @@ class AdaptiveCache { * @returns "redis" if Redis is available and healthy, "local" otherwise */ getCurrentBackend(): "redis" | "local" { - return this.useRedis() ? "redis" : "local"; + return "local"; } /** @@ -237,11 +140,6 @@ class AdaptiveCache { * @returns TTL in seconds, 0 if no expiration, -1 if key doesn't exist */ getTtl(key: string): number { - // Note: This only works for local cache, Redis TTL is not supported - if (this.useRedis()) { - logger.warn(`getTtl called for key ${key} but Redis TTL lookup is not implemented`); - } - const ttl = localCache.getTtl(key); if (ttl === undefined) { return -1; @@ -254,9 +152,6 @@ class AdaptiveCache { * Note: Only returns local cache keys, Redis keys are not included */ keys(): string[] { - if (this.useRedis()) { - logger.warn("keys() called but Redis keys are not included, only local cache keys returned"); - } return localCache.keys(); } } diff --git a/server/private/lib/cache.ts b/server/private/lib/cache.ts new file mode 100644 index 00000000..1d8c2453 --- /dev/null +++ b/server/private/lib/cache.ts @@ -0,0 +1,266 @@ +import NodeCache from "node-cache"; +import logger from "@server/logger"; +import { redisManager } from "@server/private/lib/redis"; + +// Create local cache with maxKeys limit to prevent memory leaks +// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient +export const localCache = new NodeCache({ + stdTTL: 3600, + checkperiod: 120, + maxKeys: 10000 +}); + +// Log cache statistics periodically for monitoring +setInterval(() => { + const stats = localCache.getStats(); + logger.debug( + `Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%` + ); +}, 300000); // Every 5 minutes + +/** + * Adaptive cache that uses Redis when available in multi-node environments, + * otherwise falls back to local memory cache for single-node deployments. + */ +class AdaptiveCache { + private useRedis(): boolean { + return redisManager.isRedisEnabled() && redisManager.getHealthStatus().isHealthy; + } + + /** + * Set a value in the cache + * @param key - Cache key + * @param value - Value to cache (will be JSON stringified for Redis) + * @param ttl - Time to live in seconds (0 = no expiration) + * @returns boolean indicating success + */ + async set(key: string, value: any, ttl?: number): Promise { + const effectiveTtl = ttl === 0 ? undefined : ttl; + + if (this.useRedis()) { + try { + const serialized = JSON.stringify(value); + const success = await redisManager.set(key, serialized, effectiveTtl); + + if (success) { + logger.debug(`Set key in Redis: ${key}`); + return true; + } + + // Redis failed, fall through to local cache + logger.debug(`Redis set failed for key ${key}, falling back to local cache`); + } catch (error) { + logger.error(`Redis set error for key ${key}:`, error); + // Fall through to local cache + } + } + + // Use local cache as fallback or primary + const success = localCache.set(key, value, effectiveTtl || 0); + if (success) { + logger.debug(`Set key in local cache: ${key}`); + } + return success; + } + + /** + * Get a value from the cache + * @param key - Cache key + * @returns The cached value or undefined if not found + */ + async get(key: string): Promise { + if (this.useRedis()) { + try { + const value = await redisManager.get(key); + + if (value !== null) { + logger.debug(`Cache hit in Redis: ${key}`); + return JSON.parse(value) as T; + } + + logger.debug(`Cache miss in Redis: ${key}`); + return undefined; + } catch (error) { + logger.error(`Redis get error for key ${key}:`, error); + // Fall through to local cache + } + } + + // Use local cache as fallback or primary + const value = localCache.get(key); + if (value !== undefined) { + logger.debug(`Cache hit in local cache: ${key}`); + } else { + logger.debug(`Cache miss in local cache: ${key}`); + } + return value; + } + + /** + * Delete a value from the cache + * @param key - Cache key or array of keys + * @returns Number of deleted entries + */ + async del(key: string | string[]): Promise { + const keys = Array.isArray(key) ? key : [key]; + let deletedCount = 0; + + if (this.useRedis()) { + try { + for (const k of keys) { + const success = await redisManager.del(k); + if (success) { + deletedCount++; + logger.debug(`Deleted key from Redis: ${k}`); + } + } + + if (deletedCount === keys.length) { + return deletedCount; + } + + // Some Redis deletes failed, fall through to local cache + logger.debug(`Some Redis deletes failed, falling back to local cache`); + } catch (error) { + logger.error(`Redis del error for keys ${keys.join(", ")}:`, error); + // Fall through to local cache + deletedCount = 0; + } + } + + // Use local cache as fallback or primary + for (const k of keys) { + const success = localCache.del(k); + if (success > 0) { + deletedCount++; + logger.debug(`Deleted key from local cache: ${k}`); + } + } + + return deletedCount; + } + + /** + * Check if a key exists in the cache + * @param key - Cache key + * @returns boolean indicating if key exists + */ + async has(key: string): Promise { + if (this.useRedis()) { + try { + const value = await redisManager.get(key); + return value !== null; + } catch (error) { + logger.error(`Redis has error for key ${key}:`, error); + // Fall through to local cache + } + } + + // Use local cache as fallback or primary + return localCache.has(key); + } + + /** + * Get multiple values from the cache + * @param keys - Array of cache keys + * @returns Array of values (undefined for missing keys) + */ + async mget(keys: string[]): Promise<(T | undefined)[]> { + if (this.useRedis()) { + try { + const results: (T | undefined)[] = []; + + for (const key of keys) { + const value = await redisManager.get(key); + if (value !== null) { + results.push(JSON.parse(value) as T); + } else { + results.push(undefined); + } + } + + return results; + } catch (error) { + logger.error(`Redis mget error:`, error); + // Fall through to local cache + } + } + + // Use local cache as fallback or primary + return keys.map((key) => localCache.get(key)); + } + + /** + * Flush all keys from the cache + */ + async flushAll(): Promise { + if (this.useRedis()) { + logger.warn("Adaptive cache flushAll called - Redis flush not implemented, only local cache will be flushed"); + } + + localCache.flushAll(); + logger.debug("Flushed local cache"); + } + + /** + * Get cache statistics + * Note: Only returns local cache stats, Redis stats are not included + */ + getStats() { + return localCache.getStats(); + } + + /** + * Get the current cache backend being used + * @returns "redis" if Redis is available and healthy, "local" otherwise + */ + getCurrentBackend(): "redis" | "local" { + return this.useRedis() ? "redis" : "local"; + } + + /** + * Take a key from the cache and delete it + * @param key - Cache key + * @returns The value or undefined if not found + */ + async take(key: string): Promise { + const value = await this.get(key); + if (value !== undefined) { + await this.del(key); + } + return value; + } + + /** + * Get TTL (time to live) for a key + * @param key - Cache key + * @returns TTL in seconds, 0 if no expiration, -1 if key doesn't exist + */ + getTtl(key: string): number { + // Note: This only works for local cache, Redis TTL is not supported + if (this.useRedis()) { + logger.warn(`getTtl called for key ${key} but Redis TTL lookup is not implemented`); + } + + const ttl = localCache.getTtl(key); + if (ttl === undefined) { + return -1; + } + return Math.max(0, Math.floor((ttl - Date.now()) / 1000)); + } + + /** + * Get all keys from the cache + * Note: Only returns local cache keys, Redis keys are not included + */ + keys(): string[] { + if (this.useRedis()) { + logger.warn("keys() called but Redis keys are not included, only local cache keys returned"); + } + return localCache.keys(); + } +} + +// Export singleton instance +export const cache = new AdaptiveCache(); +export default cache; diff --git a/server/private/lib/certificates.ts b/server/private/lib/certificates.ts index c113ddd9..5f0f035d 100644 --- a/server/private/lib/certificates.ts +++ b/server/private/lib/certificates.ts @@ -17,7 +17,7 @@ import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm"; import { decryptData } from "@server/lib/encryption"; import * as fs from "fs"; import logger from "@server/logger"; -import cache from "@server/lib/cache"; +import cache from "#dynamic/lib/cache"; let encryptionKeyHex = ""; let encryptionKey: Buffer; diff --git a/server/private/lib/logAccessAudit.ts b/server/private/lib/logAccessAudit.ts index 88e553ad..e2ea67b3 100644 --- a/server/private/lib/logAccessAudit.ts +++ b/server/private/lib/logAccessAudit.ts @@ -15,7 +15,7 @@ import { accessAuditLog, logsDb, db, orgs } from "@server/db"; import { getCountryCodeForIp } from "@server/lib/geoip"; import logger from "@server/logger"; import { and, eq, lt } from "drizzle-orm"; -import cache from "@server/lib/cache"; +import cache from "#dynamic/lib/cache"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; import { stripPortFromHost } from "@server/lib/ip"; diff --git a/server/private/middlewares/logActionAudit.ts b/server/private/middlewares/logActionAudit.ts index d0474dc3..bf5ebdd4 100644 --- a/server/private/middlewares/logActionAudit.ts +++ b/server/private/middlewares/logActionAudit.ts @@ -18,7 +18,7 @@ import HttpCode from "@server/types/HttpCode"; import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import { and, eq, lt } from "drizzle-orm"; -import cache from "@server/lib/cache"; +import cache from "#dynamic/lib/cache"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; async function getActionDays(orgId: string): Promise { diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 287cb030..1e36bd4d 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -1,7 +1,7 @@ import { logsDb, primaryLogsDb, db, orgs, requestAuditLog } from "@server/db"; import logger from "@server/logger"; import { and, eq, lt, sql } from "drizzle-orm"; -import cache from "@server/lib/cache"; +import cache from "#dynamic/lib/cache"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; import { stripPortFromHost } from "@server/lib/ip"; diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 6d537d52..e99052cd 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -37,7 +37,7 @@ import { enforceResourceSessionLength } from "#dynamic/lib/checkOrgAccessPolicy"; import { logRequestAudit } from "./logRequestAudit"; -import { localCache } from "@server/lib/cache"; +import { localCache } from "#dynamic/lib/cache"; import { APP_VERSION } from "@server/lib/consts"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; diff --git a/server/routers/newt/handleSocketMessages.ts b/server/routers/newt/handleSocketMessages.ts index 2dd10008..383ab554 100644 --- a/server/routers/newt/handleSocketMessages.ts +++ b/server/routers/newt/handleSocketMessages.ts @@ -2,7 +2,7 @@ import { MessageHandler } from "@server/routers/ws"; import logger from "@server/logger"; import { Newt } from "@server/db"; import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint"; -import cache from "@server/lib/cache"; +import cache from "#dynamic/lib/cache"; export const handleDockerStatusMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 5664ee9c..5049ac1f 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -10,7 +10,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { build } from "@server/build"; -import { cache } from "@server/lib/cache"; +import { cache } from "#dynamic/lib/cache"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { getOrgTierData } from "#dynamic/lib/billing"; diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index e5685a5a..14f3024d 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -8,7 +8,7 @@ import { sites, userSites } from "@server/db"; -import cache from "@server/lib/cache"; +import cache from "#dynamic/lib/cache"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; diff --git a/server/routers/site/socketIntegration.ts b/server/routers/site/socketIntegration.ts index 6a72a5d4..fe6e7b95 100644 --- a/server/routers/site/socketIntegration.ts +++ b/server/routers/site/socketIntegration.ts @@ -11,7 +11,7 @@ import { fromError } from "zod-validation-error"; import stoi from "@server/lib/stoi"; import { sendToClient } from "#dynamic/routers/ws"; import { fetchContainers, dockerSocket } from "../newt/dockerSocket"; -import cache from "@server/lib/cache"; +import cache from "#dynamic/lib/cache"; export interface ContainerNetwork { networkId: string; diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 26fa8e55..b7fe615a 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -19,7 +19,7 @@ import { UserType } from "@server/types/UserTypes"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; import { build } from "@server/build"; -import cache from "@server/lib/cache"; +import cache from "#dynamic/lib/cache"; const inviteUserParamsSchema = z.strictObject({ orgId: z.string()