perf(concurrency): remove unsupported product admission limits

This commit is contained in:
mesalogo
2026-10-07 15:52:07 +08:00
parent 1f85d0c0e9
commit 8473189fe2
19 changed files with 306 additions and 90 deletions
+16 -11
View File
@@ -25,8 +25,8 @@ function abortError(signal?: AbortSignal): Error {
}
export class SubagentScheduler {
private readonly concurrency: number
private readonly queueLimit: number
private readonly concurrency?: number
private readonly queueLimit?: number
private readonly timeoutMs: number
private readonly queue: QueueEntry<unknown>[] = []
private readonly activeControllers = new Set<AbortController>()
@@ -35,14 +35,14 @@ export class SubagentScheduler {
private readonly idleWaiters = new Set<() => void>()
constructor(options: SubagentSchedulerOptions = {}) {
this.concurrency = options.concurrency ?? 3
this.queueLimit = options.queueLimit ?? 20
this.concurrency = options.concurrency
this.queueLimit = options.queueLimit
this.timeoutMs = options.timeoutMs ?? 120_000
if (
!Number.isSafeInteger(this.concurrency) ||
this.concurrency < 1 ||
!Number.isSafeInteger(this.queueLimit) ||
this.queueLimit < 0 ||
(this.concurrency !== undefined &&
(!Number.isSafeInteger(this.concurrency) || this.concurrency < 1)) ||
(this.queueLimit !== undefined &&
(!Number.isSafeInteger(this.queueLimit) || this.queueLimit < 0)) ||
!Number.isSafeInteger(this.timeoutMs) ||
this.timeoutMs < 1
) {
@@ -60,7 +60,12 @@ export class SubagentScheduler {
if (signal?.aborted) {
return Promise.reject(abortError(signal))
}
if (this.active >= this.concurrency && this.queue.length >= this.queueLimit) {
if (
this.concurrency !== undefined &&
this.active >= this.concurrency &&
this.queueLimit !== undefined &&
this.queue.length >= this.queueLimit
) {
return Promise.reject(new Error('子专家任务队列已满'))
}
return new Promise<T>((resolve, reject) => {
@@ -78,7 +83,7 @@ export class SubagentScheduler {
entry.removeAbortListener = () =>
signal.removeEventListener('abort', onAbort)
}
if (this.active < this.concurrency) {
if (this.concurrency === undefined || this.active < this.concurrency) {
this.start(entry)
} else {
this.queue.push(entry as QueueEntry<unknown>)
@@ -159,7 +164,7 @@ export class SubagentScheduler {
private drain(): void {
while (
!this.disposed &&
this.active < this.concurrency &&
(this.concurrency === undefined || this.active < this.concurrency) &&
this.queue.length > 0
) {
const entry = this.queue.shift()!
+5 -8
View File
@@ -67,7 +67,7 @@ export function redactChannelError(value: string): string {
export class ChannelService {
private allowedSenderIds: ReadonlySet<string> = new Set()
private readonly allowGroupMessages: boolean
private readonly maximumConcurrency: number
private readonly maximumConcurrency?: number
private readonly maximumInputLength: number
private readonly dedupStore: DedupStore
private readonly outbox: Outbox
@@ -99,12 +99,9 @@ export class ChannelService {
this.updateAllowedSenderIds(options.allowedSenderIds ?? [])
this.allowGroupMessages = options.allowGroupMessages ?? false
this.maximumConcurrency = boundedInteger(
options.maximumConcurrency,
2,
100,
'通道并发限制'
)
this.maximumConcurrency = options.maximumConcurrency === undefined
? undefined
: boundedInteger(options.maximumConcurrency, 1, Number.MAX_SAFE_INTEGER, '通道并发限制')
this.maximumInputLength = boundedInteger(
options.maximumInputLength,
8_000,
@@ -340,7 +337,7 @@ export class ChannelService {
return
}
if (this.active.size >= this.maximumConcurrency) {
if (this.maximumConcurrency !== undefined && this.active.size >= this.maximumConcurrency) {
durableResult = await this.tryDeliver(
this.result(message, {
status: 'busy',
+13 -5
View File
@@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'
import { ReadonlyQueryReader, deserializeWorkerError } from './readonly-query-reader'
import { DesktopStorageTransport } from './desktop-storage-transport'
import {
storageDataBytes, STORAGE_MAX_PENDING, type DesktopStorageOptions, type StorageArgs, type StorageChange, type StorageDomain, type StorageMethod,
storageDataBytes, type DesktopStorageOptions, type StorageArgs, type StorageChange, type StorageDomain, type StorageMethod,
type StorageResponse, type StorageResult
} from './desktop-storage-contracts'
import type { AssistantStorageProgress } from '../shared/assistant-storage-contracts'
@@ -37,7 +37,7 @@ export class DesktopStorageClient {
const transport = new DesktopStorageTransport(options.entryPath ?? join(dirname(fileURLToPath(import.meta.url)), 'desktop-storage-entry.js'))
this.transport = transport
this.requests = new ReadonlyQueryReader('assistant', '', '', Date.now, {
createTransport: () => transport, maxPending: STORAGE_MAX_PENDING
createTransport: () => transport
})
transport.on('storageMessage', (message: StorageResponse) => {
if (this.transport !== transport) return
@@ -47,10 +47,18 @@ export class DesktopStorageClient {
else if (message.type === 'progress') options.onProgress?.(message.progress)
else if (message.type === 'changed') options.onChanged?.(message.domain)
})
transport.on('storageFailure', error => { if (this.transport === transport) this.failed(error) })
transport.on('storageFailure', error => {
if (this.transport !== transport) return
this.requests.failTransport(error)
this.failed(error)
})
transport.on('exit', () => {
if (this.transport !== transport) return
if (this.state !== 'closing' && this.state !== 'closed') this.failed(new Error('Desktop storage exited; unconfirmed writes require domain reconciliation'))
if (this.state !== 'closing' && this.state !== 'closed') {
const error = new Error('Desktop storage exited; unconfirmed writes require domain reconciliation')
this.requests.failTransport(error)
this.failed(error)
}
})
transport.once('spawn', () => { if (this.transport === transport && this.state === 'opening') this.open() })
}
@@ -85,7 +93,7 @@ export class DesktopStorageClient {
this.restarting = (async () => {
if (transport.needsReplacement) {
await transport.waitForExit()
await this.requests.close()
await this.requests.close(false)
if (this.state !== 'opening') return
transport.removeAllListeners()
this.startHost()
-2
View File
@@ -166,7 +166,5 @@ export type StorageResponse =
| { type: 'failed'; error: StorageError }
| { type: 'closed' }
/** In-flight storage requests; later requests wait in order, they are not rejected. */
export const STORAGE_MAX_PENDING = 32
/** Per-batch payload guidance for domain writers that split large saves. */
export const STORAGE_MAX_BYTES = 32 * 1024 * 1024
+1 -2
View File
@@ -9,7 +9,6 @@ import { createDesktopStorageFiles } from './desktop-storage-files'
import { createDesktopRuntimeStorageAdapters } from './desktop-storage-runtime-operations'
import { defaultDocumentParsingSettings } from './document-parsing-settings-store'
import { MAGIC_NOTE_MAX_VIDEO_BYTES, type MagicNoteContent } from '../shared/magic-notes-contracts'
import { STORAGE_MAX_PENDING } from './desktop-storage-contracts'
import { createHash } from 'node:crypto'
const root = process.env.GB_STORAGE_TEST_ROOT!
@@ -32,7 +31,7 @@ async function run(): Promise<void> {
const burst = Array.from({ length: 100 }, (_, index) =>
index % 2 ? storage.call('assistant', 'listConversationQueueItems', [`burst-${index}`]) : storage.call('assistant', 'listConversationSummaries', []))
assert.equal((await Promise.all(burst)).length, 100)
assert.ok(storage.admissionHighWaterOperations <= STORAGE_MAX_PENDING)
assert.ok(storage.admissionHighWaterOperations >= 100)
const sshWrite = { project: { name: 'Remote project', description: '', rootPath: '/srv/work' },
executionSpace: { kind: 'ssh' as const, hostId: randomUUID(), remoteRootPath: '/srv/work' } }
const ssh = await storage.call('assistant', 'createSshProject', [sshWrite])
+2 -2
View File
@@ -11,7 +11,7 @@ import { openDesktopStorageFiles, type DesktopStorageFilesOwner } from './deskto
import { DesktopStorageRuntimeOwner, runtimeStorageMethods } from './desktop-storage-runtime-operations'
import type { ReviewState } from './assistant/supervision-review-store'
import {
assistantStorageMethods, knowledgeStorageMethods, repositoryStorageMethods, STORAGE_MAX_PENDING,
assistantStorageMethods, knowledgeStorageMethods, repositoryStorageMethods,
type DesktopStorageDomains, type DesktopStorageOptions, type StorageError, type StorageRequest, type StorageResponse
} from './desktop-storage-contracts'
@@ -57,7 +57,7 @@ export class DesktopStorageOwner {
if (message.type === 'cancel') {
const active = this.active.get(message.id)
if (active) active.controller.abort()
else if (message.id > this.lastCallId && this.cancelled.size < STORAGE_MAX_PENDING) this.cancelled.add(message.id)
else if (message.id > this.lastCallId) this.cancelled.add(message.id)
return
}
if (message.type === 'open') {
+7 -7
View File
@@ -7,7 +7,7 @@ import { join, resolve } from 'node:path'
import { build } from 'esbuild'
import { expect, it } from 'vitest'
import { ReadonlyQueryReader, deserializeWorkerError, type ReadonlyQueryRequest } from './readonly-query-reader'
import { storageDataBytes, STORAGE_MAX_PENDING } from './desktop-storage-contracts'
import { storageDataBytes } from './desktop-storage-contracts'
import { AssistantDatabase } from './assistant/assistant-database'
import { DatabaseSync } from 'node:sqlite'
@@ -48,18 +48,18 @@ it('retains cancelled capacity until settlement, returns committed success, and
expect(reader.pendingCount).toBe(0)
})
it('bounds in-flight requests, starts waiting requests in order, and never rejects for capacity', async () => {
it('passes storage requests through without a product admission limit', async () => {
const transport = new Transport()
const reader = new ReadonlyQueryReader('assistant', '', '', Date.now, { createTransport: () => transport, maxPending: STORAGE_MAX_PENDING })
const calls = Array.from({ length: STORAGE_MAX_PENDING * 3 }, (_, index) => reader.call(index % 3 ? 'assistant.listConversationQueueItems' : 'assistant.appendConversationMessage', [index]))
expect(reader.pendingCount).toBe(STORAGE_MAX_PENDING)
expect(reader.waitingCount).toBe(STORAGE_MAX_PENDING * 2)
const reader = new ReadonlyQueryReader('assistant', '', '', Date.now, { createTransport: () => transport })
const calls = Array.from({ length: 96 }, (_, index) => reader.call(index % 3 ? 'assistant.listConversationQueueItems' : 'assistant.appendConversationMessage', [index]))
expect(reader.pendingCount).toBe(96)
expect(reader.waitingCount).toBe(0)
const settled = new Set<number>()
while (settled.size < calls.length) { settleAll(transport, settled); await Promise.resolve() }
const posted = transport.messages.filter(message => message.type === 'query').map(message => message.args[0])
expect(posted).toEqual(calls.map((_, index) => index))
await expect(Promise.all(calls)).resolves.toHaveLength(calls.length)
expect(reader.admissionHighWaterOperations).toBe(STORAGE_MAX_PENDING)
expect(reader.admissionHighWaterOperations).toBe(96)
await reader.close()
})
-2
View File
@@ -1044,8 +1044,6 @@ if (hasSingleInstanceLock) {
})
})
directModelSubagentScheduler = new SubagentScheduler({
concurrency: 3,
queueLimit: 20,
timeoutMs: 10 * 60_000
})
const executionSpaceResolver = new ExecutionSpaceResolver(
+32 -13
View File
@@ -37,17 +37,17 @@ type Pending = {
cleanup: () => void
}
type Waiting = { start: () => void; cancel: () => void }
type Waiting = { start: () => void; cancel: () => void; fail: (error: Error) => void }
/** The utility-process adapter uses the same request correlation as read workers. */
export type QueryTransport = Pick<EventEmitter, 'on' | 'once'> & Pick<Worker, 'postMessage' | 'ref' | 'unref' | 'terminate'>
export interface QueryTransportOptions {
createTransport: () => QueryTransport
/** In-flight bound; further requests wait in order instead of failing. */
/** Optional test or caller policy; production storage leaves this unset. */
maxPending?: number
}
const MAX_PENDING = 32
const DEFAULT_MAX_PENDING = Number.POSITIVE_INFINITY
const RESTART_BACKOFF_MS = 30_000
export function deserializeWorkerError(error: { name: string; message: string; issues?: unknown; code?: string; cause?: Parameters<typeof deserializeWorkerError>[0] }): Error {
@@ -83,11 +83,11 @@ export class ReadonlyQueryReader {
private readonly transportOptions?: QueryTransportOptions
) {}
private get maxPending(): number { return this.transportOptions?.maxPending ?? MAX_PENDING }
private get maxPending(): number { return this.transportOptions?.maxPending ?? DEFAULT_MAX_PENDING }
/** False while backing off after a crash or startup failure. */
get available(): boolean {
return !this.closed && !this.stopping && this.now() >= this.disabledUntil && this.pending.size < this.maxPending
return !this.closed && !this.stopping && this.now() >= this.disabledUntil
}
get pendingCount(): number { return this.pending.size }
@@ -111,13 +111,18 @@ export class ReadonlyQueryReader {
if (!this.transportOptions) return Promise.reject(new ReadonlyWorkerUnavailableError('Readonly query worker is busy'))
return new Promise<T>((resolve, reject) => {
const entry: Waiting = {
start: () => { signal?.removeEventListener('abort', entry.cancel); this.dispatch<T>(op, args, signal).then(resolve, reject) },
cancel: () => {
const index = this.waiting.indexOf(entry)
if (index >= 0) this.waiting.splice(index, 1)
reject(signal?.reason)
start: () => { signal?.removeEventListener('abort', entry.cancel); this.dispatch<T>(op, args, signal).then(resolve, reject) },
cancel: () => {
const index = this.waiting.indexOf(entry)
if (index >= 0) this.waiting.splice(index, 1)
reject(signal?.reason)
},
fail: error => {
const index = this.waiting.indexOf(entry)
if (index >= 0) this.waiting.splice(index, 1)
reject(error)
}
}
}
signal?.addEventListener('abort', entry.cancel, { once: true })
this.waiting.push(entry)
})
@@ -215,6 +220,20 @@ export class ReadonlyQueryReader {
return worker
}
/** Settle storage requests when its utility transport is lost. */
failTransport(error: Error): void {
if (this.closed || !this.transportOptions || !this.worker) return
this.worker = undefined
const pending = [...this.pending.values()]
this.pending.clear()
for (const request of pending) {
request.cleanup()
request.reject(Object.assign(new Error('Storage process lost; unconfirmed writes require domain reconciliation', { cause: error }), { code: 'STORAGE_UNCONFIRMED' }))
}
for (const waiting of this.waiting.splice(0)) waiting.fail(new ReadonlyWorkerUnavailableError('Storage process lost before request dispatch', { cause: error }))
this.drained?.()
}
/** Test hook: simulate a crash by terminating the worker. */
async terminateWorkerForTest(): Promise<void> {
await this.worker?.terminate()
@@ -226,7 +245,7 @@ export class ReadonlyQueryReader {
this.disabledUntil = 0
}
close(): Promise<void> {
close(terminateTransport = true): Promise<void> {
if (this.closePromise) return this.closePromise
this.closed = true
for (const waiting of this.waiting.splice(0)) waiting.start()
@@ -236,7 +255,7 @@ export class ReadonlyQueryReader {
await this.stopping
const worker = this.worker
this.worker = undefined
await worker?.terminate()
if (terminateTransport) await worker?.terminate()
})()
return this.closePromise
}