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:
@@ -88,9 +88,9 @@ describe('DingTalkChannelDriver', () => {
|
||||
receive(envelope())
|
||||
await vi.waitFor(() => expect(failure).toHaveBeenCalledOnce())
|
||||
expect(failure.mock.calls[0]?.[0]).toMatchObject({ message: '钉钉回复请求失败 (130101)' })
|
||||
expect(outbox.listUndelivered()[0]).toMatchObject({ state: 'failed', attempts: 1 })
|
||||
expect((await outbox.listUndelivered())[0]).toMatchObject({ state: 'failed', attempts: 1 })
|
||||
await vi.waitFor(() => expect(requests).toHaveLength(2), { timeout: 3000 })
|
||||
await vi.waitFor(() => expect(outbox.listUndelivered()).toEqual([]))
|
||||
await vi.waitFor(async () => expect(await outbox.listUndelivered()).toEqual([]))
|
||||
expect(requests[1]).toBe(requests[0])
|
||||
expect(executor).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
|
||||
@@ -25,46 +25,46 @@ const message = {
|
||||
}
|
||||
|
||||
describe('SqliteChannelOutbox', () => {
|
||||
it('marks a permanent failure terminal, releases media and counts the attempt once', () => {
|
||||
it('marks a permanent failure terminal, releases media and counts the attempt once', async () => {
|
||||
const outbox = createOutbox()
|
||||
const entry = outbox.enqueue(message)
|
||||
outbox.markTerminal(entry.id)
|
||||
outbox.markTerminal(entry.id)
|
||||
outbox.markFailed(entry.id)
|
||||
outbox.markDelivered(entry.id)
|
||||
expect(outbox.listUndelivered()).toEqual([{
|
||||
const entry = await outbox.enqueue(message)
|
||||
await outbox.markTerminal(entry.id)
|
||||
await outbox.markTerminal(entry.id)
|
||||
await outbox.markFailed(entry.id)
|
||||
await outbox.markDelivered(entry.id)
|
||||
expect(await outbox.listUndelivered()).toEqual([{
|
||||
...entry, state: 'terminal', attempts: 1,
|
||||
message: { ...message, attachments: undefined }
|
||||
}])
|
||||
expect(outbox.listUndelivered()[0]!.message).not.toHaveProperty('attachments')
|
||||
expect((await outbox.listUndelivered())[0]!.message).not.toHaveProperty('attachments')
|
||||
})
|
||||
|
||||
it('keeps retryable entries ahead of terminal entries under channel and global limits', () => {
|
||||
it('keeps retryable entries ahead of terminal entries under channel and global limits', async () => {
|
||||
const outbox = createOutbox()
|
||||
const terminal = outbox.enqueue(message)
|
||||
outbox.markTerminal(terminal.id)
|
||||
const retry = outbox.enqueue({ ...message, eventId: 'retry' })
|
||||
outbox.markFailed(retry.id)
|
||||
outbox.markFailed(retry.id)
|
||||
const terminal = await outbox.enqueue(message)
|
||||
await outbox.markTerminal(terminal.id)
|
||||
const retry = await outbox.enqueue({ ...message, eventId: 'retry' })
|
||||
await outbox.markFailed(retry.id)
|
||||
await outbox.markFailed(retry.id)
|
||||
for (const channel of [undefined, 'telegram']) {
|
||||
expect(outbox.listUndelivered(channel, 1).map(entry => entry.id)).toEqual([retry.id])
|
||||
expect(outbox.listUndelivered(channel).map(entry => entry.id)).toEqual([retry.id, terminal.id])
|
||||
expect((await outbox.listUndelivered(channel, 1)).map(entry => entry.id)).toEqual([retry.id])
|
||||
expect((await outbox.listUndelivered(channel)).map(entry => entry.id)).toEqual([retry.id, terminal.id])
|
||||
}
|
||||
expect(outbox.listUndelivered('weixin')).toEqual([])
|
||||
expect(await outbox.listUndelivered('weixin')).toEqual([])
|
||||
})
|
||||
|
||||
it('budgets retry bytes after putting terminal results last in both windows', () => {
|
||||
it('budgets retry bytes after putting terminal results last in both windows', async () => {
|
||||
const outbox = createOutbox()
|
||||
// Retained terminal text alone exceeds the 20 MiB retry window.
|
||||
for (let index = 0; index < 22; index++) {
|
||||
const entry = outbox.enqueue({ ...message, eventId: `terminal-${index}`, output: 'x'.repeat(1024 * 1024) })
|
||||
outbox.markTerminal(entry.id)
|
||||
const entry = await outbox.enqueue({ ...message, eventId: `terminal-${index}`, output: 'x'.repeat(1024 * 1024) })
|
||||
await outbox.markTerminal(entry.id)
|
||||
}
|
||||
const retry = outbox.enqueue({ ...message, eventId: 'retry' })
|
||||
outbox.markFailed(retry.id)
|
||||
outbox.markFailed(retry.id)
|
||||
const retry = await outbox.enqueue({ ...message, eventId: 'retry' })
|
||||
await outbox.markFailed(retry.id)
|
||||
await outbox.markFailed(retry.id)
|
||||
for (const channel of [undefined, 'telegram']) {
|
||||
const entries = outbox.listUndelivered(channel)
|
||||
const entries = await outbox.listUndelivered(channel)
|
||||
expect(entries[0]!.id).toBe(retry.id)
|
||||
expect(entries.length).toBeLessThan(23)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AssistantDatabase } from '../assistant/assistant-database'
|
||||
import type { AssistantStoragePort, Awaitable } from '../assistant-storage-port'
|
||||
import type {
|
||||
DedupStore,
|
||||
Outbox,
|
||||
@@ -7,40 +7,40 @@ import type {
|
||||
import type { ChannelResultMessage } from '../../shared/channel-contracts'
|
||||
|
||||
export class SqliteChannelDedupStore implements DedupStore {
|
||||
constructor(private readonly database: AssistantDatabase) {}
|
||||
constructor(private readonly database: Pick<AssistantStoragePort, 'claimChannelEvent' | 'releaseChannelEvent'>) {}
|
||||
|
||||
claim(channel: string, accountId: string, eventId: string): boolean {
|
||||
claim(channel: string, accountId: string, eventId: string): Awaitable<boolean> {
|
||||
return this.database.claimChannelEvent(channel, accountId, eventId)
|
||||
}
|
||||
|
||||
release(channel: string, accountId: string, eventId: string): void {
|
||||
this.database.releaseChannelEvent(channel, accountId, eventId)
|
||||
release(channel: string, accountId: string, eventId: string): Awaitable<void> {
|
||||
return this.database.releaseChannelEvent(channel, accountId, eventId)
|
||||
}
|
||||
}
|
||||
|
||||
export class SqliteChannelOutbox implements Outbox {
|
||||
constructor(private readonly database: AssistantDatabase) {}
|
||||
constructor(private readonly database: Pick<AssistantStoragePort, 'enqueueChannelResult' | 'markChannelResult' | 'listUndeliveredChannelResults'>) {}
|
||||
|
||||
enqueue(message: ChannelResultMessage): OutboxEntry {
|
||||
enqueue(message: ChannelResultMessage): Awaitable<OutboxEntry> {
|
||||
return this.database.enqueueChannelResult(message)
|
||||
}
|
||||
|
||||
markDelivered(id: string): void {
|
||||
this.database.markChannelResult(id, 'delivered')
|
||||
markDelivered(id: string): Awaitable<void> {
|
||||
return this.database.markChannelResult(id, 'delivered')
|
||||
}
|
||||
|
||||
markFailed(id: string): void {
|
||||
this.database.markChannelResult(id, 'failed')
|
||||
markFailed(id: string): Awaitable<void> {
|
||||
return this.database.markChannelResult(id, 'failed')
|
||||
}
|
||||
|
||||
markTerminal(id: string): void {
|
||||
this.database.markChannelResult(id, 'terminal')
|
||||
markTerminal(id: string): Awaitable<void> {
|
||||
return this.database.markChannelResult(id, 'terminal')
|
||||
}
|
||||
|
||||
listUndelivered(
|
||||
channel?: string,
|
||||
limit?: number
|
||||
): readonly OutboxEntry[] {
|
||||
): Awaitable<readonly OutboxEntry[]> {
|
||||
return this.database.listUndeliveredChannelResults(
|
||||
channel,
|
||||
limit
|
||||
|
||||
@@ -89,9 +89,9 @@ describe('Telegram delivery through loopback HTTP and SQLite', () => {
|
||||
await vi.waitFor(async () => expect((await f.manager.snapshot()).telegram.status).toMatchObject({
|
||||
state: 'error', lastError: expect.stringContaining('polling conflict')
|
||||
}))
|
||||
await vi.waitFor(() => expect(f.outbox.listUndelivered().every(entry => entry.state === 'terminal')).toBe(true))
|
||||
await vi.waitFor(async () => expect((await f.outbox.listUndelivered()).every(entry => entry.state === 'terminal')).toBe(true))
|
||||
expect(f.state.sends.map(send => send.text)).toEqual(['same-bot'])
|
||||
expect(f.outbox.listUndelivered().map(entry => [entry.message.eventId, entry.state])).toEqual([['other-bot', 'terminal']])
|
||||
expect((await f.outbox.listUndelivered()).map(entry => [entry.message.eventId, entry.state])).toEqual([['other-bot', 'terminal']])
|
||||
expect(f.state.polls).toBe(0)
|
||||
f.state.webhook = ''
|
||||
await f.manager.reload('telegram')
|
||||
@@ -107,7 +107,7 @@ describe('Telegram delivery through loopback HTTP and SQLite', () => {
|
||||
f.state.sendErrors.push({ code: 403 })
|
||||
f.inbound(1)
|
||||
await f.manager.initialize()
|
||||
await vi.waitFor(() => expect(f.outbox.listUndelivered()[0]).toMatchObject({ state: 'terminal', attempts: 1 }))
|
||||
await vi.waitFor(async () => expect((await f.outbox.listUndelivered())[0]).toMatchObject({ state: 'terminal', attempts: 1 }))
|
||||
await vi.waitFor(() => expect(f.state.polls).toBeGreaterThanOrEqual(2))
|
||||
expect((await f.manager.snapshot()).telegram.status).toEqual({ state: 'running',
|
||||
lastError: expect.stringMatching(/sending failed.*forbidden/i) })
|
||||
@@ -129,7 +129,7 @@ describe('Telegram delivery through loopback HTTP and SQLite', () => {
|
||||
await f.manager.initialize()
|
||||
await vi.waitFor(() => expect(f.state.sends).toHaveLength(5), { timeout: 10_000 })
|
||||
expect(f.state.sends[4]!.time - f.state.sends[3]!.time).toBeGreaterThanOrEqual(2995)
|
||||
await vi.waitFor(() => expect(f.outbox.listUndelivered()).toEqual([]))
|
||||
await vi.waitFor(async () => expect(await f.outbox.listUndelivered()).toEqual([]))
|
||||
expect(f.state.sends.every(send => send.text === 'synthetic result')).toBe(true)
|
||||
expect(f.execute).toHaveBeenCalledOnce()
|
||||
} finally { await f.close() }
|
||||
@@ -144,10 +144,10 @@ describe('Telegram delivery through loopback HTTP and SQLite', () => {
|
||||
await f.manager.initialize()
|
||||
await vi.waitFor(() => expect(f.state.sends).toHaveLength(4), { timeout: 5000 })
|
||||
await f.manager.stopAll()
|
||||
expect(f.outbox.listUndelivered()[0]).toMatchObject({ state: 'pending', attempts: 0 })
|
||||
expect((await f.outbox.listUndelivered())[0]).toMatchObject({ state: 'pending', attempts: 0 })
|
||||
expect(f.state.sends).toHaveLength(4)
|
||||
await f.manager.initialize()
|
||||
await vi.waitFor(() => expect(f.outbox.listUndelivered()).toEqual([]))
|
||||
await vi.waitFor(async () => expect(await f.outbox.listUndelivered()).toEqual([]))
|
||||
expect(f.state.sends).toHaveLength(5)
|
||||
expect(f.execute).not.toHaveBeenCalled()
|
||||
} finally { await f.close() }
|
||||
|
||||
Reference in New Issue
Block a user