Skip to content

Commit 6569dc7

Browse files
leshniakclaude
andcommitted
fix: add IDB backing store corruption healing mechanism
Adds a Dexie-style heal pattern to createStore for Chromium's Internal error opening backing store error (884K errors/month). - isBackingStoreError() detects the Chromium-specific corruption - Shared healAttemptsRemaining counter (3, reset on success) - On backing store error: clear cached connection, retry once - Clear dbp on rejection so retries get fresh indexedDB.open() - 5 new tests: mid-session heal, init heal, budget exhaustion, budget reset, error classification No deleteDatabase(), no provider swap, no UI changes. Scoped to IDBKeyValProvider only -- SQLite provider untouched. Ref: Expensify/App#90636 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b506f32 commit 6569dc7

2 files changed

Lines changed: 227 additions & 23 deletions

File tree

lib/storage/providers/IDBKeyValProvider/createStore.ts

Lines changed: 59 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,25 @@ import * as IDB from 'idb-keyval';
22
import type {UseStore} from 'idb-keyval';
33
import * as Logger from '../../../Logger';
44

5+
const HEAL_ATTEMPTS_MAX = 3;
6+
7+
/**
8+
* Detects the Chromium-specific IDB backing store corruption error.
9+
* Fires when LevelDB files backing IndexedDB are corrupted and Chrome's
10+
* internal recovery (RepairDB -> delete -> recreate) also fails.
11+
* https://github.com/Expensify/App/issues/87862
12+
*/
13+
function isBackingStoreError(error: unknown): boolean {
14+
return error instanceof DOMException && error.name === 'UnknownError' && error.message.includes('Internal error opening backing store');
15+
}
16+
517
// This is a copy of the createStore function from idb-keyval, we need a custom implementation
618
// because we need to create the database manually in order to ensure that the store exists before we use it.
719
// If the store does not exist, idb-keyval will throw an error
820
// source: https://github.com/jakearchibald/idb-keyval/blob/9d19315b4a83897df1e0193dccdc29f78466a0f3/src/index.ts#L12
921
function createStore(dbName: string, storeName: string): UseStore {
1022
let dbp: Promise<IDBDatabase> | undefined;
23+
let healAttemptsRemaining = HEAL_ATTEMPTS_MAX;
1124

1225
const attachHandlers = (db: IDBDatabase) => {
1326
// Browsers may close idle IDB connections at any time, especially Safari.
@@ -36,11 +49,11 @@ function createStore(dbName: string, storeName: string): UseStore {
3649
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
3750
dbp = IDB.promisifyRequest(request);
3851

39-
dbp.then(
40-
attachHandlers,
41-
// eslint-disable-next-line @typescript-eslint/no-empty-function
42-
() => {},
43-
);
52+
dbp.then(attachHandlers, () => {
53+
// Clear the cached rejected promise so the next operation retries
54+
// with a fresh indexedDB.open() instead of returning the same rejection.
55+
dbp = undefined;
56+
});
4457
return dbp;
4558
};
4659

@@ -67,8 +80,9 @@ function createStore(dbName: string, storeName: string): UseStore {
6780
};
6881

6982
dbp = IDB.promisifyRequest(request);
70-
// eslint-disable-next-line @typescript-eslint/no-empty-function
71-
dbp.then(attachHandlers, () => {});
83+
dbp.then(attachHandlers, () => {
84+
dbp = undefined;
85+
});
7286
return dbp;
7387
};
7488

@@ -78,23 +92,45 @@ function createStore(dbName: string, storeName: string): UseStore {
7892
.then((db) => callback(db.transaction(storeName, txMode).objectStore(storeName)));
7993
}
8094

81-
// If the connection was closed between getDB() resolving and db.transaction() executing,
82-
// the transaction throws InvalidStateError. We catch it and retry once with a fresh connection.
95+
function resetHealBudget<T>(result: T): T {
96+
healAttemptsRemaining = HEAL_ATTEMPTS_MAX;
97+
return result;
98+
}
99+
100+
// Handles two recoverable error classes:
101+
// 1. InvalidStateError — connection closed between getDB() resolving and db.transaction().
102+
// Retry once with a fresh connection.
103+
// 2. Backing store corruption (Chromium UnknownError) — close + reopen the IDB connection.
104+
// Bounded by a shared heal budget (3 attempts, reset on success).
105+
// Mirrors Dexie's PR1398_maxLoop pattern: https://github.com/dexie/Dexie.js/blob/master/src/functions/temp-transaction.ts
83106
return (txMode, callback) =>
84-
executeTransaction(txMode, callback).catch((error) => {
85-
if (error instanceof DOMException && error.name === 'InvalidStateError') {
86-
Logger.logAlert('IDB InvalidStateError, retrying with fresh connection', {
87-
dbName,
88-
storeName,
89-
txMode,
90-
errorMessage: error.message,
91-
});
92-
dbp = undefined;
93-
// Retry only once — this call is not wrapped, so if it also fails the error propagates normally.
94-
return executeTransaction(txMode, callback);
95-
}
96-
throw error;
97-
});
107+
executeTransaction(txMode, callback)
108+
.then(resetHealBudget)
109+
.catch((error) => {
110+
if (error instanceof DOMException && error.name === 'InvalidStateError') {
111+
Logger.logAlert('IDB InvalidStateError, retrying with fresh connection', {
112+
dbName,
113+
storeName,
114+
txMode,
115+
errorMessage: error.message,
116+
});
117+
dbp = undefined;
118+
return executeTransaction(txMode, callback).then(resetHealBudget);
119+
}
120+
121+
if (isBackingStoreError(error) && healAttemptsRemaining > 0) {
122+
healAttemptsRemaining--;
123+
Logger.logInfo('IDB heal: backing store error, attempting close + reopen', {
124+
dbName,
125+
storeName,
126+
healAttemptsRemaining,
127+
});
128+
dbp = undefined;
129+
return executeTransaction(txMode, callback).then(resetHealBudget);
130+
}
131+
132+
throw error;
133+
});
98134
}
99135

100136
export default createStore;

tests/unit/storage/providers/createStoreTest.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,4 +245,172 @@ describe('createStore', () => {
245245
expect(result).toBe('value');
246246
});
247247
});
248+
249+
describe('backing store healing', () => {
250+
/**
251+
* Helper: creates a DOMException matching Chromium's backing store corruption error.
252+
*/
253+
function backingStoreError() {
254+
return new DOMException('Internal error opening backing store for indexedDB.open.', 'UnknownError');
255+
}
256+
257+
it('should heal mid-session by closing and reopening the connection', async () => {
258+
const store = createStore(uniqueDBName(), STORE_NAME);
259+
260+
await store('readwrite', (s) => {
261+
s.put('value', 'key1');
262+
return IDB.promisifyRequest(s.transaction);
263+
});
264+
265+
const original = IDBDatabase.prototype.transaction;
266+
let callCount = 0;
267+
jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) {
268+
callCount++;
269+
if (callCount === 1) {
270+
throw backingStoreError();
271+
}
272+
return original.apply(this, args);
273+
});
274+
275+
const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1')));
276+
expect(result).toBe('value');
277+
expect(callCount).toBe(2);
278+
expect(logInfoSpy).toHaveBeenCalledWith('IDB heal: backing store error, attempting close + reopen', expect.objectContaining({healAttemptsRemaining: 2}));
279+
});
280+
281+
it('should heal on init when indexedDB.open() rejects with UnknownError', async () => {
282+
const dbName = uniqueDBName();
283+
284+
// Pre-create the DB with data
285+
const setupStore = createStore(dbName, STORE_NAME);
286+
await setupStore('readwrite', (s) => {
287+
s.put('persisted', 'key1');
288+
return IDB.promisifyRequest(s.transaction);
289+
});
290+
291+
// Fresh store instance (simulates app restart — dbp starts undefined)
292+
const store = createStore(dbName, STORE_NAME);
293+
294+
// Mock indexedDB.open to fail twice, then succeed.
295+
// First failure: initial open in the first store() call.
296+
// Second failure: the heal retry's open in that same call — propagates error.
297+
// Third call (second store() invocation): succeeds, proving budget still has 1 left.
298+
const realOpen = indexedDB.open.bind(indexedDB);
299+
let openCallCount = 0;
300+
jest.spyOn(indexedDB, 'open').mockImplementation((name: string, version?: number) => {
301+
openCallCount++;
302+
if (openCallCount <= 2) {
303+
const req = {} as IDBOpenDBRequest;
304+
Promise.resolve().then(() => {
305+
Object.defineProperty(req, 'error', {value: backingStoreError(), configurable: true});
306+
req.onerror?.(new Event('error') as Event & {target: IDBOpenDBRequest});
307+
});
308+
return req;
309+
}
310+
return realOpen(name, version);
311+
});
312+
313+
// First call: open fails, heal retry also fails — error propagates
314+
await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('key1')))).rejects.toThrow('Internal error opening backing store');
315+
316+
// Second call: open succeeds (mock exhausted), heals
317+
const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1')));
318+
expect(result).toBe('persisted');
319+
expect(openCallCount).toBeGreaterThanOrEqual(3);
320+
});
321+
322+
it('should stop healing after budget exhausts across multiple operations', async () => {
323+
const store = createStore(uniqueDBName(), STORE_NAME);
324+
325+
// All opens fail permanently
326+
jest.spyOn(indexedDB, 'open').mockImplementation(() => {
327+
const req = {} as IDBOpenDBRequest;
328+
Promise.resolve().then(() => {
329+
Object.defineProperty(req, 'error', {value: backingStoreError(), configurable: true});
330+
req.onerror?.(new Event('error') as Event & {target: IDBOpenDBRequest});
331+
});
332+
return req;
333+
});
334+
335+
// Each call consumes 1 heal attempt (initial fails, heal retry also fails, propagates)
336+
await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('k')))).rejects.toThrow('Internal error opening backing store');
337+
await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('k')))).rejects.toThrow('Internal error opening backing store');
338+
await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('k')))).rejects.toThrow('Internal error opening backing store');
339+
340+
// Budget exhausted — 4th call should NOT attempt healing
341+
logInfoSpy.mockClear();
342+
await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('k')))).rejects.toThrow('Internal error opening backing store');
343+
expect(logInfoSpy).not.toHaveBeenCalledWith(expect.stringContaining('IDB heal'), expect.anything());
344+
});
345+
346+
it('should reset heal budget after a successful operation', async () => {
347+
const dbName = uniqueDBName();
348+
const store = createStore(dbName, STORE_NAME);
349+
350+
await store('readwrite', (s) => {
351+
s.put('value', 'key1');
352+
return IDB.promisifyRequest(s.transaction);
353+
});
354+
355+
const original = IDBDatabase.prototype.transaction;
356+
357+
// Drain budget to 1 remaining: fail twice, each heals successfully
358+
for (let i = 0; i < 2; i++) {
359+
let callCount = 0;
360+
const spy = jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) {
361+
callCount++;
362+
if (callCount === 1) {
363+
throw backingStoreError();
364+
}
365+
spy.mockRestore();
366+
return original.apply(this, args);
367+
});
368+
await store('readonly', (s) => IDB.promisifyRequest(s.get('key1'))); }
369+
370+
// Clean success — resets budget to 3
371+
jest.restoreAllMocks();
372+
logInfoSpy = jest.spyOn(Logger, 'logInfo');
373+
await store('readonly', (s) => IDB.promisifyRequest(s.get('key1')));
374+
375+
// Now fail 3 more times — all should still heal (proving budget was reset)
376+
for (let i = 0; i < 3; i++) {
377+
let callCount = 0;
378+
const spy = jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) {
379+
callCount++;
380+
if (callCount === 1) {
381+
throw backingStoreError();
382+
}
383+
spy.mockRestore();
384+
return original.apply(this, args);
385+
});
386+
const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1'))); expect(result).toBe('value');
387+
}
388+
});
389+
390+
it('should not attempt healing for non-backing-store errors', async () => {
391+
const store = createStore(uniqueDBName(), STORE_NAME);
392+
393+
await store('readwrite', (s) => {
394+
s.put('value', 'key1');
395+
return IDB.promisifyRequest(s.transaction);
396+
});
397+
398+
// UnknownError but different message
399+
jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(() => {
400+
throw new DOMException('Some other unknown error', 'UnknownError');
401+
});
402+
await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('key1')))).rejects.toThrow('Some other unknown error');
403+
404+
jest.restoreAllMocks();
405+
logInfoSpy = jest.spyOn(Logger, 'logInfo');
406+
407+
// QuotaExceededError
408+
jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(() => {
409+
throw new DOMException('Quota exceeded', 'QuotaExceededError');
410+
});
411+
await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('key1')))).rejects.toThrow('Quota exceeded');
412+
413+
expect(logInfoSpy).not.toHaveBeenCalledWith(expect.stringContaining('IDB heal'), expect.anything());
414+
});
415+
});
248416
});

0 commit comments

Comments
 (0)