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
53 changes: 53 additions & 0 deletions lib/OnyxUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -773,11 +773,57 @@ function reportStorageQuota(error?: Error): Promise<void> {
});
}

// 64 KB — Chromium wraps any IndexedDB value larger than this into an external blob file
// (application/vnd.blink-idb-value-wrapper); that file write is what fails as "Failed to write blobs".
const IDB_VALUE_WRAP_THRESHOLD_BYTES = 64 * 1024;

/**
* Diagnostic for UNRECOVERABLE blob-write failures: reports the key(s) in the failed write and each
* value's approximate serialized size (never the values themselves), so telemetry can reveal whether
* the failing writes are the >64 KB ones Chromium externalizes to blob files, and which keys they are.
*/
function summarizeFailedWritePayload(defaultParams: unknown): string {
try {
const params = defaultParams as Record<string, unknown> | undefined;
let pairs: Array<[string, unknown]> = [];
if (params && typeof params === 'object') {
if ('key' in params && 'value' in params) {
pairs = [[String(params.key), params.value]];
} else if ('collection' in params && params.collection && typeof params.collection === 'object') {
pairs = Object.entries(params.collection as Record<string, unknown>);
} else {
pairs = Object.entries(params as Record<string, unknown>);
}
}
const sized = pairs
.map(([key, value]) => {
let bytes = -1;
try {
bytes = JSON.stringify(value)?.length ?? 0;
} catch {
bytes = -1;
}
return {key, bytes};
})
.sort((a, b) => b.bytes - a.bytes);
const overThreshold = sized.filter((entry) => entry.bytes >= IDB_VALUE_WRAP_THRESHOLD_BYTES).length;
const largest = sized
.slice(0, 5)
.map((entry) => `${entry.key}=${entry.bytes}B`)
.join(', ');
return `pairs: ${sized.length}. over64KB: ${overThreshold}. largest: [${largest}].`;
} catch {
return 'payload summary unavailable.';
}
}

/**
* Handles storage operation failures based on the error class (see lib/storage/errors.ts).
* The connection layer (createStore) owns connection/transport recovery; this operation layer owns
* capacity recovery (eviction) so that a given failure is retried by exactly one layer:
* - INVALID_DATA: logs an alert and throws (the same data will always fail).
* - UNRECOVERABLE: an engine-level write fault or permanently invalid payload that no retry,
* reopen, or eviction can fix — skip the write quietly (no throw, unlike INVALID_DATA).
* - TRANSIENT / FATAL: the connection layer already retried (transient) or exhausted its heal budget
* and alerted (fatal). Retrying here would only re-amplify, so we skip the write quietly.
* - CAPACITY: evicts the least recently accessed evictable key and retries, under a session-level
Expand Down Expand Up @@ -820,6 +866,13 @@ function retryOperation<TMethod extends RetriableOnyxOperation>(
return Promise.resolve();
}

if (errorClass === StorageErrorClass.UNRECOVERABLE) {
Logger.logInfo(
`Storage operation skipped retry; ${errorClass} errors cannot be recovered by retrying. Error: ${error}. onyxMethod: ${onyxMethod.name}. ${summarizeFailedWritePayload(defaultParams)}`,
);
return Promise.resolve();
}

if (nextRetryAttempt > MAX_STORAGE_OPERATION_RETRY_ATTEMPTS) {
Logger.logAlert(`Storage operation failed after ${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS} retries. Error: ${error}. onyxMethod: ${onyxMethod.name}.`);
return Promise.resolve();
Expand Down
4 changes: 4 additions & 0 deletions lib/storage/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ const StorageErrorClass = {
CAPACITY: 'capacity',
/** Non-serializable payload. Never retriable — the same data will always fail. */
INVALID_DATA: 'invalidData',
/** Engine-level write fault or a permanently invalid payload that no retry, reopen, or
* eviction can fix, and that must not crash the caller (unlike INVALID_DATA). Owner:
* operation layer — skip the write quietly, never retry. */
UNRECOVERABLE: 'unrecoverable',
/** Backing-store corruption. Owner: connection layer — budgeted heal, then give up. */
FATAL: 'fatal',
/** Unmatched by the active provider. Owner: operation layer — bounded retry, and log the shape so
Expand Down
7 changes: 7 additions & 0 deletions lib/storage/providers/IDBKeyValProvider/classifyError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ function classifyIDBError(error: unknown): ValueOf<typeof StorageErrorClass> {
return StorageErrorClass.CAPACITY;
}

// Chromium blob-file write failure (IOError / InvalidBlob) when externalizing large IDB
// values. Not disk pressure and not the caller's data — an engine-level fault; retrying
// never succeeds, so skip quietly instead of amplifying.
if (message.includes('failed to write blobs')) {
return StorageErrorClass.UNRECOVERABLE;
}

// Backing-store corruption (Chromium LevelDB). Recoverable only via a budgeted reopen.
if (message.includes('internal error opening backing store')) {
return StorageErrorClass.FATAL;
Expand Down
26 changes: 26 additions & 0 deletions tests/unit/storage/providers/classifyIDBErrorTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import classifyIDBError from '../../../../lib/storage/providers/IDBKeyValProvider/classifyError';
import {StorageErrorClass} from '../../../../lib/storage/errors';

describe('classifyIDBError', () => {
it('classifies "Failed to write blobs (IOError)" as UNRECOVERABLE', () => {
const error = new DOMException('Failed to write blobs (IOError)', 'DataError');
expect(classifyIDBError(error)).toBe(StorageErrorClass.UNRECOVERABLE);
});

it('classifies "Failed to write blobs (InvalidBlob)" as UNRECOVERABLE', () => {
const error = new DOMException('Failed to write blobs (InvalidBlob)', 'DataError');
expect(classifyIDBError(error)).toBe(StorageErrorClass.UNRECOVERABLE);
});

it('classifies quota exceeded as CAPACITY', () => {
expect(classifyIDBError(new DOMException('quota', 'QuotaExceededError'))).toBe(StorageErrorClass.CAPACITY);
});

it('classifies backing-store corruption as FATAL', () => {
expect(classifyIDBError(new DOMException('Internal error opening backing store for indexedDB.open.', 'UnknownError'))).toBe(StorageErrorClass.FATAL);
});

it('classifies an unrecognized error as UNKNOWN', () => {
expect(classifyIDBError(new DOMException('something we have never seen', 'WeirdError'))).toBe(StorageErrorClass.UNKNOWN);
});
});
Loading