perf: parse, chunk and hash documents in a worker thread and add startup and document-parse benchmarks

This commit is contained in:
mesalogo
2026-10-03 13:54:24 +08:00
parent 9d03217f99
commit 10c5162811
15 changed files with 1787 additions and 33 deletions
+375
View File
@@ -0,0 +1,375 @@
import { createHash } from 'node:crypto'
import { Worker } from 'node:worker_threads'
import type { KnowledgeChunkingSettings } from '../shared/knowledge-contracts'
import {
assertDocumentBuffer,
chunkDocumentAdvanced,
DocumentTextUnavailableError,
extractPdfTextPages,
parseDocument,
type DocumentChunk,
type ParsedDocument,
type PdfTextExtraction,
type PdfTextExtractionOptions
} from './knowledge/document-parser'
import { extractPptxPages, type PptxPage } from './knowledge/pptx-parser'
import type {
DocumentParseOperation,
DocumentParseRequest,
DocumentParseResponse,
SerializedDocumentParseError
} from './document-parse-errors'
export type { DocumentParseOperation } from './document-parse-errors'
// PERF-15/16: pure document work (PDF / Office / text parsing, chunking,
// hashing) runs in a single worker thread with a FIFO queue, so Main only
// orchestrates. Database writes stay on the caller's side, in the same order.
// Same fallback rules as readonly-query-reader: if the worker cannot start or
// crashes, the call runs inline (the previous behaviour) and the worker is
// retried after a backoff. Aborting a running task terminates the worker.
/** Worker infrastructure failure; callers fall back to the inline path. */
export class DocumentParseWorkerUnavailableError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options)
this.name = 'DocumentParseWorkerUnavailableError'
}
}
type Task = {
id: number
op: DocumentParseOperation
args: unknown[]
transfer: ArrayBuffer[]
resolve: (value: unknown) => void
reject: (error: unknown) => void
cleanup: () => void
}
const MAX_QUEUED = 32
const RESTART_BACKOFF_MS = 30_000
type PdfExceptionConstructor = new (message: string) => Error
type PdfExceptionClasses = Partial<Record<string, PdfExceptionConstructor>>
const pdfExceptionNames = [
'AbortException',
'InvalidPDFException',
'PasswordException',
'ResponseException',
'UnknownErrorException'
] as const
let pdfExceptions: Promise<PdfExceptionClasses> | undefined
/** PDF.js exception classes, loaded only once a PDF error crosses the boundary. */
function loadPdfExceptions(): Promise<PdfExceptionClasses> {
pdfExceptions ??= import('pdfjs-dist/legacy/build/pdf.mjs').then(
(pdfjs) => {
const module = pdfjs as unknown as Record<string, unknown>
return Object.fromEntries(pdfExceptionNames.flatMap((name) =>
typeof module[name] === 'function' ? [[name, module[name] as PdfExceptionConstructor]] : []))
},
() => ({})
)
return pdfExceptions
}
/**
* Rebuilds an error with the same class, name, message, cause and primitive
* fields as the one thrown inline (stack traces differ by nature).
*/
export async function deserializeDocumentParseError(error: SerializedDocumentParseError): Promise<Error> {
let result: Error
if (error.name === 'DocumentTextUnavailableError') {
result = new DocumentTextUnavailableError(error.message)
} else if ((pdfExceptionNames as readonly string[]).includes(error.name)) {
const Constructor = (await loadPdfExceptions())[error.name]
result = Constructor ? new Constructor(error.message) : new Error(error.message)
} else {
const Constructor = error.name === 'RangeError' ? RangeError
: error.name === 'TypeError' ? TypeError : Error
result = new Constructor(error.message)
}
if (result.name !== error.name) result.name = error.name
if (error.cause) {
Object.defineProperty(result, 'cause', {
value: await deserializeDocumentParseError(error.cause), writable: true, configurable: true
})
}
for (const [key, value] of Object.entries(error.extra ?? {})) {
Object.defineProperty(result, key, { value, writable: true, configurable: true, enumerable: true })
}
return result
}
export class DocumentParseWorkerClient {
private worker?: Worker
private nextId = 0
private disabledUntil = 0
private closed = false
private active?: Task
private readonly queue: Task[] = []
constructor(
private readonly workerPath: string,
private readonly now: () => number = Date.now
) {}
/** False while closed, backing off after a crash, or when the queue is full. */
get available(): boolean {
return !this.closed && this.now() >= this.disabledUntil &&
this.queue.length < MAX_QUEUED
}
get pendingCount(): number {
return this.queue.length + (this.active ? 1 : 0)
}
call<T>(
op: DocumentParseOperation,
args: unknown[],
transfer: ArrayBuffer[] = [],
signal?: AbortSignal
): Promise<T> {
if (this.closed) return Promise.reject(new DocumentParseWorkerUnavailableError('Document parse worker closed'))
if (signal?.aborted) return Promise.reject(signal.reason)
if (this.now() < this.disabledUntil) {
return Promise.reject(new DocumentParseWorkerUnavailableError('Document parse worker is backing off'))
}
if (this.queue.length >= MAX_QUEUED) {
return Promise.reject(new DocumentParseWorkerUnavailableError('Document parse worker is busy'))
}
return new Promise<T>((resolve, reject) => {
const task: Task = {
id: ++this.nextId,
op,
args,
transfer,
resolve: resolve as (value: unknown) => void,
reject,
cleanup: () => signal?.removeEventListener('abort', onAbort)
}
const onAbort = (): void => {
if (this.active === task) {
// The task is CPU-bound in the worker; stop it by terminating the worker.
this.active = undefined
const worker = this.worker
this.worker = undefined
if (worker) void worker.terminate()
} else {
const index = this.queue.indexOf(task)
if (index < 0) return
this.queue.splice(index, 1)
}
task.cleanup()
reject(signal!.reason)
this.pump()
}
signal?.addEventListener('abort', onAbort, { once: true })
this.queue.push(task)
this.pump()
})
}
private pump(): void {
if (this.active || this.closed) return
const task = this.queue.shift()
if (!task) {
this.worker?.unref()
return
}
let worker: Worker
try {
worker = this.ensureWorker()
} catch (error) {
this.disabledUntil = this.now() + RESTART_BACKOFF_MS
this.failAll(task, new DocumentParseWorkerUnavailableError('Document parse worker failed to start', { cause: error }))
return
}
this.active = task
// Keep the process alive only while a task is outstanding.
worker.ref()
try {
worker.postMessage(
{ id: task.id, op: task.op, args: task.args } satisfies DocumentParseRequest,
task.transfer
)
} catch (error) {
this.active = undefined
task.cleanup()
task.reject(new DocumentParseWorkerUnavailableError('Document parse task could not be posted', { cause: error }))
this.pump()
}
}
private failAll(first: Task | undefined, error: Error): void {
const tasks = [...(first ? [first] : []), ...this.queue.splice(0)]
for (const task of tasks) {
task.cleanup()
task.reject(error)
}
}
private ensureWorker(): Worker {
if (this.worker) return this.worker
const worker = new Worker(this.workerPath)
this.worker = worker
worker.on('message', (message: DocumentParseResponse) => {
const task = this.active
if (this.worker !== worker || !task || task.id !== message.id) return
this.active = undefined
task.cleanup()
if ('error' in message) {
void deserializeDocumentParseError(message.error).then(task.reject)
} else {
task.resolve(message.result)
}
this.pump()
})
const failed = (error: Error): void => {
// Ignore exits of workers that were terminated on purpose (abort, close).
if (this.worker !== worker) return
this.worker = undefined
this.disabledUntil = this.now() + RESTART_BACKOFF_MS
const active = this.active
this.active = undefined
this.failAll(active, new DocumentParseWorkerUnavailableError(error.message, { cause: error }))
void worker.terminate()
}
worker.once('error', failed)
worker.once('exit', (code) => failed(new Error(`Document parse worker exited (${code})`)))
worker.unref()
return worker
}
/** Test hook: simulate a crash by terminating the worker. */
async terminateWorkerForTest(): Promise<void> {
await this.worker?.terminate()
}
/** Test hook: end the post-crash backoff. */
resetBackoffForTest(): void {
this.disabledUntil = 0
}
close(): void {
this.closed = true
const worker = this.worker
this.worker = undefined
const active = this.active
this.active = undefined
this.failAll(active, new DocumentParseWorkerUnavailableError('Document parse worker closed'))
if (worker) void worker.terminate()
}
}
let sharedClient: DocumentParseWorkerClient | undefined
/** Enables the parse worker for this process (Main calls it once at startup). */
export function configureDocumentParseWorker(workerPath: string | undefined): void {
sharedClient?.close()
sharedClient = workerPath ? new DocumentParseWorkerClient(workerPath) : undefined
}
export function closeDocumentParseWorker(): void {
sharedClient?.close()
sharedClient = undefined
}
/** A private copy of the bytes that can be transferred without detaching the caller's buffer. */
function transferableCopy(buffer: Uint8Array): ArrayBuffer {
return Uint8Array.prototype.slice.call(buffer).buffer as ArrayBuffer
}
/**
* Runs `op` in the worker when available, otherwise inline. Worker
* infrastructure failures fall back to the inline path; operation errors and
* aborts propagate unchanged. An already-aborted signal also runs inline, so
* validation and abort errors surface in exactly the inline order.
*/
export async function runOffMain<T>(
client: DocumentParseWorkerClient | undefined,
op: DocumentParseOperation,
request: () => { args: unknown[]; transfer?: ArrayBuffer[] },
signal: AbortSignal | undefined,
inline: () => T | Promise<T>
): Promise<T> {
if (!client?.available || signal?.aborted) return inline()
const { args, transfer } = request()
try {
return await client.call<T>(op, args, transfer, signal)
} catch (error) {
if (error instanceof DocumentParseWorkerUnavailableError && !signal?.aborted) return inline()
throw error
}
}
export function parseDocumentOffMain(
name: string,
buffer: Buffer,
signal?: AbortSignal,
client: DocumentParseWorkerClient | undefined = sharedClient
): Promise<ParsedDocument> {
// Cheap size checks stay inline so oversized input is never copied.
try {
assertDocumentBuffer(buffer)
} catch (error) {
return Promise.reject(error)
}
return runOffMain(client, 'parseDocument', () => {
const bytes = transferableCopy(buffer)
return { args: [name, bytes], transfer: [bytes] }
}, signal, () => parseDocument(name, buffer, signal))
}
export function extractPdfTextPagesOffMain(
buffer: Buffer,
options: PdfTextExtractionOptions = {},
client: DocumentParseWorkerClient | undefined = sharedClient
): Promise<PdfTextExtraction> {
const { signal, ...limits } = options
return runOffMain(client, 'extractPdfTextPages', () => {
const bytes = transferableCopy(buffer)
return { args: [bytes, limits], transfer: [bytes] }
}, signal, () => extractPdfTextPages(buffer, options))
}
export function extractPptxPagesOffMain(
buffer: Buffer,
client: DocumentParseWorkerClient | undefined = sharedClient
): Promise<PptxPage[]> {
return runOffMain(client, 'extractPptxPages', () => {
const bytes = transferableCopy(buffer)
return { args: [bytes], transfer: [bytes] }
}, undefined, () => extractPptxPages(buffer))
}
/**
* Not cancellable on purpose: the inline call it replaces was synchronous, so
* an abort during chunking keeps surfacing at the caller's next checkpoint.
*/
export function chunkDocumentOffMain(
document: ParsedDocument,
settings: KnowledgeChunkingSettings,
client: DocumentParseWorkerClient | undefined = sharedClient
): Promise<DocumentChunk[]> {
return runOffMain(client, 'chunkDocument', () => ({
// Only the fields chunking reads; images (Buffers) would otherwise be cloned.
args: [{ title: document.title, sourceFormat: document.sourceFormat, content: '', sections: document.sections, warnings: [] }, settings]
}), undefined, () => chunkDocumentAdvanced(document, settings))
}
/** Hex SHA-256 of `buffer`, computed in the worker for large inputs. */
export function sha256OffMain(
buffer: Uint8Array,
client: DocumentParseWorkerClient | undefined = sharedClient
): Promise<string> {
const inline = (): string => createHash('sha256').update(buffer).digest('hex')
// Small inputs hash faster than a round trip.
if (buffer.byteLength < 1024 * 1024) return Promise.resolve().then(inline)
return runOffMain(client, 'sha256', () => {
const bytes = transferableCopy(buffer)
return { args: [bytes], transfer: [bytes] }
}, undefined, inline)
}
+28
View File
@@ -0,0 +1,28 @@
// Wire format shared by document-parse-client (Main) and document-parse-worker.
export type DocumentParseOperation =
| 'parseDocument'
| 'extractPdfTextPages'
| 'extractPptxPages'
| 'chunkDocument'
| 'sha256'
export type DocumentParseRequest = {
id: number
op: DocumentParseOperation
args: unknown[]
}
export type SerializedDocumentParseError = {
name: string
message: string
/** Primitive own properties some error types carry (PDF.js, Node). */
extra?: Record<string, unknown>
cause?: SerializedDocumentParseError
}
export type DocumentParseResponse =
| { id: number; result: unknown }
| { id: number; error: SerializedDocumentParseError }
export const documentParseErrorExtraKeys = ['code', 'status', 'missing', 'details', 'extraDelay'] as const
+284
View File
@@ -0,0 +1,284 @@
// @vitest-environment node
import { createHash } from 'node:crypto'
import { isDeepStrictEqual } from 'node:util'
import { afterAll, beforeAll, describe, expect, it, onTestFinished } from 'vitest'
import { strToU8, zipSync } from 'fflate'
import type { KnowledgeChunkingSettings } from '../shared/knowledge-contracts'
import {
buildDocumentParseWorker,
createDocx,
createLargePdf,
createPptx,
createTextPdf,
createXlsx,
sentenceGenerator
} from '../../tests/support/document-parse-worker-fixture'
import { createImagePptx } from '../../tests/support/pptx-fixture'
import {
chunkDocumentOffMain,
DocumentParseWorkerClient,
DocumentParseWorkerUnavailableError,
extractPdfTextPagesOffMain,
extractPptxPagesOffMain,
parseDocumentOffMain,
sha256OffMain
} from './document-parse-client'
import {
chunkDocumentAdvanced,
DocumentTextUnavailableError,
extractPdfTextPages,
parseDocument
} from './knowledge/document-parser'
import { extractPptxPages } from './knowledge/pptx-parser'
let workerPath: string
let dispose: () => Promise<void>
let client: DocumentParseWorkerClient
beforeAll(async () => {
({ workerPath, dispose } = await buildDocumentParseWorker())
client = new DocumentParseWorkerClient(workerPath)
}, 60_000)
afterAll(async () => {
client?.close()
await dispose?.()
})
const sentence = sentenceGenerator(3)
const markdown = [
'# GoodBuddy',
'知识内容。'.repeat(400),
'## 安装',
...Array.from({ length: 40 }, (_, index) => `Step ${index}: ${sentence(30)}.`),
'### 配置\r\n' + sentence(200),
'# 附录',
sentence(600)
].join('\n')
/** Fixture corpus: every format the parser accepts, plus edge cases. */
const fixtures: Array<{ name: string; buffer: Buffer }> = [
{ name: 'notes.md', buffer: Buffer.from(markdown) },
{ name: 'plain.txt', buffer: Buffer.from(` ${sentence(5_000)} `) },
{ name: 'data.json', buffer: Buffer.from(JSON.stringify({ items: Array.from({ length: 200 }, (_, id) => ({ id, text: sentence(8) })) }, null, 2)) },
{ name: 'page.html', buffer: Buffer.from('<main><h1>安全标题</h1><p>网页正文</p><table><tr><td>a</td><td>b</td></tr></table></main><script>恶意脚本</script><style>x{}</style>') },
{ name: 'large.txt', buffer: Buffer.from('x'.repeat(5_000_100)) },
{ name: 'sample.pdf', buffer: createTextPdf([['PDF body text']]) },
{ name: 'multi.pdf', buffer: createTextPdf([['first line', 'second (line)'], [], ['third page', 'ends.']]) },
{ name: 'big.pdf', buffer: createLargePdf(40, 40) },
{ name: 'sample.docx', buffer: createDocx(['文档正文', '第二段', ''], [[['h1', 'h2'], ['v1', 'v2']]]) },
{ name: 'sample.xlsx', buffer: createXlsx([[['name', 'qty', 'note'], ['apple', '3', 'fresh']], [['b', '7', 'inline']]]) },
{ name: 'shared.xlsx', buffer: Buffer.from(zipSync({ 'xl/sharedStrings.xml': strToU8('<sst><si><t>表格内容</t></si></sst>') })) },
{ name: 'sample.pptx', buffer: createPptx(['幻灯片内容', 'second slide', 'third']) },
{ name: 'image.pptx', buffer: createPptx(['with image', 'two'], true) }
]
const failureFixtures: Array<{ name: string; buffer: Buffer }> = [
{ name: 'archive.zip', buffer: Buffer.from('not supported') },
{ name: 'empty.txt', buffer: Buffer.alloc(0) },
{ name: 'large.txt', buffer: Buffer.alloc(20 * 1024 * 1024 + 1) },
{ name: 'expanded.docx', buffer: Buffer.from(zipSync({ 'word/document.xml': new Uint8Array(11 * 1024 * 1024) })) },
{ name: 'invalid.txt', buffer: Buffer.from([0xc3, 0x28]) },
{ name: 'nulls.txt', buffer: Buffer.from('a\0b\0c\0d') },
{ name: 'blank.md', buffer: Buffer.from(' \n\n ') },
{ name: 'broken.pdf', buffer: Buffer.from('%PDF-1.4 not really') },
{ name: 'empty.pdf', buffer: createTextPdf([[]]) },
{ name: 'corrupt.xlsx', buffer: Buffer.from('PK not a zip') }
]
const chunkingModes: KnowledgeChunkingSettings[] = [
{ version: 1, mode: 'fixed', targetCharacters: 500, overlapCharacters: 50, parentCharacters: 4_800, childCharacters: 900, contextualIndexingEnabled: false },
{ version: 1, mode: 'structure', targetCharacters: 800, overlapCharacters: 80, parentCharacters: 4_800, childCharacters: 900, contextualIndexingEnabled: true },
{ version: 1, mode: 'parent-child', targetCharacters: 1_600, overlapCharacters: 100, parentCharacters: 1_600, childCharacters: 400, contextualIndexingEnabled: false }
]
/** Byte-level comparison: JSON (key order) plus deep equality (undefined fields, typed arrays). */
function expectIdentical(actual: unknown, expected: unknown): void {
expect(JSON.stringify(actual)).toBe(JSON.stringify(expected))
expect(isDeepStrictEqual(normalizeBytes(actual), normalizeBytes(expected))).toBe(true)
}
function normalizeBytes(value: unknown): unknown {
if (value instanceof Uint8Array) return { bytes: Buffer.from(value).toString('hex') }
if (Array.isArray(value)) return value.map(normalizeBytes)
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, normalizeBytes(item)]))
}
return value
}
async function settle<T>(run: () => Promise<T>): Promise<{ ok: true; value: T } | { ok: false; error: unknown }> {
try {
return { ok: true, value: await run() }
} catch (error) {
return { ok: false, error }
}
}
function describeError(error: unknown): unknown {
return error instanceof Error
? { constructor: error.constructor, name: error.name, message: error.message,
own: Object.entries(error).filter(([key]) => key !== 'stack'),
unavailable: error instanceof DocumentTextUnavailableError,
cause: describeError(error.cause) }
: error
}
describe('document parse worker equivalence', () => {
it('parses every fixture to output identical to the inline parser', async () => {
for (const fixture of fixtures) {
const inline = await parseDocument(fixture.name, fixture.buffer)
const before = Buffer.from(fixture.buffer)
const offMain = await parseDocumentOffMain(fixture.name, fixture.buffer, undefined, client)
expectIdentical(offMain, inline)
// The caller's buffer is not detached or modified by the transfer.
expect(fixture.buffer.equals(before)).toBe(true)
}
}, 60_000)
it('chunks every fixture identically in all chunking modes', async () => {
// large.txt (5M chars, no break points) is excluded: splitNatural's backwards
// boundary scan makes it take minutes on either path.
for (const fixture of fixtures.filter((item) => item.name !== 'large.txt')) {
const parsed = await parseDocument(fixture.name, fixture.buffer)
for (const settings of chunkingModes) {
const inline = await settle(async () => chunkDocumentAdvanced(parsed, settings))
const offMain = await settle(() => chunkDocumentOffMain(parsed, settings, client))
expect(offMain.ok).toBe(inline.ok)
if (inline.ok && offMain.ok) expectIdentical(offMain.value, inline.value)
else expect(describeError(offMain.ok ? undefined : offMain.error)).toEqual(describeError(inline.ok ? undefined : inline.error))
}
}
}, 60_000)
it('extracts PDF pages and PPTX pages identically', async () => {
// Pre-existing PDF.js behaviour on the inline path: cancelling a text stream
// at the character limit can leave a stray "Controller is already closed".
const strayPdfRejection = (reason: unknown): void => {
if ((reason as { code?: string })?.code !== 'ERR_INVALID_STATE') throw reason
}
process.on('unhandledRejection', strayPdfRejection)
onTestFinished(async () => {
await new Promise((resolve) => setTimeout(resolve, 50))
process.removeListener('unhandledRejection', strayPdfRejection)
})
for (const fixture of fixtures.filter((item) => item.name.endsWith('.pdf'))) {
for (const options of [{}, { maximumCharacters: 50 }, { maximumPages: 1 }]) {
const inline = await settle(() => extractPdfTextPages(fixture.buffer, options))
const offMain = await settle(() => extractPdfTextPagesOffMain(fixture.buffer, options, client))
if (inline.ok && offMain.ok) expectIdentical(offMain.value, inline.value)
else expect(describeError(offMain.ok ? undefined : offMain.error)).toEqual(describeError(inline.ok ? undefined : inline.error))
}
}
for (const buffer of [createPptx(['a', 'b'], true), createImagePptx('native', 3), createPptx(['plain'])]) {
const inline = extractPptxPages(buffer)
const offMain = await extractPptxPagesOffMain(buffer, client)
expectIdentical(offMain, inline)
for (const page of offMain) {
for (const image of page.images) expect(image.data).toBeInstanceOf(Uint8Array)
}
}
}, 60_000)
it('hashes identically', async () => {
for (const buffer of [Buffer.alloc(0), Buffer.from('abc'), createLargePdf(40, 40), Buffer.alloc(3 * 1024 * 1024, 7)]) {
expect(await sha256OffMain(buffer, client)).toBe(createHash('sha256').update(buffer).digest('hex'))
}
})
it('maps errors to the same types and messages as the inline parser', async () => {
for (const fixture of failureFixtures) {
const inline = await settle(() => parseDocument(fixture.name, fixture.buffer))
const offMain = await settle(() => parseDocumentOffMain(fixture.name, fixture.buffer, undefined, client))
expect(inline.ok, fixture.name).toBe(false)
expect(offMain.ok, fixture.name).toBe(false)
expect(describeError(offMain.ok ? undefined : offMain.error), fixture.name)
.toEqual(describeError(inline.ok ? undefined : inline.error))
}
const tooManySections = {
title: 'Too many', sourceFormat: '.txt', content: '', warnings: [],
sections: Array.from({ length: 10_001 }, (_, index) => ({ locator: `s-${index}`, content: 'content' }))
}
const inline = await settle(async () => chunkDocumentAdvanced(tooManySections, chunkingModes[0]!))
const offMain = await settle(() => chunkDocumentOffMain(tooManySections, chunkingModes[0]!, client))
expect(describeError(offMain.ok ? undefined : offMain.error)).toEqual(describeError(inline.ok ? undefined : inline.error))
const range = await settle(() => extractPdfTextPagesOffMain(createTextPdf([['x']]), { maximumPages: 0 }, client))
expect(range.ok ? undefined : range.error).toBeInstanceOf(RangeError)
})
})
describe('document parse worker lifecycle', () => {
it('cancels a running parse by terminating the worker and keeps serving later tasks', async () => {
const local = new DocumentParseWorkerClient(workerPath)
try {
const big = createLargePdf(400, 50)
const controller = new AbortController()
const reason = new Error('文档解析已取消')
const running = parseDocumentOffMain('big.pdf', big, controller.signal, local)
const queued = parseDocumentOffMain('notes.md', Buffer.from(markdown), undefined, local)
const queuedCancelled = new AbortController()
const queuedAbort = expect(parseDocumentOffMain('plain.txt', Buffer.from('x'), queuedCancelled.signal, local)).rejects.toBe(reason)
queuedCancelled.abort(reason)
await new Promise((resolve) => setTimeout(resolve, 30))
controller.abort(reason)
await expect(running).rejects.toBe(reason)
await queuedAbort
expectIdentical(await queued, await parseDocument('notes.md', Buffer.from(markdown)))
expect(local.pendingCount).toBe(0)
} finally {
local.close()
}
}, 60_000)
it('rejects with the abort reason when already aborted, without starting the worker', async () => {
const local = new DocumentParseWorkerClient(workerPath)
try {
const controller = new AbortController()
const reason = new Error('cancelled')
controller.abort(reason)
await expect(parseDocumentOffMain('a.txt', Buffer.from('a'), controller.signal, local)).rejects.toBe(reason)
} finally {
local.close()
}
})
it('falls back to the inline path when the worker crashes or cannot start', async () => {
const local = new DocumentParseWorkerClient(workerPath)
try {
const running = local.call('parseDocument', ['big.pdf', Uint8Array.from(createLargePdf(200, 50)).buffer])
await new Promise((resolve) => setTimeout(resolve, 20))
await local.terminateWorkerForTest()
await expect(running).rejects.toBeInstanceOf(DocumentParseWorkerUnavailableError)
expect(local.available).toBe(false)
// While backing off, calls run inline with identical output.
const buffer = Buffer.from(markdown)
expectIdentical(await parseDocumentOffMain('notes.md', buffer, undefined, local), await parseDocument('notes.md', buffer))
local.resetBackoffForTest()
expect(local.available).toBe(true)
expectIdentical(await parseDocumentOffMain('notes.md', buffer, undefined, local), await parseDocument('notes.md', buffer))
} finally {
local.close()
}
const missing = new DocumentParseWorkerClient(`${workerPath}.missing.cjs`)
try {
const buffer = createDocx(['fallback'])
expectIdentical(await parseDocumentOffMain('a.docx', buffer, undefined, missing), await parseDocument('a.docx', buffer))
expect(missing.available).toBe(false)
} finally {
missing.close()
}
}, 60_000)
it('runs tasks one at a time in FIFO order', async () => {
const local = new DocumentParseWorkerClient(workerPath)
try {
const names = fixtures.slice(0, 6)
const results = await Promise.all(names.map((fixture) => parseDocumentOffMain(fixture.name, fixture.buffer, undefined, local)))
for (const [index, fixture] of names.entries()) {
expectIdentical(results[index], await parseDocument(fixture.name, fixture.buffer))
}
} finally {
local.close()
}
}, 60_000)
})
+101
View File
@@ -0,0 +1,101 @@
import { createHash } from 'node:crypto'
import { parentPort } from 'node:worker_threads'
import type { KnowledgeChunkingSettings } from '../shared/knowledge-contracts'
import {
chunkDocumentAdvanced,
extractPdfTextPages,
parseDocument,
type ParsedDocument
} from './knowledge/document-parser'
import { extractPptxPages } from './knowledge/pptx-parser'
import {
documentParseErrorExtraKeys,
type DocumentParseRequest,
type DocumentParseResponse,
type SerializedDocumentParseError
} from './document-parse-errors'
// PERF-15/16: CPU-heavy, pure document work (PDF / Office / text parsing,
// chunking, hashing) runs here so Main only orchestrates. Every handler calls
// the same production function as the inline path, so results are identical.
// No database access and no file system access happen in this worker.
type Handler = (args: unknown[]) => Promise<unknown> | unknown
const asBuffer = (value: unknown): Buffer => {
const bytes = value as ArrayBuffer
return Buffer.from(bytes, 0, bytes.byteLength)
}
const handlers: Record<string, Handler> = {
parseDocument: ([name, bytes]) => parseDocument(name as string, asBuffer(bytes)),
extractPdfTextPages: ([bytes, options]) => extractPdfTextPages(
asBuffer(bytes),
options as { maximumPages?: number; maximumCharacters?: number }
),
extractPptxPages: ([bytes]) => extractPptxPages(asBuffer(bytes)),
chunkDocument: ([document, settings]) => chunkDocumentAdvanced(
document as ParsedDocument,
settings as KnowledgeChunkingSettings
),
sha256: ([bytes]) => createHash('sha256').update(asBuffer(bytes)).digest('hex')
}
/** ArrayBuffers backing typed arrays in the result (PPTX images); transferred, not copied. */
function transferables(value: unknown): ArrayBuffer[] {
const buffers = new Set<ArrayBuffer>()
if (Array.isArray(value)) {
for (const page of value as Array<{ images?: Array<{ data?: unknown }> }>) {
for (const image of page?.images ?? []) {
const data = image?.data
if (
data instanceof Uint8Array &&
data.buffer instanceof ArrayBuffer &&
data.byteOffset === 0 &&
data.byteLength === data.buffer.byteLength
) {
buffers.add(data.buffer)
}
}
}
}
return [...buffers]
}
function serializeError(error: unknown, depth = 0): SerializedDocumentParseError {
if (!(error instanceof Error)) return { name: 'Error', message: String(error) }
const extra: Record<string, unknown> = {}
for (const key of documentParseErrorExtraKeys) {
const value = (error as unknown as Record<string, unknown>)[key]
if (value !== undefined && (typeof value !== 'object' || value === null)) extra[key] = value
}
return {
name: error.name,
message: error.message,
...(Object.keys(extra).length > 0 ? { extra } : {}),
...(error.cause !== undefined && depth < 3 ? { cause: serializeError(error.cause, depth + 1) } : {})
}
}
// PDF.js can leave a stray rejection ("Controller is already closed") after a
// text stream is cancelled at the character limit. Unhandled, it would kill
// this thread and push the next tasks onto the inline fallback. Task errors are
// awaited by the handler and still reported through the response.
process.on('unhandledRejection', () => undefined)
parentPort!.on('message', (message: DocumentParseRequest) => {
void (async () => {
let response: DocumentParseResponse
let transfer: ArrayBuffer[] = []
try {
const handler = Object.hasOwn(handlers, message.op) ? handlers[message.op] : undefined
if (!handler) throw new Error(`Unknown document parse operation: ${message.op}`)
const result = await handler(message.args)
transfer = message.op === 'extractPptxPages' ? transferables(result) : []
response = { id: message.id, result }
} catch (error) {
response = { id: message.id, error: serializeError(error) }
}
parentPort!.postMessage(response, transfer)
})()
})
+9 -7
View File
@@ -18,12 +18,14 @@ import { HttpDocumentOcr } from './http-document-ocr'
import { hasExtractedDocumentText } from './document-extracted-text'
import { renderOcrPdf, renderSelectedOcrPdf } from './render-ocr-pdf'
import type { DocumentResultStorage } from './document-result-storage'
import { extractPptxPages } from './knowledge/pptx-parser'
import {
extractPdfTextPagesOffMain,
extractPptxPagesOffMain,
parseDocumentOffMain
} from './document-parse-client'
import {
assertDocumentBuffer,
DocumentTextUnavailableError,
extractPdfTextPages,
parseDocument,
type ParsedDocument,
type ParsedSection,
type PdfTextPage
@@ -279,10 +281,10 @@ export class DocumentParsingService {
return this.parsePptx(name, buffer, purpose, operation, signal)
}
if (extname(name).toLowerCase() !== '.pdf') {
return parseDocument(name, buffer, signal)
return parseDocumentOffMain(name, buffer, signal)
}
const extracted = await extractPdfTextPages(buffer, { signal })
const extracted = await extractPdfTextPagesOffMain(buffer, { signal })
const { pages } = extracted
ensureNotAborted(signal)
const mode = effectiveOcrMode(settings, purpose)
@@ -517,7 +519,7 @@ export class DocumentParsingService {
const mode = effectiveOcrMode(settings, purpose)
if (mode === 'disabled') {
try {
return await parseDocument(name, buffer, signal)
return await parseDocumentOffMain(name, buffer, signal)
} catch (error) {
if (error instanceof DocumentTextUnavailableError) {
throw new DocumentTextUnavailableError(
@@ -527,7 +529,7 @@ export class DocumentParsingService {
throw error
}
}
const pages = extractPptxPages(buffer)
const pages = await extractPptxPagesOffMain(buffer)
ensureNotAborted(signal)
const native: ParsedSection[] = pages
.filter((page) => page.content)
+31 -15
View File
@@ -1,3 +1,4 @@
import { startupMark, startupSpan } from './startup-marks'
import { localInferenceService } from './local-inference-service'
import { NativeClientCoordinator } from './agent/native-client-coordinator'
import { createEmbeddingUtilityTransport } from './knowledge/embedding-utility-transport'
@@ -98,6 +99,7 @@ import { DocumentParsingSettingsStore } from './document-parsing-settings-store'
import { DocumentOcrModelManager } from './document-ocr-model-manager'
import { DocumentOcrBroker } from './document-ocr-broker'
import { DocumentParsingService } from './document-parsing-service'
import { configureDocumentParseWorker } from './document-parse-client'
import { DocumentResultStorage } from './document-result-storage'
import { ConversationAttachmentStorage } from './conversation-attachment-storage'
import { ReleaseNotesService } from './release-notes-service'
@@ -553,6 +555,7 @@ function buildTray(): Tray {
const storageUpgradeController = new AbortController()
let storageUpgrade: Promise<void> | undefined
startupMark('main:module-evaluated')
if (hasSingleInstanceLock) {
app.on('second-instance', () => {
if (mainWindow) {
@@ -561,6 +564,7 @@ if (hasSingleInstanceLock) {
})
void app.whenReady().then(async () => {
startupMark('main:when-ready')
session.defaultSession.setPermissionRequestHandler(
(webContents, permission, callback, details) => {
const mediaTypes =
@@ -582,15 +586,18 @@ if (hasSingleInstanceLock) {
details.mediaType === 'audio'
)
startupMark('main:create-window:start')
mainWindow = createMainWindow(() => isQuitting, observeDesktopFailure)
startupMark('main:create-window:end')
registerDesktopNotificationActivation(mainWindow)
tray = buildTray()
startupMark('main:tray-built')
storageUpgrade = prepareAssistantStorage(
mainWindow,
join(app.getPath('userData'), 'assistant.sqlite'),
storageUpgradeController.signal
)
await storageUpgrade
await startupSpan('main:storage-upgrade-check', () => storageUpgrade!)
if (storageUpgradeController.signal.aborted) return
const defaultWorkspace = process.env.GOODBUDDY_WORKSPACE ?? homedir()
const secureCipher = {
@@ -670,7 +677,7 @@ if (hasSingleInstanceLock) {
},
baseEnvironment: buildCredentialFilteredUserEnvironment()
})
await startupLocalToolEnvironmentService.initialize()
await startupSpan('main:local-tool-environment', () => startupLocalToolEnvironmentService.initialize())
localToolEnvironmentService =
startupLocalToolEnvironmentService
const sshHostStore = new SshHostStore(
@@ -682,11 +689,11 @@ if (hasSingleInstanceLock) {
resourcesPath: process.resourcesPath,
packaged: app.isPackaged
})
const controlPlanePackageInstaller = await readFile(
const controlPlanePackageInstaller = await startupSpan('main:read-control-plane-installer', () => readFile(
resolveControlPlanePackageInstallerPath({
appPath: app.getAppPath()
})
)
))
const agentPackageManager = new AgentPackageManager({
userDataPath: app.getPath('userData'),
desktopVersion: app.getVersion(),
@@ -728,7 +735,7 @@ if (hasSingleInstanceLock) {
return selection.provider === 'continue' ? resolved.continueModelProfile : resolved.opencodeModelProfile
}
})
await startupManagedRemoteExecutionServices.initialize()
await startupSpan('main:managed-remote-init', () => startupManagedRemoteExecutionServices.initialize())
managedRemoteExecutionServices =
startupManagedRemoteExecutionServices
const beginRemoteHostInvalidation = (hostId: string): void => {
@@ -777,10 +784,10 @@ if (hasSingleInstanceLock) {
)
})
const [initialRuntimeSettings, initialResolvedSettings] =
await Promise.all([
await startupSpan('main:runtime-settings-load', () => Promise.all([
settingsStore.getPublicSettings(),
settingsStore.getResolvedSettings()
])
]))
globalTlsPolicy = new GlobalTlsPolicy(app)
globalTlsPolicy.install()
const capabilityService = new CapabilityService(
@@ -836,6 +843,7 @@ if (hasSingleInstanceLock) {
(await applicationSettingsStore.get()).modelDownloadSource
})
documentOcrBroker = new DocumentOcrBroker(mainWindow)
configureDocumentParseWorker(join(app.getAppPath(), 'out/main/document-parse-worker.js'))
documentParsingService = new DocumentParsingService(
documentParsingSettingsStore,
documentOcrModelManager,
@@ -1208,7 +1216,8 @@ if (hasSingleInstanceLock) {
resolved.target
)
}
const configuredRuntime = await runStartupPrerequisites({
startupMark('main:services-constructed')
const configuredRuntime = await startupSpan('main:prerequisites', () => runStartupPrerequisites({
prepareDeepSeekHome: async () => {
await mkdir(deepSeekHarnessHome, {
recursive: true,
@@ -1216,7 +1225,7 @@ if (hasSingleInstanceLock) {
})
},
initializeKnowledgeAndGateway: async () => {
await startupKnowledgeService.initialize()
await startupSpan('main:knowledge-init', () => startupKnowledgeService.initialize())
const embeddingProvider = await createEmbeddingProvider(
initialResolvedSettings,
startupEmbeddingModelManager
@@ -1242,11 +1251,12 @@ if (hasSingleInstanceLock) {
() => undefined
)
])
await startupKnowledgeGateway.start()
await startupSpan('main:knowledge-gateway-start', () => startupKnowledgeGateway.start())
},
hydrateConfiguredRuntime: () =>
createConfiguredRuntime(initialResolvedSettings),
startupSpan('main:runtime-hydrate', () => createConfiguredRuntime(initialResolvedSettings)),
initializeAssistant: () => {
startupMark('main:assistant-db-init:start')
startupAssistantDatabase.initialize(defaultWorkspace)
startupAssistantDatabase.enableReadonlyWorker(readonlyQueryWorkerPath)
imageGenerationService!.initialize()
@@ -1259,13 +1269,14 @@ if (hasSingleInstanceLock) {
initialRuntimeSettings
)
)
startupMark('main:assistant-db-init:end')
}
})
}))
const [initialSubagentRuntime, initialSubagentProfileRuntimes] =
await Promise.all([
await startupSpan('main:subagent-runtimes', () => Promise.all([
createSubagentRuntime(initialResolvedSettings),
createSubagentProfileRuntimes(initialResolvedSettings)
])
]))
const subagentService = new SubagentService(
initialSubagentRuntime,
startupAssistantDatabase,
@@ -1285,8 +1296,10 @@ if (hasSingleInstanceLock) {
createSelectedStatusRuntime,
(conversationId) => localRuntimeRegistry.releaseConversation(conversationId)
)
startupMark('main:attachments-reconcile:start')
conversationAttachmentStorage = new ConversationAttachmentStorage(app.getPath('userData'), documentParsingService.results!)
conversationAttachmentStorage.reconcile((conversationId, kind, ownerId) => startupAssistantDatabase.hasAttachmentOwner(conversationId, kind, ownerId), true)
startupMark('main:attachments-reconcile:end')
const contextManager = new ContextManager({
parseDocument: documentParsingService.parse,
assets: conversationAttachmentStorage,
@@ -1308,7 +1321,7 @@ if (hasSingleInstanceLock) {
},
process.platform
)
await shortcutSettingsService.initialize()
await startupSpan('main:shortcuts-init', () => shortcutSettingsService.initialize())
let runtimeReconfigurationQueue: Promise<void> = Promise.resolve()
let runtimeReconfigurationClosing = false
@@ -1513,6 +1526,7 @@ if (hasSingleInstanceLock) {
obsidianService, launchEnvironmentProvider: startupLocalToolEnvironmentService.launchEnvironmentProvider
})
})
startupMark('main:ipc-register:start')
removeIpcHandlers = registerIpcHandlers(
mainWindow,
runtime,
@@ -1581,7 +1595,9 @@ if (hasSingleInstanceLock) {
mainWindow,
startupFeedbackService
)
startupMark('main:ipc-register:end')
loadMainWindow(mainWindow)
startupMark('main:load-main-window')
removeDeviceSharingIpc = registerDeviceSharingIpc(mainWindow,
new DeviceSharingService(join(app.getPath('userData'), 'device-sharing-settings.json'), app.getVersion()))
setImmediate(() => {
+13 -9
View File
@@ -21,11 +21,14 @@ import {
} from 'node:path'
import {
buildChunkContextPrefix,
chunkDocumentAdvanced,
parseDocument,
supportedDocumentExtensions,
type ParsedDocument
} from './document-parser'
import {
chunkDocumentOffMain,
parseDocumentOffMain,
sha256OffMain
} from '../document-parse-client'
import type { DocumentResultStorage } from '../document-result-storage'
import { parsedCompleteness } from '../document-result-storage'
import {
@@ -282,7 +285,7 @@ export class KnowledgeService {
this.urlImporter = options.urlImporter ?? new UrlImporter()
this.documentParser =
options.parseDocument ??
((name, buffer) => parseDocument(name, buffer))
((name, buffer) => parseDocumentOffMain(name, buffer))
this.embeddingProvider = options.embeddingProvider
this.rerankProvider = options.rerankProvider
const embeddingBatchSize = options.embeddingBatchSize ?? 16
@@ -769,7 +772,7 @@ export class KnowledgeService {
const documentId = input.id ?? randomUUID()
const imagesOnly = parsedCompleteness(parsed) === 'images-only'
if (imagesOnly && this.database.getDocument(documentId)?.metadata.status === 'ready') throw new Error('未提取到可索引文字,保留上次正文与索引')
const chunks = imagesOnly ? [] : this.createDocumentChunks(parsed, library)
const chunks = imagesOnly ? [] : await this.createDocumentChunks(parsed, library)
let embeddingReplacement: PreparedEmbeddingReplacement | undefined
let embeddingFailure: PreparedDocumentPublication['embeddingFailure']
const embeddingProvider = this.embeddingProvider
@@ -2484,7 +2487,7 @@ export class KnowledgeService {
const prepared = await this.prepareDocumentPublication(
{
...document,
checksum: createHash('sha256').update(buffer).digest('hex'),
checksum: await sha256OffMain(buffer),
metadata: {
...document.metadata,
status: 'ready',
@@ -3137,7 +3140,7 @@ export class KnowledgeService {
progress: 35,
message: '正在解析文档内容'
})
const checksum = createHash('sha256').update(buffer).digest('hex')
const checksum = await sha256OffMain(buffer)
const previous = existing.find(
(document) => document.externalId === file.relativePath
)
@@ -3835,11 +3838,12 @@ export class KnowledgeService {
}
}
private createDocumentChunks(
private async createDocumentChunks(
parsed: ParsedDocument,
library: KnowledgeBase
): ReplaceChunkInput[] {
const chunks = chunkDocumentAdvanced(parsed, library.chunkingSettings)
): Promise<ReplaceChunkInput[]> {
// Pure CPU work runs off Main; chunk ids are still assigned here.
const chunks = await chunkDocumentOffMain(parsed, library.chunkingSettings)
const ids = chunks.map(() => randomUUID())
return chunks.map((chunk) => ({
id: ids[chunk.position],
+3 -2
View File
@@ -1,7 +1,8 @@
import { lookup as dnsLookup } from 'node:dns/promises'
import { request as httpRequest } from 'node:http'
import { request as httpsRequest } from 'node:https'
import { parseDocument, type ParsedDocument } from './document-parser'
import type { ParsedDocument } from './document-parser'
import { parseDocumentOffMain } from '../document-parse-client'
type ResolvedAddress = {
address: string
@@ -224,7 +225,7 @@ export class UrlImporter {
.trim() || url.hostname
).slice(0, 240)
: url.pathname.split('/').filter(Boolean).at(-1) ?? url.hostname
const document = await parseDocument(
const document = await parseDocumentOffMain(
isHtml ? `${title}.html` : `${title}.txt`,
response.body
)
+32
View File
@@ -0,0 +1,32 @@
import { performance } from 'node:perf_hooks'
// PERF-01 startup phase marks. Inactive unless GB_PERF_STARTUP_MARKS=1 (set by
// build/run-startup-perf.cjs); when inactive each call is one boolean check.
// Marks hold only fixed phase names and process-relative times, never user
// content. The startup harness reads them from the global below.
const enabled = process.env.GB_PERF_STARTUP_MARKS === '1'
type StartupMark = { name: string; at: number }
const marks: StartupMark[] | undefined = enabled ? [] : undefined
if (marks) {
;(globalThis as { __goodbuddyStartupMarks?: StartupMark[] }).__goodbuddyStartupMarks = marks
}
export function startupMark(name: string): void {
if (marks) marks.push({ name, at: performance.now() })
}
// Takes a thunk so the span also covers the synchronous part of the operation
// (everything before its first await).
export function startupSpan<T>(name: string, operation: () => Promise<T>): Promise<T> {
if (!marks) return operation()
startupMark(`${name}:start`)
let started: Promise<T>
try {
started = operation()
} catch (error) {
startupMark(`${name}:end`)
throw error
}
startupMark(`${name}:sync-end`)
return started.finally(() => startupMark(`${name}:end`))
}