diff --git a/apps/api/src/config/logger.ts b/apps/api/src/config/logger.ts new file mode 100644 index 0000000..ab2c411 --- /dev/null +++ b/apps/api/src/config/logger.ts @@ -0,0 +1,14 @@ +import pino from 'pino'; + +export const logger = pino({ + level: process.env.LOG_LEVEL || 'info', + transport: + process.env.NODE_ENV !== 'production' + ? { + target: 'pino-pretty', + options: { + colorize: true, + }, + } + : undefined, +}); diff --git a/apps/api/src/services/cache.service.ts b/apps/api/src/services/cache.service.ts index b951aa3..8d83053 100644 --- a/apps/api/src/services/cache.service.ts +++ b/apps/api/src/services/cache.service.ts @@ -3,7 +3,7 @@ import { env } from '../config/env'; import { logger } from '../config/logger'; export class CacheService { - private redis: Redis | null = null; + private redis: Redis.Redis | null = null; private fallbackCache: Map = new Map(); private isRedisHealthy = false; @@ -27,7 +27,7 @@ export class CacheService { logger.info({ redisUrl: env.REDIS_URL }, '🔌 Distributed Redis cache connected successfully'); }); - this.redis.on('error', (err) => { + this.redis.on('error', (err: Error) => { this.isRedisHealthy = false; logger.warn({ error: err.message }, '⚠️ Redis client disconnected or unavailable. Graceful fallback active.'); }); @@ -44,6 +44,10 @@ export class CacheService { } } + getRedisClient(): Redis.Redis | null { + return this.isRedisHealthy ? this.redis : null; + } + async get(key: string): Promise { const startTime = process.hrtime(); try { diff --git a/apps/api/src/services/lock.service.ts b/apps/api/src/services/lock.service.ts new file mode 100644 index 0000000..f64e156 --- /dev/null +++ b/apps/api/src/services/lock.service.ts @@ -0,0 +1,77 @@ +import { randomUUID } from 'crypto'; +import { cacheService } from './cache.service'; + +export interface DistributedLock { + key: string; + token: string; + ttlMs: number; +} + +export class LockService { + /** + * Acquires a distributed lock. + * @param key The unique string identifying the lock. + * @param ttlMs Time to live in milliseconds. + * @returns A DistributedLock object if acquired, null if already held. + * @throws Error if validation fails or Redis encounters a connection/command error. + */ + async acquire(key: string, ttlMs: number): Promise { + if (ttlMs <= 0 || !isFinite(ttlMs)) { + throw new Error('TTL must be a finite positive number'); + } + + if (!key || key.trim() === '') { + throw new Error('Lock key must be non-empty'); + } + + const client = cacheService.getRedisClient(); + if (!client) { + throw new Error('Redis client unavailable, cannot acquire lock securely'); + } + + const token = randomUUID(); + + // Use atomic SET with NX (only if not exists) and PX (expire time in ms) + const result = await client.set(key, token, 'NX', 'PX', ttlMs); + + if (result === 'OK') { + return { key, token, ttlMs }; + } + + // Redis confirmed the key already exists + return null; + } + + /** + * Releases a distributed lock if and only if the current token matches. + * @param lock The lock object to release. + * @returns true if released, false if the lock was already released or held by a different process. + * @throws Error if the lock object is invalid or Redis encounters an error. + */ + async release(lock: DistributedLock): Promise { + if (!lock || !lock.key || !lock.token) { + throw new Error('Invalid lock object'); + } + + const client = cacheService.getRedisClient(); + if (!client) { + throw new Error('Redis client unavailable, cannot release lock securely'); + } + + // Atomic compare-and-delete Lua script + const luaScript = ` + if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("DEL", KEYS[1]) + else + return 0 + end + `; + + // eval(script, numkeys, key, arg) + const result = await client.eval(luaScript, 1, lock.key, lock.token); + + return result === 1; + } +} + +export const lockService = new LockService(); diff --git a/apps/api/test/lock.test.ts b/apps/api/test/lock.test.ts new file mode 100644 index 0000000..61e2dc1 --- /dev/null +++ b/apps/api/test/lock.test.ts @@ -0,0 +1,112 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { lockService } from '../src/services/lock.service'; +import { cacheService } from '../src/services/cache.service'; + +describe('Distributed Lock Service', () => { + let mockRedisClient: any; + + beforeEach(() => { + mockRedisClient = { + set: vi.fn(), + eval: vi.fn(), + }; + + // Override the getRedisClient method to return our mock + vi.spyOn(cacheService, 'getRedisClient').mockReturnValue(mockRedisClient); + }); + + test('successful acquisition', async () => { + mockRedisClient.set.mockResolvedValue('OK'); + + const lock = await lockService.acquire('test-lock', 10000); + + expect(lock).not.toBeNull(); + expect(lock?.key).toBe('test-lock'); + expect(lock?.token).toBeDefined(); + expect(lock?.ttlMs).toBe(10000); + + expect(mockRedisClient.set).toHaveBeenCalledWith( + 'test-lock', + expect.any(String), + 'NX', + 'PX', + 10000 + ); + }); + + test('duplicate acquisition failure', async () => { + // Redis returns null when NX condition fails + mockRedisClient.set.mockResolvedValue(null); + + const lock = await lockService.acquire('test-lock-2', 5000); + + expect(lock).toBeNull(); + }); + + test('independent lock keys', async () => { + mockRedisClient.set.mockResolvedValue('OK'); + + const lock1 = await lockService.acquire('key-1', 1000); + const lock2 = await lockService.acquire('key-2', 1000); + + expect(lock1?.token).not.toBe(lock2?.token); + expect(mockRedisClient.set).toHaveBeenCalledTimes(2); + }); + + test('successful release', async () => { + mockRedisClient.eval.mockResolvedValue(1); // 1 means key was deleted + + const lock = { key: 'release-key', token: 'token-123', ttlMs: 1000 }; + const result = await lockService.release(lock); + + expect(result).toBe(true); + expect(mockRedisClient.eval).toHaveBeenCalledWith( + expect.any(String), + 1, + 'release-key', + 'token-123' + ); + }); + + test('wrong ownership token cannot release', async () => { + mockRedisClient.eval.mockResolvedValue(0); // 0 means token didn't match, key not deleted + + const lock = { key: 'release-key', token: 'wrong-token', ttlMs: 1000 }; + const result = await lockService.release(lock); + + expect(result).toBe(false); + }); + + test('Redis acquisition failure', async () => { + mockRedisClient.set.mockRejectedValue(new Error('Connection lost')); + + await expect(lockService.acquire('error-key', 1000)).rejects.toThrow('Connection lost'); + }); + + test('Redis release failure', async () => { + mockRedisClient.eval.mockRejectedValue(new Error('Connection lost')); + + const lock = { key: 'error-key', token: 'token-123', ttlMs: 1000 }; + await expect(lockService.release(lock)).rejects.toThrow('Connection lost'); + }); + + test('invalid key', async () => { + await expect(lockService.acquire('', 1000)).rejects.toThrow('Lock key must be non-empty'); + await expect(lockService.acquire(' ', 1000)).rejects.toThrow('Lock key must be non-empty'); + }); + + test('invalid TTL', async () => { + await expect(lockService.acquire('key', 0)).rejects.toThrow('TTL must be a finite positive number'); + await expect(lockService.acquire('key', -500)).rejects.toThrow('TTL must be a finite positive number'); + await expect(lockService.acquire('key', Infinity)).rejects.toThrow('TTL must be a finite positive number'); + }); + + test('fails if Redis client is not available', async () => { + vi.spyOn(cacheService, 'getRedisClient').mockReturnValue(null); + + await expect(lockService.acquire('key', 1000)).rejects.toThrow('Redis client unavailable'); + + const lock = { key: 'key', token: 'token', ttlMs: 1000 }; + await expect(lockService.release(lock)).rejects.toThrow('Redis client unavailable'); + }); +});