UNPKG

@sailboat-computer/data-storage

Version:

Shared data storage library for sailboat computer v3

344 lines (303 loc) 11.8 kB
/** * Retry Behavior Tests * * Tests the behavior of the retry mechanism in the UnifiedStorageManager. */ import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs'; import { StorageTier, StorageConfig } from '../../src/types'; import { createUnifiedStorageManager } from '../../src/storage'; import { withRetry } from '../../src/utils/errors'; describe('Retry Behavior Tests', () => { // Create a temporary directory for local storage const tempDir = path.join(os.tmpdir(), 'data-storage-tests', `test-${Date.now()}`); // Storage configuration let config: StorageConfig; beforeAll(() => { // Create temp directory fs.mkdirSync(tempDir, { recursive: true }); // Create storage configuration config = { providers: { hot: { type: 'redis' as any, config: { host: 'localhost', port: 6379 } }, batching: { timeBased: { enabled: true, intervalMs: 1000 }, sizeBased: { enabled: true, maxBatchSize: 10 }, priorityOverride: true } } }; }); afterAll(() => { // Clean up temp directory fs.rmSync(tempDir, { recursive: true, force: true }); }); it('should retry failed operations', async () => { // Arrange const storageManager = createUnifiedStorageManager(config, { localStorage: { directory: tempDir, maxSizeBytes: 10 * 1024 * 1024, // 10MB encrypt: false, compressionLevel: 0 }, resilience: { retry: { maxRetries: 3, baseDelayMs: 10 // Short delay for testing } } }); // Mock the provider factory with failures then success const mockRemoteStore = jest.fn() .mockRejectedValueOnce(new Error('Remote storage error 1')) .mockRejectedValueOnce(new Error('Remote storage error 2')) .mockResolvedValueOnce('remote-id-3'); (storageManager as any).remoteStorageManager = { initialize: jest.fn().mockResolvedValue(undefined), store: mockRemoteStore, retrieve: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), storeBatch: jest.fn().mockResolvedValue([]), retrieveBatch: jest.fn().mockResolvedValue([]), cleanup: jest.fn().mockResolvedValue({ itemsRemoved: 0, bytesFreed: 0, duration: 0 }), migrate: jest.fn().mockResolvedValue(undefined), getStatus: jest.fn().mockResolvedValue({ connected: true, healthy: true, queryPerformance: { hot: { averageQueryTime: 50, queriesPerSecond: 10 }, warm: { averageQueryTime: 100, queriesPerSecond: 5 }, cold: { averageQueryTime: 200, queriesPerSecond: 2 }, overall: 'healthy', providers: { hot: 'healthy', warm: 'healthy', cold: 'healthy' } } }), close: jest.fn().mockResolvedValue(undefined) }; // Create a spy on the setTimeout function const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); // Act await storageManager.initialize(); // Store attempt (should fail twice, then succeed on third try) const id = await storageManager.store({ value: 1 }, 'test'); // Assert expect(mockRemoteStore).toHaveBeenCalledTimes(3); expect(id).toBe('remote-id-3'); // Get metrics to verify retry count const metrics = storageManager.getResilienceMetrics(); expect(Object.values(metrics.retry.retryCount).reduce((sum, count) => sum + count, 0)).toBe(2); // Clean up setTimeoutSpy.mockRestore(); await storageManager.close(); }); it('should fall back to local storage after max retries', async () => { // Arrange const storageManager = createUnifiedStorageManager(config, { localStorage: { directory: tempDir, maxSizeBytes: 10 * 1024 * 1024, // 10MB encrypt: false, compressionLevel: 0 }, resilience: { retry: { maxRetries: 2, baseDelayMs: 10 // Short delay for testing } } }); // Mock the provider factory with persistent failures const mockRemoteStore = jest.fn() .mockRejectedValueOnce(new Error('Remote storage error 1')) .mockRejectedValueOnce(new Error('Remote storage error 2')) .mockRejectedValueOnce(new Error('Remote storage error 3')); (storageManager as any).remoteStorageManager = { initialize: jest.fn().mockResolvedValue(undefined), store: mockRemoteStore, retrieve: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), storeBatch: jest.fn().mockResolvedValue([]), retrieveBatch: jest.fn().mockResolvedValue([]), cleanup: jest.fn().mockResolvedValue({ itemsRemoved: 0, bytesFreed: 0, duration: 0 }), migrate: jest.fn().mockResolvedValue(undefined), getStatus: jest.fn().mockResolvedValue({ connected: true, healthy: true, queryPerformance: { hot: { averageQueryTime: 50, queriesPerSecond: 10 }, warm: { averageQueryTime: 100, queriesPerSecond: 5 }, cold: { averageQueryTime: 200, queriesPerSecond: 2 }, overall: 'healthy', providers: { hot: 'healthy', warm: 'healthy', cold: 'healthy' } } }), close: jest.fn().mockResolvedValue(undefined) }; // Create a spy on the local storage provider const localStoreSpy = jest.fn().mockResolvedValue('local-id'); (storageManager as any).localStorageProvider = { initialize: jest.fn().mockResolvedValue(undefined), store: localStoreSpy, retrieve: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), storeBatch: jest.fn().mockResolvedValue([]), cleanup: jest.fn().mockResolvedValue({ itemsRemoved: 0, bytesFreed: 0, duration: 0 }), getStatus: jest.fn().mockResolvedValue({ connected: true, healthy: true, queryPerformance: { hot: { averageQueryTime: 10, queriesPerSecond: 100 }, warm: { averageQueryTime: 10, queriesPerSecond: 100 }, cold: { averageQueryTime: 10, queriesPerSecond: 100 }, overall: 'healthy', providers: { hot: 'healthy', warm: 'healthy', cold: 'healthy' } } }), close: jest.fn().mockResolvedValue(undefined), getLocalStorageStatus: jest.fn().mockResolvedValue({ available: true, healthy: true, totalSizeBytes: 1024 * 1024 * 1024, usedSizeBytes: 1024 * 1024, freeSizeBytes: 1023 * 1024 * 1024, itemCount: 10 }) }; // Act await storageManager.initialize(); // Store attempt (should fail after max retries and fall back to local storage) const id = await storageManager.store({ value: 1 }, 'test'); // Assert expect(mockRemoteStore).toHaveBeenCalledTimes(3); // Initial attempt + 2 retries expect(localStoreSpy).toHaveBeenCalledTimes(1); expect(id).toBe('local-id'); // Get metrics to verify retry count const metrics = storageManager.getResilienceMetrics(); expect(Object.values(metrics.retry.retryCount).reduce((sum, count) => sum + count, 0)).toBe(2); // Verify pending sync contains the stored item expect((storageManager as any).pendingSync.size).toBe(1); expect((storageManager as any).pendingSync.has(id)).toBe(true); // Clean up await storageManager.close(); }); it('should use exponential backoff with jitter', async () => { // Arrange const storageManager = createUnifiedStorageManager(config, { localStorage: { directory: tempDir, maxSizeBytes: 10 * 1024 * 1024, // 10MB encrypt: false, compressionLevel: 0 }, resilience: { retry: { maxRetries: 3, baseDelayMs: 10 // Short delay for testing } } }); // Mock the provider factory with failures const mockRemoteStore = jest.fn() .mockRejectedValueOnce(new Error('Remote storage error 1')) .mockRejectedValueOnce(new Error('Remote storage error 2')) .mockRejectedValueOnce(new Error('Remote storage error 3')) .mockRejectedValueOnce(new Error('Remote storage error 4')); (storageManager as any).remoteStorageManager = { initialize: jest.fn().mockResolvedValue(undefined), store: mockRemoteStore, retrieve: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue(undefined), delete: jest.fn().mockResolvedValue(undefined), storeBatch: jest.fn().mockResolvedValue([]), retrieveBatch: jest.fn().mockResolvedValue([]), cleanup: jest.fn().mockResolvedValue({ itemsRemoved: 0, bytesFreed: 0, duration: 0 }), migrate: jest.fn().mockResolvedValue(undefined), getStatus: jest.fn().mockResolvedValue({ connected: true, healthy: true, queryPerformance: { hot: { averageQueryTime: 50, queriesPerSecond: 10 }, warm: { averageQueryTime: 100, queriesPerSecond: 5 }, cold: { averageQueryTime: 200, queriesPerSecond: 2 }, overall: 'healthy', providers: { hot: 'healthy', warm: 'healthy', cold: 'healthy' } } }), close: jest.fn().mockResolvedValue(undefined) }; // Create a spy on the setTimeout function const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); // Act await storageManager.initialize(); try { // Store attempt (should fail after max retries) await storageManager.store({ value: 1 }, 'test'); } catch (error) { // Expected to fail } // Assert expect(mockRemoteStore).toHaveBeenCalledTimes(4); // Initial attempt + 3 retries // Verify setTimeout was called with increasing delays const calls = setTimeoutSpy.mock.calls; const retryDelays = calls .map(call => call[1]) // Extract delay argument .filter((delay): delay is number => typeof delay === 'number' && delay >= 10 && delay <= 100); // Filter retry delays with type guard expect(retryDelays.length).toBeGreaterThanOrEqual(3); // Verify delays are increasing (exponential backoff) for (let i = 1; i < retryDelays.length; i++) { expect(retryDelays[i]).toBeGreaterThan(retryDelays[i - 1]); } // Clean up setTimeoutSpy.mockRestore(); await storageManager.close(); }); it('should directly use withRetry utility function', async () => { // Test the withRetry utility function directly const successAfterFailures = jest.fn() .mockRejectedValueOnce(new Error('Failure 1')) .mockRejectedValueOnce(new Error('Failure 2')) .mockResolvedValueOnce('success'); const alwaysFails = jest.fn() .mockRejectedValue(new Error('Always fails')); // Function that succeeds after failures should eventually return const result = await withRetry(successAfterFailures, 3, 10); expect(result).toBe('success'); expect(successAfterFailures).toHaveBeenCalledTimes(3); // Function that always fails should throw after max retries await expect(withRetry(alwaysFails, 2, 10)).rejects.toThrow('Always fails'); expect(alwaysFails).toHaveBeenCalledTimes(3); // Initial attempt + 2 retries }); });