@sailboat-computer/data-storage
Version:
Shared data storage library for sailboat computer v3
568 lines (497 loc) • 21.4 kB
text/typescript
/**
* Graceful Degradation Tests
*
* Tests the behavior of the UnifiedStorageManager when some storage tiers are unavailable.
*/
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';
describe('Graceful Degradation 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 with multiple tiers
config = {
providers: {
hot: {
type: 'redis' as any,
config: {
host: 'localhost',
port: 6379
}
},
warm: {
type: 'influxdb' as any,
config: {
url: 'http://localhost:8086',
token: 'token',
org: 'org',
bucket: 'bucket'
}
},
cold: {
type: 'postgresql' as any,
config: {
host: 'localhost',
port: 5432,
database: 'db',
user: 'user',
password: 'password'
}
},
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 continue operation when hot tier is unavailable', async () => {
// Arrange
const storageManager = createUnifiedStorageManager(config, {
localStorage: {
directory: tempDir,
maxSizeBytes: 10 * 1024 * 1024, // 10MB
encrypt: false,
compressionLevel: 0
}
});
// Mock the provider factory with hot tier unavailable
const mockHotStore = jest.fn().mockRejectedValue(new Error('Hot tier unavailable'));
const mockWarmStore = jest.fn().mockResolvedValue('warm-id');
const mockColdStore = jest.fn().mockResolvedValue('cold-id');
(storageManager as any).remoteStorageManager = {
initialize: jest.fn().mockResolvedValue(undefined),
store: jest.fn().mockImplementation((data, category, options) => {
if (options?.tier === StorageTier.HOT) {
return mockHotStore(data, category, options);
} else if (options?.tier === StorageTier.WARM) {
return mockWarmStore(data, category, options);
} else if (options?.tier === StorageTier.COLD) {
return mockColdStore(data, category, options);
} else {
return mockHotStore(data, category, options); // Default to hot tier
}
}),
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: 0, queriesPerSecond: 0 },
warm: { averageQueryTime: 100, queriesPerSecond: 5 },
cold: { averageQueryTime: 200, queriesPerSecond: 2 },
overall: 'degraded',
providers: {
hot: 'unhealthy',
warm: 'healthy',
cold: 'healthy'
},
issues: {
severity: 'warning',
message: 'Hot tier is unavailable',
component: 'hot-tier',
timestamp: new Date()
}
}
}),
close: jest.fn().mockResolvedValue(undefined)
};
// Act
await storageManager.initialize();
// Store data in hot tier (should fail and fall back to warm tier)
const hotId = await storageManager.store({ value: 1 }, 'test', { tier: StorageTier.HOT });
// Store data in warm tier (should succeed)
const warmId = await storageManager.store({ value: 2 }, 'test', { tier: StorageTier.WARM });
// Store data in cold tier (should succeed)
const coldId = await storageManager.store({ value: 3 }, 'test', { tier: StorageTier.COLD });
// Get status
const status = await storageManager.getStatus();
// Assert
expect(mockHotStore).toHaveBeenCalledTimes(1);
expect(mockWarmStore).toHaveBeenCalledTimes(2); // Once for warm tier, once for fallback from hot
expect(mockColdStore).toHaveBeenCalledTimes(1);
expect(hotId).toBe('warm-id'); // Should fall back to warm tier
expect(warmId).toBe('warm-id');
expect(coldId).toBe('cold-id');
expect(status.healthy).toBe(false); // System is degraded
expect(status.queryPerformance.overall).toBe('degraded');
// Clean up
await storageManager.close();
});
it('should continue operation when warm tier is unavailable', async () => {
// Arrange
const storageManager = createUnifiedStorageManager(config, {
localStorage: {
directory: tempDir,
maxSizeBytes: 10 * 1024 * 1024, // 10MB
encrypt: false,
compressionLevel: 0
}
});
// Mock the provider factory with warm tier unavailable
const mockHotStore = jest.fn().mockResolvedValue('hot-id');
const mockWarmStore = jest.fn().mockRejectedValue(new Error('Warm tier unavailable'));
const mockColdStore = jest.fn().mockResolvedValue('cold-id');
(storageManager as any).remoteStorageManager = {
initialize: jest.fn().mockResolvedValue(undefined),
store: jest.fn().mockImplementation((data, category, options) => {
if (options?.tier === StorageTier.HOT) {
return mockHotStore(data, category, options);
} else if (options?.tier === StorageTier.WARM) {
return mockWarmStore(data, category, options);
} else if (options?.tier === StorageTier.COLD) {
return mockColdStore(data, category, options);
} else {
return mockHotStore(data, category, options); // Default to hot tier
}
}),
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: 0, queriesPerSecond: 0 },
cold: { averageQueryTime: 200, queriesPerSecond: 2 },
overall: 'degraded',
providers: {
hot: 'healthy',
warm: 'unhealthy',
cold: 'healthy'
},
issues: {
severity: 'warning',
message: 'Warm tier is unavailable',
component: 'warm-tier',
timestamp: new Date()
}
}
}),
close: jest.fn().mockResolvedValue(undefined)
};
// Act
await storageManager.initialize();
// Store data in hot tier (should succeed)
const hotId = await storageManager.store({ value: 1 }, 'test', { tier: StorageTier.HOT });
// Store data in warm tier (should fail and fall back to hot tier)
const warmId = await storageManager.store({ value: 2 }, 'test', { tier: StorageTier.WARM });
// Store data in cold tier (should succeed)
const coldId = await storageManager.store({ value: 3 }, 'test', { tier: StorageTier.COLD });
// Get status
const status = await storageManager.getStatus();
// Assert
expect(mockHotStore).toHaveBeenCalledTimes(2); // Once for hot tier, once for fallback from warm
expect(mockWarmStore).toHaveBeenCalledTimes(1);
expect(mockColdStore).toHaveBeenCalledTimes(1);
expect(hotId).toBe('hot-id');
expect(warmId).toBe('hot-id'); // Should fall back to hot tier
expect(coldId).toBe('cold-id');
expect(status.healthy).toBe(false); // System is degraded
expect(status.queryPerformance.overall).toBe('degraded');
// Clean up
await storageManager.close();
});
it('should continue operation when all remote tiers are unavailable', async () => {
// Arrange
const storageManager = createUnifiedStorageManager(config, {
localStorage: {
directory: tempDir,
maxSizeBytes: 10 * 1024 * 1024, // 10MB
encrypt: false,
compressionLevel: 0
}
});
// Mock the provider factory with all tiers unavailable
const mockHotStore = jest.fn().mockRejectedValue(new Error('Hot tier unavailable'));
const mockWarmStore = jest.fn().mockRejectedValue(new Error('Warm tier unavailable'));
const mockColdStore = jest.fn().mockRejectedValue(new Error('Cold tier unavailable'));
(storageManager as any).remoteStorageManager = {
initialize: jest.fn().mockResolvedValue(undefined),
store: jest.fn().mockImplementation((data, category, options) => {
if (options?.tier === StorageTier.HOT) {
return mockHotStore(data, category, options);
} else if (options?.tier === StorageTier.WARM) {
return mockWarmStore(data, category, options);
} else if (options?.tier === StorageTier.COLD) {
return mockColdStore(data, category, options);
} else {
return mockHotStore(data, category, options); // Default to hot tier
}
}),
retrieve: jest.fn().mockRejectedValue(new Error('Remote storage unavailable')),
update: jest.fn().mockRejectedValue(new Error('Remote storage unavailable')),
delete: jest.fn().mockRejectedValue(new Error('Remote storage unavailable')),
storeBatch: jest.fn().mockRejectedValue(new Error('Remote storage unavailable')),
retrieveBatch: jest.fn().mockRejectedValue(new Error('Remote storage unavailable')),
cleanup: jest.fn().mockRejectedValue(new Error('Remote storage unavailable')),
migrate: jest.fn().mockRejectedValue(new Error('Remote storage unavailable')),
getStatus: jest.fn().mockResolvedValue({
connected: false,
healthy: false,
error: 'All remote tiers are unavailable',
queryPerformance: {
hot: { averageQueryTime: 0, queriesPerSecond: 0 },
warm: { averageQueryTime: 0, queriesPerSecond: 0 },
cold: { averageQueryTime: 0, queriesPerSecond: 0 },
overall: 'unhealthy',
providers: {
hot: 'unhealthy',
warm: 'unhealthy',
cold: 'unhealthy'
},
issues: {
severity: 'error',
message: 'All remote tiers are unavailable',
component: 'remote-storage',
timestamp: new Date()
}
}
}),
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([{ data: { value: 'test' }, metadata: { id: 'local-id', category: 'test' } }]),
update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
storeBatch: jest.fn().mockResolvedValue(['local-id-1', 'local-id-2']),
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 data in hot tier (should fail and fall back to local storage)
const hotId = await storageManager.store({ value: 1 }, 'test', { tier: StorageTier.HOT });
// Store data in warm tier (should fail and fall back to local storage)
const warmId = await storageManager.store({ value: 2 }, 'test', { tier: StorageTier.WARM });
// Store data in cold tier (should fail and fall back to local storage)
const coldId = await storageManager.store({ value: 3 }, 'test', { tier: StorageTier.COLD });
// Retrieve data (should use local storage)
const results = await storageManager.retrieve({ category: 'test' });
// Get status
const status = await storageManager.getStatus();
// Get local storage status
const localStatus = await storageManager.getLocalStorageStatus();
// Assert
expect(mockHotStore).toHaveBeenCalledTimes(1);
expect(mockWarmStore).toHaveBeenCalledTimes(1);
expect(mockColdStore).toHaveBeenCalledTimes(1);
expect(localStoreSpy).toHaveBeenCalledTimes(3);
expect(hotId).toBe('local-id');
expect(warmId).toBe('local-id');
expect(coldId).toBe('local-id');
expect(results.length).toBe(1);
expect(results[0].data.value).toBe('test');
expect(status.connected).toBe(true); // System is connected via local storage
expect(status.healthy).toBe(false); // System is degraded
expect(status.queryPerformance.overall).toBe('unhealthy');
expect(localStatus.available).toBe(true);
expect(localStatus.healthy).toBe(true);
// Verify pending sync contains the stored items
expect((storageManager as any).pendingSync.size).toBe(3);
expect((storageManager as any).pendingSync.has(hotId)).toBe(true);
expect((storageManager as any).pendingSync.has(warmId)).toBe(true);
expect((storageManager as any).pendingSync.has(coldId)).toBe(true);
// Clean up
await storageManager.close();
});
it('should recover when tiers become available again', async () => {
// Arrange
const storageManager = createUnifiedStorageManager(config, {
localStorage: {
directory: tempDir,
maxSizeBytes: 10 * 1024 * 1024, // 10MB
encrypt: false,
compressionLevel: 0
}
});
// Mock the provider factory with tiers initially unavailable, then available
const mockHotStore = jest.fn()
.mockRejectedValueOnce(new Error('Hot tier unavailable'))
.mockResolvedValue('hot-id');
const mockWarmStore = jest.fn()
.mockRejectedValueOnce(new Error('Warm tier unavailable'))
.mockResolvedValue('warm-id');
const mockColdStore = jest.fn()
.mockRejectedValueOnce(new Error('Cold tier unavailable'))
.mockResolvedValue('cold-id');
(storageManager as any).remoteStorageManager = {
initialize: jest.fn().mockResolvedValue(undefined),
store: jest.fn().mockImplementation((data, category, options) => {
if (options?.tier === StorageTier.HOT) {
return mockHotStore(data, category, options);
} else if (options?.tier === StorageTier.WARM) {
return mockWarmStore(data, category, options);
} else if (options?.tier === StorageTier.COLD) {
return mockColdStore(data, category, options);
} else {
return mockHotStore(data, category, options); // Default to hot tier
}
}),
retrieve: jest.fn()
.mockRejectedValueOnce(new Error('Remote storage unavailable'))
.mockResolvedValue([{ data: { value: 'remote' }, metadata: { id: 'remote-id', category: 'test' } }]),
update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
storeBatch: jest.fn().mockResolvedValue(['remote-id-1', 'remote-id-2']),
retrieveBatch: jest.fn().mockResolvedValue([]),
cleanup: jest.fn().mockResolvedValue({ itemsRemoved: 0, bytesFreed: 0, duration: 0 }),
migrate: jest.fn().mockResolvedValue(undefined),
getStatus: jest.fn()
.mockResolvedValueOnce({
connected: false,
healthy: false,
error: 'All remote tiers are unavailable',
queryPerformance: {
hot: { averageQueryTime: 0, queriesPerSecond: 0 },
warm: { averageQueryTime: 0, queriesPerSecond: 0 },
cold: { averageQueryTime: 0, queriesPerSecond: 0 },
overall: 'unhealthy',
providers: {
hot: 'unhealthy',
warm: 'unhealthy',
cold: 'unhealthy'
}
}
})
.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([{ data: { value: 'local' }, metadata: { id: 'local-id', category: 'test' } }]),
update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
storeBatch: jest.fn().mockResolvedValue(['local-id-1', 'local-id-2']),
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();
// First phase: tiers are unavailable
// Store data in hot tier (should fail and fall back to local storage)
const hotId1 = await storageManager.store({ value: 1 }, 'test', { tier: StorageTier.HOT });
// Retrieve data (should use local storage)
const results1 = await storageManager.retrieve({ category: 'test' });
// Get status (should be degraded)
const status1 = await storageManager.getStatus();
// Second phase: tiers are available again
// Store data in hot tier (should succeed)
const hotId2 = await storageManager.store({ value: 2 }, 'test', { tier: StorageTier.HOT });
// Retrieve data (should use remote storage)
const results2 = await storageManager.retrieve({ category: 'test' });
// Get status (should be healthy)
const status2 = await storageManager.getStatus();
// Force synchronization (should sync local data to remote)
const syncResult = await storageManager.forceSynchronization();
// Assert
expect(hotId1).toBe('local-id');
expect(hotId2).toBe('hot-id');
expect(results1[0].data.value).toBe('local');
expect(results2[0].data.value).toBe('remote');
expect(status1.healthy).toBe(false);
expect(status1.queryPerformance.overall).toBe('unhealthy');
expect(status2.healthy).toBe(true);
expect(status2.queryPerformance.overall).toBe('healthy');
expect(syncResult.success).toBe(true);
expect(syncResult.itemCount).toBeGreaterThan(0);
// Clean up
await storageManager.close();
});
});