perf(concurrency): remove unsupported product admission limits
This commit is contained in:
@@ -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()!
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 +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()
|
||||
})
|
||||
|
||||
|
||||
@@ -1044,8 +1044,6 @@ if (hasSingleInstanceLock) {
|
||||
})
|
||||
})
|
||||
directModelSubagentScheduler = new SubagentScheduler({
|
||||
concurrency: 3,
|
||||
queueLimit: 20,
|
||||
timeoutMs: 10 * 60_000
|
||||
})
|
||||
const executionSpaceResolver = new ExecutionSpaceResolver(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user