@cjhyy/code-shell-chat 0.9.46 → 0.9.47

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.
@@ -7,6 +7,13 @@ export interface DeliveryQueueConfig {
7
7
  retryBaseMs: number;
8
8
  retryMaxMs: number;
9
9
  completedTtlMs: number;
10
+ /**
11
+ * Failed attempts during which a retrying record keeps later same-target
12
+ * messages waiting. Past this, it keeps retrying without holding its target,
13
+ * so one message that always fails cannot block a conversation forever.
14
+ * Defaults to {@link DEFAULT_RETRY_HOLD_ATTEMPTS}.
15
+ */
16
+ retryHoldAttempts?: number;
10
17
  /**
11
18
  * Directory for spooled attachment bytes. Defaults to `<path>.attachments`.
12
19
  *
@@ -21,6 +28,8 @@ export interface DeliveryQueueConfig {
21
28
  /** Largest single attachment to spool. Bigger ones fall back to memory-only. */
22
29
  maxSpooledAttachmentBytes?: number;
23
30
  }
31
+ /** With default backoff (1s doubling to 30s) this holds a target for about two minutes. */
32
+ export declare const DEFAULT_RETRY_HOLD_ATTEMPTS = 8;
24
33
  export interface DeliveryQueueStatus {
25
34
  pending: number;
26
35
  inFlight: number;
@@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
3
  import { chmod, lstat, mkdir, open, readdir, rename, rm, writeFile } from "node:fs/promises";
4
4
  import { dirname } from "node:path";
5
+ /** With default backoff (1s doubling to 30s) this holds a target for about two minutes. */
6
+ export const DEFAULT_RETRY_HOLD_ATTEMPTS = 8;
5
7
  const MAX_DELIVERY_STATE_BYTES = 64 * 1024 * 1024;
6
8
  const MAX_COMPLETED_KEYS = 100_000;
7
9
  const MAX_SPOOLED_ATTACHMENTS = 32;
@@ -69,6 +71,11 @@ export class DeliveryQueue {
69
71
  */
70
72
  async stop() {
71
73
  this.stopped = true;
74
+ // A duplicate must not be acknowledged while the original is still loading
75
+ // attachments. Wake those waiters without waiting on unbounded media I/O.
76
+ for (const preparation of this.preparing.values()) {
77
+ preparation.reject(new Error("Chat Gateway inbox is stopped"));
78
+ }
72
79
  if (this.retryTimer)
73
80
  clearTimeout(this.retryTimer);
74
81
  this.retryTimer = undefined;
@@ -99,6 +106,8 @@ export class DeliveryQueue {
99
106
  let previousPreparation;
100
107
  let preparation;
101
108
  let releasePreparation;
109
+ let acceptance;
110
+ let duplicateAcceptance;
102
111
  const reserved = await this.withMutation(async () => {
103
112
  if (this.stopped)
104
113
  throw new Error("Chat Gateway inbox is stopped");
@@ -108,14 +117,29 @@ export class DeliveryQueue {
108
117
  // Completed keys persisted before keys named the account cannot say
109
118
  // which account accepted them; stay conservative until they expire.
110
119
  (legacyDedupeKey !== undefined && this.completed.has(legacyDedupeKey)) ||
111
- this.pending.some((entry) => entry.dedupeKey === dedupeKey) ||
112
- [...this.preparing.values()].some((candidate) => candidate === dedupeKey))) {
120
+ this.pending.some((entry) => entry.dedupeKey === dedupeKey))) {
113
121
  return false;
114
122
  }
123
+ if (dedupeKey) {
124
+ const duplicate = [...this.preparing.values()].find((candidate) => candidate.dedupeKey === dedupeKey);
125
+ if (duplicate) {
126
+ duplicateAcceptance = duplicate.accepted;
127
+ return false;
128
+ }
129
+ }
115
130
  if (this.pending.length + this.preparing.size >= this.config.maxPending) {
116
131
  throw new DeliveryBackpressureError(this.config.maxPending);
117
132
  }
118
- this.preparing.set(id, dedupeKey);
133
+ let resolve;
134
+ let reject;
135
+ const accepted = new Promise((yes, no) => {
136
+ resolve = yes;
137
+ reject = no;
138
+ });
139
+ // The original enqueue may have no duplicate waiting on this promise.
140
+ void accepted.catch(() => undefined);
141
+ acceptance = { dedupeKey, accepted, resolve, reject };
142
+ this.preparing.set(id, acceptance);
119
143
  previousPreparation = this.preparationByTarget.get(target);
120
144
  preparation = new Promise((resolve) => {
121
145
  releasePreparation = resolve;
@@ -123,8 +147,11 @@ export class DeliveryQueue {
123
147
  this.preparationByTarget.set(target, preparation);
124
148
  return true;
125
149
  });
126
- if (!reserved)
150
+ if (!reserved) {
151
+ // Never hold the mutation lock while waiting for the original to persist.
152
+ await duplicateAcceptance;
127
153
  return "duplicate";
154
+ }
128
155
  let spooled;
129
156
  let record;
130
157
  try {
@@ -154,6 +181,7 @@ export class DeliveryQueue {
154
181
  this.pending.push(record);
155
182
  if (persistent)
156
183
  await this.persist();
184
+ acceptance.resolve();
157
185
  }
158
186
  catch (error) {
159
187
  if (record) {
@@ -169,6 +197,7 @@ export class DeliveryQueue {
169
197
  });
170
198
  }
171
199
  catch (error) {
200
+ acceptance.reject(error);
172
201
  this.preparing.delete(id);
173
202
  await this.discardSpool(record ?? {
174
203
  id,
@@ -200,11 +229,25 @@ export class DeliveryQueue {
200
229
  clearTimeout(this.retryTimer);
201
230
  this.retryTimer = undefined;
202
231
  const now = Date.now();
232
+ const holdAttempts = this.config.retryHoldAttempts ?? DEFAULT_RETRY_HOLD_ATTEMPTS;
203
233
  while (this.inFlight.size < this.config.maxConcurrent) {
234
+ const delayedTargets = new Set();
204
235
  const entry = this.pending.find((candidate) => {
205
- if (this.inFlight.has(candidate.id) || candidate.nextAttemptAt > now)
236
+ if (this.inFlight.has(candidate.id))
237
+ return false;
238
+ const key = targetKey(candidate.message);
239
+ if (delayedTargets.has(key))
206
240
  return false;
207
- return ((this.targetInFlight.get(targetKey(candidate.message)) ?? 0) < this.config.maxPerTarget);
241
+ if (candidate.nextAttemptAt > now) {
242
+ // Bounded hold: a record that keeps failing stops blocking its
243
+ // conversation but stays queued for its own retries.
244
+ if (candidate.attempts < holdAttempts)
245
+ delayedTargets.add(key);
246
+ return false;
247
+ }
248
+ // Already-running peers may share a target when configured, but a
249
+ // queued retry must not be overtaken by later messages for that target.
250
+ return (this.targetInFlight.get(key) ?? 0) < this.config.maxPerTarget;
208
251
  });
209
252
  if (!entry)
210
253
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-chat",
3
- "version": "0.9.46",
3
+ "version": "0.9.47",
4
4
  "description": "A standalone, transport-agnostic multi-channel chat gateway for Node.js and Bun.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",