2026-01-07 01:24:47 +09:00
|
|
|
import type { BackgroundTaskConfig } from "../../config/schema"
|
|
|
|
|
|
2026-01-14 15:09:32 -08:00
|
|
|
/**
|
|
|
|
|
* Queue entry with settled-flag pattern to prevent double-resolution.
|
|
|
|
|
*
|
|
|
|
|
* The settled flag ensures that cancelWaiters() doesn't reject
|
|
|
|
|
* an entry that was already resolved by release().
|
|
|
|
|
*/
|
|
|
|
|
interface QueueEntry {
|
|
|
|
|
resolve: () => void
|
|
|
|
|
rawReject: (error: Error) => void
|
|
|
|
|
settled: boolean
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 01:24:47 +09:00
|
|
|
export class ConcurrencyManager {
|
|
|
|
|
private config?: BackgroundTaskConfig
|
|
|
|
|
private counts: Map<string, number> = new Map()
|
2026-01-14 15:09:32 -08:00
|
|
|
private queues: Map<string, QueueEntry[]> = new Map()
|
2026-01-07 01:24:47 +09:00
|
|
|
|
|
|
|
|
constructor(config?: BackgroundTaskConfig) {
|
|
|
|
|
this.config = config
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getConcurrencyLimit(model: string): number {
|
|
|
|
|
const modelLimit = this.config?.modelConcurrency?.[model]
|
|
|
|
|
if (modelLimit !== undefined) {
|
|
|
|
|
return modelLimit === 0 ? Infinity : modelLimit
|
|
|
|
|
}
|
|
|
|
|
const provider = model.split('/')[0]
|
|
|
|
|
const providerLimit = this.config?.providerConcurrency?.[provider]
|
|
|
|
|
if (providerLimit !== undefined) {
|
|
|
|
|
return providerLimit === 0 ? Infinity : providerLimit
|
|
|
|
|
}
|
|
|
|
|
const defaultLimit = this.config?.defaultConcurrency
|
|
|
|
|
if (defaultLimit !== undefined) {
|
|
|
|
|
return defaultLimit === 0 ? Infinity : defaultLimit
|
|
|
|
|
}
|
|
|
|
|
return 5
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async acquire(model: string): Promise<void> {
|
|
|
|
|
const limit = this.getConcurrencyLimit(model)
|
|
|
|
|
if (limit === Infinity) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const current = this.counts.get(model) ?? 0
|
|
|
|
|
if (current < limit) {
|
|
|
|
|
this.counts.set(model, current + 1)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 15:09:32 -08:00
|
|
|
return new Promise<void>((resolve, reject) => {
|
2026-01-07 01:24:47 +09:00
|
|
|
const queue = this.queues.get(model) ?? []
|
2026-01-14 15:09:32 -08:00
|
|
|
|
|
|
|
|
const entry: QueueEntry = {
|
|
|
|
|
resolve: () => {
|
|
|
|
|
if (entry.settled) return
|
|
|
|
|
entry.settled = true
|
|
|
|
|
resolve()
|
|
|
|
|
},
|
|
|
|
|
rawReject: reject,
|
|
|
|
|
settled: false,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
queue.push(entry)
|
2026-01-07 01:24:47 +09:00
|
|
|
this.queues.set(model, queue)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
release(model: string): void {
|
|
|
|
|
const limit = this.getConcurrencyLimit(model)
|
|
|
|
|
if (limit === Infinity) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const queue = this.queues.get(model)
|
2026-01-14 15:09:32 -08:00
|
|
|
|
|
|
|
|
// Try to hand off to a waiting entry (skip any settled entries from cancelWaiters)
|
|
|
|
|
while (queue && queue.length > 0) {
|
2026-01-07 01:24:47 +09:00
|
|
|
const next = queue.shift()!
|
2026-01-14 15:09:32 -08:00
|
|
|
if (!next.settled) {
|
|
|
|
|
// Hand off the slot to this waiter (count stays the same)
|
|
|
|
|
next.resolve()
|
|
|
|
|
return
|
2026-01-07 01:24:47 +09:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-14 15:09:32 -08:00
|
|
|
|
|
|
|
|
// No handoff occurred - decrement the count to free the slot
|
|
|
|
|
const current = this.counts.get(model) ?? 0
|
|
|
|
|
if (current > 0) {
|
|
|
|
|
this.counts.set(model, current - 1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Cancel all waiting acquires for a model. Used during cleanup.
|
|
|
|
|
*/
|
|
|
|
|
cancelWaiters(model: string): void {
|
|
|
|
|
const queue = this.queues.get(model)
|
|
|
|
|
if (queue) {
|
|
|
|
|
for (const entry of queue) {
|
|
|
|
|
if (!entry.settled) {
|
|
|
|
|
entry.settled = true
|
|
|
|
|
entry.rawReject(new Error(`Concurrency queue cancelled for model: ${model}`))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
this.queues.delete(model)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Clear all state. Used during manager cleanup/shutdown.
|
|
|
|
|
* Cancels all pending waiters.
|
|
|
|
|
*/
|
|
|
|
|
clear(): void {
|
|
|
|
|
for (const [model] of this.queues) {
|
|
|
|
|
this.cancelWaiters(model)
|
|
|
|
|
}
|
|
|
|
|
this.counts.clear()
|
|
|
|
|
this.queues.clear()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get current count for a model (for testing/debugging)
|
|
|
|
|
*/
|
|
|
|
|
getCount(model: string): number {
|
|
|
|
|
return this.counts.get(model) ?? 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get queue length for a model (for testing/debugging)
|
|
|
|
|
*/
|
|
|
|
|
getQueueLength(model: string): number {
|
|
|
|
|
return this.queues.get(model)?.length ?? 0
|
2026-01-07 01:24:47 +09:00
|
|
|
}
|
|
|
|
|
}
|