Skip to content
Draft
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
40 changes: 17 additions & 23 deletions lib/storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,32 +26,26 @@ function degradePerformance(error: Error) {
}

/**
* Runs a piece of code and degrades performance if certain errors are thrown
* Runs a piece of code and degrades performance if certain errors are thrown.
* Catches both sync throws and async rejections from the storage provider.
*/
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 waitPromise = waitForInitialization ? initPromise : Promise.resolve();

return waitPromise.then(() => fn()).catch((error) => {
if (error instanceof Error) {
// IndexedDB error when backing store is corrupted (https://github.com/Expensify/App/issues/87862)
if (error.message.includes('Internal error opening backing store for indexedDB.open')) {
degradePerformance(error);
}
});

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

throw error;
});
}

Expand Down
52 changes: 45 additions & 7 deletions lib/storage/providers/IDBKeyValProvider/createStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@ import * as IDB from 'idb-keyval';
import type {UseStore} from 'idb-keyval';
import * as Logger from '../../../Logger';

/**
* Attempts to heal a corrupted IDB by deleting the database entirely.
* On success the next `indexedDB.open()` will recreate it from scratch.
*/
function healCorruptedDatabase(dbName: string): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(dbName);
request.onsuccess = () => {
Logger.logInfo('IDB database deleted successfully, will recreate on next operation', {dbName});
resolve();
};
request.onerror = () => {
Logger.logAlert('IDB deleteDatabase failed, corruption is unrecoverable', {dbName});
reject(request.error ?? new DOMException('Failed to delete corrupted database', 'UnknownError'));
};
request.onblocked = () => {
Logger.logAlert('IDB deleteDatabase blocked by other connections', {dbName});
reject(new DOMException('deleteDatabase blocked', 'UnknownError'));
};
});
}

// This is a copy of the createStore function from idb-keyval, we need a custom implementation
// because we need to create the database manually in order to ensure that the store exists before we use it.
// If the store does not exist, idb-keyval will throw an error
Expand Down Expand Up @@ -36,11 +58,11 @@ function createStore(dbName: string, storeName: string): UseStore {
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
dbp = IDB.promisifyRequest(request);

dbp.then(
attachHandlers,
// eslint-disable-next-line @typescript-eslint/no-empty-function
() => {},
);
dbp.then(attachHandlers, () => {
// Clear the cached rejected promise so the next operation retries
// with a fresh indexedDB.open() instead of returning the same rejection.
dbp = undefined;
});
return dbp;
};

Expand All @@ -67,8 +89,9 @@ function createStore(dbName: string, storeName: string): UseStore {
};

dbp = IDB.promisifyRequest(request);
// eslint-disable-next-line @typescript-eslint/no-empty-function
dbp.then(attachHandlers, () => {});
dbp.then(attachHandlers, () => {
dbp = undefined;
});
return dbp;
};

Expand All @@ -80,6 +103,10 @@ function createStore(dbName: string, storeName: string): UseStore {

// If the connection was closed between getDB() resolving and db.transaction() executing,
// the transaction throws InvalidStateError. We catch it and retry once with a fresh connection.
//
// If the LevelDB backing store is corrupted (Chromium-specific), we attempt to heal by
// deleting the database and retrying. If healing fails, the error propagates up to
// tryOrDegradePerformance which falls back to MemoryOnlyProvider.
return (txMode, callback) =>
executeTransaction(txMode, callback).catch((error) => {
if (error instanceof DOMException && error.name === 'InvalidStateError') {
Expand All @@ -93,6 +120,17 @@ function createStore(dbName: string, storeName: string): UseStore {
// Retry only once — this call is not wrapped, so if it also fails the error propagates normally.
return executeTransaction(txMode, callback);
}

if (error instanceof DOMException && error.message.includes('Internal error opening backing store')) {
Logger.logAlert('IDB backing store corrupted, attempting to heal', {
dbName,
storeName,
errorMessage: error.message,
});
dbp = undefined;
return healCorruptedDatabase(dbName).then(() => executeTransaction(txMode, callback));
}

throw error;
});
}
Expand Down
141 changes: 141 additions & 0 deletions tests/unit/storage/StorageCorruptionTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/* eslint-disable import/first */
jest.unmock('../../../lib/storage');
jest.unmock('../../../lib/storage/platforms/index');
jest.unmock('../../../lib/storage/providers/IDBKeyValProvider');

// React Native jest preset resolves platforms/index to platforms/index.native (SQLiteProvider).
// Force it to use IDBKeyValProvider for these tests.
jest.mock('../../../lib/storage/platforms/index.native', () => require('../../../lib/storage/providers/IDBKeyValProvider'));

import type {UseStore} from 'idb-keyval';
import type StorageModule from '../../../lib/storage';
import type IDBKeyValProviderModule from '../../../lib/storage/providers/IDBKeyValProvider';

// Each test gets fresh module instances to avoid shared state (e.g. degraded provider persisting).
let storage: typeof StorageModule;
let IDBKeyValProvider: typeof IDBKeyValProviderModule;

/**
* Creates a DOMException that matches the real Chromium "Internal error opening backing store" error.
*/
function createBackingStoreError(): DOMException {
return new DOMException('Internal error opening backing store for indexedDB.open.', 'UnknownError');
}

describe('Storage corruption detection and healing', () => {
beforeEach(() => {
jest.resetModules();
storage = require('../../../lib/storage').default;
IDBKeyValProvider = require('../../../lib/storage/providers/IDBKeyValProvider').default;
storage.init();
});

afterEach(() => {
jest.restoreAllMocks();
});

describe('Async error handling in tryOrDegradePerformance', () => {
it('should catch async IDB rejections and degrade to MemoryOnlyProvider', async () => {
await storage.setItem('test_key', {id: 'test_value'});
expect(await storage.getItem('test_key')).toEqual({id: 'test_value'});

// Replace the store function entirely — bypasses createStore's heal logic,
// tests that tryOrDegradePerformance catches async rejections.
IDBKeyValProvider.store = (() => Promise.reject(createBackingStoreError())) as unknown as UseStore;

await expect(storage.setItem('key2', {id: 'value2'})).rejects.toThrow('Internal error opening backing store');
expect(storage.getStorageProvider().name).toBe('MemoryOnlyProvider');
});
});

describe('Corruption healing in createStore', () => {
it('should detect corruption, delete the database, re-init, and recover', async () => {
await storage.setItem('important_key', {id: 'important_value'});
expect(await storage.getItem('important_key')).toEqual({id: 'important_value'});

// Re-init to get a fresh createStore instance with dbp = undefined
IDBKeyValProvider.init();

// Mock indexedDB.open to fail once with backing store error, then work normally.
// idb-keyval's promisifyRequest sets request.onsuccess/onerror as properties,
// so we must trigger those callbacks, not dispatch DOM events.
let openCallCount = 0;
const realOpen = indexedDB.open.bind(indexedDB);
const openSpy = jest.spyOn(indexedDB, 'open').mockImplementation((name: string, version?: number) => {
openCallCount++;
if (openCallCount <= 1) {
const req = {} as IDBOpenDBRequest;
Promise.resolve().then(() => {
Object.defineProperty(req, 'error', {value: createBackingStoreError(), configurable: true});
if (typeof req.onerror === 'function') {
req.onerror(new Event('error') as Event & {target: IDBOpenDBRequest});
}
});
return req;
}
openSpy.mockRestore();
return realOpen(name, version);
});

const deleteDbSpy = jest.spyOn(indexedDB, 'deleteDatabase');

await storage.setItem('new_key', {id: 'new_value'});

expect(deleteDbSpy).toHaveBeenCalledWith('OnyxDB');

const recovered = await storage.getItem('new_key');
expect(recovered).toEqual({id: 'new_value'});
});

it('should degrade to MemoryOnlyProvider when healing fails', async () => {
await storage.setItem('key1', {id: 'value1'});

// All IDB operations fail permanently
IDBKeyValProvider.store = (() => Promise.reject(createBackingStoreError())) as unknown as UseStore;

// deleteDatabase also fails
jest.spyOn(indexedDB, 'deleteDatabase').mockImplementation(() => {
const req = {} as IDBOpenDBRequest;
Promise.resolve().then(() => {
Object.defineProperty(req, 'error', {value: createBackingStoreError(), configurable: true});
if (typeof req.onerror === 'function') {
req.onerror(new Event('error') as Event & {target: IDBOpenDBRequest});
}
});
return req;
});

try {
await storage.setItem('key2', {id: 'value2'});
} catch {
// Expected
}

// Subsequent operations should work via MemoryOnlyProvider
await storage.setItem('memory_key', {id: 'memory_value'});
expect(await storage.getItem('memory_key')).toEqual({id: 'memory_value'});
expect(storage.getStorageProvider().name).toBe('MemoryOnlyProvider');
});
});

describe('Error classification', () => {
it('should only trigger corruption healing for backing store errors, not other IDB errors', async () => {
await storage.setItem('key1', {id: 'value1'});

// QuotaExceeded — not a backing store error
IDBKeyValProvider.store = (() => Promise.reject(new DOMException('Quota exceeded', 'QuotaExceededError'))) as unknown as UseStore;

const deleteDbSpy = jest.spyOn(indexedDB, 'deleteDatabase');

try {
await storage.setItem('key2', {id: 'value2'});
} catch {
// May or may not throw
}

expect(deleteDbSpy).not.toHaveBeenCalled();
// Should NOT have degraded — QuotaExceeded is not a backing store error
expect(storage.getStorageProvider().name).not.toBe('MemoryOnlyProvider');
});
});
});
Loading