diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index d7299284..74241a4c 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -230,7 +230,7 @@ export class UsageService { const orgIdToUse = await this.getBillingOrg(orgId); const cacheKey = `customer_${orgIdToUse}_${featureId}`; - const cached = cache.get(cacheKey); + const cached = await cache.get(cacheKey); if (cached) { return cached; @@ -253,7 +253,7 @@ export class UsageService { const customerId = customer.customerId; // Cache the result - cache.set(cacheKey, customerId, 300); // 5 minute TTL + await cache.set(cacheKey, customerId, 300); // 5 minute TTL return customerId; } catch (error) { diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 4910d945..51222f23 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -1,9 +1,10 @@ import NodeCache from "node-cache"; import logger from "@server/logger"; +import { redisManager } from "@server/private/lib/redis"; -// Create cache with maxKeys limit to prevent memory leaks +// 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 cache = new NodeCache({ +const localCache = new NodeCache({ stdTTL: 3600, checkperiod: 120, maxKeys: 10000 @@ -11,10 +12,255 @@ export const cache = new NodeCache({ // Log cache statistics periodically for monitoring setInterval(() => { - const stats = cache.getStats(); + const stats = localCache.getStats(); logger.debug( - `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}%` + `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 -export default cache; +/** + * 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; \ No newline at end of file diff --git a/server/private/lib/certificates.ts b/server/private/lib/certificates.ts index bc1dffcd..c113ddd9 100644 --- a/server/private/lib/certificates.ts +++ b/server/private/lib/certificates.ts @@ -55,7 +55,7 @@ export async function getValidCertificatesForDomains( if (useCache) { for (const domain of domains) { const cacheKey = `cert:${domain}`; - const cachedCert = cache.get(cacheKey); + const cachedCert = await cache.get(cacheKey); if (cachedCert) { finalResults.push(cachedCert); // Valid cache hit } else { @@ -169,7 +169,7 @@ export async function getValidCertificatesForDomains( // Add to cache for future requests, using the *requested domain* as the key if (useCache) { const cacheKey = `cert:${domain}`; - cache.set(cacheKey, resultCert, 180); + await cache.set(cacheKey, resultCert, 180); } } } diff --git a/server/private/lib/logAccessAudit.ts b/server/private/lib/logAccessAudit.ts index 3024283b..88e553ad 100644 --- a/server/private/lib/logAccessAudit.ts +++ b/server/private/lib/logAccessAudit.ts @@ -21,7 +21,7 @@ import { stripPortFromHost } from "@server/lib/ip"; async function getAccessDays(orgId: string): Promise { // check cache first - const cached = cache.get(`org_${orgId}_accessDays`); + const cached = await cache.get(`org_${orgId}_accessDays`); if (cached !== undefined) { return cached; } @@ -39,7 +39,7 @@ async function getAccessDays(orgId: string): Promise { } // store the result in cache - cache.set( + await cache.set( `org_${orgId}_accessDays`, org.settingsLogRetentionDaysAction, 300 @@ -146,14 +146,14 @@ export async function logAccessAudit(data: { async function getCountryCodeFromIp(ip: string): Promise { const geoIpCacheKey = `geoip_access:${ip}`; - let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey); + let cachedCountryCode: string | undefined = await cache.get(geoIpCacheKey); if (!cachedCountryCode) { cachedCountryCode = await getCountryCodeForIp(ip); // do it locally // Only cache successful lookups to avoid filling cache with undefined values if (cachedCountryCode) { // Cache for longer since IP geolocation doesn't change frequently - cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes + await cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes } } diff --git a/server/private/middlewares/logActionAudit.ts b/server/private/middlewares/logActionAudit.ts index ee74725c..d0474dc3 100644 --- a/server/private/middlewares/logActionAudit.ts +++ b/server/private/middlewares/logActionAudit.ts @@ -23,7 +23,7 @@ import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; async function getActionDays(orgId: string): Promise { // check cache first - const cached = cache.get(`org_${orgId}_actionDays`); + const cached = await cache.get(`org_${orgId}_actionDays`); if (cached !== undefined) { return cached; } @@ -41,7 +41,7 @@ async function getActionDays(orgId: string): Promise { } // store the result in cache - cache.set( + await cache.set( `org_${orgId}_actionDays`, org.settingsLogRetentionDaysAction, 300 diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 4075e526..287cb030 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -130,7 +130,7 @@ export async function shutdownAuditLogger() { async function getRetentionDays(orgId: string): Promise { // check cache first - const cached = cache.get(`org_${orgId}_retentionDays`); + const cached = await cache.get(`org_${orgId}_retentionDays`); if (cached !== undefined) { return cached; } @@ -149,7 +149,7 @@ async function getRetentionDays(orgId: string): Promise { } // store the result in cache - cache.set( + await cache.set( `org_${orgId}_retentionDays`, org.settingsLogRetentionDaysRequest, 300 diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index b5c66c0e..472f2c5a 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -137,7 +137,7 @@ export async function verifyResourceSession( headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; org: Org; } - | undefined = cache.get(resourceCacheKey); + | undefined = await cache.get(resourceCacheKey); if (!resourceData) { const result = await getResourceByDomain(cleanHost); @@ -161,7 +161,7 @@ export async function verifyResourceSession( } resourceData = result; - cache.set(resourceCacheKey, resourceData, 5); + await cache.set(resourceCacheKey, resourceData, 5); } const { @@ -405,7 +405,7 @@ export async function verifyResourceSession( // check for HTTP Basic Auth header const clientHeaderAuthKey = `headerAuth:${clientHeaderAuth}`; if (headerAuth && clientHeaderAuth) { - if (cache.get(clientHeaderAuthKey)) { + if (await cache.get(clientHeaderAuthKey)) { logger.debug( "Resource allowed because header auth is valid (cached)" ); @@ -428,7 +428,7 @@ export async function verifyResourceSession( headerAuth.headerAuthHash ) ) { - cache.set(clientHeaderAuthKey, clientHeaderAuth, 5); + await cache.set(clientHeaderAuthKey, clientHeaderAuth, 5); logger.debug("Resource allowed because header auth is valid"); logRequestAudit( @@ -520,7 +520,7 @@ export async function verifyResourceSession( if (resourceSessionToken) { const sessionCacheKey = `session:${resourceSessionToken}`; - let resourceSession: any = cache.get(sessionCacheKey); + let resourceSession: any = await cache.get(sessionCacheKey); if (!resourceSession) { const result = await validateResourceSessionToken( @@ -529,7 +529,7 @@ export async function verifyResourceSession( ); resourceSession = result?.resourceSession; - cache.set(sessionCacheKey, resourceSession, 5); + await cache.set(sessionCacheKey, resourceSession, 5); } if (resourceSession?.isRequestToken) { @@ -662,7 +662,7 @@ export async function verifyResourceSession( }:${resource.resourceId}`; let allowedUserData: BasicUserData | null | undefined = - cache.get(userAccessCacheKey); + await cache.get(userAccessCacheKey); if (allowedUserData === undefined) { allowedUserData = await isUserAllowedToAccessResource( @@ -671,7 +671,7 @@ export async function verifyResourceSession( resourceData.org ); - cache.set(userAccessCacheKey, allowedUserData, 5); + await cache.set(userAccessCacheKey, allowedUserData, 5); } if ( @@ -974,11 +974,11 @@ async function checkRules( ): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> { const ruleCacheKey = `rules:${resourceId}`; - let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey); + let rules: ResourceRule[] | undefined = await cache.get(ruleCacheKey); if (!rules) { rules = await getResourceRules(resourceId); - cache.set(ruleCacheKey, rules, 5); + await cache.set(ruleCacheKey, rules, 5); } if (rules.length === 0) { @@ -1208,13 +1208,13 @@ async function isIpInAsn( async function getAsnFromIp(ip: string): Promise { const asnCacheKey = `asn:${ip}`; - let cachedAsn: number | undefined = cache.get(asnCacheKey); + let cachedAsn: number | undefined = await cache.get(asnCacheKey); if (!cachedAsn) { cachedAsn = await getAsnForIp(ip); // do it locally // Cache for longer since IP ASN doesn't change frequently if (cachedAsn) { - cache.set(asnCacheKey, cachedAsn, 300); // 5 minutes + await cache.set(asnCacheKey, cachedAsn, 300); // 5 minutes } } @@ -1224,14 +1224,14 @@ async function getAsnFromIp(ip: string): Promise { async function getCountryCodeFromIp(ip: string): Promise { const geoIpCacheKey = `geoip:${ip}`; - let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey); + let cachedCountryCode: string | undefined = await cache.get(geoIpCacheKey); if (!cachedCountryCode) { cachedCountryCode = await getCountryCodeForIp(ip); // do it locally // Only cache successful lookups to avoid filling cache with undefined values if (cachedCountryCode) { // Cache for longer since IP geolocation doesn't change frequently - cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes + await cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes } } diff --git a/server/routers/newt/handleSocketMessages.ts b/server/routers/newt/handleSocketMessages.ts index f26f69c9..2dd10008 100644 --- a/server/routers/newt/handleSocketMessages.ts +++ b/server/routers/newt/handleSocketMessages.ts @@ -24,8 +24,8 @@ export const handleDockerStatusMessage: MessageHandler = async (context) => { if (available) { logger.info(`Newt ${newt.newtId} has Docker socket access`); - cache.set(`${newt.newtId}:socketPath`, socketPath, 0); - cache.set(`${newt.newtId}:isAvailable`, available, 0); + await cache.set(`${newt.newtId}:socketPath`, socketPath, 0); + await cache.set(`${newt.newtId}:isAvailable`, available, 0); } else { logger.warn(`Newt ${newt.newtId} does not have Docker socket access`); } @@ -54,7 +54,7 @@ export const handleDockerContainersMessage: MessageHandler = async ( ); if (containers && containers.length > 0) { - cache.set(`${newt.newtId}:dockerContainers`, containers, 0); + await cache.set(`${newt.newtId}:dockerContainers`, containers, 0); } else { logger.warn(`Newt ${newt.newtId} does not have Docker containers`); } diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index e94be3a9..5664ee9c 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -194,9 +194,9 @@ export async function updateOrg( } // invalidate the cache for all of the orgs retention days - cache.del(`org_${orgId}_retentionDays`); - cache.del(`org_${orgId}_actionDays`); - cache.del(`org_${orgId}_accessDays`); + await cache.del(`org_${orgId}_retentionDays`); + await cache.del(`org_${orgId}_actionDays`); + await cache.del(`org_${orgId}_accessDays`); return response(res, { data: updatedOrg[0], diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index e4881b1a..e5685a5a 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -23,7 +23,7 @@ import { fromError } from "zod-validation-error"; async function getLatestNewtVersion(): Promise { try { - const cachedVersion = cache.get("latestNewtVersion"); + const cachedVersion = await cache.get("latestNewtVersion"); if (cachedVersion) { return cachedVersion; } @@ -55,7 +55,7 @@ async function getLatestNewtVersion(): Promise { tags = tags.filter((version) => !version.name.includes("rc")); const latestVersion = tags[0].name; - cache.set("latestNewtVersion", latestVersion); + await cache.set("latestNewtVersion", latestVersion); return latestVersion; } catch (error: any) { diff --git a/server/routers/site/socketIntegration.ts b/server/routers/site/socketIntegration.ts index e0ad09d1..6a72a5d4 100644 --- a/server/routers/site/socketIntegration.ts +++ b/server/routers/site/socketIntegration.ts @@ -150,7 +150,7 @@ async function triggerFetch(siteId: number) { // clear the cache for this Newt ID so that the site has to keep asking for the containers // this is to ensure that the site always gets the latest data - cache.del(`${newt.newtId}:dockerContainers`); + await cache.del(`${newt.newtId}:dockerContainers`); return { siteId, newtId: newt.newtId }; } @@ -158,7 +158,7 @@ async function triggerFetch(siteId: number) { async function queryContainers(siteId: number) { const { newt } = await getSiteAndNewt(siteId); - const result = cache.get(`${newt.newtId}:dockerContainers`) as Container[]; + const result = await cache.get(`${newt.newtId}:dockerContainers`); if (!result) { throw createHttpError( HttpCode.TOO_EARLY, @@ -173,7 +173,7 @@ async function isDockerAvailable(siteId: number): Promise { const { newt } = await getSiteAndNewt(siteId); const key = `${newt.newtId}:isAvailable`; - const isAvailable = cache.get(key); + const isAvailable = await cache.get(key); return !!isAvailable; } @@ -186,9 +186,11 @@ async function getDockerStatus( const keys = ["isAvailable", "socketPath"]; const mappedKeys = keys.map((x) => `${newt.newtId}:${x}`); + const values = await cache.mget(mappedKeys); + const result = { - isAvailable: cache.get(mappedKeys[0]) as boolean, - socketPath: cache.get(mappedKeys[1]) as string | undefined + isAvailable: values[0] as boolean, + socketPath: values[1] as string | undefined }; return result; diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 693ef3b9..26fa8e55 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -191,7 +191,7 @@ export async function inviteUser( } if (existingInvite.length) { - const attempts = cache.get(email) || 0; + const attempts = (await cache.get(email)) || 0; if (attempts >= 3) { return next( createHttpError( @@ -201,7 +201,7 @@ export async function inviteUser( ); } - cache.set(email, attempts + 1); + await cache.set(email, attempts + 1); const inviteId = existingInvite[0].inviteId; // Retrieve the original inviteId const token = generateRandomString(