UNPKG

syncguard

Version:

Functional TypeScript library for distributed locking across microservices. Prevents race conditions with Redis, Firestore, and custom backends. Features automatic lock management, timeout handling, and extensible architecture.

451 lines (450 loc) 19.9 kB
// SPDX-License-Identifier: MIT /** * Integration tests for Redis lock implementation * * These tests verify the complete lock functionality using a real Redis instance: * - Basic lock operations (acquire, release, extend, isLocked) * - Automatic lock management with callback pattern * - Lock contention and timing behavior * - TTL expiration and cleanup * - Concurrent access patterns * - Error recovery and edge cases * * Prerequisites: * - Redis server running on localhost:6379 * - Tests use database 15 to avoid conflicts with other data */ import { describe, expect, it, beforeEach, afterEach, beforeAll, afterAll, } from "bun:test"; import Redis from "ioredis"; import { createLock } from "../../redis"; describe("Redis Lock Integration Tests", () => { let redis; let lock; const testKeyPrefix = "test:syncguard:"; beforeAll(async () => { // Connect to local Redis instance for testing redis = new Redis({ host: "localhost", port: 6379, db: 15, // Dedicated test database lazyConnect: true, maxRetriesPerRequest: 3, }); // Verify Redis connectivity try { await redis.ping(); } catch (error) { console.warn("⚠️ Redis not available - integration tests will fail"); console.warn(" Please ensure Redis is running on localhost:6379"); } }); beforeEach(async () => { // Create lock instance with test-optimized settings lock = createLock(redis, { keyPrefix: testKeyPrefix, retryDelayMs: 25, // Faster retries for testing maxRetries: 10, // More retries for reliable tests }); // Clean slate for each test const keys = await redis.keys(`${testKeyPrefix}*`); if (keys.length > 0) { await redis.del(...keys); } }); afterEach(async () => { // Clean up all test locks const keys = await redis.keys(`${testKeyPrefix}*`); if (keys.length > 0) { await redis.del(...keys); } }); afterAll(async () => { await redis.quit(); }); describe("Core Lock Operations", () => { it("should successfully perform complete lock lifecycle", async () => { const resourceKey = "user:profile:12345"; // 1. Acquire lock const acquireResult = await lock.acquire({ key: resourceKey, ttlMs: 30000, // 30 seconds }); expect(acquireResult.success).toBe(true); if (acquireResult.success) { // Verify lock properties expect(acquireResult.lockId).toBeDefined(); expect(typeof acquireResult.lockId).toBe("string"); expect(acquireResult.expiresAt).toBeInstanceOf(Date); expect(acquireResult.expiresAt.getTime()).toBeGreaterThan(Date.now()); // 2. Verify resource is locked const isLocked = await lock.isLocked(resourceKey); expect(isLocked).toBe(true); // 3. Release lock const released = await lock.release(acquireResult.lockId); expect(released).toBe(true); // 4. Verify resource is unlocked const isLockedAfter = await lock.isLocked(resourceKey); expect(isLockedAfter).toBe(false); } }); it("should automatically manage lock lifecycle with callback pattern", async () => { const resourceKey = "api:rate-limit:user:789"; let criticalSectionExecuted = false; let lockWasActiveInside = false; // Execute critical section with automatic lock management const result = await lock(async () => { criticalSectionExecuted = true; // Verify lock is active during execution lockWasActiveInside = await lock.isLocked(resourceKey); // Simulate some work await new Promise((resolve) => setTimeout(resolve, 10)); return "operation completed"; }, { key: resourceKey, ttlMs: 15000, // 15 seconds }); // Verify execution results expect(criticalSectionExecuted).toBe(true); expect(lockWasActiveInside).toBe(true); expect(result).toBe("operation completed"); // Verify lock was automatically released const isLockedAfter = await lock.isLocked(resourceKey); expect(isLockedAfter).toBe(false); }); it("should automatically release lock even when callback throws error", async () => { const resourceKey = "payment:transaction:error-test"; let lockWasActiveBeforeError = false; try { await lock(async () => { // Verify lock is active lockWasActiveBeforeError = await lock.isLocked(resourceKey); // Simulate an error during critical section throw new Error("Simulated processing error"); }, { key: resourceKey, ttlMs: 10000, }); // Should not reach here expect(true).toBe(false); } catch (error) { expect(error.message).toBe("Simulated processing error"); expect(lockWasActiveBeforeError).toBe(true); } // Critical: Lock must be released even after error const isLockedAfter = await lock.isLocked(resourceKey); expect(isLockedAfter).toBe(false); }); it("should handle multiple lock operations on different resources", async () => { const resources = [ "database:connection:1", "cache:key:user-session", "file:upload:temp-123", ]; // Acquire locks on all resources simultaneously const results = await Promise.all(resources.map((key) => lock.acquire({ key, ttlMs: 20000 }))); // All acquisitions should succeed results.forEach((result, index) => { expect(result.success).toBe(true); if (result.success) { expect(result.lockId).toBeDefined(); } }); // Verify all resources are locked const lockStatuses = await Promise.all(resources.map((key) => lock.isLocked(key))); expect(lockStatuses).toEqual([true, true, true]); // Release all locks const releaseResults = await Promise.all(results.map((result, index) => { if (result.success) { return lock.release(result.lockId); } return false; })); // All releases should succeed expect(releaseResults).toEqual([true, true, true]); // Verify all resources are unlocked const finalStatuses = await Promise.all(resources.map((key) => lock.isLocked(key))); expect(finalStatuses).toEqual([false, false, false]); }); }); describe("Lock Contention", () => { it("should prevent concurrent access and ensure data consistency", async () => { const resourceKey = "shared:counter"; let sharedCounter = 0; const incrementResults = []; // Two operations that modify shared state const operation1 = lock(async () => { const current = sharedCounter; await Bun.sleep(30); // Simulate some work sharedCounter = current + 1; incrementResults.push(sharedCounter); }, { key: resourceKey }); // Slight delay to ensure operation1 starts first await Bun.sleep(10); const operation2 = lock(async () => { const current = sharedCounter; await Bun.sleep(30); // Simulate some work sharedCounter = current + 1; incrementResults.push(sharedCounter); }, { key: resourceKey, retryDelayMs: 10, maxRetries: 50, timeoutMs: 2000, }); // Wait for both operations const results = await Promise.allSettled([operation1, operation2]); // At least one operation should succeed const successCount = results.filter((r) => r.status === "fulfilled").length; expect(successCount).toBeGreaterThan(0); // If both succeeded, counter should be 2 and results should be [1, 2] if (successCount === 2) { expect(sharedCounter).toBe(2); expect(incrementResults).toEqual([1, 2]); } else { // If only one succeeded, counter should be 1 expect(sharedCounter).toBe(1); expect(incrementResults).toEqual([1]); } }); it("should allow concurrent access to different resources", async () => { const startTime = Date.now(); // Lock different resources concurrently await Promise.all([ lock(async () => { await Bun.sleep(100); }, { key: "resource:5" }), lock(async () => { await Bun.sleep(100); }, { key: "resource:6" }), lock(async () => { await Bun.sleep(100); }, { key: "resource:7" }), ]); const elapsed = Date.now() - startTime; // Should complete in ~100ms (parallel), not 300ms (sequential) expect(elapsed).toBeLessThan(200); }); it("should respect acquisition timeout and fail gracefully", async () => { const resourceKey = "resource:timeout-test"; // First lock holds for longer than second lock's timeout const longRunningLock = lock(async () => { await Bun.sleep(800); // Hold lock for 800ms }, { key: resourceKey, ttlMs: 60000, // Long TTL so it doesn't expire }); // Give first lock time to acquire await Bun.sleep(50); // Second lock attempts with short timeout const shortTimeoutLock = lock(async () => { throw new Error("This should not execute"); }, { key: resourceKey, timeoutMs: 300, // Will timeout before first lock releases maxRetries: 50, retryDelayMs: 5, }); const results = await Promise.allSettled([ longRunningLock, shortTimeoutLock, ]); // First should succeed expect(results[0].status).toBe("fulfilled"); // Second should fail expect(results[1].status).toBe("rejected"); if (results[1].status === "rejected") { // Could fail with timeout or lock contention message const errorMessage = results[1].reason.message; const isExpectedFailure = errorMessage.includes("timeout") || errorMessage.includes("Lock already held") || errorMessage.includes("Failed to acquire lock"); expect(isExpectedFailure).toBe(true); } }); }); describe("lock expiration", () => { it("should auto-expire locks after TTL", async () => { const key = "resource:9"; // Acquire lock with short TTL const result = await lock.acquire({ key, ttlMs: 200 }); expect(result.success).toBe(true); if (result.success) { // Verify lock is held expect(await lock.isLocked(key)).toBe(true); // Wait for TTL to expire await Bun.sleep(250); // Lock should be expired expect(await lock.isLocked(key)).toBe(false); // Another process should be able to acquire it const result2 = await lock.acquire({ key }); expect(result2.success).toBe(true); if (result2.success) { await lock.release(result2.lockId); } } }); it("should extend lock TTL", async () => { const key = "resource:10"; // Acquire lock with short TTL const result = await lock.acquire({ key, ttlMs: 500 }); expect(result.success).toBe(true); if (result.success) { // Wait a bit await Bun.sleep(300); // Extend the lock const extended = await lock.extend(result.lockId, 1000); expect(extended).toBe(true); // Wait past original expiry await Bun.sleep(300); // Lock should still be held expect(await lock.isLocked(key)).toBe(true); // Clean up await lock.release(result.lockId); } }); it("should not extend expired lock", async () => { const key = "resource:11"; // Acquire lock with very short TTL const result = await lock.acquire({ key, ttlMs: 100 }); expect(result.success).toBe(true); if (result.success) { // Wait for lock to expire await Bun.sleep(150); // Try to extend expired lock const extended = await lock.extend(result.lockId, 1000); expect(extended).toBe(false); } }); }); describe("error handling", () => { it("should handle release of non-existent lock", async () => { const released = await lock.release("non-existent-lock-id"); expect(released).toBe(false); }); it("should handle double release", async () => { const key = "resource:12"; const result = await lock.acquire({ key }); expect(result.success).toBe(true); if (result.success) { // First release should succeed const released1 = await lock.release(result.lockId); expect(released1).toBe(true); // Second release should fail gracefully const released2 = await lock.release(result.lockId); expect(released2).toBe(false); } }); it("should prevent release by wrong lock owner", async () => { const key = "resource:13"; // First lock const result1 = await lock.acquire({ key }); expect(result1.success).toBe(true); if (result1.success) { // Try to release with wrong lockId const released = await lock.release("wrong-lock-id"); expect(released).toBe(false); // Original lock should still be held expect(await lock.isLocked(key)).toBe(true); // Clean up with correct lockId await lock.release(result1.lockId); } }); }); describe("Stress Testing", () => { it("should demonstrate lock contention behavior under concurrent load", async () => { const numOperations = 5; // Moderate load for consistent testing const resourceKey = "resource:stress-test"; let successfulOperations = 0; const errors = []; // Create concurrent lock operations const promises = Array.from({ length: numOperations }, async (_, index) => { try { await lock(async () => { successfulOperations++; await Bun.sleep(10); // Brief critical section }, { key: resourceKey, retryDelayMs: 15, maxRetries: 30, timeoutMs: 3000, }); } catch (error) { errors.push(error); } }); await Promise.all(promises); // Verify some operations succeeded (exact number depends on timing) expect(successfulOperations).toBeGreaterThan(0); expect(successfulOperations).toBeLessThanOrEqual(numOperations); // Lock contention failures are expected and acceptable if (errors.length > 0) { errors.forEach((error) => { expect(error.message).toMatch(/Failed to acquire lock|timeout|Lock already held/); }); } // Verify no dangling locks remain expect(await lock.isLocked(resourceKey)).toBe(false); }); it("should handle rapid acquire/release cycles", async () => { const key = "resource:rapid"; const cycles = 10; for (let i = 0; i < cycles; i++) { const result = await lock.acquire({ key }); expect(result.success).toBe(true); if (result.success) { // Verify lock is held expect(await lock.isLocked(key)).toBe(true); // Release immediately const released = await lock.release(result.lockId); expect(released).toBe(true); // Verify lock is released expect(await lock.isLocked(key)).toBe(false); } } }); }); describe("cleanup behavior", () => { it("should clean up expired locks during isLocked check", async () => { const key = "resource:cleanup"; // Create a lock with very short TTL const result = await lock.acquire({ key, ttlMs: 100 }); expect(result.success).toBe(true); if (result.success) { // Wait for it to expire await Bun.sleep(150); // isLocked should trigger cleanup and return false const isLocked = await lock.isLocked(key); expect(isLocked).toBe(false); // Verify the lock was actually cleaned up (can acquire immediately) const result2 = await lock.acquire({ key }); expect(result2.success).toBe(true); if (result2.success) { await lock.release(result2.lockId); } } }); it("should handle orphaned index entries", async () => { const key = "resource:orphan"; // Acquire a lock const result = await lock.acquire({ key }); expect(result.success).toBe(true); if (result.success) { // Manually delete the main lock key, leaving orphaned index await redis.del(`${testKeyPrefix}${key}`); // Release should handle the orphaned index gracefully const released = await lock.release(result.lockId); expect(released).toBe(false); // Can't release non-existent lock // Should be able to acquire new lock const result2 = await lock.acquire({ key }); expect(result2.success).toBe(true); if (result2.success) { await lock.release(result2.lockId); } } }); }); });