qwenproxy-cli 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +14 -14
- package/README.md +906 -906
- package/bin/qwenproxy.js +5 -1
- package/package.json +77 -78
- package/src/api/error-classifier.ts +159 -159
- package/src/api/error-helpers.ts +118 -118
- package/src/api/models.ts +261 -261
- package/src/api/server.ts +860 -859
- package/src/cache/memory-cache.ts +385 -385
- package/src/clean-cache.ts +204 -204
- package/src/core/account-concurrency.ts +671 -671
- package/src/core/account-manager.ts +301 -297
- package/src/core/account-priority.ts +163 -163
- package/src/core/accounts.ts +186 -186
- package/src/core/config.ts +383 -383
- package/src/core/crypto-utils.ts +79 -79
- package/src/core/database.ts +276 -276
- package/src/core/errors.ts +118 -118
- package/src/core/logger.ts +269 -269
- package/src/core/memory-usage.ts +84 -84
- package/src/core/metrics.ts +291 -291
- package/src/core/model-alias.ts +77 -77
- package/src/core/model-registry.ts +544 -544
- package/src/core/mutex.ts +119 -119
- package/src/core/paths.ts +199 -199
- package/src/core/prompt-limits.ts +214 -214
- package/src/core/reasoning-effort.ts +102 -102
- package/src/core/stream-registry.ts +96 -96
- package/src/core/waf-isolation.ts +117 -117
- package/src/core/watchdog.ts +195 -195
- package/src/delete-chats.ts +23 -23
- package/src/index.ts +65 -64
- package/src/login.ts +147 -147
- package/src/reset-cooldowns.ts +11 -11
- package/src/routes/anthropic/index.ts +355 -355
- package/src/routes/anthropic/translate.ts +522 -522
- package/src/routes/anthropic/types.ts +154 -154
- package/src/routes/anthropic/validation.ts +144 -144
- package/src/routes/chat/account.ts +1817 -1817
- package/src/routes/chat/context.ts +241 -241
- package/src/routes/chat/errors.ts +85 -85
- package/src/routes/chat/helpers.ts +268 -268
- package/src/routes/chat/index.ts +618 -618
- package/src/routes/chat/media.ts +285 -285
- package/src/routes/chat/retry-policy.ts +754 -754
- package/src/routes/chat/stop.ts +98 -98
- package/src/routes/chat/streaming.ts +2710 -2710
- package/src/routes/chat/validation.ts +526 -526
- package/src/routes/chat.ts +2 -2
- package/src/routes/completions.ts +290 -290
- package/src/routes/images.ts +139 -139
- package/src/routes/responses/adapter.ts +503 -503
- package/src/routes/responses/index.ts +405 -405
- package/src/routes/responses/state.ts +230 -230
- package/src/routes/responses/streaming.ts +528 -528
- package/src/routes/responses/types.ts +285 -285
- package/src/routes/responses/validation.ts +202 -202
- package/src/routes/upload.ts +731 -731
- package/src/routes/videos.ts +214 -214
- package/src/services/auth-playwright.ts +173 -173
- package/src/services/captcha-coordinator.ts +161 -161
- package/src/services/captcha-solver.ts +553 -553
- package/src/services/chat-cleanup.ts +80 -80
- package/src/services/context-meter.ts +317 -317
- package/src/services/fingerprint.ts +242 -242
- package/src/services/human-behavior.ts +173 -173
- package/src/services/media-generation.ts +1748 -1748
- package/src/services/playwright.ts +2878 -2800
- package/src/services/qwen-chat-pool.ts +345 -345
- package/src/services/qwen-errors.ts +133 -133
- package/src/services/qwen-headers.ts +79 -79
- package/src/services/qwen-thread-state.ts +393 -393
- package/src/services/qwen-url.ts +19 -19
- package/src/services/qwen.ts +3126 -3126
- package/src/services/session-keeper.ts +88 -88
- package/src/services/token-estimation-metrics.ts +118 -118
- package/src/sync/claude-code.ts +75 -75
- package/src/sync/codex.ts +123 -123
- package/src/sync/index.ts +362 -362
- package/src/sync/omp.ts +105 -105
- package/src/sync/opencode.ts +214 -214
- package/src/sync/types.ts +53 -53
- package/src/sync/utils.ts +27 -27
- package/src/sync-clients.ts +189 -189
- package/src/tools/instructions.ts +137 -137
- package/src/tools/manifest.ts +81 -81
- package/src/tools/parser.ts +2989 -2989
- package/src/tools/toolcall-tags.ts +142 -142
- package/src/tui/app.ts +259 -264
- package/src/tui/index.ts +61 -61
- package/src/tui/markdown.ts +258 -258
- package/src/tui/proxy-client.ts +331 -326
- package/src/tui/screen.ts +294 -278
- package/src/tui/server-manager.ts +270 -270
- package/src/tui/theme.ts +432 -432
- package/src/tui/types.ts +33 -33
- package/src/tui/views/accounts-view.ts +656 -656
- package/src/tui/views/chat-view.ts +1018 -823
- package/src/tui/views/logs-view.ts +479 -413
- package/src/tui/views/status-view.ts +204 -204
- package/src/tui/views/storage-view.ts +304 -291
- package/src/tui/views/sync-view.ts +409 -409
- package/src/types/ali-oss.d.ts +32 -32
- package/src/update-cli.ts +121 -0
- package/src/utils/context-truncation.ts +84 -84
- package/src/utils/json.ts +380 -380
- package/src/utils/session-id.ts +37 -37
- package/src/utils/tool-call-guard.ts +84 -84
- package/src/utils/types.ts +109 -109
|
@@ -1,671 +1,671 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-account concurrency guard. Limits the number of simultaneous upstream
|
|
3
|
-
* streams per account and queues excess requests with FIFO ordering, timeout,
|
|
4
|
-
* and abort support.
|
|
5
|
-
*
|
|
6
|
-
* Includes stale-lease detection: if a lease is held longer than
|
|
7
|
-
* `concurrency.leaseMaxDurationMs` it is considered leaked and force-released.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { config } from "./config.ts";
|
|
11
|
-
import { logger } from "./logger.ts";
|
|
12
|
-
import { getStream } from "./stream-registry.ts";
|
|
13
|
-
|
|
14
|
-
export interface AccountLease {
|
|
15
|
-
accountId: string;
|
|
16
|
-
/** Unique id for correlating lease acquire/release in logs. */
|
|
17
|
-
leaseId: string;
|
|
18
|
-
release(): void;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface AcquireAccountLeaseOptions {
|
|
22
|
-
signal?: AbortSignal;
|
|
23
|
-
/**
|
|
24
|
-
* Deadline for hanging on a free slot.
|
|
25
|
-
* - `number`: reject with `account_busy` after this many ms.
|
|
26
|
-
* - `null`: wait without a deadline until a slot frees or `signal` aborts.
|
|
27
|
-
* - `undefined`: fall back to `concurrency.busyWaitMs`.
|
|
28
|
-
*/
|
|
29
|
-
timeoutMs?: number | null;
|
|
30
|
-
/** Human-readable label for diagnostics (e.g. reqId or chat session). */
|
|
31
|
-
label?: string;
|
|
32
|
-
/** AbortController registered with the lease; aborted on session-level replacement. */
|
|
33
|
-
leaseAbortController?: AbortController;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
interface ActiveLeaseInfo {
|
|
37
|
-
leaseId: string;
|
|
38
|
-
acquiredAt: number;
|
|
39
|
-
label: string;
|
|
40
|
-
/** Aborted when a same-session retry supersedes this lease. */
|
|
41
|
-
abortController?: AbortController;
|
|
42
|
-
/** Set once the stream is acquired; used by the emit-aware supersede. */
|
|
43
|
-
completionId?: string;
|
|
44
|
-
/** Parallel-escape lease (own chat): excluded from the unemitted check. */
|
|
45
|
-
parallelEscape?: boolean;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
interface QueueEntry {
|
|
49
|
-
resolve: (lease: AccountLease) => void;
|
|
50
|
-
reject: (err: Error) => void;
|
|
51
|
-
timer: ReturnType<typeof setTimeout> | null;
|
|
52
|
-
onAbort: (() => void) | null;
|
|
53
|
-
signal: AbortSignal | null;
|
|
54
|
-
enqueuedAt: number;
|
|
55
|
-
/** Preserved so the lease granted from the queue keeps the session label. */
|
|
56
|
-
label: string;
|
|
57
|
-
/** Preserved so latest-wins can abort queued-then-granted leases. */
|
|
58
|
-
leaseAbortController?: AbortController;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
interface AccountSlot {
|
|
62
|
-
activeLeases: ActiveLeaseInfo[];
|
|
63
|
-
queue: QueueEntry[];
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const slots = new Map<string, AccountSlot>();
|
|
67
|
-
let leaseCounter = 0;
|
|
68
|
-
|
|
69
|
-
function generateLeaseId(): string {
|
|
70
|
-
return `lease_${(++leaseCounter).toString(36)}_${Date.now().toString(36)}`;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function getSlot(accountId: string): AccountSlot {
|
|
74
|
-
let slot = slots.get(accountId);
|
|
75
|
-
if (!slot) {
|
|
76
|
-
slot = { activeLeases: [], queue: [] };
|
|
77
|
-
slots.set(accountId, slot);
|
|
78
|
-
}
|
|
79
|
-
return slot;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Format active lease holders for log output. */
|
|
83
|
-
function formatHolders(slot: AccountSlot): string {
|
|
84
|
-
if (slot.activeLeases.length === 0) return "none";
|
|
85
|
-
const now = Date.now();
|
|
86
|
-
return slot.activeLeases
|
|
87
|
-
.map((l) => `${l.label || "unknown"}(held ${Math.round((now - l.acquiredAt) / 1000)}s)`)
|
|
88
|
-
.join(", ");
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* True when the account slot is at capacity AND every active lease belongs to
|
|
93
|
-
* a session other than `label`.
|
|
94
|
-
*
|
|
95
|
-
* Used to decide how long a request may wait for the slot: when ANOTHER
|
|
96
|
-
* session is generating on this account (not our own thread), waiting long is
|
|
97
|
-
* wasted latency — the other session may hold the slot for minutes. The
|
|
98
|
-
* request should fail fast with `account_busy` (short wait) and rotate to a
|
|
99
|
-
* different account instead of queueing for the full `queueWaitForeverCapMs`.
|
|
100
|
-
*/
|
|
101
|
-
export function isAccountSlotHeldByOtherSession(
|
|
102
|
-
accountId: string,
|
|
103
|
-
label: string | null | undefined,
|
|
104
|
-
): boolean {
|
|
105
|
-
if (!label) return false;
|
|
106
|
-
const slot = slots.get(accountId);
|
|
107
|
-
if (!slot) return false;
|
|
108
|
-
if (slot.activeLeases.length === 0) return false;
|
|
109
|
-
if (slot.activeLeases.length < config.concurrency.maxStreamsPerAccount) {
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
return slot.activeLeases.every((l) => l.label !== label);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function createLease(
|
|
116
|
-
accountId: string,
|
|
117
|
-
label: string,
|
|
118
|
-
abortController?: AbortController,
|
|
119
|
-
parallelEscape?: boolean,
|
|
120
|
-
): AccountLease {
|
|
121
|
-
const leaseId = generateLeaseId();
|
|
122
|
-
const slot = getSlot(accountId);
|
|
123
|
-
const info: ActiveLeaseInfo = {
|
|
124
|
-
leaseId,
|
|
125
|
-
acquiredAt: Date.now(),
|
|
126
|
-
label,
|
|
127
|
-
abortController,
|
|
128
|
-
parallelEscape,
|
|
129
|
-
};
|
|
130
|
-
slot.activeLeases.push(info);
|
|
131
|
-
logger.debug("[concurrency] lease acquired", {
|
|
132
|
-
accountId,
|
|
133
|
-
leaseId,
|
|
134
|
-
label,
|
|
135
|
-
parallelEscape: !!parallelEscape,
|
|
136
|
-
activeLeases: slot.activeLeases.length,
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
let released = false;
|
|
140
|
-
return {
|
|
141
|
-
accountId,
|
|
142
|
-
leaseId,
|
|
143
|
-
release() {
|
|
144
|
-
if (released) return;
|
|
145
|
-
released = true;
|
|
146
|
-
const idx = slot.activeLeases.findIndex((l) => l.leaseId === leaseId);
|
|
147
|
-
if (idx !== -1) slot.activeLeases.splice(idx, 1);
|
|
148
|
-
const heldMs = Date.now() - info.acquiredAt;
|
|
149
|
-
logger.debug("[concurrency] lease released", {
|
|
150
|
-
accountId,
|
|
151
|
-
leaseId,
|
|
152
|
-
label,
|
|
153
|
-
heldMs,
|
|
154
|
-
});
|
|
155
|
-
if (logger.isLevelEnabled("info")) {
|
|
156
|
-
console.log(
|
|
157
|
-
`🚦 [Server] Stream slot released | account=${accountId} | held ${heldMs}ms | label=${label} | leaseId=${leaseId.substring(0, 8)}`,
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
releaseSlot(accountId);
|
|
161
|
-
},
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function releaseSlot(accountId: string): void {
|
|
166
|
-
const slot = slots.get(accountId);
|
|
167
|
-
if (!slot) return;
|
|
168
|
-
|
|
169
|
-
// Deliver to the next waiter in FIFO order
|
|
170
|
-
while (slot.queue.length > 0 && slot.activeLeases.length < config.concurrency.maxStreamsPerAccount) {
|
|
171
|
-
const entry = slot.queue.shift();
|
|
172
|
-
if (!entry) break;
|
|
173
|
-
cleanupEntry(entry);
|
|
174
|
-
if (logger.isLevelEnabled("info")) {
|
|
175
|
-
console.log(
|
|
176
|
-
`🚦 [Server] Stream slot granted | account=${accountId} | waited ${Date.now() - entry.enqueuedAt}ms | ${slot.queue.length} still queued`,
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
entry.resolve(createLease(accountId, entry.label, entry.leaseAbortController));
|
|
180
|
-
return; // one at a time to preserve ordering
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Clean up empty slots to avoid unbounded map growth
|
|
184
|
-
if (slot.activeLeases.length === 0 && slot.queue.length === 0) {
|
|
185
|
-
slots.delete(accountId);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function cleanupEntry(entry: QueueEntry): void {
|
|
190
|
-
if (entry.timer) {
|
|
191
|
-
clearTimeout(entry.timer);
|
|
192
|
-
entry.timer = null;
|
|
193
|
-
}
|
|
194
|
-
if (entry.onAbort && entry.signal) {
|
|
195
|
-
entry.signal.removeEventListener("abort", entry.onAbort);
|
|
196
|
-
entry.onAbort = null;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Check for and force-release stale leases that exceeded the max duration.
|
|
202
|
-
* Returns the number of leases force-released.
|
|
203
|
-
*/
|
|
204
|
-
function sweepStaleLeases(accountId: string): number {
|
|
205
|
-
const slot = slots.get(accountId);
|
|
206
|
-
if (!slot) return 0;
|
|
207
|
-
|
|
208
|
-
const maxDuration = config.concurrency.leaseMaxDurationMs;
|
|
209
|
-
if (maxDuration <= 0) return 0;
|
|
210
|
-
|
|
211
|
-
const now = Date.now();
|
|
212
|
-
let swept = 0;
|
|
213
|
-
|
|
214
|
-
for (let i = slot.activeLeases.length - 1; i >= 0; i--) {
|
|
215
|
-
const lease = slot.activeLeases[i];
|
|
216
|
-
const heldMs = now - lease.acquiredAt;
|
|
217
|
-
if (heldMs > maxDuration) {
|
|
218
|
-
slot.activeLeases.splice(i, 1);
|
|
219
|
-
swept++;
|
|
220
|
-
console.warn(
|
|
221
|
-
`⚠️ [Server] Stale lease force-released | account=${accountId} | label=${lease.label} | held ${Math.round(heldMs / 1000)}s (limit: ${Math.round(maxDuration / 1000)}s) | leaseId=${lease.leaseId}`,
|
|
222
|
-
);
|
|
223
|
-
logger.warn("[concurrency] stale lease force-released (possible leak)", {
|
|
224
|
-
accountId,
|
|
225
|
-
leaseId: lease.leaseId,
|
|
226
|
-
label: lease.label,
|
|
227
|
-
heldMs,
|
|
228
|
-
maxDurationMs: maxDuration,
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// If we swept any, try to deliver to waiters
|
|
234
|
-
if (swept > 0) {
|
|
235
|
-
releaseSlot(accountId);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
return swept;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Try to acquire a lease without waiting. Returns null if the account is at
|
|
243
|
-
* capacity.
|
|
244
|
-
*/
|
|
245
|
-
export function tryAcquireAccountLease(
|
|
246
|
-
accountId: string,
|
|
247
|
-
label?: string,
|
|
248
|
-
leaseAbortController?: AbortController,
|
|
249
|
-
parallelEscape?: boolean,
|
|
250
|
-
): AccountLease | null {
|
|
251
|
-
const slot = getSlot(accountId);
|
|
252
|
-
sweepStaleLeases(accountId);
|
|
253
|
-
if (slot.activeLeases.length < config.concurrency.maxStreamsPerAccount) {
|
|
254
|
-
return createLease(
|
|
255
|
-
accountId,
|
|
256
|
-
label ?? "try-acquire",
|
|
257
|
-
leaseAbortController,
|
|
258
|
-
parallelEscape,
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
return null;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* Acquire a lease, waiting in FIFO order if the account is at capacity.
|
|
266
|
-
* Rejects on timeout or abort.
|
|
267
|
-
*/
|
|
268
|
-
export function acquireAccountLease(
|
|
269
|
-
accountId: string,
|
|
270
|
-
options?: AcquireAccountLeaseOptions,
|
|
271
|
-
): Promise<AccountLease> {
|
|
272
|
-
const signal = options?.signal ?? null;
|
|
273
|
-
const label = options?.label ?? "unlabeled";
|
|
274
|
-
|
|
275
|
-
// Already aborted — reject before touching any slot
|
|
276
|
-
if (signal?.aborted) {
|
|
277
|
-
return Promise.reject(new Error("Aborted before acquiring account lease"));
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const slot = getSlot(accountId);
|
|
281
|
-
|
|
282
|
-
// Sweep stale leases before checking capacity — a leaked lease should not
|
|
283
|
-
// block new requests indefinitely.
|
|
284
|
-
sweepStaleLeases(accountId);
|
|
285
|
-
|
|
286
|
-
// A NORMAL request never queues behind an auxiliary (parallel-escape) lease:
|
|
287
|
-
// the disposable title must yield its slot immediately. Belt-and-suspenders
|
|
288
|
-
// on top of the emit-aware supersede (which kills parallel leases even before
|
|
289
|
-
// they emit) — covers the case where the supersede could not find the lease.
|
|
290
|
-
if (
|
|
291
|
-
slot.activeLeases.length > 0 &&
|
|
292
|
-
slot.activeLeases.every((l) => l.parallelEscape)
|
|
293
|
-
) {
|
|
294
|
-
for (const lease of [...slot.activeLeases]) {
|
|
295
|
-
logger.debug("[concurrency] preempting parallel-escape lease", {
|
|
296
|
-
accountId,
|
|
297
|
-
leaseId: lease.leaseId,
|
|
298
|
-
label: lease.label,
|
|
299
|
-
});
|
|
300
|
-
lease.abortController?.abort();
|
|
301
|
-
const i = slot.activeLeases.indexOf(lease);
|
|
302
|
-
if (i !== -1) slot.activeLeases.splice(i, 1);
|
|
303
|
-
}
|
|
304
|
-
releaseSlot(accountId); // deliver to the earliest waiter, if any
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// Fast path: capacity available
|
|
308
|
-
if (slot.activeLeases.length < config.concurrency.maxStreamsPerAccount) {
|
|
309
|
-
return Promise.resolve(createLease(accountId, label, options?.leaseAbortController));
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
const hasExplicitTimeout =
|
|
313
|
-
options != null &&
|
|
314
|
-
Object.prototype.hasOwnProperty.call(options, "timeoutMs");
|
|
315
|
-
const timeoutMs = hasExplicitTimeout
|
|
316
|
-
? options?.timeoutMs
|
|
317
|
-
: config.concurrency.busyWaitMs;
|
|
318
|
-
|
|
319
|
-
return new Promise<AccountLease>((resolve, reject) => {
|
|
320
|
-
const entry: QueueEntry = {
|
|
321
|
-
resolve,
|
|
322
|
-
reject,
|
|
323
|
-
timer: null,
|
|
324
|
-
onAbort: null,
|
|
325
|
-
signal,
|
|
326
|
-
enqueuedAt: Date.now(),
|
|
327
|
-
label,
|
|
328
|
-
leaseAbortController: options?.leaseAbortController,
|
|
329
|
-
};
|
|
330
|
-
|
|
331
|
-
const removeSelf = () => {
|
|
332
|
-
const idx = slot.queue.indexOf(entry);
|
|
333
|
-
if (idx !== -1) slot.queue.splice(idx, 1);
|
|
334
|
-
cleanupEntry(entry);
|
|
335
|
-
if (slot.activeLeases.length === 0 && slot.queue.length === 0) {
|
|
336
|
-
slots.delete(accountId);
|
|
337
|
-
}
|
|
338
|
-
};
|
|
339
|
-
|
|
340
|
-
// Timeout — only when the caller supplied a finite deadline. `null` means
|
|
341
|
-
// "wait as long as it takes" (bounded by the abort signal), which is what
|
|
342
|
-
// the last-usable account or the thread owner must do to stay lossless.
|
|
343
|
-
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
344
|
-
entry.timer = setTimeout(() => {
|
|
345
|
-
removeSelf();
|
|
346
|
-
console.log(
|
|
347
|
-
`🚦 [Server] Stream wait timeout | account=${accountId} | waited ${timeoutMs}ms | ${slot.queue.length} still queued`,
|
|
348
|
-
);
|
|
349
|
-
const busyError = new Error(
|
|
350
|
-
`Account ${accountId} busy: timed out after ${timeoutMs}ms waiting for a free slot`,
|
|
351
|
-
) as Error & { code?: string };
|
|
352
|
-
busyError.code = "account_busy";
|
|
353
|
-
reject(busyError);
|
|
354
|
-
}, timeoutMs);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// Abort signal
|
|
358
|
-
if (signal) {
|
|
359
|
-
entry.onAbort = () => {
|
|
360
|
-
removeSelf();
|
|
361
|
-
if (logger.isLevelEnabled("info")) {
|
|
362
|
-
console.log(
|
|
363
|
-
`🚦 [Server] Stream waiter aborted | account=${accountId} | waited ${Date.now() - entry.enqueuedAt}ms | ${slot.queue.length} still queued`,
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
reject(new Error("Aborted while waiting for account lease"));
|
|
367
|
-
};
|
|
368
|
-
signal.addEventListener("abort", entry.onAbort, { once: true });
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
slot.queue.push(entry);
|
|
372
|
-
|
|
373
|
-
const holders = formatHolders(slot);
|
|
374
|
-
if (logger.isLevelEnabled("info")) {
|
|
375
|
-
console.log(
|
|
376
|
-
`🚦 [Server] Stream slot busy | account=${accountId} | held by: ${holders} | queued at position ${slot.queue.length} | timeout=${typeof timeoutMs === "number" && Number.isFinite(timeoutMs) ? `${timeoutMs}ms` : "unbounded"}`,
|
|
377
|
-
);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
logger.debug("[concurrency] queued for account lease", {
|
|
381
|
-
accountId,
|
|
382
|
-
activeLeases: slot.activeLeases.length,
|
|
383
|
-
holders: slot.activeLeases.map((l) => ({ label: l.label, heldMs: Date.now() - l.acquiredAt })),
|
|
384
|
-
queueLength: slot.queue.length,
|
|
385
|
-
timeoutMs,
|
|
386
|
-
});
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Abort and remove the active lease matching the given label (session id).
|
|
392
|
-
* Used when a client retries the same session: the old generation is no longer
|
|
393
|
-
* needed, so we abort it and free the slot for the new request.
|
|
394
|
-
*
|
|
395
|
-
* With `onlyIfEmitted`, a lease whose stream has NOT yet emitted a chunk to the
|
|
396
|
-
* client is PROTECTED: killing it would waste a generation the client has not
|
|
397
|
-
* consumed (a parallel title request racing the main stream, or a stream still
|
|
398
|
-
* mid-creation). Leases without a completionId (stream not acquired yet) are
|
|
399
|
-
* protected too. Once the stream emits, latest-wins applies (tool loop).
|
|
400
|
-
* Returns true if a lease was aborted, false otherwise.
|
|
401
|
-
*/
|
|
402
|
-
export function abortLeaseByLabel(
|
|
403
|
-
accountId: string,
|
|
404
|
-
label: string,
|
|
405
|
-
opts?: { onlyIfEmitted?: boolean },
|
|
406
|
-
): boolean {
|
|
407
|
-
const slot = slots.get(accountId);
|
|
408
|
-
if (!slot) return false;
|
|
409
|
-
|
|
410
|
-
const idx = slot.activeLeases.findIndex((l) => l.label === label);
|
|
411
|
-
if (idx === -1) return false;
|
|
412
|
-
|
|
413
|
-
const lease = slot.activeLeases[idx];
|
|
414
|
-
if (opts?.onlyIfEmitted) {
|
|
415
|
-
// A parallel-escape lease (auxiliary title on its own chat) is DISPOSABLE:
|
|
416
|
-
// any normal request of the same session may preempt it, even before it
|
|
417
|
-
// emits — the main conversation must never block on the title's slot.
|
|
418
|
-
if (!lease.parallelEscape) {
|
|
419
|
-
const stream = lease.completionId
|
|
420
|
-
? getStream(lease.completionId)
|
|
421
|
-
: undefined;
|
|
422
|
-
if (!stream?.emittedChunk) {
|
|
423
|
-
// Unemitted (still thinking / mid-creation): protect the generation.
|
|
424
|
-
if (logger.isLevelEnabled("info")) {
|
|
425
|
-
console.log(
|
|
426
|
-
`🛡️ [Server] Supersede skipped (protected unemitted) | account=${accountId} | label=${label} | leaseId=${lease.leaseId} | completionId=${lease.completionId ?? "none"} | emitted=${stream?.emittedChunk ?? false}`,
|
|
427
|
-
);
|
|
428
|
-
}
|
|
429
|
-
return false;
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
const heldMs = Date.now() - lease.acquiredAt;
|
|
435
|
-
slot.activeLeases.splice(idx, 1);
|
|
436
|
-
|
|
437
|
-
if (logger.isLevelEnabled("info")) {
|
|
438
|
-
console.log(
|
|
439
|
-
`🔄 [Server] Session retry supersedes active lease | account=${accountId} | label=${label} | held ${Math.round(heldMs / 1000)}s | leaseId=${lease.leaseId}`,
|
|
440
|
-
);
|
|
441
|
-
}
|
|
442
|
-
logger.info("[concurrency] session retry superseded active lease", {
|
|
443
|
-
accountId,
|
|
444
|
-
label,
|
|
445
|
-
heldMs,
|
|
446
|
-
leaseId: lease.leaseId,
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
// Abort the stream associated with the old lease
|
|
450
|
-
if (lease.abortController) {
|
|
451
|
-
lease.abortController.abort();
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// Free the slot for the next waiter
|
|
455
|
-
releaseSlot(accountId);
|
|
456
|
-
return true;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/**
|
|
460
|
-
* Abort any active lease matching the given session label ACROSS ALL accounts.
|
|
461
|
-
* Used BEFORE the per-chat lock: the client can fire the next turn while the
|
|
462
|
-
* previous stream is still open (streaming tool calls). Killing the stale
|
|
463
|
-
* generation first lets the new request take the chat lock immediately instead
|
|
464
|
-
* of waiting for the stale-lease sweep (600s) when the old upstream stalled.
|
|
465
|
-
* Returns true if at least one lease was aborted.
|
|
466
|
-
*/
|
|
467
|
-
export function abortLeaseBySessionLabel(
|
|
468
|
-
label: string,
|
|
469
|
-
opts?: { onlyIfEmitted?: boolean },
|
|
470
|
-
): boolean {
|
|
471
|
-
let aborted = false;
|
|
472
|
-
for (const accountId of slots.keys()) {
|
|
473
|
-
if (abortLeaseByLabel(accountId, label, opts)) aborted = true;
|
|
474
|
-
}
|
|
475
|
-
return aborted;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* True when the session has an ACTIVE lease whose stream has not emitted a
|
|
480
|
-
* chunk yet (still thinking / mid-creation). A parallel request (title) must
|
|
481
|
-
* not kill it, and must run on its own chat instead of waiting.
|
|
482
|
-
*/
|
|
483
|
-
export function hasUnemittedSessionStream(label: string): boolean {
|
|
484
|
-
for (const slot of slots.values()) {
|
|
485
|
-
for (const lease of slot.activeLeases) {
|
|
486
|
-
if (lease.label !== label) continue;
|
|
487
|
-
// A parallel-escape lease runs on its OWN chat — it must not poison the
|
|
488
|
-
// check and cascade every following turn into a new chat too.
|
|
489
|
-
if (lease.parallelEscape) continue;
|
|
490
|
-
const stream = lease.completionId
|
|
491
|
-
? getStream(lease.completionId)
|
|
492
|
-
: undefined;
|
|
493
|
-
if (!stream?.emittedChunk) {
|
|
494
|
-
logger.debug("[concurrency] unemitted session stream found", {
|
|
495
|
-
label,
|
|
496
|
-
leaseId: lease.leaseId,
|
|
497
|
-
completionId: lease.completionId ?? null,
|
|
498
|
-
});
|
|
499
|
-
return true;
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
return false;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
/**
|
|
507
|
-
* Attach the acquired stream's completion id to a lease so the emit-aware
|
|
508
|
-
* supersede can check whether the stream reached the client. Called by the
|
|
509
|
-
* stream owner right after createQwenStream succeeds.
|
|
510
|
-
*/
|
|
511
|
-
export function markLeaseCompletion(
|
|
512
|
-
accountId: string,
|
|
513
|
-
leaseId: string,
|
|
514
|
-
completionId: string,
|
|
515
|
-
): void {
|
|
516
|
-
const slot = slots.get(accountId);
|
|
517
|
-
const lease = slot?.activeLeases.find((l) => l.leaseId === leaseId);
|
|
518
|
-
if (lease) {
|
|
519
|
-
lease.completionId = completionId;
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
/**
|
|
524
|
-
* Check whether an account currently has no free slots.
|
|
525
|
-
*/
|
|
526
|
-
export function isAccountBusy(accountId: string): boolean {
|
|
527
|
-
const slot = slots.get(accountId);
|
|
528
|
-
if (!slot) return false;
|
|
529
|
-
return slot.activeLeases.length >= config.concurrency.maxStreamsPerAccount;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/**
|
|
533
|
-
* Check whether an account is currently serving at least one stream.
|
|
534
|
-
* Different question from isAccountBusy: that one answers "are all slots
|
|
535
|
-
* taken", while callers that must not disturb a generation in flight care
|
|
536
|
-
* about a single active lease even when spare slots remain.
|
|
537
|
-
*/
|
|
538
|
-
export function hasActiveAccountLease(accountId: string): boolean {
|
|
539
|
-
const slot = slots.get(accountId);
|
|
540
|
-
if (!slot) return false;
|
|
541
|
-
return slot.activeLeases.length > 0;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
/**
|
|
545
|
-
* Mark an account as temporarily busy (e.g. after chat_in_progress).
|
|
546
|
-
* This is a lightweight hint that prevents immediate re-selection without
|
|
547
|
-
* consuming a lease slot.
|
|
548
|
-
*/
|
|
549
|
-
const temporaryBusyUntil = new Map<string, number>();
|
|
550
|
-
|
|
551
|
-
export function markAccountTemporarilyBusy(
|
|
552
|
-
accountId: string,
|
|
553
|
-
durationMs: number,
|
|
554
|
-
): number {
|
|
555
|
-
const requestedUntil = Date.now() + Math.max(0, durationMs);
|
|
556
|
-
const currentUntil = temporaryBusyUntil.get(accountId) ?? 0;
|
|
557
|
-
const effectiveUntil = Math.max(currentUntil, requestedUntil);
|
|
558
|
-
temporaryBusyUntil.set(accountId, effectiveUntil);
|
|
559
|
-
return effectiveUntil;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
export function isAccountTemporarilyBusy(accountId: string): boolean {
|
|
563
|
-
const until = temporaryBusyUntil.get(accountId);
|
|
564
|
-
if (!until) return false;
|
|
565
|
-
if (Date.now() >= until) {
|
|
566
|
-
temporaryBusyUntil.delete(accountId);
|
|
567
|
-
return false;
|
|
568
|
-
}
|
|
569
|
-
return true;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
export function clearTemporaryBusy(
|
|
573
|
-
accountId: string,
|
|
574
|
-
expectedUntil?: number,
|
|
575
|
-
): void {
|
|
576
|
-
if (
|
|
577
|
-
expectedUntil !== undefined &&
|
|
578
|
-
temporaryBusyUntil.get(accountId) !== expectedUntil
|
|
579
|
-
) {
|
|
580
|
-
return;
|
|
581
|
-
}
|
|
582
|
-
temporaryBusyUntil.delete(accountId);
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
/**
|
|
586
|
-
* Snapshot for logging/metrics.
|
|
587
|
-
*/
|
|
588
|
-
export function getAccountConcurrencySnapshot(): Array<{
|
|
589
|
-
accountId: string;
|
|
590
|
-
active: number;
|
|
591
|
-
waiting: number;
|
|
592
|
-
limit: number;
|
|
593
|
-
holders: Array<{ label: string; heldMs: number }>;
|
|
594
|
-
}> {
|
|
595
|
-
const result: Array<{
|
|
596
|
-
accountId: string;
|
|
597
|
-
active: number;
|
|
598
|
-
waiting: number;
|
|
599
|
-
limit: number;
|
|
600
|
-
holders: Array<{ label: string; heldMs: number }>;
|
|
601
|
-
}> = [];
|
|
602
|
-
const now = Date.now();
|
|
603
|
-
for (const [accountId, slot] of slots) {
|
|
604
|
-
result.push({
|
|
605
|
-
accountId,
|
|
606
|
-
active: slot.activeLeases.length,
|
|
607
|
-
waiting: slot.queue.length,
|
|
608
|
-
limit: config.concurrency.maxStreamsPerAccount,
|
|
609
|
-
holders: slot.activeLeases.map((l) => ({
|
|
610
|
-
label: l.label,
|
|
611
|
-
heldMs: now - l.acquiredAt,
|
|
612
|
-
})),
|
|
613
|
-
});
|
|
614
|
-
}
|
|
615
|
-
return result;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
/**
|
|
619
|
-
* Sweep all accounts for stale leases. Call periodically (e.g. from a timer
|
|
620
|
-
* or before each request batch) to catch leaked leases proactively.
|
|
621
|
-
*/
|
|
622
|
-
export function sweepAllStaleLeases(): number {
|
|
623
|
-
let total = 0;
|
|
624
|
-
for (const accountId of slots.keys()) {
|
|
625
|
-
total += sweepStaleLeases(accountId);
|
|
626
|
-
}
|
|
627
|
-
return total;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
// Periodic stale-lease sweep: runs every 30s to catch leaked leases even when
|
|
631
|
-
// no new requests arrive. This is the safety net that prevents "slot busy
|
|
632
|
-
// forever" from a single missed release() call.
|
|
633
|
-
const SWEEP_INTERVAL_MS = 30_000;
|
|
634
|
-
let sweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
635
|
-
|
|
636
|
-
export function startLeaseSweepTimer(): void {
|
|
637
|
-
if (sweepTimer) return;
|
|
638
|
-
sweepTimer = setInterval(() => {
|
|
639
|
-
const swept = sweepAllStaleLeases();
|
|
640
|
-
if (swept > 0) {
|
|
641
|
-
logger.info("[concurrency] periodic stale lease sweep", { swept });
|
|
642
|
-
}
|
|
643
|
-
}, SWEEP_INTERVAL_MS);
|
|
644
|
-
// Allow the process to exit even if the timer is running
|
|
645
|
-
if (sweepTimer && typeof sweepTimer === "object" && "unref" in sweepTimer) {
|
|
646
|
-
(sweepTimer as NodeJS.Timeout).unref();
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
export function stopLeaseSweepTimer(): void {
|
|
651
|
-
if (sweepTimer) {
|
|
652
|
-
clearInterval(sweepTimer);
|
|
653
|
-
sweepTimer = null;
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
/**
|
|
658
|
-
* Reset all state. For tests only.
|
|
659
|
-
*/
|
|
660
|
-
export function resetAccountConcurrencyForTests(): void {
|
|
661
|
-
stopLeaseSweepTimer();
|
|
662
|
-
for (const [, slot] of slots) {
|
|
663
|
-
for (const entry of slot.queue) {
|
|
664
|
-
cleanupEntry(entry);
|
|
665
|
-
entry.reject(new Error("Reset for tests"));
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
slots.clear();
|
|
669
|
-
temporaryBusyUntil.clear();
|
|
670
|
-
leaseCounter = 0;
|
|
671
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Per-account concurrency guard. Limits the number of simultaneous upstream
|
|
3
|
+
* streams per account and queues excess requests with FIFO ordering, timeout,
|
|
4
|
+
* and abort support.
|
|
5
|
+
*
|
|
6
|
+
* Includes stale-lease detection: if a lease is held longer than
|
|
7
|
+
* `concurrency.leaseMaxDurationMs` it is considered leaked and force-released.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { config } from "./config.ts";
|
|
11
|
+
import { logger } from "./logger.ts";
|
|
12
|
+
import { getStream } from "./stream-registry.ts";
|
|
13
|
+
|
|
14
|
+
export interface AccountLease {
|
|
15
|
+
accountId: string;
|
|
16
|
+
/** Unique id for correlating lease acquire/release in logs. */
|
|
17
|
+
leaseId: string;
|
|
18
|
+
release(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AcquireAccountLeaseOptions {
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
/**
|
|
24
|
+
* Deadline for hanging on a free slot.
|
|
25
|
+
* - `number`: reject with `account_busy` after this many ms.
|
|
26
|
+
* - `null`: wait without a deadline until a slot frees or `signal` aborts.
|
|
27
|
+
* - `undefined`: fall back to `concurrency.busyWaitMs`.
|
|
28
|
+
*/
|
|
29
|
+
timeoutMs?: number | null;
|
|
30
|
+
/** Human-readable label for diagnostics (e.g. reqId or chat session). */
|
|
31
|
+
label?: string;
|
|
32
|
+
/** AbortController registered with the lease; aborted on session-level replacement. */
|
|
33
|
+
leaseAbortController?: AbortController;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ActiveLeaseInfo {
|
|
37
|
+
leaseId: string;
|
|
38
|
+
acquiredAt: number;
|
|
39
|
+
label: string;
|
|
40
|
+
/** Aborted when a same-session retry supersedes this lease. */
|
|
41
|
+
abortController?: AbortController;
|
|
42
|
+
/** Set once the stream is acquired; used by the emit-aware supersede. */
|
|
43
|
+
completionId?: string;
|
|
44
|
+
/** Parallel-escape lease (own chat): excluded from the unemitted check. */
|
|
45
|
+
parallelEscape?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface QueueEntry {
|
|
49
|
+
resolve: (lease: AccountLease) => void;
|
|
50
|
+
reject: (err: Error) => void;
|
|
51
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
52
|
+
onAbort: (() => void) | null;
|
|
53
|
+
signal: AbortSignal | null;
|
|
54
|
+
enqueuedAt: number;
|
|
55
|
+
/** Preserved so the lease granted from the queue keeps the session label. */
|
|
56
|
+
label: string;
|
|
57
|
+
/** Preserved so latest-wins can abort queued-then-granted leases. */
|
|
58
|
+
leaseAbortController?: AbortController;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface AccountSlot {
|
|
62
|
+
activeLeases: ActiveLeaseInfo[];
|
|
63
|
+
queue: QueueEntry[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const slots = new Map<string, AccountSlot>();
|
|
67
|
+
let leaseCounter = 0;
|
|
68
|
+
|
|
69
|
+
function generateLeaseId(): string {
|
|
70
|
+
return `lease_${(++leaseCounter).toString(36)}_${Date.now().toString(36)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getSlot(accountId: string): AccountSlot {
|
|
74
|
+
let slot = slots.get(accountId);
|
|
75
|
+
if (!slot) {
|
|
76
|
+
slot = { activeLeases: [], queue: [] };
|
|
77
|
+
slots.set(accountId, slot);
|
|
78
|
+
}
|
|
79
|
+
return slot;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Format active lease holders for log output. */
|
|
83
|
+
function formatHolders(slot: AccountSlot): string {
|
|
84
|
+
if (slot.activeLeases.length === 0) return "none";
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
return slot.activeLeases
|
|
87
|
+
.map((l) => `${l.label || "unknown"}(held ${Math.round((now - l.acquiredAt) / 1000)}s)`)
|
|
88
|
+
.join(", ");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* True when the account slot is at capacity AND every active lease belongs to
|
|
93
|
+
* a session other than `label`.
|
|
94
|
+
*
|
|
95
|
+
* Used to decide how long a request may wait for the slot: when ANOTHER
|
|
96
|
+
* session is generating on this account (not our own thread), waiting long is
|
|
97
|
+
* wasted latency — the other session may hold the slot for minutes. The
|
|
98
|
+
* request should fail fast with `account_busy` (short wait) and rotate to a
|
|
99
|
+
* different account instead of queueing for the full `queueWaitForeverCapMs`.
|
|
100
|
+
*/
|
|
101
|
+
export function isAccountSlotHeldByOtherSession(
|
|
102
|
+
accountId: string,
|
|
103
|
+
label: string | null | undefined,
|
|
104
|
+
): boolean {
|
|
105
|
+
if (!label) return false;
|
|
106
|
+
const slot = slots.get(accountId);
|
|
107
|
+
if (!slot) return false;
|
|
108
|
+
if (slot.activeLeases.length === 0) return false;
|
|
109
|
+
if (slot.activeLeases.length < config.concurrency.maxStreamsPerAccount) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return slot.activeLeases.every((l) => l.label !== label);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function createLease(
|
|
116
|
+
accountId: string,
|
|
117
|
+
label: string,
|
|
118
|
+
abortController?: AbortController,
|
|
119
|
+
parallelEscape?: boolean,
|
|
120
|
+
): AccountLease {
|
|
121
|
+
const leaseId = generateLeaseId();
|
|
122
|
+
const slot = getSlot(accountId);
|
|
123
|
+
const info: ActiveLeaseInfo = {
|
|
124
|
+
leaseId,
|
|
125
|
+
acquiredAt: Date.now(),
|
|
126
|
+
label,
|
|
127
|
+
abortController,
|
|
128
|
+
parallelEscape,
|
|
129
|
+
};
|
|
130
|
+
slot.activeLeases.push(info);
|
|
131
|
+
logger.debug("[concurrency] lease acquired", {
|
|
132
|
+
accountId,
|
|
133
|
+
leaseId,
|
|
134
|
+
label,
|
|
135
|
+
parallelEscape: !!parallelEscape,
|
|
136
|
+
activeLeases: slot.activeLeases.length,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
let released = false;
|
|
140
|
+
return {
|
|
141
|
+
accountId,
|
|
142
|
+
leaseId,
|
|
143
|
+
release() {
|
|
144
|
+
if (released) return;
|
|
145
|
+
released = true;
|
|
146
|
+
const idx = slot.activeLeases.findIndex((l) => l.leaseId === leaseId);
|
|
147
|
+
if (idx !== -1) slot.activeLeases.splice(idx, 1);
|
|
148
|
+
const heldMs = Date.now() - info.acquiredAt;
|
|
149
|
+
logger.debug("[concurrency] lease released", {
|
|
150
|
+
accountId,
|
|
151
|
+
leaseId,
|
|
152
|
+
label,
|
|
153
|
+
heldMs,
|
|
154
|
+
});
|
|
155
|
+
if (logger.isLevelEnabled("info")) {
|
|
156
|
+
console.log(
|
|
157
|
+
`🚦 [Server] Stream slot released | account=${accountId} | held ${heldMs}ms | label=${label} | leaseId=${leaseId.substring(0, 8)}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
releaseSlot(accountId);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function releaseSlot(accountId: string): void {
|
|
166
|
+
const slot = slots.get(accountId);
|
|
167
|
+
if (!slot) return;
|
|
168
|
+
|
|
169
|
+
// Deliver to the next waiter in FIFO order
|
|
170
|
+
while (slot.queue.length > 0 && slot.activeLeases.length < config.concurrency.maxStreamsPerAccount) {
|
|
171
|
+
const entry = slot.queue.shift();
|
|
172
|
+
if (!entry) break;
|
|
173
|
+
cleanupEntry(entry);
|
|
174
|
+
if (logger.isLevelEnabled("info")) {
|
|
175
|
+
console.log(
|
|
176
|
+
`🚦 [Server] Stream slot granted | account=${accountId} | waited ${Date.now() - entry.enqueuedAt}ms | ${slot.queue.length} still queued`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
entry.resolve(createLease(accountId, entry.label, entry.leaseAbortController));
|
|
180
|
+
return; // one at a time to preserve ordering
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Clean up empty slots to avoid unbounded map growth
|
|
184
|
+
if (slot.activeLeases.length === 0 && slot.queue.length === 0) {
|
|
185
|
+
slots.delete(accountId);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function cleanupEntry(entry: QueueEntry): void {
|
|
190
|
+
if (entry.timer) {
|
|
191
|
+
clearTimeout(entry.timer);
|
|
192
|
+
entry.timer = null;
|
|
193
|
+
}
|
|
194
|
+
if (entry.onAbort && entry.signal) {
|
|
195
|
+
entry.signal.removeEventListener("abort", entry.onAbort);
|
|
196
|
+
entry.onAbort = null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Check for and force-release stale leases that exceeded the max duration.
|
|
202
|
+
* Returns the number of leases force-released.
|
|
203
|
+
*/
|
|
204
|
+
function sweepStaleLeases(accountId: string): number {
|
|
205
|
+
const slot = slots.get(accountId);
|
|
206
|
+
if (!slot) return 0;
|
|
207
|
+
|
|
208
|
+
const maxDuration = config.concurrency.leaseMaxDurationMs;
|
|
209
|
+
if (maxDuration <= 0) return 0;
|
|
210
|
+
|
|
211
|
+
const now = Date.now();
|
|
212
|
+
let swept = 0;
|
|
213
|
+
|
|
214
|
+
for (let i = slot.activeLeases.length - 1; i >= 0; i--) {
|
|
215
|
+
const lease = slot.activeLeases[i];
|
|
216
|
+
const heldMs = now - lease.acquiredAt;
|
|
217
|
+
if (heldMs > maxDuration) {
|
|
218
|
+
slot.activeLeases.splice(i, 1);
|
|
219
|
+
swept++;
|
|
220
|
+
console.warn(
|
|
221
|
+
`⚠️ [Server] Stale lease force-released | account=${accountId} | label=${lease.label} | held ${Math.round(heldMs / 1000)}s (limit: ${Math.round(maxDuration / 1000)}s) | leaseId=${lease.leaseId}`,
|
|
222
|
+
);
|
|
223
|
+
logger.warn("[concurrency] stale lease force-released (possible leak)", {
|
|
224
|
+
accountId,
|
|
225
|
+
leaseId: lease.leaseId,
|
|
226
|
+
label: lease.label,
|
|
227
|
+
heldMs,
|
|
228
|
+
maxDurationMs: maxDuration,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// If we swept any, try to deliver to waiters
|
|
234
|
+
if (swept > 0) {
|
|
235
|
+
releaseSlot(accountId);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return swept;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Try to acquire a lease without waiting. Returns null if the account is at
|
|
243
|
+
* capacity.
|
|
244
|
+
*/
|
|
245
|
+
export function tryAcquireAccountLease(
|
|
246
|
+
accountId: string,
|
|
247
|
+
label?: string,
|
|
248
|
+
leaseAbortController?: AbortController,
|
|
249
|
+
parallelEscape?: boolean,
|
|
250
|
+
): AccountLease | null {
|
|
251
|
+
const slot = getSlot(accountId);
|
|
252
|
+
sweepStaleLeases(accountId);
|
|
253
|
+
if (slot.activeLeases.length < config.concurrency.maxStreamsPerAccount) {
|
|
254
|
+
return createLease(
|
|
255
|
+
accountId,
|
|
256
|
+
label ?? "try-acquire",
|
|
257
|
+
leaseAbortController,
|
|
258
|
+
parallelEscape,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Acquire a lease, waiting in FIFO order if the account is at capacity.
|
|
266
|
+
* Rejects on timeout or abort.
|
|
267
|
+
*/
|
|
268
|
+
export function acquireAccountLease(
|
|
269
|
+
accountId: string,
|
|
270
|
+
options?: AcquireAccountLeaseOptions,
|
|
271
|
+
): Promise<AccountLease> {
|
|
272
|
+
const signal = options?.signal ?? null;
|
|
273
|
+
const label = options?.label ?? "unlabeled";
|
|
274
|
+
|
|
275
|
+
// Already aborted — reject before touching any slot
|
|
276
|
+
if (signal?.aborted) {
|
|
277
|
+
return Promise.reject(new Error("Aborted before acquiring account lease"));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const slot = getSlot(accountId);
|
|
281
|
+
|
|
282
|
+
// Sweep stale leases before checking capacity — a leaked lease should not
|
|
283
|
+
// block new requests indefinitely.
|
|
284
|
+
sweepStaleLeases(accountId);
|
|
285
|
+
|
|
286
|
+
// A NORMAL request never queues behind an auxiliary (parallel-escape) lease:
|
|
287
|
+
// the disposable title must yield its slot immediately. Belt-and-suspenders
|
|
288
|
+
// on top of the emit-aware supersede (which kills parallel leases even before
|
|
289
|
+
// they emit) — covers the case where the supersede could not find the lease.
|
|
290
|
+
if (
|
|
291
|
+
slot.activeLeases.length > 0 &&
|
|
292
|
+
slot.activeLeases.every((l) => l.parallelEscape)
|
|
293
|
+
) {
|
|
294
|
+
for (const lease of [...slot.activeLeases]) {
|
|
295
|
+
logger.debug("[concurrency] preempting parallel-escape lease", {
|
|
296
|
+
accountId,
|
|
297
|
+
leaseId: lease.leaseId,
|
|
298
|
+
label: lease.label,
|
|
299
|
+
});
|
|
300
|
+
lease.abortController?.abort();
|
|
301
|
+
const i = slot.activeLeases.indexOf(lease);
|
|
302
|
+
if (i !== -1) slot.activeLeases.splice(i, 1);
|
|
303
|
+
}
|
|
304
|
+
releaseSlot(accountId); // deliver to the earliest waiter, if any
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Fast path: capacity available
|
|
308
|
+
if (slot.activeLeases.length < config.concurrency.maxStreamsPerAccount) {
|
|
309
|
+
return Promise.resolve(createLease(accountId, label, options?.leaseAbortController));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const hasExplicitTimeout =
|
|
313
|
+
options != null &&
|
|
314
|
+
Object.prototype.hasOwnProperty.call(options, "timeoutMs");
|
|
315
|
+
const timeoutMs = hasExplicitTimeout
|
|
316
|
+
? options?.timeoutMs
|
|
317
|
+
: config.concurrency.busyWaitMs;
|
|
318
|
+
|
|
319
|
+
return new Promise<AccountLease>((resolve, reject) => {
|
|
320
|
+
const entry: QueueEntry = {
|
|
321
|
+
resolve,
|
|
322
|
+
reject,
|
|
323
|
+
timer: null,
|
|
324
|
+
onAbort: null,
|
|
325
|
+
signal,
|
|
326
|
+
enqueuedAt: Date.now(),
|
|
327
|
+
label,
|
|
328
|
+
leaseAbortController: options?.leaseAbortController,
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const removeSelf = () => {
|
|
332
|
+
const idx = slot.queue.indexOf(entry);
|
|
333
|
+
if (idx !== -1) slot.queue.splice(idx, 1);
|
|
334
|
+
cleanupEntry(entry);
|
|
335
|
+
if (slot.activeLeases.length === 0 && slot.queue.length === 0) {
|
|
336
|
+
slots.delete(accountId);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// Timeout — only when the caller supplied a finite deadline. `null` means
|
|
341
|
+
// "wait as long as it takes" (bounded by the abort signal), which is what
|
|
342
|
+
// the last-usable account or the thread owner must do to stay lossless.
|
|
343
|
+
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
344
|
+
entry.timer = setTimeout(() => {
|
|
345
|
+
removeSelf();
|
|
346
|
+
console.log(
|
|
347
|
+
`🚦 [Server] Stream wait timeout | account=${accountId} | waited ${timeoutMs}ms | ${slot.queue.length} still queued`,
|
|
348
|
+
);
|
|
349
|
+
const busyError = new Error(
|
|
350
|
+
`Account ${accountId} busy: timed out after ${timeoutMs}ms waiting for a free slot`,
|
|
351
|
+
) as Error & { code?: string };
|
|
352
|
+
busyError.code = "account_busy";
|
|
353
|
+
reject(busyError);
|
|
354
|
+
}, timeoutMs);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Abort signal
|
|
358
|
+
if (signal) {
|
|
359
|
+
entry.onAbort = () => {
|
|
360
|
+
removeSelf();
|
|
361
|
+
if (logger.isLevelEnabled("info")) {
|
|
362
|
+
console.log(
|
|
363
|
+
`🚦 [Server] Stream waiter aborted | account=${accountId} | waited ${Date.now() - entry.enqueuedAt}ms | ${slot.queue.length} still queued`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
reject(new Error("Aborted while waiting for account lease"));
|
|
367
|
+
};
|
|
368
|
+
signal.addEventListener("abort", entry.onAbort, { once: true });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
slot.queue.push(entry);
|
|
372
|
+
|
|
373
|
+
const holders = formatHolders(slot);
|
|
374
|
+
if (logger.isLevelEnabled("info")) {
|
|
375
|
+
console.log(
|
|
376
|
+
`🚦 [Server] Stream slot busy | account=${accountId} | held by: ${holders} | queued at position ${slot.queue.length} | timeout=${typeof timeoutMs === "number" && Number.isFinite(timeoutMs) ? `${timeoutMs}ms` : "unbounded"}`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
logger.debug("[concurrency] queued for account lease", {
|
|
381
|
+
accountId,
|
|
382
|
+
activeLeases: slot.activeLeases.length,
|
|
383
|
+
holders: slot.activeLeases.map((l) => ({ label: l.label, heldMs: Date.now() - l.acquiredAt })),
|
|
384
|
+
queueLength: slot.queue.length,
|
|
385
|
+
timeoutMs,
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Abort and remove the active lease matching the given label (session id).
|
|
392
|
+
* Used when a client retries the same session: the old generation is no longer
|
|
393
|
+
* needed, so we abort it and free the slot for the new request.
|
|
394
|
+
*
|
|
395
|
+
* With `onlyIfEmitted`, a lease whose stream has NOT yet emitted a chunk to the
|
|
396
|
+
* client is PROTECTED: killing it would waste a generation the client has not
|
|
397
|
+
* consumed (a parallel title request racing the main stream, or a stream still
|
|
398
|
+
* mid-creation). Leases without a completionId (stream not acquired yet) are
|
|
399
|
+
* protected too. Once the stream emits, latest-wins applies (tool loop).
|
|
400
|
+
* Returns true if a lease was aborted, false otherwise.
|
|
401
|
+
*/
|
|
402
|
+
export function abortLeaseByLabel(
|
|
403
|
+
accountId: string,
|
|
404
|
+
label: string,
|
|
405
|
+
opts?: { onlyIfEmitted?: boolean },
|
|
406
|
+
): boolean {
|
|
407
|
+
const slot = slots.get(accountId);
|
|
408
|
+
if (!slot) return false;
|
|
409
|
+
|
|
410
|
+
const idx = slot.activeLeases.findIndex((l) => l.label === label);
|
|
411
|
+
if (idx === -1) return false;
|
|
412
|
+
|
|
413
|
+
const lease = slot.activeLeases[idx];
|
|
414
|
+
if (opts?.onlyIfEmitted) {
|
|
415
|
+
// A parallel-escape lease (auxiliary title on its own chat) is DISPOSABLE:
|
|
416
|
+
// any normal request of the same session may preempt it, even before it
|
|
417
|
+
// emits — the main conversation must never block on the title's slot.
|
|
418
|
+
if (!lease.parallelEscape) {
|
|
419
|
+
const stream = lease.completionId
|
|
420
|
+
? getStream(lease.completionId)
|
|
421
|
+
: undefined;
|
|
422
|
+
if (!stream?.emittedChunk) {
|
|
423
|
+
// Unemitted (still thinking / mid-creation): protect the generation.
|
|
424
|
+
if (logger.isLevelEnabled("info")) {
|
|
425
|
+
console.log(
|
|
426
|
+
`🛡️ [Server] Supersede skipped (protected unemitted) | account=${accountId} | label=${label} | leaseId=${lease.leaseId} | completionId=${lease.completionId ?? "none"} | emitted=${stream?.emittedChunk ?? false}`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const heldMs = Date.now() - lease.acquiredAt;
|
|
435
|
+
slot.activeLeases.splice(idx, 1);
|
|
436
|
+
|
|
437
|
+
if (logger.isLevelEnabled("info")) {
|
|
438
|
+
console.log(
|
|
439
|
+
`🔄 [Server] Session retry supersedes active lease | account=${accountId} | label=${label} | held ${Math.round(heldMs / 1000)}s | leaseId=${lease.leaseId}`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
logger.info("[concurrency] session retry superseded active lease", {
|
|
443
|
+
accountId,
|
|
444
|
+
label,
|
|
445
|
+
heldMs,
|
|
446
|
+
leaseId: lease.leaseId,
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// Abort the stream associated with the old lease
|
|
450
|
+
if (lease.abortController) {
|
|
451
|
+
lease.abortController.abort();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Free the slot for the next waiter
|
|
455
|
+
releaseSlot(accountId);
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Abort any active lease matching the given session label ACROSS ALL accounts.
|
|
461
|
+
* Used BEFORE the per-chat lock: the client can fire the next turn while the
|
|
462
|
+
* previous stream is still open (streaming tool calls). Killing the stale
|
|
463
|
+
* generation first lets the new request take the chat lock immediately instead
|
|
464
|
+
* of waiting for the stale-lease sweep (600s) when the old upstream stalled.
|
|
465
|
+
* Returns true if at least one lease was aborted.
|
|
466
|
+
*/
|
|
467
|
+
export function abortLeaseBySessionLabel(
|
|
468
|
+
label: string,
|
|
469
|
+
opts?: { onlyIfEmitted?: boolean },
|
|
470
|
+
): boolean {
|
|
471
|
+
let aborted = false;
|
|
472
|
+
for (const accountId of slots.keys()) {
|
|
473
|
+
if (abortLeaseByLabel(accountId, label, opts)) aborted = true;
|
|
474
|
+
}
|
|
475
|
+
return aborted;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* True when the session has an ACTIVE lease whose stream has not emitted a
|
|
480
|
+
* chunk yet (still thinking / mid-creation). A parallel request (title) must
|
|
481
|
+
* not kill it, and must run on its own chat instead of waiting.
|
|
482
|
+
*/
|
|
483
|
+
export function hasUnemittedSessionStream(label: string): boolean {
|
|
484
|
+
for (const slot of slots.values()) {
|
|
485
|
+
for (const lease of slot.activeLeases) {
|
|
486
|
+
if (lease.label !== label) continue;
|
|
487
|
+
// A parallel-escape lease runs on its OWN chat — it must not poison the
|
|
488
|
+
// check and cascade every following turn into a new chat too.
|
|
489
|
+
if (lease.parallelEscape) continue;
|
|
490
|
+
const stream = lease.completionId
|
|
491
|
+
? getStream(lease.completionId)
|
|
492
|
+
: undefined;
|
|
493
|
+
if (!stream?.emittedChunk) {
|
|
494
|
+
logger.debug("[concurrency] unemitted session stream found", {
|
|
495
|
+
label,
|
|
496
|
+
leaseId: lease.leaseId,
|
|
497
|
+
completionId: lease.completionId ?? null,
|
|
498
|
+
});
|
|
499
|
+
return true;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Attach the acquired stream's completion id to a lease so the emit-aware
|
|
508
|
+
* supersede can check whether the stream reached the client. Called by the
|
|
509
|
+
* stream owner right after createQwenStream succeeds.
|
|
510
|
+
*/
|
|
511
|
+
export function markLeaseCompletion(
|
|
512
|
+
accountId: string,
|
|
513
|
+
leaseId: string,
|
|
514
|
+
completionId: string,
|
|
515
|
+
): void {
|
|
516
|
+
const slot = slots.get(accountId);
|
|
517
|
+
const lease = slot?.activeLeases.find((l) => l.leaseId === leaseId);
|
|
518
|
+
if (lease) {
|
|
519
|
+
lease.completionId = completionId;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Check whether an account currently has no free slots.
|
|
525
|
+
*/
|
|
526
|
+
export function isAccountBusy(accountId: string): boolean {
|
|
527
|
+
const slot = slots.get(accountId);
|
|
528
|
+
if (!slot) return false;
|
|
529
|
+
return slot.activeLeases.length >= config.concurrency.maxStreamsPerAccount;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Check whether an account is currently serving at least one stream.
|
|
534
|
+
* Different question from isAccountBusy: that one answers "are all slots
|
|
535
|
+
* taken", while callers that must not disturb a generation in flight care
|
|
536
|
+
* about a single active lease even when spare slots remain.
|
|
537
|
+
*/
|
|
538
|
+
export function hasActiveAccountLease(accountId: string): boolean {
|
|
539
|
+
const slot = slots.get(accountId);
|
|
540
|
+
if (!slot) return false;
|
|
541
|
+
return slot.activeLeases.length > 0;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Mark an account as temporarily busy (e.g. after chat_in_progress).
|
|
546
|
+
* This is a lightweight hint that prevents immediate re-selection without
|
|
547
|
+
* consuming a lease slot.
|
|
548
|
+
*/
|
|
549
|
+
const temporaryBusyUntil = new Map<string, number>();
|
|
550
|
+
|
|
551
|
+
export function markAccountTemporarilyBusy(
|
|
552
|
+
accountId: string,
|
|
553
|
+
durationMs: number,
|
|
554
|
+
): number {
|
|
555
|
+
const requestedUntil = Date.now() + Math.max(0, durationMs);
|
|
556
|
+
const currentUntil = temporaryBusyUntil.get(accountId) ?? 0;
|
|
557
|
+
const effectiveUntil = Math.max(currentUntil, requestedUntil);
|
|
558
|
+
temporaryBusyUntil.set(accountId, effectiveUntil);
|
|
559
|
+
return effectiveUntil;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export function isAccountTemporarilyBusy(accountId: string): boolean {
|
|
563
|
+
const until = temporaryBusyUntil.get(accountId);
|
|
564
|
+
if (!until) return false;
|
|
565
|
+
if (Date.now() >= until) {
|
|
566
|
+
temporaryBusyUntil.delete(accountId);
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export function clearTemporaryBusy(
|
|
573
|
+
accountId: string,
|
|
574
|
+
expectedUntil?: number,
|
|
575
|
+
): void {
|
|
576
|
+
if (
|
|
577
|
+
expectedUntil !== undefined &&
|
|
578
|
+
temporaryBusyUntil.get(accountId) !== expectedUntil
|
|
579
|
+
) {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
temporaryBusyUntil.delete(accountId);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Snapshot for logging/metrics.
|
|
587
|
+
*/
|
|
588
|
+
export function getAccountConcurrencySnapshot(): Array<{
|
|
589
|
+
accountId: string;
|
|
590
|
+
active: number;
|
|
591
|
+
waiting: number;
|
|
592
|
+
limit: number;
|
|
593
|
+
holders: Array<{ label: string; heldMs: number }>;
|
|
594
|
+
}> {
|
|
595
|
+
const result: Array<{
|
|
596
|
+
accountId: string;
|
|
597
|
+
active: number;
|
|
598
|
+
waiting: number;
|
|
599
|
+
limit: number;
|
|
600
|
+
holders: Array<{ label: string; heldMs: number }>;
|
|
601
|
+
}> = [];
|
|
602
|
+
const now = Date.now();
|
|
603
|
+
for (const [accountId, slot] of slots) {
|
|
604
|
+
result.push({
|
|
605
|
+
accountId,
|
|
606
|
+
active: slot.activeLeases.length,
|
|
607
|
+
waiting: slot.queue.length,
|
|
608
|
+
limit: config.concurrency.maxStreamsPerAccount,
|
|
609
|
+
holders: slot.activeLeases.map((l) => ({
|
|
610
|
+
label: l.label,
|
|
611
|
+
heldMs: now - l.acquiredAt,
|
|
612
|
+
})),
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
return result;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Sweep all accounts for stale leases. Call periodically (e.g. from a timer
|
|
620
|
+
* or before each request batch) to catch leaked leases proactively.
|
|
621
|
+
*/
|
|
622
|
+
export function sweepAllStaleLeases(): number {
|
|
623
|
+
let total = 0;
|
|
624
|
+
for (const accountId of slots.keys()) {
|
|
625
|
+
total += sweepStaleLeases(accountId);
|
|
626
|
+
}
|
|
627
|
+
return total;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Periodic stale-lease sweep: runs every 30s to catch leaked leases even when
|
|
631
|
+
// no new requests arrive. This is the safety net that prevents "slot busy
|
|
632
|
+
// forever" from a single missed release() call.
|
|
633
|
+
const SWEEP_INTERVAL_MS = 30_000;
|
|
634
|
+
let sweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
635
|
+
|
|
636
|
+
export function startLeaseSweepTimer(): void {
|
|
637
|
+
if (sweepTimer) return;
|
|
638
|
+
sweepTimer = setInterval(() => {
|
|
639
|
+
const swept = sweepAllStaleLeases();
|
|
640
|
+
if (swept > 0) {
|
|
641
|
+
logger.info("[concurrency] periodic stale lease sweep", { swept });
|
|
642
|
+
}
|
|
643
|
+
}, SWEEP_INTERVAL_MS);
|
|
644
|
+
// Allow the process to exit even if the timer is running
|
|
645
|
+
if (sweepTimer && typeof sweepTimer === "object" && "unref" in sweepTimer) {
|
|
646
|
+
(sweepTimer as NodeJS.Timeout).unref();
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export function stopLeaseSweepTimer(): void {
|
|
651
|
+
if (sweepTimer) {
|
|
652
|
+
clearInterval(sweepTimer);
|
|
653
|
+
sweepTimer = null;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Reset all state. For tests only.
|
|
659
|
+
*/
|
|
660
|
+
export function resetAccountConcurrencyForTests(): void {
|
|
661
|
+
stopLeaseSweepTimer();
|
|
662
|
+
for (const [, slot] of slots) {
|
|
663
|
+
for (const entry of slot.queue) {
|
|
664
|
+
cleanupEntry(entry);
|
|
665
|
+
entry.reject(new Error("Reset for tests"));
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
slots.clear();
|
|
669
|
+
temporaryBusyUntil.clear();
|
|
670
|
+
leaseCounter = 0;
|
|
671
|
+
}
|