feat(storage): move desktop SQLite ownership into a storage utility process
Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.
- Remove the Main readWithFallback path and the supervision worker's
separate business write connection; manifests are scanned read-only and
committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
directories and reclaim confirmed stale ones; stop writing duplicate
parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
acceptance fixtures, and maintenance/architecture documentation.
Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
This commit is contained in:
@@ -1,13 +1,11 @@
|
||||
import type { EventEmitter } from 'node:events'
|
||||
import type { BrowserWindow, IpcMainInvokeEvent } from 'electron'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
import type { AssistantStorageProgress } from '../shared/assistant-storage-contracts'
|
||||
import { prepareAssistantStorage } from './assistant-storage-startup'
|
||||
import { getPendingAssistantStorageUpgrade } from './assistant/assistant-storage-upgrade'
|
||||
import { loadMainWindow } from './window'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
workers: [] as Array<EventEmitter & { options: { workerData: { upgrade: unknown } } }>,
|
||||
handlers: new Map<string, (event: unknown, input?: unknown) => unknown>(),
|
||||
quit: vi.fn()
|
||||
}))
|
||||
@@ -18,60 +16,111 @@ vi.mock('electron', () => ({
|
||||
removeHandler: (channel: string) => mocks.handlers.delete(channel)
|
||||
}
|
||||
}))
|
||||
vi.mock('node:fs/promises', () => {
|
||||
const stat = vi.fn(async () => ({ size: 1024 }))
|
||||
return { stat, default: { stat } }
|
||||
})
|
||||
vi.mock('./window', () => ({ loadMainWindow: vi.fn() }))
|
||||
vi.mock('./assistant/assistant-storage-upgrade', () => ({ getPendingAssistantStorageUpgrade: vi.fn() }))
|
||||
vi.mock('node:worker_threads', async () => {
|
||||
const { EventEmitter } = await import('node:events')
|
||||
class Worker extends EventEmitter {
|
||||
constructor(_path: string, readonly options: { workerData: { upgrade: unknown } }) {
|
||||
super()
|
||||
mocks.workers.push(this)
|
||||
}
|
||||
}
|
||||
return { Worker, default: { Worker } }
|
||||
})
|
||||
beforeEach(() => {
|
||||
mocks.workers.length = 0
|
||||
mocks.handlers.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it.each([false, true])('retains the original reclaimSpace=%s decision across startup retries', async (reclaimSpace) => {
|
||||
const upgrade = { migrateNotes: reclaimSpace, reclaimSpace }
|
||||
vi.mocked(getPendingAssistantStorageUpgrade).mockReturnValueOnce(upgrade).mockReturnValue(undefined)
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<void>((done, fail) => { resolve = done; reject = fail })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const webContents = { mainFrame: { url: 'app://main' }, getURL: () => 'app://main' }
|
||||
const window = { webContents, isDestroyed: () => false, setProgressBar: vi.fn() } as unknown as BrowserWindow
|
||||
const event = { sender: webContents, senderFrame: webContents.mainFrame } as unknown as IpcMainInvokeEvent
|
||||
const preparation = prepareAssistantStorage(window, '/test/assistant.sqlite', new AbortController().signal)
|
||||
await vi.waitFor(() => expect(mocks.workers).toHaveLength(1))
|
||||
mocks.workers[0]!.emit('message', {
|
||||
progress: { stage: 'upgrading', processed: 0, total: 0, bytesBefore: 1024 }
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const opening = deferred(), retrying = deferred(), closing = deferred()
|
||||
const storage = { ready: opening.promise, retry: vi.fn(() => retrying.promise), close: vi.fn(() => closing.promise) }
|
||||
let publish!: (progress: AssistantStorageProgress) => void
|
||||
const create = vi.fn((listener: typeof publish) => { publish = listener; return storage })
|
||||
const preparation = prepareAssistantStorage(window, create, controller.signal)
|
||||
return { window, event, controller, opening, retrying, closing, storage, create, publish, preparation }
|
||||
}
|
||||
|
||||
it('publishes utility progress and retries the same client before allowing startup', async () => {
|
||||
const f = fixture()
|
||||
f.publish({ stage: 'converting', processed: 1, total: 2, bytesBefore: 1024 })
|
||||
expect(loadMainWindow).toHaveBeenCalledOnce()
|
||||
expect(mocks.handlers.get(ipcChannels.storageUpgradeProgress)!(event)).toMatchObject({ stage: 'upgrading' })
|
||||
mocks.workers[0]!.emit('message', { error: 'Retry this startup' })
|
||||
mocks.workers[0]!.emit('exit', 0)
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.handlers.get(ipcChannels.storageUpgradeProgress)!(event)).toMatchObject({ stage: 'failed' })
|
||||
)
|
||||
mocks.handlers.get(ipcChannels.storageUpgradeAction)!(event, 'retry')
|
||||
await vi.waitFor(() => expect(mocks.workers).toHaveLength(2))
|
||||
expect(getPendingAssistantStorageUpgrade).toHaveBeenCalledOnce()
|
||||
expect(mocks.workers.map(worker => worker.options.workerData.upgrade)).toEqual([upgrade, upgrade])
|
||||
mocks.workers[1]!.emit('message', { done: true })
|
||||
mocks.workers[1]!.emit('exit', 0)
|
||||
await preparation
|
||||
expect(f.window.setProgressBar).toHaveBeenLastCalledWith(0.5)
|
||||
f.opening.reject(new Error('Retry this startup'))
|
||||
await vi.waitFor(() => expect(mocks.handlers.get(ipcChannels.storageUpgradeProgress)!(f.event)).toMatchObject({ stage: 'failed' }))
|
||||
mocks.handlers.get(ipcChannels.storageUpgradeAction)!(f.event, 'retry')
|
||||
await vi.waitFor(() => expect(f.storage.retry).toHaveBeenCalledOnce())
|
||||
const completed = vi.fn()
|
||||
void f.preparation.then(completed)
|
||||
await Promise.resolve()
|
||||
expect(completed).not.toHaveBeenCalled()
|
||||
expect(f.create).toHaveBeenCalledOnce()
|
||||
f.retrying.resolve()
|
||||
await f.preparation
|
||||
expect(mocks.handlers.size).toBe(0)
|
||||
expect(window.setProgressBar).toHaveBeenLastCalledWith(-1)
|
||||
expect(f.window.setProgressBar).toHaveBeenLastCalledWith(-1)
|
||||
expect(f.storage.close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not open an upgrade page or worker for an already-current database', async () => {
|
||||
vi.mocked(getPendingAssistantStorageUpgrade).mockReturnValue(undefined)
|
||||
await prepareAssistantStorage({} as BrowserWindow, '/test/assistant.sqlite', new AbortController().signal)
|
||||
expect(mocks.workers).toHaveLength(0)
|
||||
it('keeps the client identity and waits for replacement readiness across repeated process failures', async () => {
|
||||
const f = fixture()
|
||||
f.opening.reject(new Error('Initial utility exited'))
|
||||
await vi.waitFor(() => expect(mocks.handlers.get(ipcChannels.storageUpgradeProgress)!(f.event)).toMatchObject({ stage: 'failed' }))
|
||||
mocks.handlers.get(ipcChannels.storageUpgradeAction)!(f.event, 'retry')
|
||||
await vi.waitFor(() => expect(f.storage.retry).toHaveBeenCalledTimes(1))
|
||||
f.retrying.reject(new Error('Replacement utility exited'))
|
||||
await vi.waitFor(() => expect(mocks.handlers.get(ipcChannels.storageUpgradeProgress)!(f.event)).toMatchObject({ error: 'Replacement utility exited' }))
|
||||
const replacement = deferred()
|
||||
f.storage.retry.mockReturnValue(replacement.promise)
|
||||
mocks.handlers.get(ipcChannels.storageUpgradeAction)!(f.event, 'retry')
|
||||
await vi.waitFor(() => expect(f.storage.retry).toHaveBeenCalledTimes(2))
|
||||
expect(f.create).toHaveBeenCalledTimes(1)
|
||||
replacement.resolve()
|
||||
await f.preparation
|
||||
expect(f.storage.close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not open an upgrade page when storage becomes ready without an upgrade', async () => {
|
||||
const f = fixture()
|
||||
f.opening.resolve()
|
||||
await f.preparation
|
||||
expect(loadMainWindow).not.toHaveBeenCalled()
|
||||
expect(mocks.handlers.size).toBe(0)
|
||||
})
|
||||
|
||||
it.each([false, true])('awaits utility close on quit while failed=%s', async failed => {
|
||||
const f = fixture()
|
||||
if (failed) {
|
||||
f.opening.reject(new Error('Startup failed'))
|
||||
await vi.waitFor(() => expect(loadMainWindow).toHaveBeenCalledOnce())
|
||||
}
|
||||
f.controller.abort()
|
||||
await vi.waitFor(() => expect(f.storage.close).toHaveBeenCalledOnce())
|
||||
const settled = vi.fn()
|
||||
void f.preparation.then(settled)
|
||||
await Promise.resolve()
|
||||
expect(settled).not.toHaveBeenCalled()
|
||||
f.closing.resolve()
|
||||
await f.preparation
|
||||
expect(f.storage.retry).not.toHaveBeenCalled()
|
||||
expect(mocks.handlers.size).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects upgrade actions from an unrelated sender', async () => {
|
||||
const f = fixture()
|
||||
expect(() => mocks.handlers.get(ipcChannels.storageUpgradeAction)!({ sender: {} }, 'retry')).toThrow()
|
||||
f.opening.resolve()
|
||||
await f.preparation
|
||||
})
|
||||
|
||||
it('closes the owner when readiness fails at the same time as quit and reports an unconfirmed close', async () => {
|
||||
const f = fixture()
|
||||
f.opening.reject(new Error('Storage exited'))
|
||||
f.controller.abort()
|
||||
await vi.waitFor(() => expect(f.storage.close).toHaveBeenCalledOnce())
|
||||
const result = expect(f.preparation).rejects.toThrow('Drain unconfirmed')
|
||||
f.closing.reject(new Error('Drain unconfirmed'))
|
||||
await result
|
||||
expect(mocks.handlers.size).toBe(0)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user