@gobing-ai/ts-infra 0.5.4 → 0.5.5

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.
@@ -1 +1 @@
1
- {"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"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"}
@@ -20,6 +20,13 @@ export class APIError extends Error {
20
20
  this.name = 'APIError';
21
21
  }
22
22
  }
23
+ /** Task 0086 R17: upper bound for error bodies kept on APIError. */
24
+ const MAX_ERROR_BODY = 4096;
25
+ function truncateBody(text) {
26
+ return text.length <= MAX_ERROR_BODY
27
+ ? text
28
+ : `${text.slice(0, MAX_ERROR_BODY)}…[truncated ${text.length - MAX_ERROR_BODY} chars]`;
29
+ }
23
30
  function sanitizeUrlForObservability(url) {
24
31
  try {
25
32
  const parsed = new URL(url);
@@ -165,7 +172,9 @@ export class APIClient {
165
172
  'http.request.method': method,
166
173
  'error.type': 'Timeout',
167
174
  });
168
- const timeoutError = new APIError(0, `Request timed out after ${timeoutMs}ms: ${method} ${url}`);
175
+ const timeoutError = new APIError(0,
176
+ // Task 0086 R17: observable URL only — the raw URL can carry query-string secrets.
177
+ `Request timed out after ${timeoutMs}ms: ${method} ${observableUrl}`);
169
178
  this.emitRequestError(method, observableUrl, errorForObservability(timeoutError));
170
179
  callerError = timeoutError;
171
180
  throw new Error(errorForObservability(timeoutError));
@@ -204,7 +213,9 @@ export class APIClient {
204
213
  'http.request.method': method,
205
214
  'error.type': `HTTP_${response.status}`,
206
215
  });
207
- throw new APIError(response.status, text);
216
+ // Task 0086 R17: bound the stored body — a huge HTML error
217
+ // page must not blow up logs or telemetry payloads.
218
+ throw new APIError(response.status, truncateBody(text));
208
219
  }
209
220
  const contentType = response.headers.get('content-type') ?? '';
210
221
  if (contentType.includes('application/json')) {
@@ -39,6 +39,8 @@ export declare class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
39
39
  private running;
40
40
  private inFlight;
41
41
  private pollPromise;
42
+ /** Task 0086 R6: set by stop() so an in-flight processOnce claims no further batches. */
43
+ private stopRequested;
42
44
  constructor(dao: QueueJobDao, config?: QueueConsumerConfig);
43
45
  register(type: string, handler: JobHandler<T>): void;
44
46
  start(): Promise<void>;
@@ -47,6 +49,8 @@ export declare class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
47
49
  stats(): Promise<QueueStats>;
48
50
  /** Process one batch immediately. Useful for tests, schedulers, and manual drains. */
49
51
  processOnce(): Promise<number>;
52
+ /** Run one claimed batch to settlement, tracking in-flight for drain. */
53
+ private runClaimed;
50
54
  private schedule;
51
55
  private poll;
52
56
  private processJob;
@@ -1 +1 @@
1
- {"version":3,"file":"db-job-queue.d.ts","sourceRoot":"","sources":["../../src/job-queue/db-job-queue.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAkB,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAChF,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AACvD,OAAO,KAAK,EACR,WAAW,EAKd,MAAM,WAAW,CAAC;AAWnB,OAAO,KAAK,EAAE,cAAc,EAAO,UAAU,EAAE,QAAQ,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAQ7G,kFAAkF;AAClF,qBAAa,UAAU,CAAC,CAAC,GAAG,OAAO,CAAE,YAAW,QAAQ,CAAC,CAAC,CAAC;IAEnD,QAAQ,CAAC,GAAG,EAAE,WAAW;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;gBADf,GAAG,EAAE,WAAW,EACR,MAAM,CAAC,EAAE,QAAQ,CAAC,WAAW,CAAC,YAAA;IAG7C,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC;IAO5E,YAAY,CAAC,IAAI,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,CAAC,CAAA;KAAE,GAAG,cAAc,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAY3F,KAAK,IAAI,OAAO,CAAC,UAAU,CAAC;CAGrC;AAcD,sGAAsG;AACtG,qBAAa,eAAe,CAAC,CAAC,GAAG,OAAO,CAAE,YAAW,aAAa,CAAC,CAAC,CAAC;IAyB7D,OAAO,CAAC,QAAQ,CAAC,GAAG;IAxBxB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAoC;IAC7D,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAoC;IAChE,kGAAkG;IAClG,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAgB;IACjD,wFAAwF;IACxF,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA2E;IAC1G;;;OAGG;IACH,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAgE;IAC1F,OAAO,CAAC,KAAK,CAA8C;IAC3D,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,QAAQ,CAAK;IACrB,OAAO,CAAC,WAAW,CAA8B;gBAG5B,GAAG,EAAE,WAAW,EACjC,MAAM,GAAE,mBAAwB;IAyBpC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,IAAI;IAI9C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAkBtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAsDrB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAQvC,KAAK,IAAI,OAAO,CAAC,UAAU,CAAC;IAIlC,sFAAsF;IAChF,WAAW,IAAI,OAAO,CAAC,MAAM,CAAC;IA4BpC,OAAO,CAAC,QAAQ;YAWF,IAAI;YAgBJ,UAAU;IAmHxB;;;;;OAKG;YACW,iBAAiB;YA2BjB,WAAW;CAsC5B"}
1
+ {"version":3,"file":"db-job-queue.d.ts","sourceRoot":"","sources":["../../src/job-queue/db-job-queue.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAkB,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAChF,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AACvD,OAAO,KAAK,EACR,WAAW,EAKd,MAAM,WAAW,CAAC;AAWnB,OAAO,KAAK,EAAE,cAAc,EAAO,UAAU,EAAE,QAAQ,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAQ7G,kFAAkF;AAClF,qBAAa,UAAU,CAAC,CAAC,GAAG,OAAO,CAAE,YAAW,QAAQ,CAAC,CAAC,CAAC;IAEnD,QAAQ,CAAC,GAAG,EAAE,WAAW;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;gBADf,GAAG,EAAE,WAAW,EACR,MAAM,CAAC,EAAE,QAAQ,CAAC,WAAW,CAAC,YAAA;IAG7C,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC;IAO5E,YAAY,CAAC,IAAI,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,CAAC,CAAA;KAAE,GAAG,cAAc,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAY3F,KAAK,IAAI,OAAO,CAAC,UAAU,CAAC;CAGrC;AAcD,sGAAsG;AACtG,qBAAa,eAAe,CAAC,CAAC,GAAG,OAAO,CAAE,YAAW,aAAa,CAAC,CAAC,CAAC;IA2B7D,OAAO,CAAC,QAAQ,CAAC,GAAG;IA1BxB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAoC;IAC7D,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAoC;IAChE,kGAAkG;IAClG,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAgB;IACjD,wFAAwF;IACxF,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA2E;IAC1G;;;OAGG;IACH,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAgE;IAC1F,OAAO,CAAC,KAAK,CAA8C;IAC3D,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,QAAQ,CAAK;IACrB,OAAO,CAAC,WAAW,CAA8B;IACjD,yFAAyF;IACzF,OAAO,CAAC,aAAa,CAAS;gBAGT,GAAG,EAAE,WAAW,EACjC,MAAM,GAAE,mBAAwB;IAyBpC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,IAAI;IAI9C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAmBtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAuDrB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAQvC,KAAK,IAAI,OAAO,CAAC,UAAU,CAAC;IAIlC,sFAAsF;IAChF,WAAW,IAAI,OAAO,CAAC,MAAM,CAAC;IA8BpC,yEAAyE;YAC3D,UAAU;IAcxB,OAAO,CAAC,QAAQ;YAWF,IAAI;YAgBJ,UAAU;IAsHxB;;;;;OAKG;YACW,iBAAiB;YA2BjB,WAAW;CAyC5B"}
@@ -77,6 +77,8 @@ export class DBQueueConsumer {
77
77
  running = false;
78
78
  inFlight = 0;
79
79
  pollPromise = null;
80
+ /** Task 0086 R6: set by stop() so an in-flight processOnce claims no further batches. */
81
+ stopRequested = false;
80
82
  constructor(dao, config = {}) {
81
83
  this.dao = dao;
82
84
  this.pollInterval = nonNegativeFiniteConfig('pollInterval', config.pollInterval ?? 1_000);
@@ -104,6 +106,7 @@ export class DBQueueConsumer {
104
106
  if (this.running)
105
107
  return;
106
108
  this.running = true;
109
+ this.stopRequested = false;
107
110
  this.schedule(0);
108
111
  const sink = this.eventSink;
109
112
  if (sink !== undefined) {
@@ -120,6 +123,7 @@ export class DBQueueConsumer {
120
123
  }
121
124
  async stop() {
122
125
  const wasRunning = this.running;
126
+ this.stopRequested = true;
123
127
  this.running = false;
124
128
  if (this.timer !== null) {
125
129
  clearTimeout(this.timer);
@@ -186,25 +190,42 @@ export class DBQueueConsumer {
186
190
  return traceAsync('queue.poll', async () => {
187
191
  await this.dao.resetStuckJobs(this.visibilityTimeout);
188
192
  await this.dao.failExpiredJobs();
189
- const jobs = await this.dao.claimReady(this.batchSize, { leaseMs: this.visibilityTimeout });
193
+ // Task 0086 R6: claim only as much as can still start. A stop()
194
+ // landing mid-cycle stops further claims; already-claimed attempts
195
+ // still settle. With defaults (maxConcurrency === batchSize) this is
196
+ // exactly one claim per cycle, the same as before.
197
+ let claimed = 0;
190
198
  let processed = 0;
191
- for (let index = 0; index < jobs.length; index += this.maxConcurrency) {
192
- const batch = jobs.slice(index, index + this.maxConcurrency);
193
- await Promise.all(batch.map(async (job) => {
194
- this.inFlight += 1;
195
- try {
196
- await this.processJob(job);
197
- processed += 1;
198
- }
199
- finally {
200
- this.inFlight -= 1;
201
- }
202
- }));
199
+ while (claimed < this.batchSize && !this.stopRequested) {
200
+ const requested = Math.min(this.maxConcurrency, this.batchSize - claimed);
201
+ const jobs = await this.dao.claimReady(requested, { leaseMs: this.visibilityTimeout });
202
+ // A short claim means the queue is drained: stop instead of
203
+ // re-arming jobs that were just retried within this cycle.
204
+ if (jobs.length < requested) {
205
+ claimed += jobs.length;
206
+ await this.runClaimed(jobs, () => (processed += 1));
207
+ break;
208
+ }
209
+ claimed += jobs.length;
210
+ await this.runClaimed(jobs, () => (processed += 1));
203
211
  }
204
- addSpanAttributes({ 'queue.claimed': jobs.length, 'queue.processed': processed });
212
+ addSpanAttributes({ 'queue.claimed': claimed, 'queue.processed': processed });
205
213
  return processed;
206
214
  });
207
215
  }
216
+ /** Run one claimed batch to settlement, tracking in-flight for drain. */
217
+ async runClaimed(jobs, onProcessed) {
218
+ await Promise.all(jobs.map(async (job) => {
219
+ this.inFlight += 1;
220
+ try {
221
+ await this.processJob(job);
222
+ onProcessed();
223
+ }
224
+ finally {
225
+ this.inFlight -= 1;
226
+ }
227
+ }));
228
+ }
208
229
  schedule(delay) {
209
230
  this.timer = setTimeout(() => {
210
231
  // Retained so stop() can await the cycle it interrupts; poll() contains its
@@ -325,7 +346,11 @@ export class DBQueueConsumer {
325
346
  }
326
347
  }
327
348
  else {
328
- await this.dao.markCompleted(record.id, token);
349
+ // Task 0086 R10: gate metric + event on the fenced write — a
350
+ // lost lease means another consumer owns the job now.
351
+ const applied = await this.dao.markCompleted(record.id, token);
352
+ if (!applied)
353
+ return;
329
354
  getQueueJobCompletedTotal().add(1, { type: job.type });
330
355
  const completed = {
331
356
  jobId: job.id,
@@ -381,7 +406,9 @@ export class DBQueueConsumer {
381
406
  const attempts = job.attempts + 1;
382
407
  const message = error instanceof Error ? error.message : String(error);
383
408
  if (attempts >= job.maxRetries) {
384
- await this.dao.markFailed(job.id, attempts, message, attemptToken);
409
+ const applied = await this.dao.markFailed(job.id, attempts, message, attemptToken);
410
+ if (!applied)
411
+ return; // Task 0086 R10: fenced write lost — no metric, no event.
385
412
  getQueueJobFailedTotal().add(1, { type: job.type });
386
413
  const failed = {
387
414
  jobId: job.id,
@@ -397,7 +424,10 @@ export class DBQueueConsumer {
397
424
  }
398
425
  const delay = Math.min(this.maxDelay, this.baseDelay * 2 ** Math.max(0, attempts - 1));
399
426
  const nextRetryAt = Date.now() + delay;
400
- await this.dao.markForRetry(job.id, attempts, message, nextRetryAt, attemptToken);
427
+ // Task 0086 R10: gate the retry event on the fenced write.
428
+ const retryApplied = await this.dao.markForRetry(job.id, attempts, message, nextRetryAt, attemptToken);
429
+ if (!retryApplied)
430
+ return;
401
431
  const retrying = {
402
432
  jobId: job.id,
403
433
  type: job.type,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-infra",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "@gobing-ai/ts-infra — Infrastructure backbone: event bus, job queue, scheduler, telemetry, API client, and logging.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -78,12 +78,12 @@
78
78
  "release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-infra-v<version> && git push --tags' && exit 1"
79
79
  },
80
80
  "dependencies": {
81
- "@gobing-ai/ts-utils": "^0.5.4",
81
+ "@gobing-ai/ts-utils": "^0.5.5",
82
82
  "@logtape/logtape": "^2.0.0"
83
83
  },
84
84
  "peerDependencies": {
85
- "@gobing-ai/ts-db": "^0.5.4",
86
- "@gobing-ai/ts-runtime": "^0.5.4",
85
+ "@gobing-ai/ts-db": "^0.5.5",
86
+ "@gobing-ai/ts-runtime": "^0.5.5",
87
87
  "@opentelemetry/api": "^1.9.0",
88
88
  "@opentelemetry/sdk-trace-node": "^2.0.0",
89
89
  "@opentelemetry/sdk-metrics": "^2.0.0",
@@ -116,8 +116,8 @@
116
116
  }
117
117
  },
118
118
  "devDependencies": {
119
- "@gobing-ai/ts-db": "^0.5.4",
120
- "@gobing-ai/ts-runtime": "^0.5.4",
119
+ "@gobing-ai/ts-db": "^0.5.5",
120
+ "@gobing-ai/ts-runtime": "^0.5.5",
121
121
  "@types/bun": "1.3.14",
122
122
  "@opentelemetry/api": "^1.9.0",
123
123
  "@opentelemetry/sdk-trace-node": "^2.0.0",
package/src/api-client.ts CHANGED
@@ -75,6 +75,15 @@ export class APIError extends Error {
75
75
  }
76
76
  }
77
77
 
78
+ /** Task 0086 R17: upper bound for error bodies kept on APIError. */
79
+ const MAX_ERROR_BODY = 4096;
80
+
81
+ function truncateBody(text: string): string {
82
+ return text.length <= MAX_ERROR_BODY
83
+ ? text
84
+ : `${text.slice(0, MAX_ERROR_BODY)}…[truncated ${text.length - MAX_ERROR_BODY} chars]`;
85
+ }
86
+
78
87
  function sanitizeUrlForObservability(url: string): string {
79
88
  try {
80
89
  const parsed = new URL(url);
@@ -250,7 +259,8 @@ export class APIClient {
250
259
  });
251
260
  const timeoutError = new APIError(
252
261
  0,
253
- `Request timed out after ${timeoutMs}ms: ${method} ${url}`,
262
+ // Task 0086 R17: observable URL only — the raw URL can carry query-string secrets.
263
+ `Request timed out after ${timeoutMs}ms: ${method} ${observableUrl}`,
254
264
  );
255
265
  this.emitRequestError(method, observableUrl, errorForObservability(timeoutError));
256
266
  callerError = timeoutError;
@@ -305,7 +315,9 @@ export class APIClient {
305
315
  'http.request.method': method,
306
316
  'error.type': `HTTP_${response.status}`,
307
317
  });
308
- throw new APIError(response.status, text);
318
+ // Task 0086 R17: bound the stored body — a huge HTML error
319
+ // page must not blow up logs or telemetry payloads.
320
+ throw new APIError(response.status, truncateBody(text));
309
321
  }
310
322
 
311
323
  const contentType = response.headers.get('content-type') ?? '';
@@ -92,6 +92,8 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
92
92
  private running = false;
93
93
  private inFlight = 0;
94
94
  private pollPromise: Promise<void> | null = null;
95
+ /** Task 0086 R6: set by stop() so an in-flight processOnce claims no further batches. */
96
+ private stopRequested = false;
95
97
 
96
98
  constructor(
97
99
  private readonly dao: QueueJobDao,
@@ -127,6 +129,7 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
127
129
  async start(): Promise<void> {
128
130
  if (this.running) return;
129
131
  this.running = true;
132
+ this.stopRequested = false;
130
133
  this.schedule(0);
131
134
  const sink = this.eventSink;
132
135
  if (sink !== undefined) {
@@ -144,6 +147,7 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
144
147
 
145
148
  async stop(): Promise<void> {
146
149
  const wasRunning = this.running;
150
+ this.stopRequested = true;
147
151
  this.running = false;
148
152
  if (this.timer !== null) {
149
153
  clearTimeout(this.timer);
@@ -214,29 +218,46 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
214
218
  await this.dao.resetStuckJobs(this.visibilityTimeout);
215
219
  await this.dao.failExpiredJobs();
216
220
 
217
- const jobs = await this.dao.claimReady(this.batchSize, { leaseMs: this.visibilityTimeout });
221
+ // Task 0086 R6: claim only as much as can still start. A stop()
222
+ // landing mid-cycle stops further claims; already-claimed attempts
223
+ // still settle. With defaults (maxConcurrency === batchSize) this is
224
+ // exactly one claim per cycle, the same as before.
225
+ let claimed = 0;
218
226
  let processed = 0;
219
-
220
- for (let index = 0; index < jobs.length; index += this.maxConcurrency) {
221
- const batch = jobs.slice(index, index + this.maxConcurrency);
222
- await Promise.all(
223
- batch.map(async (job) => {
224
- this.inFlight += 1;
225
- try {
226
- await this.processJob(job);
227
- processed += 1;
228
- } finally {
229
- this.inFlight -= 1;
230
- }
231
- }),
232
- );
227
+ while (claimed < this.batchSize && !this.stopRequested) {
228
+ const requested = Math.min(this.maxConcurrency, this.batchSize - claimed);
229
+ const jobs = await this.dao.claimReady(requested, { leaseMs: this.visibilityTimeout });
230
+ // A short claim means the queue is drained: stop instead of
231
+ // re-arming jobs that were just retried within this cycle.
232
+ if (jobs.length < requested) {
233
+ claimed += jobs.length;
234
+ await this.runClaimed(jobs, () => (processed += 1));
235
+ break;
236
+ }
237
+ claimed += jobs.length;
238
+ await this.runClaimed(jobs, () => (processed += 1));
233
239
  }
234
240
 
235
- addSpanAttributes({ 'queue.claimed': jobs.length, 'queue.processed': processed });
241
+ addSpanAttributes({ 'queue.claimed': claimed, 'queue.processed': processed });
236
242
  return processed;
237
243
  });
238
244
  }
239
245
 
246
+ /** Run one claimed batch to settlement, tracking in-flight for drain. */
247
+ private async runClaimed(jobs: QueueJobRecord[], onProcessed: () => void): Promise<void> {
248
+ await Promise.all(
249
+ jobs.map(async (job) => {
250
+ this.inFlight += 1;
251
+ try {
252
+ await this.processJob(job);
253
+ onProcessed();
254
+ } finally {
255
+ this.inFlight -= 1;
256
+ }
257
+ }),
258
+ );
259
+ }
260
+
240
261
  private schedule(delay: number): void {
241
262
  this.timer = setTimeout(() => {
242
263
  // Retained so stop() can await the cycle it interrupts; poll() contains its
@@ -359,7 +380,10 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
359
380
  });
360
381
  }
361
382
  } else {
362
- await this.dao.markCompleted(record.id, token);
383
+ // Task 0086 R10: gate metric + event on the fenced write — a
384
+ // lost lease means another consumer owns the job now.
385
+ const applied = await this.dao.markCompleted(record.id, token);
386
+ if (!applied) return;
363
387
  getQueueJobCompletedTotal().add(1, { type: job.type });
364
388
  const completed: QueueJobCompletedDetail = {
365
389
  jobId: job.id,
@@ -421,7 +445,8 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
421
445
  const attempts = job.attempts + 1;
422
446
  const message = error instanceof Error ? error.message : String(error);
423
447
  if (attempts >= job.maxRetries) {
424
- await this.dao.markFailed(job.id, attempts, message, attemptToken);
448
+ const applied = await this.dao.markFailed(job.id, attempts, message, attemptToken);
449
+ if (!applied) return; // Task 0086 R10: fenced write lost — no metric, no event.
425
450
  getQueueJobFailedTotal().add(1, { type: job.type });
426
451
  const failed: QueueJobFailedDetail = {
427
452
  jobId: job.id,
@@ -438,7 +463,9 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
438
463
 
439
464
  const delay = Math.min(this.maxDelay, this.baseDelay * 2 ** Math.max(0, attempts - 1));
440
465
  const nextRetryAt = Date.now() + delay;
441
- await this.dao.markForRetry(job.id, attempts, message, nextRetryAt, attemptToken);
466
+ // Task 0086 R10: gate the retry event on the fenced write.
467
+ const retryApplied = await this.dao.markForRetry(job.id, attempts, message, nextRetryAt, attemptToken);
468
+ if (!retryApplied) return;
442
469
  const retrying: QueueJobRetryingDetail = {
443
470
  jobId: job.id,
444
471
  type: job.type,