mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
fix(profile-state): keep the SQLite failure behind a startup open error across the writer worker (#29280)
This commit is contained in:
@@ -56,8 +56,11 @@ export function encodeProfileStateWriterError(
|
||||
if (error instanceof ProfileStateDocumentCorruptionError) {
|
||||
return { code: error.code, message: error.message, outcome, domain: error.domain }
|
||||
}
|
||||
if (error instanceof ProfileStateDatabaseOpenError) {
|
||||
const cause = encodeOpenErrorCause(error.cause)
|
||||
return { code: error.code, message: error.message, outcome, ...(cause ? { cause } : {}) }
|
||||
}
|
||||
if (
|
||||
error instanceof ProfileStateDatabaseOpenError ||
|
||||
error instanceof ProfileStateWriterError ||
|
||||
error instanceof ProfileStateIndeterminateWriteError ||
|
||||
error instanceof ProfileStateReadRollbackError
|
||||
@@ -88,8 +91,29 @@ export function decodeProfileStateWriterError(data: ProfileStateWriterErrorData)
|
||||
data.code === 'identity-mismatch' ||
|
||||
data.code === 'invalid-profile-id'
|
||||
) {
|
||||
return new ProfileStateDatabaseOpenError(data.code, data.message)
|
||||
return new ProfileStateDatabaseOpenError(
|
||||
data.code,
|
||||
data.message,
|
||||
data.cause ? Object.assign(new Error(data.cause.message), data.cause) : undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
return new ProfileStateWriterError(data.code, data.message, data.outcome)
|
||||
}
|
||||
|
||||
// Why: open failures are rare and fatal to startup; without the SQLite code the log cannot tell
|
||||
// a lock race from real damage (#27333). Messages are SQLite's or our validators', not user data.
|
||||
function encodeOpenErrorCause(cause: unknown): ProfileStateWriterErrorData['cause'] {
|
||||
if (!(cause instanceof Error)) {
|
||||
return undefined
|
||||
}
|
||||
const code = 'code' in cause && typeof cause.code === 'string' ? cause.code : undefined
|
||||
// node:sqlite's extended result code (e.g. 261 = SQLITE_BUSY_RECOVERY) is the precise verdict.
|
||||
const errcode =
|
||||
'errcode' in cause && typeof cause.errcode === 'number' ? cause.errcode : undefined
|
||||
return {
|
||||
message: cause.message.slice(0, 500),
|
||||
...(code ? { code } : {}),
|
||||
...(errcode !== undefined ? { errcode } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import {
|
||||
decodeProfileStateWriterError,
|
||||
encodeProfileStateWriterError
|
||||
} from './profile-state-writer-errors'
|
||||
import { ProfileStateDatabaseOpenError } from './profile-state-database-errors'
|
||||
|
||||
function acrossWorker(error: unknown): Error {
|
||||
return decodeProfileStateWriterError(
|
||||
JSON.parse(JSON.stringify(encodeProfileStateWriterError(error)))
|
||||
)
|
||||
}
|
||||
|
||||
// #27333: the startup failure logged `cause: undefined`, hiding which SQLite call failed.
|
||||
it('keeps the SQLite failure behind a database open error across the writer boundary', () => {
|
||||
const sqliteFailure = Object.assign(new Error('database is locked'), {
|
||||
code: 'ERR_SQLITE_ERROR',
|
||||
errcode: 261
|
||||
})
|
||||
const decoded = acrossWorker(
|
||||
new ProfileStateDatabaseOpenError(
|
||||
'unreadable',
|
||||
'Unable to initialize profile state database',
|
||||
sqliteFailure
|
||||
)
|
||||
)
|
||||
|
||||
expect(decoded).toBeInstanceOf(ProfileStateDatabaseOpenError)
|
||||
expect(decoded.cause).toBeInstanceOf(Error)
|
||||
expect(decoded.cause).toMatchObject({
|
||||
code: 'ERR_SQLITE_ERROR',
|
||||
errcode: 261,
|
||||
message: 'database is locked'
|
||||
})
|
||||
})
|
||||
|
||||
it('carries a real SQLite error code from a failed open', () => {
|
||||
const db = new Database(':memory:')
|
||||
let failure: unknown
|
||||
try {
|
||||
db.exec('SELECT * FROM missing_table')
|
||||
} catch (error) {
|
||||
failure = error
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
const decoded = acrossWorker(
|
||||
new ProfileStateDatabaseOpenError('unreadable', 'open failed', failure)
|
||||
)
|
||||
|
||||
expect(decoded.cause).toMatchObject({
|
||||
code: 'ERR_SQLITE_ERROR',
|
||||
errcode: 1,
|
||||
message: expect.stringContaining('missing_table')
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a cause-less open error cause-less', () => {
|
||||
const decoded = acrossWorker(new ProfileStateDatabaseOpenError('unreadable', 'gone'))
|
||||
expect(decoded).toBeInstanceOf(ProfileStateDatabaseOpenError)
|
||||
expect(decoded.cause).toBeUndefined()
|
||||
})
|
||||
@@ -30,6 +30,8 @@ export type ProfileStateWriterErrorData = {
|
||||
expectedRevision?: number
|
||||
actualRevision?: number
|
||||
domain?: string | null
|
||||
/** The SQLite failure behind a database open error, which the error class alone cannot name. */
|
||||
cause?: { code?: string; errcode?: number; message: string }
|
||||
}
|
||||
export type ProfileStateWriterResponse =
|
||||
| { id: number; ok: true; revision: number; exportedRevision?: number | null }
|
||||
@@ -97,7 +99,17 @@ function isErrorData(value: unknown): value is ProfileStateWriterErrorData {
|
||||
(value.outcome === 'known-failure' || value.outcome === 'indeterminate') &&
|
||||
(value.expectedRevision === undefined || isProfileStateRevision(value.expectedRevision)) &&
|
||||
(value.actualRevision === undefined || isProfileStateRevision(value.actualRevision)) &&
|
||||
(value.domain === undefined || value.domain === null || typeof value.domain === 'string')
|
||||
(value.domain === undefined || value.domain === null || typeof value.domain === 'string') &&
|
||||
(value.cause === undefined || isErrorCauseData(value.cause))
|
||||
)
|
||||
}
|
||||
|
||||
function isErrorCauseData(value: unknown): boolean {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.message === 'string' &&
|
||||
(value.code === undefined || typeof value.code === 'string') &&
|
||||
(value.errcode === undefined || typeof value.errcode === 'number')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user