Files
goodbuddy/src/main/document-parse-client.ts
T

371 lines
13 KiB
TypeScript

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 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 or backing off after a worker failure. */
get available(): boolean {
return !this.closed && this.now() >= this.disabledUntil
}
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'))
}
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)
}