feat: add app management, local inference controls, and task statistics
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { localInferenceService } from '../local-inference-service'
|
||||
import { EmbeddingInferenceWorker } from './embedding-inference-worker'
|
||||
import {
|
||||
EMBEDDING_INFERENCE_MAX_IN_FLIGHT,
|
||||
EMBEDDING_INFERENCE_PROTOCOL,
|
||||
EMBEDDING_INFERENCE_PROTOCOL_VERSION,
|
||||
type EmbeddingInferenceRequest
|
||||
@@ -62,6 +65,255 @@ function embedRequest(transport: TestTransport): Extract<
|
||||
}
|
||||
|
||||
describe('EmbeddingInferenceBroker', () => {
|
||||
it('drops exited process resources and uses the replacement transport after restart', async () => {
|
||||
const first = new TestTransport()
|
||||
const second = new TestTransport()
|
||||
const resourceTransport = (transport: TestTransport, pid: number): EmbeddingInferenceTransport => Object.assign(transport, {
|
||||
getResources: () => ({ scope: 'service-process' as const, pid, sampledAt: 1, workingSetBytes: 1024 })
|
||||
})
|
||||
const factory = vi.fn().mockReturnValueOnce(resourceTransport(first, 42)).mockReturnValueOnce(resourceTransport(second, 43))
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: factory })
|
||||
expect(broker.getResources()).toMatchObject({ scope: 'unavailable' })
|
||||
await broker.start()
|
||||
expect(broker.getResources()).toMatchObject({ pid: 42 })
|
||||
first.crash()
|
||||
expect(broker.getResources()).toMatchObject({ scope: 'unavailable' })
|
||||
await broker.start()
|
||||
expect(broker.getResources()).toMatchObject({ pid: 43 })
|
||||
await broker.stop()
|
||||
expect(broker.getResources()).toMatchObject({ scope: 'unavailable' })
|
||||
})
|
||||
|
||||
it('keeps source-cancelled computation in stop impact until the worker returns', async () => {
|
||||
const transport = new TestTransport()
|
||||
let finish!: (vectors: number[][]) => void
|
||||
let workerSignal!: AbortSignal
|
||||
const engine = {
|
||||
tokenizer: { tokenize: vi.fn() },
|
||||
onnx: { run: vi.fn() },
|
||||
embed: vi.fn(({ signal }: { signal: AbortSignal }) => {
|
||||
workerSignal = signal
|
||||
return new Promise<number[][]>((resolve) => { finish = resolve })
|
||||
})
|
||||
}
|
||||
const worker = new EmbeddingInferenceWorker({
|
||||
modelDirectory: process.cwd(), engine,
|
||||
port: {
|
||||
postMessage: (message) => transport.receive(message),
|
||||
onMessage: (listener) => {
|
||||
const spy = vi.spyOn(transport, 'postMessage').mockImplementation((message) => {
|
||||
transport.sent.push(message)
|
||||
listener(message)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
}
|
||||
})
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
|
||||
const act = vi.fn(async () => undefined)
|
||||
const unregister = localInferenceService.register('embedding', {
|
||||
snapshot: () => ({ id: 'embedding', name: 'Embedding', engine: 'ONNX', detail: '', ownership: 'managed-process', state: 'running', actions: ['stop', 'restart'] }),
|
||||
act
|
||||
})
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const pending = broker.embed(['delayed computation'], 'query', controller.signal)
|
||||
const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await vi.waitFor(() => expect(engine.embed).toHaveBeenCalledOnce())
|
||||
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
|
||||
controller.abort()
|
||||
await rejection
|
||||
expect(workerSignal.aborted).toBe(true)
|
||||
const activeTask = (await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)!
|
||||
expect(activeTask.state).toBe('cancelling')
|
||||
expect(activeTask.finishedAt).toBeUndefined()
|
||||
await expect(localInferenceService.act({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: [] })).rejects.toThrow('任务已变化')
|
||||
await expect(localInferenceService.act({ serviceId: 'embedding', action: 'restart', confirmedTaskIds: [] })).rejects.toThrow('任务已变化')
|
||||
localInferenceService.cancel(task.id)
|
||||
expect(transport.sent.filter((message) => message.type === 'cancel')).toHaveLength(1)
|
||||
expect(act).not.toHaveBeenCalled()
|
||||
expect(transport.close).not.toHaveBeenCalled()
|
||||
finish([[1, 2]])
|
||||
await vi.waitFor(async () => {
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({ state: 'cancelled', finishedAt: expect.any(Number) })
|
||||
})
|
||||
await localInferenceService.act({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: [] })
|
||||
expect(act).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
unregister()
|
||||
finish?.([[1, 2]])
|
||||
await worker.shutdown()
|
||||
await broker.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['abort', 'acknowledgement'], ['abort', 'late result'], ['abort', 'process exit'],
|
||||
['timeout', 'acknowledgement'], ['timeout', 'late result'], ['timeout', 'process exit']
|
||||
] as const)('keeps execution cancelling after %s until %s', async (cause, completion) => {
|
||||
vi.useFakeTimers()
|
||||
const transport = new TestTransport()
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport, timeoutMs: 100 })
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const pending = broker.embed(['cancelled work'], 'query', controller.signal)
|
||||
const rejection = expect(pending).rejects.toMatchObject({ name: cause === 'abort' ? 'AbortError' : 'TimeoutError' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const request = embedRequest(transport)
|
||||
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
|
||||
if (cause === 'abort') controller.abort()
|
||||
else await vi.advanceTimersByTimeAsync(100)
|
||||
await rejection
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
const activeTask = (await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)!
|
||||
expect(activeTask.state).toBe('cancelling')
|
||||
expect(activeTask.finishedAt).toBeUndefined()
|
||||
expect(transport.close).not.toHaveBeenCalled()
|
||||
expect(transport.sent.filter((message) => message.type === 'cancel')).toHaveLength(1)
|
||||
if (completion === 'process exit') transport.crash()
|
||||
else transport.receive(response(completion === 'late result'
|
||||
? { type: 'result', requestId: request.requestId, vectors: [[1, 2]] }
|
||||
: { type: 'error', requestId: request.requestId, code: 'CANCELLED', message: 'cancelled' }))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({
|
||||
state: cause === 'abort' ? 'cancelled' : 'failed',
|
||||
error: cause === 'abort' ? 'Embedding inference was cancelled' : 'Embedding inference timed out',
|
||||
finishedAt: expect.any(Number)
|
||||
})
|
||||
} finally {
|
||||
await broker.stop()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps rejected work active through an unconfirmed stop until process exit', async () => {
|
||||
const transport = new TestTransport()
|
||||
transport.close.mockRejectedValue(new Error('exit not confirmed'))
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
|
||||
const pending = broker.embed(['still computing'], 'query')
|
||||
const rejection = expect(pending).rejects.toThrow('停止')
|
||||
await vi.waitFor(() => expect(transport.sent).toHaveLength(1))
|
||||
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
|
||||
await expect(broker.stop()).rejects.toThrow('exit not confirmed')
|
||||
await rejection
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)?.finishedAt).toBeUndefined()
|
||||
transport.crash()
|
||||
await vi.waitFor(async () => {
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({ state: 'failed', finishedAt: expect.any(Number) })
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps cancelled requests within the in-flight bound until acknowledgement', async () => {
|
||||
const transport = new TestTransport()
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
|
||||
const controller = new AbortController()
|
||||
try {
|
||||
const callers = Array.from({ length: EMBEDDING_INFERENCE_MAX_IN_FLIGHT }, () =>
|
||||
expect(broker.embed(['pending'], 'query', controller.signal)).rejects.toMatchObject({ name: 'AbortError' }))
|
||||
await vi.waitFor(() => expect(transport.sent).toHaveLength(EMBEDDING_INFERENCE_MAX_IN_FLIGHT))
|
||||
controller.abort()
|
||||
await Promise.all(callers)
|
||||
await expect(broker.embed(['over capacity'], 'query')).rejects.toMatchObject({ code: 'OVERLOADED' })
|
||||
for (const request of transport.sent.filter((message) => message.type === 'embed')) {
|
||||
transport.receive(response({ type: 'error', requestId: request.requestId, code: 'CANCELLED', message: 'cancelled' }))
|
||||
}
|
||||
const next = broker.embed(['capacity released'], 'query')
|
||||
await vi.waitFor(() => expect(transport.sent.at(-1)?.type).toBe('embed'))
|
||||
const request = transport.sent.at(-1)!
|
||||
if (request.type !== 'embed') throw new Error('No next embed request was sent')
|
||||
transport.receive(response({ type: 'result', requestId: request.requestId, vectors: [[1, 2]] }))
|
||||
await expect(next).resolves.toEqual([[1, 2]])
|
||||
} finally {
|
||||
await broker.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps execution active after shutdown grace and close timeout until process exit', async () => {
|
||||
vi.useFakeTimers()
|
||||
const transport = new TestTransport()
|
||||
transport.close.mockImplementation(() => new Promise<void>((_resolve, reject) => {
|
||||
setTimeout(() => reject(new Error('exit not confirmed')), 5000)
|
||||
}))
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const rejection = expect(broker.embed(['still computing'], 'query', controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
|
||||
controller.abort()
|
||||
await rejection
|
||||
const shutdown = broker.shutdown()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await shutdown
|
||||
expect(transport.close).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(broker.getManagementError()).toBe('exit not confirmed')
|
||||
const activeTask = (await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)!
|
||||
expect(activeTask.state).toBe('cancelling')
|
||||
expect(activeTask.finishedAt).toBeUndefined()
|
||||
transport.crash()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({ state: 'cancelled', finishedAt: expect.any(Number) })
|
||||
} finally {
|
||||
transport.crash()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('retains a live transport and reports a failed stop instead of claiming stopped', async () => {
|
||||
const transport: EmbeddingInferenceTransport = {
|
||||
postMessage: vi.fn(), onMessage: () => () => undefined, onClose: () => () => undefined,
|
||||
close: vi.fn(async () => { throw new Error('exit not confirmed') })
|
||||
}
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
|
||||
await broker.start()
|
||||
await expect(broker.stop()).rejects.toThrow('exit not confirmed')
|
||||
expect(broker.getManagementState()).toBe('running')
|
||||
expect(broker.getManagementError()).toBe('exit not confirmed')
|
||||
})
|
||||
|
||||
it('reports startup failures and supports an explicit retry', async () => {
|
||||
const factory = vi.fn().mockRejectedValueOnce(new Error('spawn failed')).mockResolvedValue(new TestTransport())
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: factory })
|
||||
await expect(broker.start()).rejects.toThrow('START_FAILURE')
|
||||
expect(broker.getManagementState()).toBe('error')
|
||||
await broker.start()
|
||||
expect(broker.getManagementState()).toBe('running')
|
||||
expect(broker.getManagementError()).toBeUndefined()
|
||||
await broker.stop()
|
||||
})
|
||||
it('management cancellation waits for worker acknowledgement without closing shared transport', async () => {
|
||||
const transport = new TestTransport()
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
|
||||
const pending = broker.embed(['cancel this'], 'query')
|
||||
const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await vi.waitFor(() => expect(transport.sent).toHaveLength(1))
|
||||
const request = embedRequest(transport)
|
||||
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
|
||||
localInferenceService.cancel(task.id)
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)?.state).toBe('cancelling')
|
||||
expect(transport.close).not.toHaveBeenCalled()
|
||||
transport.receive(response({ type: 'error', requestId: request.requestId, code: 'CANCELLED', message: 'cancelled' }))
|
||||
await rejection
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)?.state).toBe('cancelled')
|
||||
await broker.stop()
|
||||
})
|
||||
|
||||
it('stops shared execution, rejects work until explicitly started, and never replays', async () => {
|
||||
const transports: TestTransport[] = []
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => { const transport = new TestTransport(); transports.push(transport); return transport } })
|
||||
const pending = broker.embed(['interrupted'], 'document')
|
||||
const rejection = expect(pending).rejects.toThrow('停止')
|
||||
await vi.waitFor(() => expect(transports[0]?.sent).toHaveLength(1))
|
||||
await broker.stop()
|
||||
await rejection
|
||||
expect(transports[0]!.close).toHaveBeenCalledOnce()
|
||||
await expect(broker.embed(['blocked'], 'query')).rejects.toMatchObject({ code: 'CLOSED' })
|
||||
await broker.start()
|
||||
expect(transports).toHaveLength(2)
|
||||
expect(transports[1]!.sent).toEqual([])
|
||||
await broker.stop()
|
||||
})
|
||||
it('uses UUID requests with roles and validates bounded results', async () => {
|
||||
const transport = new TestTransport()
|
||||
const broker = new EmbeddingInferenceBroker({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { localInferenceService } from '../local-inference-service'
|
||||
import type { InferenceResources } from '../../shared/local-inference-contracts'
|
||||
import {
|
||||
EMBEDDING_INFERENCE_MAX_IN_FLIGHT,
|
||||
embeddingInferenceProtocolBase,
|
||||
@@ -15,13 +17,13 @@ const MAX_TIMEOUT_MS = 300_000
|
||||
const DEFAULT_MAX_RESTARTS = 2
|
||||
const MAX_RESTARTS = 10
|
||||
const SHUTDOWN_GRACE_MS = 1_000
|
||||
const MAX_RETIRED_REQUESTS = 256
|
||||
|
||||
export interface EmbeddingInferenceTransport {
|
||||
getResources?(): InferenceResources
|
||||
postMessage(message: EmbeddingInferenceRequest): void
|
||||
onMessage(listener: (message: unknown) => void): () => void
|
||||
onClose(listener: () => void): () => void
|
||||
close(): void
|
||||
close(): void | Promise<void>
|
||||
}
|
||||
|
||||
export type EmbeddingInferenceTransportFactory = () =>
|
||||
@@ -42,6 +44,8 @@ type PendingRequest = {
|
||||
readonly expectedCount: number
|
||||
readonly resolve: (vectors: number[][]) => void
|
||||
readonly reject: (error: Error) => void
|
||||
readonly rejectCaller: (error: Error) => void
|
||||
callerError?: Error
|
||||
readonly timeout: ReturnType<typeof setTimeout>
|
||||
removeAbortListener?: () => void
|
||||
}
|
||||
@@ -62,9 +66,10 @@ export class EmbeddingInferenceBrokerError extends Error {
|
||||
| 'START_FAILURE'
|
||||
| 'ENGINE_FAILURE'
|
||||
| 'INVALID_RESULT'
|
||||
| 'OVERLOADED'
|
||||
| 'OVERLOADED',
|
||||
detail?: string
|
||||
) {
|
||||
super(`Embedding inference failed (${code})`)
|
||||
super(`Embedding inference failed (${code})${detail ? `: ${detail}` : ''}`)
|
||||
this.name = 'EmbeddingInferenceBrokerError'
|
||||
}
|
||||
}
|
||||
@@ -100,13 +105,65 @@ export class EmbeddingInferenceBroker {
|
||||
private readonly timeoutMs: number
|
||||
private readonly maxRestarts: number
|
||||
private readonly pending = new Map<string, PendingRequest>()
|
||||
private readonly retiredRequestIds = new Set<string>()
|
||||
private active?: ActiveTransport
|
||||
private starting?: Promise<ActiveTransport>
|
||||
private restarts = 0
|
||||
private everStarted = false
|
||||
private closed = false
|
||||
private shutdownComplete?: () => void
|
||||
private paused = false
|
||||
private stopping = false
|
||||
private managementError?: string
|
||||
|
||||
getManagementState(): 'stopped' | 'stopping' | 'starting' | 'running' | 'idle' | 'error' {
|
||||
if (this.stopping) return 'stopping'
|
||||
if (this.paused || this.closed) return 'stopped'
|
||||
if (this.starting) return 'starting'
|
||||
if (this.active) return 'running'
|
||||
return this.managementError ? 'error' : 'idle'
|
||||
}
|
||||
|
||||
getManagementError(): string | undefined { return this.managementError }
|
||||
|
||||
getResources(): InferenceResources {
|
||||
return this.active?.transport.getResources?.() ?? {
|
||||
scope: 'unavailable', reason: this.active ? '当前执行端未提供进程指标' : '服务进程未运行'
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.closed) throw new EmbeddingInferenceBrokerError('CLOSED')
|
||||
this.paused = false
|
||||
this.restarts = 0
|
||||
try {
|
||||
await this.ensureTransport()
|
||||
this.managementError = undefined
|
||||
} catch (error) {
|
||||
this.managementError = String(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.paused = true
|
||||
this.stopping = true
|
||||
const error = new Error('向量执行服务已由用户停止;任务不会自动重放')
|
||||
this.rejectCallers(error)
|
||||
try {
|
||||
if (this.starting) await this.starting.catch(() => undefined)
|
||||
const active = this.active
|
||||
if (!active) return
|
||||
await active.transport.close()
|
||||
this.disposeTransport(active.transport, false)
|
||||
this.rejectAll(error)
|
||||
} catch (error) {
|
||||
this.paused = false
|
||||
this.managementError = error instanceof Error ? error.message : String(error)
|
||||
throw error
|
||||
} finally {
|
||||
this.stopping = false
|
||||
}
|
||||
}
|
||||
|
||||
constructor(options: EmbeddingInferenceBrokerOptions) {
|
||||
if (typeof options.createTransport !== 'function') {
|
||||
@@ -131,12 +188,29 @@ export class EmbeddingInferenceBroker {
|
||||
texts: readonly string[],
|
||||
role: EmbeddingInferenceRole,
|
||||
signal?: AbortSignal
|
||||
): Promise<number[][]> {
|
||||
const controller = new AbortController()
|
||||
return new Promise<number[][]>((resolve, reject) => {
|
||||
// Track worker execution separately from prompt caller cancellation/timeout.
|
||||
void localInferenceService.run('embedding', role === 'query' ? '向量请求 · 查询' : '向量请求 · 文档',
|
||||
(markCancelling) => this.embedRequest(texts, role, signal, controller.signal, (error) => {
|
||||
markCancelling()
|
||||
reject(error)
|
||||
}),
|
||||
{ cancel: () => controller.abort() }).then(resolve, reject)
|
||||
})
|
||||
}
|
||||
|
||||
private async embedRequest(
|
||||
texts: readonly string[], role: EmbeddingInferenceRole, signal: AbortSignal | undefined,
|
||||
managementSignal: AbortSignal,
|
||||
rejectCaller: (error: Error) => void
|
||||
): Promise<number[][]> {
|
||||
const normalizedTexts = validateEmbeddingInferenceTexts(texts)
|
||||
if (role !== 'query' && role !== 'document') {
|
||||
throw new TypeError('role must be query or document')
|
||||
}
|
||||
if (this.closed) {
|
||||
if (this.closed || this.paused) {
|
||||
throw new EmbeddingInferenceBrokerError('CLOSED')
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
@@ -147,7 +221,7 @@ export class EmbeddingInferenceBroker {
|
||||
}
|
||||
|
||||
const active = await this.ensureTransport()
|
||||
if (this.closed) {
|
||||
if (this.closed || this.paused) {
|
||||
throw new EmbeddingInferenceBrokerError('CLOSED')
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
@@ -166,6 +240,7 @@ export class EmbeddingInferenceBroker {
|
||||
expectedCount: normalizedTexts.length,
|
||||
resolve,
|
||||
reject,
|
||||
rejectCaller,
|
||||
timeout
|
||||
}
|
||||
if (signal) {
|
||||
@@ -178,6 +253,21 @@ export class EmbeddingInferenceBroker {
|
||||
}
|
||||
this.pending.set(requestId, pending)
|
||||
|
||||
const requestCancellation = (): void => {
|
||||
// Management cancellation waits for the worker's result or CANCELLED response.
|
||||
try {
|
||||
active.transport.postMessage({ ...embeddingInferenceProtocolBase(), type: 'cancel', requestId })
|
||||
} catch {
|
||||
this.failTransport(active.transport, new EmbeddingInferenceBrokerError('PROCESS_CLOSED'))
|
||||
}
|
||||
}
|
||||
const removeSourceAbort = pending.removeAbortListener
|
||||
pending.removeAbortListener = () => {
|
||||
removeSourceAbort?.()
|
||||
managementSignal.removeEventListener('abort', requestCancellation)
|
||||
}
|
||||
managementSignal.addEventListener('abort', requestCancellation, { once: true })
|
||||
|
||||
try {
|
||||
active.transport.postMessage({
|
||||
...embeddingInferenceProtocolBase(),
|
||||
@@ -186,6 +276,7 @@ export class EmbeddingInferenceBroker {
|
||||
role,
|
||||
texts: normalizedTexts
|
||||
})
|
||||
if (managementSignal.aborted) requestCancellation()
|
||||
} catch {
|
||||
this.failTransport(
|
||||
active.transport,
|
||||
@@ -200,10 +291,7 @@ export class EmbeddingInferenceBroker {
|
||||
return
|
||||
}
|
||||
this.closed = true
|
||||
for (const requestId of this.pending.keys()) {
|
||||
this.retireRequestId(requestId)
|
||||
}
|
||||
this.rejectAll(new EmbeddingInferenceBrokerError('CLOSED'))
|
||||
this.rejectCallers(new EmbeddingInferenceBrokerError('CLOSED'))
|
||||
|
||||
let active = this.active
|
||||
if (!active && this.starting) {
|
||||
@@ -261,7 +349,7 @@ export class EmbeddingInferenceBroker {
|
||||
.then(() => this.createTransport())
|
||||
.then((transport) => {
|
||||
if (this.closed) {
|
||||
transport.close()
|
||||
void Promise.resolve(transport.close()).catch(() => undefined)
|
||||
throw new EmbeddingInferenceBrokerError('CLOSED')
|
||||
}
|
||||
const active: ActiveTransport = {
|
||||
@@ -272,7 +360,8 @@ export class EmbeddingInferenceBroker {
|
||||
removeCloseListener: transport.onClose(() =>
|
||||
this.failTransport(
|
||||
transport,
|
||||
new EmbeddingInferenceBrokerError('PROCESS_CLOSED')
|
||||
new EmbeddingInferenceBrokerError('PROCESS_CLOSED'),
|
||||
true
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -280,6 +369,7 @@ export class EmbeddingInferenceBroker {
|
||||
return active
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.managementError = error instanceof Error ? error.message : String(error)
|
||||
if (error instanceof EmbeddingInferenceBrokerError) {
|
||||
throw error
|
||||
}
|
||||
@@ -317,6 +407,7 @@ export class EmbeddingInferenceBroker {
|
||||
)
|
||||
return
|
||||
}
|
||||
this.rejectAll(new EmbeddingInferenceBrokerError('CLOSED'))
|
||||
this.shutdownComplete()
|
||||
return
|
||||
}
|
||||
@@ -330,9 +421,6 @@ export class EmbeddingInferenceBroker {
|
||||
|
||||
const pending = this.pending.get(message.requestId)
|
||||
if (!pending) {
|
||||
if (this.retiredRequestIds.delete(message.requestId)) {
|
||||
return
|
||||
}
|
||||
this.failTransport(
|
||||
transport,
|
||||
new EmbeddingInferenceBrokerError('PROTOCOL_VIOLATION')
|
||||
@@ -345,7 +433,7 @@ export class EmbeddingInferenceBroker {
|
||||
message.requestId,
|
||||
message.code === 'CANCELLED'
|
||||
? abortError()
|
||||
: new EmbeddingInferenceBrokerError(message.code)
|
||||
: new EmbeddingInferenceBrokerError(message.code, message.message)
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -365,11 +453,12 @@ export class EmbeddingInferenceBroker {
|
||||
|
||||
private cancelPending(requestId: string, error: Error): void {
|
||||
const pending = this.pending.get(requestId)
|
||||
if (!pending) {
|
||||
if (!pending || pending.callerError) {
|
||||
return
|
||||
}
|
||||
this.retireRequestId(requestId)
|
||||
this.finishPending(requestId, error)
|
||||
pending.callerError = error
|
||||
clearTimeout(pending.timeout)
|
||||
pending.rejectCaller(error)
|
||||
try {
|
||||
this.active?.transport.postMessage({
|
||||
...embeddingInferenceProtocolBase(),
|
||||
@@ -398,6 +487,7 @@ export class EmbeddingInferenceBroker {
|
||||
this.pending.delete(requestId)
|
||||
clearTimeout(pending.timeout)
|
||||
pending.removeAbortListener?.()
|
||||
error = pending.callerError ?? error
|
||||
if (error) {
|
||||
pending.reject(error)
|
||||
} else {
|
||||
@@ -405,26 +495,23 @@ export class EmbeddingInferenceBroker {
|
||||
}
|
||||
}
|
||||
|
||||
private retireRequestId(requestId: string): void {
|
||||
this.retiredRequestIds.add(requestId)
|
||||
if (this.retiredRequestIds.size > MAX_RETIRED_REQUESTS) {
|
||||
const oldest = this.retiredRequestIds.values().next().value
|
||||
if (typeof oldest === 'string') {
|
||||
this.retiredRequestIds.delete(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private failTransport(
|
||||
transport: EmbeddingInferenceTransport,
|
||||
error: Error
|
||||
error: Error,
|
||||
closed = false
|
||||
): void {
|
||||
if (this.active?.transport !== transport) {
|
||||
return
|
||||
}
|
||||
this.rejectCallers(error)
|
||||
if (closed) {
|
||||
this.disposeTransport(transport, false)
|
||||
this.rejectAll(error)
|
||||
this.shutdownComplete?.()
|
||||
return
|
||||
}
|
||||
this.managementError = error.message
|
||||
this.disposeTransport(transport, true)
|
||||
this.rejectAll(error)
|
||||
this.shutdownComplete?.()
|
||||
}
|
||||
|
||||
private disposeTransport(
|
||||
@@ -435,15 +522,30 @@ export class EmbeddingInferenceBroker {
|
||||
if (!active || active.transport !== transport) {
|
||||
return
|
||||
}
|
||||
if (close) {
|
||||
try {
|
||||
void Promise.resolve(transport.close()).then(() => {
|
||||
if (this.active !== active) return
|
||||
this.disposeTransport(transport, false)
|
||||
this.rejectAll(new EmbeddingInferenceBrokerError('PROCESS_CLOSED'))
|
||||
}).catch((error: unknown) => {
|
||||
this.managementError = error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
} catch (error) {
|
||||
this.managementError = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
return
|
||||
}
|
||||
this.active = undefined
|
||||
active.removeMessageListener()
|
||||
active.removeCloseListener()
|
||||
if (close) {
|
||||
try {
|
||||
transport.close()
|
||||
} catch {
|
||||
// The process is already unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
private rejectCallers(error: Error): void {
|
||||
for (const pending of this.pending.values()) {
|
||||
pending.callerError ??= error
|
||||
clearTimeout(pending.timeout)
|
||||
pending.rejectCaller(pending.callerError)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -206,13 +206,13 @@ export class EmbeddingInferenceWorker {
|
||||
requestId: message.requestId,
|
||||
vectors
|
||||
})
|
||||
} catch {
|
||||
} catch (error) {
|
||||
this.postError(
|
||||
message.requestId,
|
||||
controller.signal.aborted ? 'CANCELLED' : 'ENGINE_FAILURE',
|
||||
controller.signal.aborted
|
||||
? 'Embedding inference was cancelled'
|
||||
: 'Embedding engine failed'
|
||||
: error instanceof Error ? error.message : 'Embedding engine failed'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { app, utilityProcess } from 'electron'
|
||||
import { createInferenceProcessSampler } from '../inference-process-resources'
|
||||
import type { EmbeddingInferenceTransport } from './embedding-inference-broker'
|
||||
|
||||
export function createEmbeddingUtilityTransport(
|
||||
modulePath: string,
|
||||
modelDirectory: string
|
||||
): Promise<EmbeddingInferenceTransport> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = utilityProcess.fork(modulePath, [modelDirectory], {
|
||||
serviceName: 'GoodBuddy Embedding Inference',
|
||||
stdio: 'pipe',
|
||||
allowLoadingUnsignedLibraries: false
|
||||
})
|
||||
const startupError = (): void => { reject(new Error('向量进程启动失败')) }
|
||||
child.once('error', startupError)
|
||||
child.once('exit', startupError)
|
||||
child.once('spawn', () => {
|
||||
child.removeListener('error', startupError)
|
||||
child.removeListener('exit', startupError)
|
||||
resolve({
|
||||
getResources: createInferenceProcessSampler(() => child.pid, () => app.getAppMetrics()),
|
||||
postMessage: (message) => child.postMessage(message),
|
||||
onMessage: (listener) => {
|
||||
child.on('message', listener)
|
||||
return () => { child.removeListener('message', listener) }
|
||||
},
|
||||
onClose: (listener) => {
|
||||
let closed = false
|
||||
const notify = (): void => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
listener()
|
||||
}
|
||||
child.on('exit', notify)
|
||||
child.on('error', notify)
|
||||
return () => {
|
||||
child.removeListener('exit', notify)
|
||||
child.removeListener('error', notify)
|
||||
}
|
||||
},
|
||||
close: () => new Promise<void>((resolveClose, rejectClose) => {
|
||||
const finish = (): void => {
|
||||
clearTimeout(timeout)
|
||||
resolveClose()
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
child.removeListener('exit', finish)
|
||||
rejectClose(new Error('向量进程尚未确认退出,请刷新状态后重试'))
|
||||
}, 5000)
|
||||
child.once('exit', finish)
|
||||
if (!child.kill()) {
|
||||
clearTimeout(timeout)
|
||||
child.removeListener('exit', finish)
|
||||
rejectClose(new Error('无法停止向量进程,请刷新状态后重试'))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { Tokenizer } from '@huggingface/tokenizers'
|
||||
import * as ort from 'onnxruntime-web/wasm'
|
||||
import * as ort from 'onnxruntime-web'
|
||||
import type {
|
||||
EmbeddingInferenceEngine,
|
||||
EmbeddingOnnxRuntime,
|
||||
|
||||
Reference in New Issue
Block a user