Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/api/src/config/logger.ts
Original file line number Diff line number Diff line change
@@ -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,
});
8 changes: 6 additions & 2 deletions apps/api/src/services/cache.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { value: string; expiresAt: number }> = new Map();
private isRedisHealthy = false;

Expand All @@ -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.');
});
Expand All @@ -44,6 +44,10 @@ export class CacheService {
}
}

getRedisClient(): Redis.Redis | null {
return this.isRedisHealthy ? this.redis : null;
}

async get<T>(key: string): Promise<T | null> {
const startTime = process.hrtime();
try {
Expand Down
77 changes: 77 additions & 0 deletions apps/api/src/services/lock.service.ts
Original file line number Diff line number Diff line change
@@ -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<DistributedLock | null> {
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<boolean> {
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();
112 changes: 112 additions & 0 deletions apps/api/test/lock.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading