From 3febbe80cfe099d9efa4e10db33fe8fd649af5a1 Mon Sep 17 00:00:00 2001
From: eliran goshen
Date: Wed, 29 Jul 2026 17:12:02 +0200
Subject: [PATCH 1/7] Classify SQLite disk-pressure errors and stop retrying
them
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Full device disk surfaces as "disk I/O error" (SQLITE_IOERR, shm sizing on
open), "unable to open database file" (SQLITE_CANTOPEN), or "cannot rollback -
no transaction is active" (failed ROLLBACK masking the original write error).
All were UNKNOWN: 5 futile retries per operation plus per-operation alerts.
New DISK_PRESSURE class drops the write (cache stays authoritative), skips
retries and eviction (neither frees OS-level space), and logs one throttled
alert + storage quota snapshot per burst — the snapshot carries free-disk
bytes so telemetry can confirm disk pressure as the root cause.
Co-Authored-By: Claude Fable 5
---
lib/OnyxUtils.ts | 29 +++++++++++++
lib/storage/errors.ts | 4 ++
lib/storage/providers/classifySQLiteError.ts | 9 ++++
tests/unit/onyxUtilsTest.ts | 44 +++++++++++++++++++-
4 files changed, 84 insertions(+), 2 deletions(-)
diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts
index 4dc1ba2ce..ad5131c95 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,21 @@ function retryOperation(
return Promise.resolve();
}
+ // DISK_PRESSURE: the filesystem around the database is out of space or its files are unreadable, so
+ // neither retries nor in-DB eviction can succeed — every operation fails identically until the OS
+ // frees space, and the burst recovers on its own once it does. Drop the write (the cache stays
+ // authoritative) and log one alert + quota snapshot per interval instead of one per operation; the
+ // quota snapshot carries the free-disk bytes that confirm (or rule out) disk pressure in telemetry.
+ 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 +1844,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/classifySQLiteError.ts b/lib/storage/providers/classifySQLiteError.ts
index 2105764c2..c1cbc3018 100644
--- a/lib/storage/providers/classifySQLiteError.ts
+++ b/lib/storage/providers/classifySQLiteError.ts
@@ -18,6 +18,15 @@ function classifySQLiteError(error: unknown): ValueOf
return StorageErrorClass.CAPACITY;
}
+ // Filesystem-level failures around the database files, seen when the device disk is (nearly) full.
+ // "disk I/O error" (SQLITE_IOERR) fires on every operation — reads included — after SQLite fails to
+ // size the -shm file while (re)opening the database on a full disk; "unable to open database file"
+ // (SQLITE_CANTOPEN) when the -shm file cannot be created at all; "cannot rollback" is a batch-write
+ // failure whose original error was masked by the ROLLBACK itself failing on the same full disk.
+ if (message.includes('disk i/o error') || message.includes('unable to open database file') || message.includes('cannot rollback - no transaction is active')) {
+ return StorageErrorClass.DISK_PRESSURE;
+ }
+
return StorageErrorClass.UNKNOWN;
}
diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts
index 93899cfdc..b4f92309e 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,42 @@ describe('OnyxUtils', () => {
expect(retryOperationSpy).toHaveBeenCalledTimes(1);
});
+ it.each([
+ ['[NativeNitroSQLiteException][SqlExecutionError] disk I/O error'],
+ ['[NativeNitroSQLiteException][SqlExecutionError] unable to open database file'],
+ ['[NativeNitroSQLiteException][SqlExecutionError] cannot rollback - no transaction is active'],
+ ])('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');
From 5de9702d18793a5542491e9fb0b653511a66ba98 Mon Sep 17 00:00:00 2001
From: eliran goshen
Date: Wed, 29 Jul 2026 17:31:08 +0200
Subject: [PATCH 2/7] Shorten disk-pressure comments
Co-Authored-By: Claude Fable 5
---
lib/OnyxUtils.ts | 8 +++-----
lib/storage/providers/classifySQLiteError.ts | 8 +++-----
2 files changed, 6 insertions(+), 10 deletions(-)
diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts
index ad5131c95..31c50c38e 100644
--- a/lib/OnyxUtils.ts
+++ b/lib/OnyxUtils.ts
@@ -819,11 +819,9 @@ function retryOperation(
return Promise.resolve();
}
- // DISK_PRESSURE: the filesystem around the database is out of space or its files are unreadable, so
- // neither retries nor in-DB eviction can succeed — every operation fails identically until the OS
- // frees space, and the burst recovers on its own once it does. Drop the write (the cache stays
- // authoritative) and log one alert + quota snapshot per interval instead of one per operation; the
- // quota snapshot carries the free-disk bytes that confirm (or rule out) disk pressure in telemetry.
+ // 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) {
diff --git a/lib/storage/providers/classifySQLiteError.ts b/lib/storage/providers/classifySQLiteError.ts
index c1cbc3018..651fc7ce5 100644
--- a/lib/storage/providers/classifySQLiteError.ts
+++ b/lib/storage/providers/classifySQLiteError.ts
@@ -18,11 +18,9 @@ function classifySQLiteError(error: unknown): ValueOf
return StorageErrorClass.CAPACITY;
}
- // Filesystem-level failures around the database files, seen when the device disk is (nearly) full.
- // "disk I/O error" (SQLITE_IOERR) fires on every operation — reads included — after SQLite fails to
- // size the -shm file while (re)opening the database on a full disk; "unable to open database file"
- // (SQLITE_CANTOPEN) when the -shm file cannot be created at all; "cannot rollback" is a batch-write
- // failure whose original error was masked by the ROLLBACK itself failing on the same full disk.
+ // Full-disk failures around the database files: SQLITE_IOERR (cannot size the -shm file on reopen,
+ // fails reads too), SQLITE_CANTOPEN (cannot create it), and a failed ROLLBACK masking the original
+ // write error. None can succeed until the OS frees space.
if (message.includes('disk i/o error') || message.includes('unable to open database file') || message.includes('cannot rollback - no transaction is active')) {
return StorageErrorClass.DISK_PRESSURE;
}
From 2c1c4e8f3ffb5c55beecc0be66b7e95ec4d2b0db Mon Sep 17 00:00:00 2001
From: eliran goshen
Date: Wed, 5 Aug 2026 13:15:00 +0200
Subject: [PATCH 3/7] Drop the generic rollback message from disk-pressure
classification
"cannot rollback - no transaction is active" follows any error that aborted
the transaction, not just disk-full, so let it fall through to UNKNOWN's
bounded retry; a genuine full disk still surfaces as disk I/O on retry.
Co-Authored-By: Claude Fable 5
---
lib/storage/providers/classifySQLiteError.ts | 5 ++---
tests/unit/onyxUtilsTest.ts | 19 +++++++++----------
2 files changed, 11 insertions(+), 13 deletions(-)
diff --git a/lib/storage/providers/classifySQLiteError.ts b/lib/storage/providers/classifySQLiteError.ts
index 651fc7ce5..3696c106f 100644
--- a/lib/storage/providers/classifySQLiteError.ts
+++ b/lib/storage/providers/classifySQLiteError.ts
@@ -19,9 +19,8 @@ function classifySQLiteError(error: unknown): ValueOf
}
// Full-disk failures around the database files: SQLITE_IOERR (cannot size the -shm file on reopen,
- // fails reads too), SQLITE_CANTOPEN (cannot create it), and a failed ROLLBACK masking the original
- // write error. None can succeed until the OS frees space.
- if (message.includes('disk i/o error') || message.includes('unable to open database file') || message.includes('cannot rollback - no transaction is active')) {
+ // fails reads too) and SQLITE_CANTOPEN (cannot create it). Neither can succeed until the OS frees space.
+ if (message.includes('disk i/o error') || message.includes('unable to open database file')) {
return StorageErrorClass.DISK_PRESSURE;
}
diff --git a/tests/unit/onyxUtilsTest.ts b/tests/unit/onyxUtilsTest.ts
index b4f92309e..0a20a7d21 100644
--- a/tests/unit/onyxUtilsTest.ts
+++ b/tests/unit/onyxUtilsTest.ts
@@ -812,18 +812,17 @@ describe('OnyxUtils', () => {
expect(retryOperationSpy).toHaveBeenCalledTimes(1);
});
- it.each([
- ['[NativeNitroSQLiteException][SqlExecutionError] disk I/O error'],
- ['[NativeNitroSQLiteException][SqlExecutionError] unable to open database file'],
- ['[NativeNitroSQLiteException][SqlExecutionError] cannot rollback - no transaction is active'],
- ])('should not retry disk-pressure errors (%s)', async (message) => {
- StorageMock.setItem = jest.fn().mockRejectedValue(new Error(message));
+ 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'});
+ 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);
- });
+ // 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');
From d7abb2c7b77009990fa7ed62a7b8a609788a8261 Mon Sep 17 00:00:00 2001
From: eliran goshen
Date: Thu, 6 Aug 2026 10:11:46 +0200
Subject: [PATCH 4/7] Document why the rollback message stays UNKNOWN
"cannot rollback - no transaction is active" is a mask: nitro-sqlite's
rollback-on-error replaced the original failure until 9.7.0, so classifying
it as disk pressure would be a guess.
Co-Authored-By: Claude Fable 5
---
lib/storage/providers/classifySQLiteError.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/lib/storage/providers/classifySQLiteError.ts b/lib/storage/providers/classifySQLiteError.ts
index 3696c106f..f5a67009c 100644
--- a/lib/storage/providers/classifySQLiteError.ts
+++ b/lib/storage/providers/classifySQLiteError.ts
@@ -24,6 +24,9 @@ function classifySQLiteError(error: unknown): ValueOf
return StorageErrorClass.DISK_PRESSURE;
}
+ // "cannot rollback - no transaction is active" lands here deliberately: it is a mask, not a cause —
+ // nitro-sqlite's rollback-on-error replaced the original failure until 9.7.0. UNKNOWN gives it
+ // bounded retry + shape logging instead of a disk-pressure guess.
return StorageErrorClass.UNKNOWN;
}
From 994f2eea18f489793bf191253cdad1d43632aecc Mon Sep 17 00:00:00 2001
From: eliran goshen
Date: Thu, 6 Aug 2026 10:26:00 +0200
Subject: [PATCH 5/7] Regenerate API docs for disk-pressure additions
Co-Authored-By: Claude Fable 5
---
API-INTERNAL.md | 31 +++++++++++++++++++++++++++++++
1 file changed, 31 insertions(+)
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.
From c78c5786743e1a198908a2921a3fc49effd525b5 Mon Sep 17 00:00:00 2001
From: eliran goshen
Date: Thu, 6 Aug 2026 11:50:54 +0200
Subject: [PATCH 6/7] Keep the free-disk snapshot when SQLite PRAGMAs fail
under disk pressure
getDatabaseSize ran the PRAGMAs and getFreeDiskStorage in one Promise.all, so
the burst snapshot lost its free-disk bytes exactly when the connection was
failing. Degrade bytesUsed to -1 instead and always return the filesystem value.
Co-Authored-By: Claude Fable 5
---
lib/storage/providers/SQLiteProvider.ts | 21 ++++++++++++-------
.../storage/providers/SQLiteProviderTest.ts | 10 +++++++++
2 files changed, 23 insertions(+), 8 deletions(-)
diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts
index c03efb66e..8a0de23bc 100644
--- a/lib/storage/providers/SQLiteProvider.ts
+++ b/lib/storage/providers/SQLiteProvider.ts
@@ -325,16 +325,21 @@ 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 run on the SQLite connection — the very thing that's broken during disk pressure —
+ // while getFreeDiskStorage() is filesystem-level and still works. Degrade bytesUsed to -1 instead
+ // of failing the snapshot, so the free-disk bytes (the signal that matters) 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/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();
+ });
});
});
From 5378d4deaac30313804b9a7979491945eddd8e8f Mon Sep 17 00:00:00 2001
From: eliran goshen
Date: Fri, 7 Aug 2026 12:29:22 +0200
Subject: [PATCH 7/7] Tighten and remove verbose classifier comments per review
Co-Authored-By: Claude Fable 5
---
lib/storage/providers/SQLiteProvider.ts | 5 ++---
lib/storage/providers/classifySQLiteError.ts | 5 -----
2 files changed, 2 insertions(+), 8 deletions(-)
diff --git a/lib/storage/providers/SQLiteProvider.ts b/lib/storage/providers/SQLiteProvider.ts
index 8a0de23bc..013139120 100644
--- a/lib/storage/providers/SQLiteProvider.ts
+++ b/lib/storage/providers/SQLiteProvider.ts
@@ -325,9 +325,8 @@ const provider: StorageProvider = {
throw new Error('Store is not initialized!');
}
- // The PRAGMAs run on the SQLite connection — the very thing that's broken during disk pressure —
- // while getFreeDiskStorage() is filesystem-level and still works. Degrade bytesUsed to -1 instead
- // of failing the snapshot, so the free-disk bytes (the signal that matters) always get logged.
+ // 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;
diff --git a/lib/storage/providers/classifySQLiteError.ts b/lib/storage/providers/classifySQLiteError.ts
index f5a67009c..06d4f7d51 100644
--- a/lib/storage/providers/classifySQLiteError.ts
+++ b/lib/storage/providers/classifySQLiteError.ts
@@ -18,15 +18,10 @@ function classifySQLiteError(error: unknown): ValueOf
return StorageErrorClass.CAPACITY;
}
- // Full-disk failures around the database files: SQLITE_IOERR (cannot size the -shm file on reopen,
- // fails reads too) and SQLITE_CANTOPEN (cannot create it). Neither can succeed until the OS frees space.
if (message.includes('disk i/o error') || message.includes('unable to open database file')) {
return StorageErrorClass.DISK_PRESSURE;
}
- // "cannot rollback - no transaction is active" lands here deliberately: it is a mask, not a cause —
- // nitro-sqlite's rollback-on-error replaced the original failure until 9.7.0. UNKNOWN gives it
- // bounded retry + shape logging instead of a disk-pressure guess.
return StorageErrorClass.UNKNOWN;
}