diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index 4dc1ba2ce..6a548bb45 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -773,11 +773,57 @@ function reportStorageQuota(error?: Error): Promise { }); } +// 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 | 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); + } else { + pairs = Object.entries(params as Record); + } + } + 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 @@ -820,6 +866,13 @@ function retryOperation( 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(); diff --git a/lib/storage/errors.ts b/lib/storage/errors.ts index 635f55428..9a218f28d 100644 --- a/lib/storage/errors.ts +++ b/lib/storage/errors.ts @@ -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 diff --git a/lib/storage/providers/IDBKeyValProvider/classifyError.ts b/lib/storage/providers/IDBKeyValProvider/classifyError.ts index 99f3c3e64..0a619b4e6 100644 --- a/lib/storage/providers/IDBKeyValProvider/classifyError.ts +++ b/lib/storage/providers/IDBKeyValProvider/classifyError.ts @@ -19,6 +19,13 @@ function classifyIDBError(error: unknown): ValueOf { 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; diff --git a/tests/unit/storage/providers/classifyIDBErrorTest.ts b/tests/unit/storage/providers/classifyIDBErrorTest.ts new file mode 100644 index 000000000..4fe695163 --- /dev/null +++ b/tests/unit/storage/providers/classifyIDBErrorTest.ts @@ -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); + }); +});