From f79eb6da38f92aa28ca0b7ff4e0b6f0a1661e38a Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 12 Sep 2026 00:16:45 +0900 Subject: [PATCH 1/3] fix(kit): retry transient offline reset locks --- .../src/lib/offline-local-reset.spec.ts | 98 ++++++++++++++++++- .../offline/src/lib/offline-local-reset.ts | 10 ++ .../offline-repository-concurrency.spec.ts | 24 +++++ .../src/lib/offline-repository-concurrency.ts | 18 +++- 4 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 projects/kit/offline/src/lib/offline-repository-concurrency.spec.ts diff --git a/projects/kit/offline/src/lib/offline-local-reset.spec.ts b/projects/kit/offline/src/lib/offline-local-reset.spec.ts index 98ee4bc4..6101e16b 100644 --- a/projects/kit/offline/src/lib/offline-local-reset.spec.ts +++ b/projects/kit/offline/src/lib/offline-local-reset.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { migrateOfflineDatabaseEncryption, recoverOfflineLocalReset, @@ -8,6 +8,8 @@ import { } from './offline-local-reset'; describe('offline local reset', () => { + afterEach(() => vi.restoreAllMocks()); + it('persists the marker before reloading', async () => { const events: string[] = []; const markerStore = store({ @@ -306,7 +308,9 @@ describe('offline local reset', () => { }); const sqlite = connection([]); const failure = new Error('delete failed'); - vi.mocked(await sqlite.createConnection('unused', false, 'no-encryption', 1, false)).delete.mockRejectedValueOnce(failure); + const database = await sqlite.createConnection('unused', false, 'no-encryption', 1, false); + vi.mocked(database.delete).mockRejectedValueOnce(failure); + vi.mocked(sqlite.createConnection).mockResolvedValue(database); vi.mocked(sqlite.createConnection).mockClear(); await expect( @@ -322,6 +326,96 @@ describe('offline local reset', () => { ).rejects.toBe(failure); expect(markerStore.set).not.toHaveBeenCalled(); + expect(database.delete).toHaveBeenCalledOnce(); + }); + + it('retries a transient SQLite lock while deleting a legacy database', async () => { + const markerStore = store({ + get: vi.fn(async () => ({ value: null })), + }); + const sqlite = connection([]); + const database = await sqlite.createConnection('unused', false, 'no-encryption', 1, false); + vi.mocked(database.delete) + .mockRejectedValueOnce(new Error('Execute: execute failed rc: 5 message: database is locked')) + .mockResolvedValueOnce(undefined); + vi.mocked(sqlite.createConnection).mockResolvedValue(database).mockClear(); + vi.spyOn(globalThis, 'setTimeout').mockImplementation((handler: TimerHandler) => { + if (typeof handler === 'function') handler(); + return 0 as unknown as ReturnType; + }); + + await expect( + migrateOfflineDatabaseEncryption({ + markerStore, + markerKey: 'product:encryption-migration', + migrationVersion: 'plaintext-v1', + sqliteConnection: sqlite, + kitCompatibleDatabaseNames: ['product-offline'], + sourceDatabaseEncryption: false, + nativePlatform: true, + }), + ).resolves.toBe(true); + + expect(database.delete).toHaveBeenCalledTimes(2); + expect(sqlite.closeConnection).toHaveBeenCalledOnce(); + expect(markerStore.set).toHaveBeenCalledOnce(); + }); + + it('retries a transient SQLite lock while closing a deleted database', async () => { + const markerStore = store({ get: vi.fn(async () => ({ value: null })) }); + const sqlite = connection([]); + vi.mocked(sqlite.closeConnection).mockRejectedValueOnce(new Error('SQLITE_BUSY')).mockResolvedValueOnce(undefined); + vi.spyOn(globalThis, 'setTimeout').mockImplementation((handler: TimerHandler) => { + if (typeof handler === 'function') handler(); + return 0 as unknown as ReturnType; + }); + + await expect( + migrateOfflineDatabaseEncryption({ + markerStore, + markerKey: 'product:encryption-migration', + migrationVersion: 'plaintext-v1', + sqliteConnection: sqlite, + kitCompatibleDatabaseNames: ['product-offline'], + sourceDatabaseEncryption: false, + nativePlatform: true, + }), + ).resolves.toBe(true); + + expect(sqlite.closeConnection).toHaveBeenCalledTimes(2); + expect(markerStore.set).toHaveBeenCalledOnce(); + }); + + it('stops after four transient lock failures and preserves the final error', async () => { + const markerStore = store({ get: vi.fn(async () => ({ value: null })) }); + const sqlite = connection([]); + const failures = Array.from({ length: 4 }, (_, index) => new Error(`database is locked ${index + 1}`)); + const database = await sqlite.createConnection('unused', false, 'no-encryption', 1, false); + vi.mocked(database.delete) + .mockRejectedValueOnce(failures[0]) + .mockRejectedValueOnce(failures[1]) + .mockRejectedValueOnce(failures[2]) + .mockRejectedValueOnce(failures[3]); + vi.mocked(sqlite.createConnection).mockResolvedValue(database).mockClear(); + vi.spyOn(globalThis, 'setTimeout').mockImplementation((handler: TimerHandler) => { + if (typeof handler === 'function') handler(); + return 0 as unknown as ReturnType; + }); + + await expect( + migrateOfflineDatabaseEncryption({ + markerStore, + markerKey: 'product:encryption-migration', + migrationVersion: 'plaintext-v1', + sqliteConnection: sqlite, + kitCompatibleDatabaseNames: ['product-offline'], + sourceDatabaseEncryption: false, + nativePlatform: true, + }), + ).rejects.toBe(failures[3]); + + expect(database.delete).toHaveBeenCalledTimes(4); + expect(markerStore.set).not.toHaveBeenCalled(); }); }); diff --git a/projects/kit/offline/src/lib/offline-local-reset.ts b/projects/kit/offline/src/lib/offline-local-reset.ts index 7bbfc05a..592312d3 100644 --- a/projects/kit/offline/src/lib/offline-local-reset.ts +++ b/projects/kit/offline/src/lib/offline-local-reset.ts @@ -5,6 +5,7 @@ import { COMMUNITY_SQLITE_READONLY, COMMUNITY_SQLITE_VERSION, } from './offline-community-sqlite-config'; +import { isTransientSqliteLockError } from './offline-repository-concurrency'; /** Durable marker store used to request a cold-start local reset. */ export interface OfflineLocalResetMarkerStore { @@ -75,6 +76,7 @@ export interface MigrateOfflineDatabaseEncryptionOptions { } const OFFLINE_LOCAL_RESET_REQUESTED = 'requested'; +const OFFLINE_LOCAL_RESET_RETRY_DELAYS_MS = [50, 150, 300] as const; /** Persists an explicit destructive reset request, then reloads into a cold bootstrap. */ export async function requestOfflineLocalReset(options: RequestOfflineLocalResetOptions): Promise { @@ -171,6 +173,14 @@ async function resolveOfflineDatabaseEncryption( type OfflineResetOperationResult = { ok: true } | { ok: false; error: unknown }; async function settleOfflineResetOperation(operation: () => Promise): Promise { + for (const delayMs of OFFLINE_LOCAL_RESET_RETRY_DELAYS_MS) { + const result = await new Promise((resolve) => resolve(operation())).then( + () => ({ ok: true }) as const, + (error: unknown) => ({ ok: false, error }) as const, + ); + if (result.ok || !isTransientSqliteLockError(result.error)) return result; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } return new Promise((resolve) => resolve(operation())).then( () => ({ ok: true }), (error: unknown) => ({ ok: false, error }), diff --git a/projects/kit/offline/src/lib/offline-repository-concurrency.spec.ts b/projects/kit/offline/src/lib/offline-repository-concurrency.spec.ts new file mode 100644 index 00000000..ba90a550 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-repository-concurrency.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { isTransientSqliteLockError, normalizeOfflineReplicaTransientWriteError } from './offline-repository-concurrency'; + +describe('offline repository concurrency', () => { + it('finds a transient SQLite lock through causes and aggregate errors', () => { + const locked = new Error('Execute: execute failed rc: 5 message: database is locked'); + const wrapped = new Error('native operation failed', { cause: new AggregateError([new Error('other failure'), locked]) }); + + expect(isTransientSqliteLockError(wrapped)).toBe(true); + expect(normalizeOfflineReplicaTransientWriteError(wrapped)).toMatchObject({ + name: 'OfflineReplicaTransientWriteError', + reason: 'sqlite_locked', + cause: wrapped, + }); + }); + + it('terminates safely when error causes contain a cycle', () => { + const cyclic = new Error('outer failure'); + Object.defineProperty(cyclic, 'cause', { value: cyclic }); + + expect(isTransientSqliteLockError(cyclic)).toBe(false); + expect(normalizeOfflineReplicaTransientWriteError(cyclic)).toBe(cyclic); + }); +}); diff --git a/projects/kit/offline/src/lib/offline-repository-concurrency.ts b/projects/kit/offline/src/lib/offline-repository-concurrency.ts index 06f49d60..756de1da 100644 --- a/projects/kit/offline/src/lib/offline-repository-concurrency.ts +++ b/projects/kit/offline/src/lib/offline-repository-concurrency.ts @@ -20,7 +20,14 @@ export class OfflineReplicaTransientWriteError extends Error { } } -function transientSqliteLockReason(error: unknown): Extract | null { +function transientSqliteLockReason( + error: unknown, + visited = new Set(), +): Extract | null { + if (typeof error === 'object' && error !== null) { + if (visited.has(error)) return null; + visited.add(error); + } const code = typeof error === 'object' && error !== null && typeof (error as { code?: unknown }).code === 'string' ? (error as { code: string }).code.toUpperCase() @@ -38,6 +45,15 @@ function transientSqliteLockReason(error: unknown): Extract Date: Sat, 12 Sep 2026 00:24:33 +0900 Subject: [PATCH 2/3] fix(kit): preserve mixed aggregate failures --- .../lib/offline-repository-concurrency.spec.ts | 11 +++++++++-- .../src/lib/offline-repository-concurrency.ts | 16 ++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/projects/kit/offline/src/lib/offline-repository-concurrency.spec.ts b/projects/kit/offline/src/lib/offline-repository-concurrency.spec.ts index ba90a550..216d3495 100644 --- a/projects/kit/offline/src/lib/offline-repository-concurrency.spec.ts +++ b/projects/kit/offline/src/lib/offline-repository-concurrency.spec.ts @@ -4,16 +4,23 @@ import { isTransientSqliteLockError, normalizeOfflineReplicaTransientWriteError describe('offline repository concurrency', () => { it('finds a transient SQLite lock through causes and aggregate errors', () => { const locked = new Error('Execute: execute failed rc: 5 message: database is locked'); - const wrapped = new Error('native operation failed', { cause: new AggregateError([new Error('other failure'), locked]) }); + const wrapped = new Error('native operation failed', { cause: new AggregateError([new Error('SQLITE_BUSY'), locked]) }); expect(isTransientSqliteLockError(wrapped)).toBe(true); expect(normalizeOfflineReplicaTransientWriteError(wrapped)).toMatchObject({ name: 'OfflineReplicaTransientWriteError', - reason: 'sqlite_locked', + reason: 'sqlite_busy', cause: wrapped, }); }); + it('does not classify a mixed aggregate failure as a transient SQLite lock', () => { + const mixed = new AggregateError([new Error('disk full'), new Error('SQLITE_BUSY')]); + + expect(isTransientSqliteLockError(mixed)).toBe(false); + expect(normalizeOfflineReplicaTransientWriteError(mixed)).toBe(mixed); + }); + it('terminates safely when error causes contain a cycle', () => { const cyclic = new Error('outer failure'); Object.defineProperty(cyclic, 'cause', { value: cyclic }); diff --git a/projects/kit/offline/src/lib/offline-repository-concurrency.ts b/projects/kit/offline/src/lib/offline-repository-concurrency.ts index 756de1da..af11df66 100644 --- a/projects/kit/offline/src/lib/offline-repository-concurrency.ts +++ b/projects/kit/offline/src/lib/offline-repository-concurrency.ts @@ -28,6 +28,16 @@ function transientSqliteLockReason( if (visited.has(error)) return null; visited.add(error); } + if (error instanceof AggregateError) { + if (error.errors.length === 0) return null; + let aggregateReason: Extract | null = null; + for (const nested of error.errors) { + const reason = transientSqliteLockReason(nested, visited); + if (!reason) return null; + aggregateReason ??= reason; + } + return aggregateReason; + } const code = typeof error === 'object' && error !== null && typeof (error as { code?: unknown }).code === 'string' ? (error as { code: string }).code.toUpperCase() @@ -45,12 +55,6 @@ function transientSqliteLockReason( ) { return 'sqlite_locked'; } - if (error instanceof AggregateError) { - for (const nested of error.errors) { - const reason = transientSqliteLockReason(nested, visited); - if (reason) return reason; - } - } if (error instanceof Error && error.cause !== undefined) { return transientSqliteLockReason(error.cause, visited); } From c78525d927b380a0e49f130fec457f3510e2748e Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 11 Sep 2026 15:34:30 +0000 Subject: [PATCH 3/3] fix(kit): isolate aggregate branch cycle guards Co-Authored-By: rdlabo --- .../offline-repository-concurrency.spec.ts | 21 +++++++++++++++++++ .../src/lib/offline-repository-concurrency.ts | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/projects/kit/offline/src/lib/offline-repository-concurrency.spec.ts b/projects/kit/offline/src/lib/offline-repository-concurrency.spec.ts index 216d3495..069b1472 100644 --- a/projects/kit/offline/src/lib/offline-repository-concurrency.spec.ts +++ b/projects/kit/offline/src/lib/offline-repository-concurrency.spec.ts @@ -21,6 +21,27 @@ describe('offline repository concurrency', () => { expect(normalizeOfflineReplicaTransientWriteError(mixed)).toBe(mixed); }); + it('classifies an aggregate failure whose branches share one lock cause as transient', () => { + const nativeLock = new Error('Execute: execute failed rc: 5 message: database is locked'); + const aggregate = new AggregateError([ + new Error('delete failed', { cause: nativeLock }), + new Error('close failed', { cause: nativeLock }), + ]); + + expect(isTransientSqliteLockError(aggregate)).toBe(true); + expect(normalizeOfflineReplicaTransientWriteError(aggregate)).toMatchObject({ + name: 'OfflineReplicaTransientWriteError', + reason: 'sqlite_locked', + cause: aggregate, + }); + }); + + it('classifies an aggregate failure repeating one lock error as transient', () => { + const locked = new Error('SQLITE_BUSY'); + + expect(isTransientSqliteLockError(new AggregateError([locked, locked]))).toBe(true); + }); + it('terminates safely when error causes contain a cycle', () => { const cyclic = new Error('outer failure'); Object.defineProperty(cyclic, 'cause', { value: cyclic }); diff --git a/projects/kit/offline/src/lib/offline-repository-concurrency.ts b/projects/kit/offline/src/lib/offline-repository-concurrency.ts index af11df66..7b4e593c 100644 --- a/projects/kit/offline/src/lib/offline-repository-concurrency.ts +++ b/projects/kit/offline/src/lib/offline-repository-concurrency.ts @@ -32,7 +32,7 @@ function transientSqliteLockReason( if (error.errors.length === 0) return null; let aggregateReason: Extract | null = null; for (const nested of error.errors) { - const reason = transientSqliteLockReason(nested, visited); + const reason = transientSqliteLockReason(nested, new Set(visited)); if (!reason) return null; aggregateReason ??= reason; }