Skip to content
Open
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
30 changes: 8 additions & 22 deletions lib/storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,30 +29,16 @@ function degradePerformance(error: Error) {
* Runs a piece of code and degrades performance if certain errors are thrown
*/
function tryOrDegradePerformance<T>(fn: () => Promise<T> | T, waitForInitialization = true): Promise<T> {
return new Promise<T>((resolve, reject) => {
const promise = waitForInitialization ? initPromise : Promise.resolve();

promise.then(() => {
try {
resolve(fn());
} catch (error) {
// Test for known critical errors that the storage provider throws, e.g. when storage is full
if (error instanceof Error) {
// IndexedDB error when storage is full (https://github.com/Expensify/App/issues/29403)
if (error.message.includes('Internal error opening backing store for indexedDB.open')) {
degradePerformance(error);
}

// catch the error if DB connection can not be established/DB can not be created
if (error.message.includes('IDBKeyVal store could not be created')) {
degradePerformance(error);
}
}

reject(error);
const initialization = waitForInitialization ? initPromise : Promise.resolve();
return initialization
.then(() => fn())
.catch((error: unknown) => {
// catch the error if DB connection can not be established/DB can not be created
if (error instanceof Error && error.message.includes('IDBKeyVal store could not be created')) {
degradePerformance(error);
}
return Promise.reject(error);
});
});
}

const storage: Storage = {
Expand Down
128 changes: 128 additions & 0 deletions tests/unit/storage/tryOrDegradePerformanceTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import type * as LoggerModule from '../../../lib/Logger';
import type storageModule from '../../../lib/storage';

// `jestSetup.js` globally mocks `lib/storage`; this suite tests the real implementation.
jest.unmock('../../../lib/storage');

type Storage = typeof storageModule;
type Logger = typeof LoggerModule;
type LoggerCallback = Parameters<Logger['registerLogger']>[0];
type LogData = Parameters<LoggerCallback>[0];

type IsolatedModules = {
storage: Storage;
Logger: Logger;
};

type CapturedLog = {level: string; message: string};

/**
* Load a fresh copy of `lib/storage` (and its `Logger` dependency) in an isolated
* module registry so the module-private `provider` state in `lib/storage/index.ts`
* does not leak between tests.
*/
function loadIsolatedStorage(): IsolatedModules {
let storage!: Storage;
let Logger!: Logger;

jest.isolateModules(() => {
Logger = require('../../../lib/Logger');
storage = require('../../../lib/storage').default;
});

return {storage, Logger};
}

function noop() {
// intentionally empty
}

describe('storage/tryOrDegradePerformance', () => {
// Fake timers cause the init promise chain to hang.
beforeAll(() => jest.useRealTimers());

let consoleErrorSpy: jest.SpyInstance;

beforeEach(() => {
// `degradePerformance` calls `console.error` — silence it to keep test output clean.
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(noop);
});

afterEach(() => {
consoleErrorSpy.mockRestore();
});

it('falls back to MemoryOnlyProvider when a storage op rejects asynchronously with "IDBKeyVal store could not be created"', async () => {
const {storage, Logger} = loadIsolatedStorage();
const capturedLogs: CapturedLog[] = [];
Logger.registerLogger((data: LogData) => capturedLogs.push({level: data.level, message: data.message}));

storage.init();

const originalProvider = storage.getStorageProvider();
const targetError = new Error('IDBKeyVal store could not be created');
originalProvider.getAllKeys = jest.fn().mockReturnValue(Promise.reject(targetError));

await expect(storage.getAllKeys()).rejects.toBe(targetError);

expect(capturedLogs.some((log) => log.level === 'hmmm' && log.message.includes('Falling back to only using cache'))).toBe(true);
expect(storage.getStorageProvider().name).toBe('MemoryOnlyProvider');
});

it('propagates async rejections with unrelated messages without falling back', async () => {
const {storage, Logger} = loadIsolatedStorage();
const capturedLogs: CapturedLog[] = [];
Logger.registerLogger((data: LogData) => capturedLogs.push({level: data.level, message: data.message}));

storage.init();

const originalProvider = storage.getStorageProvider();
const originalProviderName = originalProvider.name;
const unrelatedError = new Error('Some unrelated storage failure');
originalProvider.getAllKeys = jest.fn().mockReturnValue(Promise.reject(unrelatedError));

await expect(storage.getAllKeys()).rejects.toBe(unrelatedError);

expect(capturedLogs.some((log) => log.level === 'hmmm' && log.message.includes('Falling back to only using cache'))).toBe(false);
expect(storage.getStorageProvider().name).toBe(originalProviderName);
});

it('falls back to MemoryOnlyProvider when a storage op throws synchronously with "IDBKeyVal store could not be created"', async () => {
const {storage, Logger} = loadIsolatedStorage();
const capturedLogs: CapturedLog[] = [];
Logger.registerLogger((data: LogData) => capturedLogs.push({level: data.level, message: data.message}));

storage.init();

const originalProvider = storage.getStorageProvider();
const targetError = new Error('IDBKeyVal store could not be created');
originalProvider.getAllKeys = jest.fn().mockImplementation(() => {
throw targetError;
});

await expect(storage.getAllKeys()).rejects.toBe(targetError);

expect(capturedLogs.some((log) => log.level === 'hmmm' && log.message.includes('Falling back to only using cache'))).toBe(true);
expect(storage.getStorageProvider().name).toBe('MemoryOnlyProvider');
});

it('propagates sync throws with unrelated messages without falling back', async () => {
const {storage, Logger} = loadIsolatedStorage();
const capturedLogs: CapturedLog[] = [];
Logger.registerLogger((data: LogData) => capturedLogs.push({level: data.level, message: data.message}));

storage.init();

const originalProvider = storage.getStorageProvider();
const originalProviderName = originalProvider.name;
const unrelatedError = new Error('Some unrelated storage failure');
originalProvider.getAllKeys = jest.fn().mockImplementation(() => {
throw unrelatedError;
});

await expect(storage.getAllKeys()).rejects.toBe(unrelatedError);

expect(capturedLogs.some((log) => log.level === 'hmmm' && log.message.includes('Falling back to only using cache'))).toBe(false);
expect(storage.getStorageProvider().name).toBe(originalProviderName);
});
});
Loading