diff --git a/API-INTERNAL.md b/API-INTERNAL.md
index 54dc85d21..bc8c0b467 100644
--- a/API-INTERNAL.md
+++ b/API-INTERNAL.md
@@ -2,9 +2,21 @@
# Internal API Reference
+## Constants
+
+
+- DISK_PRESSURE_LOG_INTERVAL_MS
+Minimum interval between disk-pressure alerts. One disk-pressure burst fails every queued operation
+with the identical error, so per-operation logging would amplify the very storm it reports.
+
+
+
## Functions
+- resetDiskPressureLogThrottle()
+Test-only: clears the disk-pressure log throttle so each test observes its own alert.
+
- getMergeQueue()
Getter - returns the merge queue.
@@ -89,6 +101,9 @@ and alerted (fatal). Retrying here would only re-amplify, so we skip the write q
CAPACITY: evicts the least recently accessed evictable key and retries, under a session-level
circuit breaker (see lib/StorageCircuitBreaker.ts) that halts the loop once eviction stops making
progress or failures storm — the per-operation budget alone cannot stop a session-wide storm.
+DISK_PRESSURE: the device disk itself is full (or the database files are unreadable), so neither
+retries nor in-DB eviction can free space — the write is dropped (cache stays authoritative) with
+a single throttled alert + quota snapshot per burst.
UNKNOWN: the provider couldn't classify it — log the full error shape (name + message +
provider) once so it's visible, then bounded retry without eviction.
@@ -157,6 +172,19 @@ Retries on failure.
+
+
+## DISK\_PRESSURE\_LOG\_INTERVAL\_MS
+Minimum interval between disk-pressure alerts. One disk-pressure burst fails every queued operation
+with the identical error, so per-operation logging would amplify the very storm it reports.
+
+**Kind**: global constant
+
+
+## resetDiskPressureLogThrottle()
+Test-only: clears the disk-pressure log throttle so each test observes its own alert.
+
+**Kind**: global function
## getMergeQueue()
@@ -333,6 +361,9 @@ capacity recovery (eviction) so that a given failure is retried by exactly one l
- CAPACITY: evicts the least recently accessed evictable key and retries, under a session-level
circuit breaker (see lib/StorageCircuitBreaker.ts) that halts the loop once eviction stops making
progress or failures storm — the per-operation budget alone cannot stop a session-wide storm.
+- DISK_PRESSURE: the device disk itself is full (or the database files are unreadable), so neither
+ retries nor in-DB eviction can free space — the write is dropped (cache stays authoritative) with
+ a single throttled alert + quota snapshot per burst.
- UNKNOWN: the provider couldn't classify it — log the full error shape (name + message +
provider) once so it's visible, then bounded retry without eviction.
diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts
index 4dc1ba2ce..31c50c38e 100644
--- a/lib/OnyxUtils.ts
+++ b/lib/OnyxUtils.ts
@@ -52,6 +52,16 @@ const METHOD = {
// Max number of retries for failed storage operations
const MAX_STORAGE_OPERATION_RETRY_ATTEMPTS = 5;
+/** Minimum interval between disk-pressure alerts. One disk-pressure burst fails every queued operation
+ * with the identical error, so per-operation logging would amplify the very storm it reports. */
+const DISK_PRESSURE_LOG_INTERVAL_MS = 60000;
+let lastDiskPressureLogTime = 0;
+
+/** Test-only: clears the disk-pressure log throttle so each test observes its own alert. */
+function resetDiskPressureLogThrottle(): void {
+ lastDiskPressureLogTime = 0;
+}
+
type OnyxMethod = ValueOf;
// Key/value store of Onyx key and arrays of values to merge
@@ -783,6 +793,9 @@ function reportStorageQuota(error?: Error): Promise {
* - CAPACITY: evicts the least recently accessed evictable key and retries, under a session-level
* circuit breaker (see lib/StorageCircuitBreaker.ts) that halts the loop once eviction stops making
* progress or failures storm — the per-operation budget alone cannot stop a session-wide storm.
+ * - DISK_PRESSURE: the device disk itself is full (or the database files are unreadable), so neither
+ * retries nor in-DB eviction can free space — the write is dropped (cache stays authoritative) with
+ * a single throttled alert + quota snapshot per burst.
* - UNKNOWN: the provider couldn't classify it — log the full error shape (name + message +
* provider) once so it's visible, then bounded retry without eviction.
*/
@@ -806,6 +819,19 @@ function retryOperation(
return Promise.resolve();
}
+ // DISK_PRESSURE: the device disk is full, so neither retries nor eviction can succeed until the OS
+ // frees space. Drop the write (cache stays authoritative) and log one alert + quota snapshot per
+ // interval — the snapshot's free-disk bytes let telemetry confirm (or rule out) disk pressure.
+ if (errorClass === StorageErrorClass.DISK_PRESSURE) {
+ const now = Date.now();
+ if (now - lastDiskPressureLogTime < DISK_PRESSURE_LOG_INTERVAL_MS) {
+ return Promise.resolve();
+ }
+ lastDiskPressureLogTime = now;
+ Logger.logAlert(`Disk-pressure storage error; skipping retries. provider: ${Storage.getStorageProvider().name}. message: ${error?.message}. onyxMethod: ${onyxMethod.name}.`);
+ return reportStorageQuota(error);
+ }
+
Logger.logInfo(
`Failed to save to storage. Error: ${error}. class: ${errorClass}. onyxMethod: ${onyxMethod.name}. retryAttempt: ${currentRetryAttempt}/${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS}`,
);
@@ -1816,6 +1842,7 @@ const OnyxUtils = {
getCollectionDataAndSendAsObject,
remove,
reportStorageQuota,
+ resetDiskPressureLogThrottle,
retryOperation,
broadcastUpdate,
hasPendingMergeForKey,
diff --git a/lib/storage/errors.ts b/lib/storage/errors.ts
index 635f55428..631b01535 100644
--- a/lib/storage/errors.ts
+++ b/lib/storage/errors.ts
@@ -15,6 +15,10 @@ const StorageErrorClass = {
TRANSIENT: 'transient',
/** Quota exceeded / disk full. Owner: operation layer — evict and retry. */
CAPACITY: 'capacity',
+ /** Filesystem-level failure around the database files (device disk full, or the files cannot be
+ * created/read). Owner: operation layer — skip retries and eviction (neither can free OS-level
+ * space) and log one throttled alert + quota snapshot per burst. */
+ DISK_PRESSURE: 'diskPressure',
/** Non-serializable payload. Never retriable — the same data will always fail. */
INVALID_DATA: 'invalidData',
/** Backing-store corruption. Owner: connection layer — budgeted heal, then give up. */
diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts
index c03efb66e..013139120 100644
--- a/lib/storage/providers/SQLiteProvider.ts
+++ b/lib/storage/providers/SQLiteProvider.ts
@@ -325,16 +325,20 @@ const provider: StorageProvider = {
throw new Error('Store is not initialized!');
}
- return Promise.all([provider.store.executeAsync('PRAGMA page_size;'), provider.store.executeAsync('PRAGMA page_count;'), getFreeDiskStorage()]).then(
- ([pageSizeResult, pageCountResult, bytesRemaining]) => {
+ // The PRAGMAs need the SQLite connection; getFreeDiskStorage() is filesystem-level. Degrade
+ // bytesUsed to -1 instead of failing, so the free-disk bytes always get logged.
+ const bytesUsedPromise = Promise.all([provider.store.executeAsync('PRAGMA page_size;'), provider.store.executeAsync('PRAGMA page_count;')])
+ .then(([pageSizeResult, pageCountResult]) => {
const pageSize = pageSizeResult.rows?.item(0)?.page_size ?? 0;
const pageCount = pageCountResult.rows?.item(0)?.page_count ?? 0;
- return {
- bytesUsed: pageSize * pageCount,
- bytesRemaining,
- };
- },
- );
+ return pageSize * pageCount;
+ })
+ .catch(() => -1);
+
+ return Promise.all([bytesUsedPromise, getFreeDiskStorage()]).then(([bytesUsed, bytesRemaining]) => ({
+ bytesUsed,
+ bytesRemaining,
+ }));
},
};
diff --git a/lib/storage/providers/classifySQLiteError.ts b/lib/storage/providers/classifySQLiteError.ts
index 2105764c2..06d4f7d51 100644
--- a/lib/storage/providers/classifySQLiteError.ts
+++ b/lib/storage/providers/classifySQLiteError.ts
@@ -18,6 +18,10 @@ function classifySQLiteError(error: unknown): ValueOf
return StorageErrorClass.CAPACITY;
}
+ if (message.includes('disk i/o error') || message.includes('unable to open database file')) {
+ return StorageErrorClass.DISK_PRESSURE;
+ }
+
return StorageErrorClass.UNKNOWN;
}
diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts
index 93899cfdc..0a20a7d21 100644
--- a/tests/unit/onyxUtilsTest.ts
+++ b/tests/unit/onyxUtilsTest.ts
@@ -748,8 +748,12 @@ describe('OnyxUtils', () => {
const diskFullError = new Error('database or disk is full');
const nonRetriableIdbError = Object.assign(new Error('Internal error opening backing store for indexedDB.open.'), {name: 'UnknownError'});
- // The circuit breaker is process-scoped, so reset it between tests to avoid state leaking.
- beforeEach(() => StorageCircuitBreaker.reset());
+ // The circuit breaker and the disk-pressure log throttle are process-scoped, so reset them
+ // between tests to avoid state leaking.
+ beforeEach(() => {
+ StorageCircuitBreaker.reset();
+ OnyxUtils.resetDiskPressureLogThrottle();
+ });
it('should retry only one time if the operation is firstly failed and then passed', async () => {
StorageMock.setItem = jest.fn(StorageMock.setItem).mockRejectedValueOnce(genericError).mockImplementation(StorageMock.setItem);
@@ -808,6 +812,41 @@ describe('OnyxUtils', () => {
expect(retryOperationSpy).toHaveBeenCalledTimes(1);
});
+ it.each([['[NativeNitroSQLiteException][SqlExecutionError] disk I/O error'], ['[NativeNitroSQLiteException][SqlExecutionError] unable to open database file']])(
+ 'should not retry disk-pressure errors (%s)',
+ async (message) => {
+ StorageMock.setItem = jest.fn().mockRejectedValue(new Error(message));
+
+ await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'});
+
+ // Called once (initial attempt only): retries cannot succeed while the device disk is full.
+ expect(retryOperationSpy).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ it('should log a single throttled alert with a quota snapshot for a disk-pressure burst', async () => {
+ const logAlertSpy = jest.spyOn(Logger, 'logAlert');
+ const logInfoSpy = jest.spyOn(Logger, 'logInfo');
+ const diskIOError = new Error('[NativeNitroSQLiteException][SqlExecutionError] disk I/O error');
+ StorageMock.setItem = jest.fn().mockRejectedValue(diskIOError);
+
+ // A burst: several operations all failing with the identical error.
+ await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data'});
+ await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data2'});
+ await Onyx.set(ONYXKEYS.TEST_KEY, {test: 'data3'});
+
+ // One alert for the whole burst (the rest are throttled within DISK_PRESSURE_LOG_INTERVAL_MS)...
+ const alerts = logAlertSpy.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].startsWith('Disk-pressure storage error'));
+ expect(alerts).toHaveLength(1);
+ expect(alerts[0][0]).toBe(`Disk-pressure storage error; skipping retries. provider: MemoryOnlyProvider. message: ${diskIOError.message}. onyxMethod: setWithRetry.`);
+ // ...paired with exactly one quota snapshot (carries the free-disk bytes on native platforms).
+ const quotaLogs = logInfoSpy.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].startsWith('Storage Quota Check'));
+ expect(quotaLogs).toHaveLength(1);
+ // And no "failed after N retries" alert: the writes were dropped, not retried to exhaustion.
+ const retryAlerts = logAlertSpy.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].startsWith('Storage operation failed after'));
+ expect(retryAlerts).toHaveLength(0);
+ });
+
it('should skip retry quietly (info, not alert) for fatal connection-layer errors', async () => {
const logAlertSpy = jest.spyOn(Logger, 'logAlert');
const logInfoSpy = jest.spyOn(Logger, 'logInfo');
diff --git a/tests/unit/storage/providers/SQLiteProviderTest.ts b/tests/unit/storage/providers/SQLiteProviderTest.ts
index 4d82b2e31..55da98760 100644
--- a/tests/unit/storage/providers/SQLiteProviderTest.ts
+++ b/tests/unit/storage/providers/SQLiteProviderTest.ts
@@ -453,5 +453,15 @@ describe('SQLiteProvider', () => {
expect(after.bytesUsed).toBeGreaterThan(before.bytesUsed);
});
+
+ it('should still report free-disk bytes when the PRAGMAs fail (disk pressure)', async () => {
+ // During disk pressure the SQLite connection itself fails, but free-disk comes from the
+ // filesystem — the snapshot must survive with bytesUsed degraded instead of rejecting.
+ const executeAsyncSpy = jest.spyOn(SQLiteProvider.store!, 'executeAsync').mockRejectedValue(new Error('[NativeNitroSQLiteException][SqlExecutionError] disk I/O error'));
+
+ await expect(SQLiteProvider.getDatabaseSize()).resolves.toEqual({bytesUsed: -1, bytesRemaining: 12345});
+
+ executeAsyncSpy.mockRestore();
+ });
});
});