Files
formbricks/packages/cache/src/service.ts
Dhruwang Jariwala 018cef61a6 feat: telemetry setup (#6888)
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-11-29 11:57:14 +00:00

333 lines
10 KiB
TypeScript

import { logger } from "@formbricks/logger";
import type { RedisClient } from "@/types/client";
import { type CacheError, CacheErrorClass, ErrorCode, type Result, err, ok } from "@/types/error";
import type { CacheKey } from "@/types/keys";
import { ZCacheKey } from "@/types/keys";
import { ZTtlMs, ZTtlMsOptional } from "@/types/service";
import { validateInputs } from "./utils/validation";
/**
* Core cache service providing basic Redis operations with JSON serialization
*/
export class CacheService {
constructor(private readonly redis: RedisClient) {}
/**
* Wraps Redis operations with connection check and timeout to prevent hanging
*/
private async withTimeout<T>(operation: Promise<T>, timeoutMs = 1000): Promise<T> {
return Promise.race([
operation,
new Promise<T>((_, reject) => {
setTimeout(() => {
reject(new CacheErrorClass(ErrorCode.RedisOperationError, "Cache operation timeout"));
}, timeoutMs);
}),
]);
}
/**
* Get the underlying Redis client for advanced operations (e.g., Lua scripts)
* Use with caution - prefer cache service methods when possible
* @returns The Redis client instance or null if not ready
*/
getRedisClient(): RedisClient | null {
if (!this.isRedisClientReady()) {
return null;
}
return this.redis;
}
/**
* Get a value from cache with automatic JSON deserialization
* @param key - Cache key to retrieve
* @returns Result containing parsed value, null if not found, or an error
*/
async get<T>(key: CacheKey): Promise<Result<T | null, CacheError>> {
// Check Redis availability first
if (!this.isRedisClientReady()) {
return err({
code: ErrorCode.RedisConnectionError,
});
}
const validation = validateInputs([key, ZCacheKey]);
if (!validation.ok) {
return validation;
}
try {
const value = await this.withTimeout(this.redis.get(key));
if (value === null) {
return ok(null);
}
// Parse JSON - all data should be valid JSON since we stringify on set
try {
return ok(JSON.parse(value) as T);
} catch (parseError) {
// JSON parse failure indicates corrupted cache data - treat as cache miss
logger.warn(
{
key,
parseError,
},
"Corrupted cache data detected, treating as cache miss"
);
return err({
code: ErrorCode.CacheCorruptionError,
});
}
} catch (error) {
logger.error({ error, key }, "Cache get operation failed");
return err({
code: ErrorCode.RedisOperationError,
});
}
}
/**
* Check if a key exists in cache (for distinguishing cache miss from cached null)
* @param key - Cache key to check
* @returns Result containing boolean indicating if key exists
*/
async exists(key: CacheKey): Promise<Result<boolean, CacheError>> {
// Check Redis availability first
if (!this.isRedisClientReady()) {
return err({
code: ErrorCode.RedisConnectionError,
});
}
const validation = validateInputs([key, ZCacheKey]);
if (!validation.ok) {
return validation;
}
try {
const exists = await this.withTimeout(this.redis.exists(key));
return ok(exists > 0);
} catch (error) {
logger.error({ error, key }, "Cache exists operation failed");
return err({
code: ErrorCode.RedisOperationError,
});
}
}
/**
* Set a value in cache with automatic JSON serialization and optional TTL
* @param key - Cache key to store under
* @param value - Value to store
* @param ttlMs - Time to live in milliseconds (optional - if omitted, key persists indefinitely)
* @returns Result containing void or an error
*/
async set(key: CacheKey, value: unknown, ttlMs?: number): Promise<Result<void, CacheError>> {
// Check Redis availability first
if (!this.isRedisClientReady()) {
return err({
code: ErrorCode.RedisConnectionError,
});
}
// Validate key and optional TTL
const validation = validateInputs([key, ZCacheKey], [ttlMs, ZTtlMsOptional]);
if (!validation.ok) {
return validation;
}
try {
// Normalize undefined to null to maintain consistent cached-null semantics
const normalizedValue = value === undefined ? null : value;
const serialized = JSON.stringify(normalizedValue);
if (ttlMs === undefined) {
// Set without expiration (persists indefinitely)
await this.withTimeout(this.redis.set(key, serialized));
} else {
// Set with expiration
await this.withTimeout(this.redis.setEx(key, Math.floor(ttlMs / 1000), serialized));
}
return ok(undefined);
} catch (error) {
logger.error({ error, key, ttlMs }, "Cache set operation failed");
return err({
code: ErrorCode.RedisOperationError,
});
}
}
/**
* Delete one or more keys from cache (idempotent)
* @param keys - Array of keys to delete
* @returns Result containing void or an error
*/
async del(keys: CacheKey[]): Promise<Result<void, CacheError>> {
// Check Redis availability first
if (!this.isRedisClientReady()) {
return err({
code: ErrorCode.RedisConnectionError,
});
}
// Validate all keys using generic validation
for (const key of keys) {
const validation = validateInputs([key, ZCacheKey]);
if (!validation.ok) {
return validation;
}
}
try {
if (keys.length > 0) {
await this.withTimeout(this.redis.del(keys));
}
return ok(undefined);
} catch (error) {
logger.error({ error, keys }, "Cache delete operation failed");
return err({
code: ErrorCode.RedisOperationError,
});
}
}
/**
* Try to acquire a distributed lock (atomic SET NX operation)
* @param key - Lock key
* @param value - Lock value (typically "locked" or instance identifier)
* @param ttlMs - Time to live in milliseconds (lock expiration)
* @returns Result containing boolean indicating if lock was acquired, or an error
*/
async tryLock(key: CacheKey, value: string, ttlMs: number): Promise<Result<boolean, CacheError>> {
// Check Redis availability first
if (!this.isRedisClientReady()) {
return err({
code: ErrorCode.RedisConnectionError,
});
}
const validation = validateInputs([key, ZCacheKey], [ttlMs, ZTtlMs]);
if (!validation.ok) {
return validation;
}
try {
// Use SET with NX (only set if not exists) and PX (expiration in milliseconds) for atomic lock acquisition
const result = await this.withTimeout(
this.redis.set(key, value, {
NX: true,
PX: ttlMs,
})
);
// SET returns "OK" if lock was acquired, null if key already exists
return ok(result === "OK");
} catch (error) {
logger.error({ error, key, ttlMs }, "Cache lock operation failed");
return err({
code: ErrorCode.RedisOperationError,
});
}
}
/**
* Cache wrapper for functions (cache-aside).
* Never throws due to cache errors; function errors propagate without retry.
* Must include null in T to support cached null values.
* @param fn - Function to execute (and optionally cache).
* @param key - Cache key
* @param ttlMs - Time to live in milliseconds
* @returns Cached value if present, otherwise fresh result from fn()
*/
async withCache<T>(fn: () => Promise<T>, key: CacheKey, ttlMs: number): Promise<T> {
if (!this.isRedisClientReady()) {
return await fn();
}
const validation = validateInputs([key, ZCacheKey], [ttlMs, ZTtlMs]);
if (!validation.ok) {
logger.warn({ error: validation.error, key }, "Invalid cache inputs, executing function directly");
return await fn();
}
const cachedValue = await this.tryGetCachedValue<T>(key, ttlMs);
if (cachedValue !== undefined) {
return cachedValue;
}
const fresh = await fn();
await this.trySetCache(key, fresh, ttlMs);
return fresh;
}
private async tryGetCachedValue<T>(key: CacheKey, ttlMs: number): Promise<T | undefined> {
try {
const cacheResult = await this.get<T>(key);
if (cacheResult.ok && cacheResult.data !== null) {
return cacheResult.data;
}
if (cacheResult.ok && cacheResult.data === null) {
const existsResult = await this.exists(key);
if (existsResult.ok && existsResult.data) {
return null as T;
}
}
if (!cacheResult.ok) {
logger.debug(
{ error: cacheResult.error, key, ttlMs },
"Cache get operation failed, fetching fresh data"
);
}
} catch (error) {
logger.debug({ error, key, ttlMs }, "Cache get/exists threw; proceeding to compute fresh value");
}
return undefined;
}
private async trySetCache(key: CacheKey, value: unknown, ttlMs: number): Promise<void> {
if (value === undefined) {
return; // Skip caching undefined values
}
try {
const setResult = await this.set(key, value, ttlMs);
if (!setResult.ok) {
logger.debug(
{ error: setResult.error, key, ttlMs },
"Failed to cache fresh data, but returning result"
);
}
} catch (error) {
logger.debug({ error, key, ttlMs }, "Cache set threw; returning fresh result");
}
}
/**
* Check if Redis is available and healthy by testing connectivity with ping
* @returns Promise<boolean> indicating if Redis is available and responsive
*/
async isRedisAvailable(): Promise<boolean> {
if (!this.isRedisClientReady()) {
return false;
}
try {
await this.withTimeout(this.redis.ping());
return true;
} catch (error) {
logger.debug({ error }, "Redis ping failed during availability check");
return false;
}
}
/**
* Fast synchronous check of Redis client state for internal use
* @returns Boolean indicating if Redis client is ready and connected
*/
private isRedisClientReady(): boolean {
return this.redis.isReady && this.redis.isOpen;
}
}