@blamejs/core 0.18.42 → 0.18.44

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/lib/queue.js CHANGED
@@ -136,6 +136,7 @@ var sweepTimer = null;
136
136
  * },
137
137
  * },
138
138
  * defaultBackend?: string, // name to use when enqueue/consume omit { backend }
139
+ * sweepIntervalMs?: number, // expired-lease sweep period; default 30s, 0 = no timer
139
140
  *
140
141
  * @example
141
142
  * b.queue.init({
@@ -153,6 +154,20 @@ function init(opts) {
153
154
  if (!opts || !opts.backends) {
154
155
  throw _err("INVALID_CONFIG", "queue.init({ backends }) is required", true);
155
156
  }
157
+ // Validate every option BEFORE touching module state. `initialized` stays
158
+ // false on a throw, so a rejected init is expected to leave nothing behind —
159
+ // but `backends` and `defaultBackend` are module-level and a later init does
160
+ // not clear them, so validating after assignment would leave the refused
161
+ // configuration's queues visible through listBackends() and selectable by
162
+ // name.
163
+ var sweepIntervalMs = opts.sweepIntervalMs === undefined
164
+ ? C.TIME.seconds(30)
165
+ : opts.sweepIntervalMs;
166
+ if (sweepIntervalMs !== 0 && !numericChecks.isPositiveInt(sweepIntervalMs)) {
167
+ throw _err("INVALID_CONFIG",
168
+ "queue.init({ sweepIntervalMs }) must be a positive integer, or 0 to run no " +
169
+ "sweep timer; got " + JSON.stringify(opts.sweepIntervalMs), true);
170
+ }
156
171
 
157
172
  // Self-register the _blamejs_jobs sealed-column declaration so payload +
158
173
  // lastError seal at rest even when this process never ran db.init (a
@@ -218,13 +233,21 @@ function init(opts) {
218
233
 
219
234
  // Sweep expired leases periodically (every 30s) so crashed-handler jobs
220
235
  // get re-pended.
221
- sweepTimer = safeAsync.repeating(function () {
236
+ //
237
+ // `sweepIntervalMs: 0` starts no timer at all. That is the mode a scheduled
238
+ // runtime wants — a Cloudflare `scheduled()` handler, a Lambda, a CronJob —
239
+ // where a resident timer is not merely useless but wrong: the isolate is
240
+ // frozen between invocations, so it either never fires, or fires inside a
241
+ // LATER invocation and touches the backend outside any request the platform
242
+ // is accounting for. b.queue.tick sweeps once per invocation itself, so
243
+ // nothing is lost by turning the timer off there.
244
+ sweepTimer = sweepIntervalMs === 0 ? null : safeAsync.repeating(function () {
222
245
  Object.keys(backends).forEach(function (n) {
223
246
  if (backends[n].sweepExpired) {
224
247
  backends[n].sweepExpired().catch(function () { /* best effort */ });
225
248
  }
226
249
  });
227
- }, C.TIME.seconds(30), { name: "queue-sweep" });
250
+ }, sweepIntervalMs, { name: "queue-sweep" });
228
251
 
229
252
  initialized = true;
230
253
  }
@@ -394,10 +417,6 @@ function consume(queueName, handler, opts) {
394
417
  if (rateLimit) rateLimit.timestamps.push(Date.now());
395
418
  }
396
419
 
397
- // Progress audit-emit rate-limit — protect the audit chain from a
398
- // chatty handler that calls progress() every loop iteration.
399
- var PROGRESS_MIN_INTERVAL_MS = 250;
400
-
401
420
  // Each consumer has its own AbortController so cancel() unblocks any
402
421
  // in-flight poll-sleep immediately rather than waiting up to
403
422
  // pollIntervalMs (default 1s) for the next while-loop iteration.
@@ -463,101 +482,13 @@ function consume(queueName, handler, opts) {
463
482
  _emit("system.queue.consume.start", {
464
483
  metadata: { queue: queueName, backend: b.name, jobId: job.jobId, attempt: job.attempts, traceId: job.traceId },
465
484
  });
466
- // Consume a rate-limit slot at handler-start so the budget
467
- // tracks invocation rate, not lease rate (a single lease that
468
- // splits work across many sub-units doesn't double-count).
485
+ // Consume a rate-limit slot at handler-start so the budget tracks
486
+ // invocation rate, not lease rate (a single lease that splits work
487
+ // across many sub-units doesn't double-count).
469
488
  _rateLimitConsume();
470
-
471
- // Handler context — second arg to handler. Carries
472
- // ctx.extendLease(ms) for long-running handlers and
473
- // ctx.progress(0..100) for surfacing job progress to the
474
- // audit chain (rate-limited so chatty handlers don't drown it).
475
- var lastProgressEmitAt = 0;
476
- var lastProgressValue = -1;
477
- var ctx = {
478
- extendLease: function (additionalMs) {
479
- if (typeof b.extendLease !== "function") {
480
- throw _err("EXTEND_LEASE_UNSUPPORTED",
481
- "queue backend '" + b.name + "' does not support extendLease",
482
- true);
483
- }
484
- return b.extendLease(job.jobId, additionalMs, { attempt: job.attempts }).then(function (ok) {
485
- if (ok) {
486
- _emit("system.queue.lease.extended", {
487
- metadata: { queue: queueName, backend: b.name, jobId: job.jobId, additionalMs: additionalMs },
488
- });
489
- }
490
- return ok;
491
- });
492
- },
493
- progress: function (pct) {
494
- if (typeof pct !== "number" || !isFinite(pct)) return;
495
- var clamped = Math.max(0, Math.min(100, Math.floor(pct)));
496
- var now = Date.now();
497
- // Always emit 0 and 100 (start/done markers); throttle the rest.
498
- var isMarker = clamped === 0 || clamped === 100;
499
- if (!isMarker && (now - lastProgressEmitAt) < PROGRESS_MIN_INTERVAL_MS) return;
500
- if (clamped === lastProgressValue && !isMarker) return;
501
- lastProgressEmitAt = now;
502
- lastProgressValue = clamped;
503
- observability.event("queue.progress", clamped, { queueName: queueName });
504
- _emit("system.queue.progress", {
505
- metadata: {
506
- queue: queueName, backend: b.name, jobId: job.jobId,
507
- attempt: job.attempts, traceId: job.traceId,
508
- percent: clamped,
509
- },
510
- });
511
- },
512
- };
513
- observability.tap("queue.consume",
514
- { queueName: queueName, backend: b.name, jobId: job.jobId, attempt: job.attempts },
515
- function () {
516
- return Promise.resolve()
517
- .then(function () { return handler(job, ctx); })
518
- .then(function () {
519
- return b.complete(job.jobId, { attempt: job.attempts }).then(function () {
520
- _emit("system.queue.consume.success", {
521
- metadata: { queue: queueName, backend: b.name, jobId: job.jobId, attempt: job.attempts, traceId: job.traceId },
522
- });
523
- observability.event("queue.complete", 1, { queueName: queueName });
524
- });
525
- }, function (err) {
526
- var msg = (err && err.message) || String(err);
527
- var willRetry = job.attempts < job.maxAttempts;
528
- return b.fail(job.jobId, msg, { retryDelayMs: _backoffDelay(job.attempts), attempt: job.attempts })
529
- .then(function () {
530
- observability.event("queue.fail", 1, { queueName: queueName, willRetry: willRetry });
531
- _emit("system.queue.consume.failure", {
532
- metadata: {
533
- queue: queueName, backend: b.name, jobId: job.jobId,
534
- attempt: job.attempts, traceId: job.traceId,
535
- maxAttempts: job.maxAttempts, willRetry: willRetry,
536
- },
537
- reason: msg,
538
- outcome: "failure",
539
- });
540
- // DLQ-write event when the job has exhausted its retries.
541
- // Operators wire this to their alerting / dashboards
542
- // — failed-after-retries is "needs human review" not
543
- // "in the normal flow." Audit chain captures the
544
- // final state for forensics.
545
- if (!willRetry) {
546
- _emit("system.queue.dlq.write", {
547
- metadata: {
548
- queue: queueName, backend: b.name, jobId: job.jobId,
549
- attempts: job.attempts, traceId: job.traceId,
550
- },
551
- reason: msg,
552
- outcome: "failure",
553
- });
554
- }
555
- });
556
- })
489
+ _runJob(b, queueName, job, handler)
557
490
  .catch(function (_e) { /* lifecycle errors swallowed — operator sees via audit */ })
558
491
  .then(function () { state.inFlight.delete(job.jobId); });
559
- }
560
- );
561
492
  })(jobs[i]);
562
493
  }
563
494
  await _pollSleep(fastPollMs);
@@ -576,6 +507,318 @@ function _backoffDelay(attempt) {
576
507
  return retryHelper.backoffDelay(attempt, _QUEUE_BACKOFF_OPTS);
577
508
  }
578
509
 
510
+ // The handler context passed as the second argument to a job handler:
511
+ // ctx.extendLease(ms) for a handler that outlives its lease, ctx.progress(0..100)
512
+ // for surfacing progress to the audit chain (rate-limited so a chatty handler
513
+ // cannot drown it).
514
+ var _PROGRESS_MIN_INTERVAL_MS = 250;
515
+ function _handlerContext(backend, queueName, job) {
516
+ var lastProgressEmitAt = 0;
517
+ var lastProgressValue = -1;
518
+ return {
519
+ extendLease: function (additionalMs) {
520
+ if (typeof backend.extendLease !== "function") {
521
+ throw _err("EXTEND_LEASE_UNSUPPORTED",
522
+ "queue backend '" + backend.name + "' does not support extendLease", true);
523
+ }
524
+ return backend.extendLease(job.jobId, additionalMs, { attempt: job.attempts }).then(function (ok) {
525
+ if (ok) {
526
+ _emit("system.queue.lease.extended", {
527
+ metadata: { queue: queueName, backend: backend.name, jobId: job.jobId, additionalMs: additionalMs },
528
+ });
529
+ }
530
+ return ok;
531
+ });
532
+ },
533
+ progress: function (pct) {
534
+ if (typeof pct !== "number" || !isFinite(pct)) return;
535
+ var clamped = Math.max(0, Math.min(100, Math.floor(pct)));
536
+ var now = Date.now();
537
+ // Always emit 0 and 100 (start/done markers); throttle the rest.
538
+ var isMarker = clamped === 0 || clamped === 100;
539
+ if (!isMarker && (now - lastProgressEmitAt) < _PROGRESS_MIN_INTERVAL_MS) return;
540
+ if (clamped === lastProgressValue && !isMarker) return;
541
+ lastProgressEmitAt = now;
542
+ lastProgressValue = clamped;
543
+ observability.event("queue.progress", clamped, { queueName: queueName });
544
+ _emit("system.queue.progress", {
545
+ metadata: {
546
+ queue: queueName, backend: backend.name, jobId: job.jobId,
547
+ attempt: job.attempts, traceId: job.traceId, percent: clamped,
548
+ },
549
+ });
550
+ },
551
+ };
552
+ }
553
+
554
+ // Run one leased job to its terminal state: complete on success, or fail with
555
+ // the deterministic backoff and — once the attempts are exhausted — the DLQ
556
+ // event. Resolves to "succeeded" or "failed"; `movedToDlq` says whether this
557
+ // failure was the last one.
558
+ //
559
+ // Both drivers go through here. b.queue.consume runs it inside a resident
560
+ // polling loop; b.queue.tick runs it over one leased batch and returns. The
561
+ // retry schedule, the attempt accounting and the DLQ transition are the part
562
+ // that is genuinely hard to get right, so there is exactly one copy of it.
563
+ function _runJob(backend, queueName, job, handler) {
564
+ var ctx = _handlerContext(backend, queueName, job);
565
+ return observability.tap("queue.consume",
566
+ { queueName: queueName, backend: backend.name, jobId: job.jobId, attempt: job.attempts },
567
+ function () {
568
+ return Promise.resolve()
569
+ .then(function () { return handler(job, ctx); })
570
+ .then(function () {
571
+ return backend.complete(job.jobId, { attempt: job.attempts }).then(function (settled) {
572
+ // complete() answers FALSE when the attempt fence no longer
573
+ // matches: this worker's lease expired, the sweep re-pended the
574
+ // job, and someone else owns it now. Nothing was settled here, so
575
+ // claiming success would emit a success event for work this worker
576
+ // did not finish and inflate the caller's tally. Report it as
577
+ // stale and stay quiet.
578
+ if (settled === false) {
579
+ observability.event("queue.stale", 1, { queueName: queueName });
580
+ return { status: "stale", movedToDlq: false };
581
+ }
582
+ _emit("system.queue.consume.success", {
583
+ metadata: { queue: queueName, backend: backend.name, jobId: job.jobId, attempt: job.attempts, traceId: job.traceId },
584
+ });
585
+ observability.event("queue.complete", 1, { queueName: queueName });
586
+ // `status`, not `outcome` — `outcome` is the audit-event
587
+ // vocabulary (success / failure / denied) and this is the job's
588
+ // terminal state for the caller, not an audit row.
589
+ return { status: "succeeded", movedToDlq: false };
590
+ });
591
+ }, function (err) {
592
+ var msg = (err && err.message) || String(err);
593
+ var willRetry = job.attempts < job.maxAttempts;
594
+ return backend.fail(job.jobId, msg, { retryDelayMs: _backoffDelay(job.attempts), attempt: job.attempts })
595
+ .then(function (settled) {
596
+ // Same fence as complete(): a false answer means the job was
597
+ // re-leased under this worker, so this failure did not land.
598
+ // Emitting the failure — or worse, the DLQ write — would report
599
+ // a terminal state for a job that is running elsewhere.
600
+ if (settled === false) {
601
+ observability.event("queue.stale", 1, { queueName: queueName });
602
+ return { status: "stale", movedToDlq: false };
603
+ }
604
+ observability.event("queue.fail", 1, { queueName: queueName, willRetry: willRetry });
605
+ _emit("system.queue.consume.failure", {
606
+ metadata: {
607
+ queue: queueName, backend: backend.name, jobId: job.jobId,
608
+ attempt: job.attempts, traceId: job.traceId,
609
+ maxAttempts: job.maxAttempts, willRetry: willRetry,
610
+ },
611
+ reason: msg,
612
+ outcome: "failure",
613
+ });
614
+ // DLQ-write event when the job has exhausted its retries.
615
+ // Operators wire this to their alerting / dashboards —
616
+ // failed-after-retries is "needs human review" not "in the
617
+ // normal flow." The audit chain captures the final state.
618
+ if (!willRetry) {
619
+ _emit("system.queue.dlq.write", {
620
+ metadata: {
621
+ queue: queueName, backend: backend.name, jobId: job.jobId,
622
+ attempts: job.attempts, traceId: job.traceId,
623
+ },
624
+ reason: msg,
625
+ outcome: "failure",
626
+ });
627
+ }
628
+ return { status: "failed", movedToDlq: !willRetry };
629
+ });
630
+ });
631
+ }
632
+ );
633
+ }
634
+
635
+ /**
636
+ * @primitive b.queue.tick
637
+ * @signature b.queue.tick(opts)
638
+ * @since 0.18.43
639
+ * @status stable
640
+ * @related b.queue.consume, b.queue.enqueue, b.queue.dlqList
641
+ *
642
+ * Drain what is due, then return. Leases at most `max` available jobs, runs
643
+ * `handler` over them, applies the same leasing, deterministic backoff and
644
+ * dead-letter transitions `b.queue.consume` applies, and settles. Nothing is
645
+ * left running when the promise resolves: no resident loop, no timer, no
646
+ * consumer registered for shutdown to wait on.
647
+ *
648
+ * Pair it with `b.queue.init({ sweepIntervalMs: 0 })`. `init` otherwise starts
649
+ * a 30-second expired-lease sweep timer, and in a frozen isolate that timer
650
+ * either never fires or fires inside a later invocation, touching the backend
651
+ * outside any request the platform is accounting for. `tick` sweeps once per
652
+ * invocation itself, so the timer buys nothing there.
653
+ *
654
+ * This is the driver for a scheduled invocation — a Cloudflare `scheduled()`
655
+ * handler, a Lambda on EventBridge, a Kubernetes CronJob. `consume` cannot
656
+ * serve those: it is a resident loop and it starts a background sweep, and such
657
+ * a runtime freezes between invocations, so the timer never fires and the
658
+ * consumer is either blocking the handler or killed mid-lease. Without a
659
+ * per-tick entry point the durable half — attempt accounting, backoff schedule,
660
+ * DLQ transition — gets reimplemented by the caller, which is the part that is
661
+ * genuinely hard to get right.
662
+ *
663
+ * Returns `{ leased, succeeded, failed, stale, unsettled, movedToDlq,
664
+ * mayHaveMore, queueDepth }`.
665
+ *
666
+ * Two of those counts are not terminal states and mean different things.
667
+ * `stale` is benign: the backend answered that this worker no longer owns the
668
+ * job, so someone else is running it. `unsettled` is not: the backend REFUSED
669
+ * to record the outcome — complete or fail rejected after its own retries — so
670
+ * the job is still inflight and this tick does not know whether the handler
671
+ * succeeded. Those are recovered by a later sweep, and if the handler had
672
+ * already succeeded its side effects run a second time, so a non-zero
673
+ * `unsettled` is worth alerting on rather than counting as noise.
674
+ *
675
+ * `mayHaveMore` is the tick-again signal: the batch came back full, so more
676
+ * may be due right now. Loop on that, not on a depth count — `queueDepth` is
677
+ * the queue's total active depth (pending plus inflight) and includes jobs
678
+ * whose `availableAt` is still in the future, so a caller looping on it would
679
+ * spin through empty ticks waiting for a delayed job to come due. `queueDepth`
680
+ * is `null` when the backend could not be asked: unknown is not empty.
681
+ *
682
+ * `stale` counts jobs this tick ran but did not settle: the handler outlived
683
+ * the lease, the job was swept and re-leased, and the backend's attempt fence
684
+ * refused the completion. Those are not failures — another worker owns them —
685
+ * and a non-zero `stale` means `leaseMs` is too short for the handler, or the
686
+ * batch too large to finish inside it.
687
+ *
688
+ * Jobs run sequentially by default. Pass `concurrency` to overlap them; the
689
+ * lease is held for `leaseMs` either way, so a batch that will take longer than
690
+ * the lease should either raise `leaseMs` or call `ctx.extendLease(ms)` from
691
+ * the handler.
692
+ *
693
+ * @opts
694
+ * queue: string, // required — the queue name to drain
695
+ * handler: function, // required — (job, ctx) => Promise, same as consume
696
+ * max: number, // default: 10 — most jobs to lease this tick
697
+ * leaseMs: number, // default: 30s — lease duration for the batch
698
+ * concurrency: number, // default: 1 — jobs run in parallel
699
+ * backend: string, // named backend; default the init default
700
+ *
701
+ * @example
702
+ * var result = await b.queue.tick({
703
+ * queue: "webhooks",
704
+ * max: 50,
705
+ * handler: async function (job) { await deliver(job.payload); },
706
+ * });
707
+ * while (result.mayHaveMore) result = await b.queue.tick({ queue: "webhooks", max: 50, handler: deliver });
708
+ * // → { leased: 50, succeeded: 48, failed: 2, stale: 0, unsettled: 0,
709
+ * // movedToDlq: 1, mayHaveMore: true, queueDepth: 62 }
710
+ */
711
+ async function tick(opts) {
712
+ _requireInit();
713
+ opts = opts || {};
714
+ var queueName = opts.queue;
715
+ if (!queueName) throw _err("MISSING_QUEUE", "tick requires opts.queue", true);
716
+ if (typeof opts.handler !== "function") {
717
+ throw _err("INVALID_HANDLER", "tick requires opts.handler to be a function", true);
718
+ }
719
+ var max = opts.max === undefined ? 10 : opts.max;
720
+ if (!numericChecks.isPositiveInt(max)) {
721
+ throw _err("BAD_MAX", "tick({ max }) must be a positive integer, got " + JSON.stringify(opts.max), true);
722
+ }
723
+ var leaseMs = opts.leaseMs === undefined ? C.TIME.seconds(30) : opts.leaseMs;
724
+ if (!numericChecks.isPositiveInt(leaseMs)) {
725
+ throw _err("BAD_LEASE", "tick({ leaseMs }) must be a positive integer, got " + JSON.stringify(opts.leaseMs), true);
726
+ }
727
+ var concurrency = opts.concurrency === undefined ? 1 : opts.concurrency;
728
+ if (!numericChecks.isPositiveInt(concurrency)) {
729
+ throw _err("BAD_CONCURRENCY", "tick({ concurrency }) must be a positive integer, got " + JSON.stringify(opts.concurrency), true);
730
+ }
731
+
732
+ var backend = _backendFor(opts);
733
+ // Same boundary b.queue.consume has: the framework-side lifecycle (lease by
734
+ // count, complete/fail by jobId, framework backoff and DLQ) is what `local`
735
+ // and `redis` implement. `sqs` is a different model — lease returns a
736
+ // receiptHandle the caller must thread back, redelivery and the dead-letter
737
+ // queue are the SQS queue's own RedrivePolicy — so driving it through here
738
+ // would call complete/fail without the receipt, leave every handled message
739
+ // in the queue, and let SQS redeliver work that already succeeded. Refuse
740
+ // rather than do that quietly; an SQS consumer drives lease/complete/fail
741
+ // directly, as lib/queue-sqs.js documents.
742
+ if (backend.protocol === "sqs") {
743
+ throw _err("TICK_UNSUPPORTED",
744
+ "queue.tick does not drive the 'sqs' protocol: SQS completes and fails by " +
745
+ "receiptHandle and owns redelivery + DLQ server-side. Drive it directly " +
746
+ "(lease -> handle -> complete/fail) as lib/queue-sqs.js describes.", true);
747
+ }
748
+ // Recover abandoned leases before taking a batch. A job whose holder died
749
+ // between leasing and completing sits in `inflight` until something re-pends
750
+ // it, and the thing that normally does is the 30-second sweep timer started
751
+ // at init — a timer that never fires in the runtime tick exists for, because
752
+ // the isolate is frozen between invocations. Without this, an invocation
753
+ // killed mid-lease strands its job permanently in exactly the deployment
754
+ // this driver serves. Best-effort: a sweep failure must not stop the tick
755
+ // from doing the work it can still do.
756
+ if (backend.sweepExpired) {
757
+ try { await backend.sweepExpired(); }
758
+ catch (e) { log.debug("tick-sweep-failed", { op: "sweepExpired", queue: queueName, error: e.message }); }
759
+ }
760
+ var jobs = await backend.lease(queueName, leaseMs, max);
761
+ jobs = jobs || [];
762
+
763
+ var result = {
764
+ leased: jobs.length,
765
+ succeeded: 0,
766
+ failed: 0,
767
+ stale: 0,
768
+ unsettled: 0,
769
+ movedToDlq: 0,
770
+ // The batch came back full, so there may be more due right now. This is
771
+ // the tick-again signal: it is exact, free, and unlike a depth count it
772
+ // cannot be fooled by jobs that are not yet available.
773
+ mayHaveMore: jobs.length === max,
774
+ queueDepth: 0,
775
+ };
776
+ var next = 0;
777
+ async function worker() {
778
+ while (next < jobs.length) {
779
+ var job = jobs[next++];
780
+ observability.event("queue.lease", 1, { queueName: queueName });
781
+ _emit("system.queue.consume.start", {
782
+ metadata: { queue: queueName, backend: backend.name, jobId: job.jobId, attempt: job.attempts, traceId: job.traceId },
783
+ });
784
+ var settled;
785
+ try { settled = await _runJob(backend, queueName, job, opts.handler); }
786
+ catch (e) {
787
+ // A throw here is the BACKEND refusing to record the outcome —
788
+ // complete or fail rejected after its own retries. The job's state was
789
+ // never changed, so it is still inflight and this tick does not know
790
+ // whether the handler succeeded. Calling that a handler failure would
791
+ // be wrong twice: it reports a terminal state the job never reached,
792
+ // and it hides an infrastructure problem inside an ordinary tally.
793
+ // Report it as unsettled; a later sweep re-pends the job.
794
+ log.debug("tick-settle-failed", { op: "_runJob", queue: queueName, jobId: job.jobId, error: e.message });
795
+ settled = { status: "unsettled", movedToDlq: false };
796
+ }
797
+ if (settled && settled.status === "stale") result.stale += 1;
798
+ else if (settled && settled.status === "unsettled") result.unsettled += 1;
799
+ else if (settled && settled.status === "succeeded") result.succeeded += 1;
800
+ else result.failed += 1;
801
+ if (settled && settled.movedToDlq) result.movedToDlq += 1;
802
+ }
803
+ }
804
+ var workers = [];
805
+ for (var w = 0; w < Math.min(concurrency, jobs.length); w++) workers.push(worker());
806
+ await Promise.all(workers);
807
+
808
+ // Depth AFTER the batch. This is the queue's total active depth — pending
809
+ // plus inflight — and it deliberately does NOT drive the tick-again
810
+ // decision: it counts jobs whose availableAt is still in the future, so a
811
+ // caller looping on it would spin through empty ticks waiting for a delayed
812
+ // job to come due. null when the backend could not be asked; unknown is not
813
+ // the same as empty.
814
+ try { result.queueDepth = await size(queueName, opts); }
815
+ catch (e) {
816
+ log.debug("tick-size-failed", { op: "size", queue: queueName, error: e.message });
817
+ result.queueDepth = null;
818
+ }
819
+ return result;
820
+ }
821
+
579
822
  /**
580
823
  * @primitive b.queue.size
581
824
  * @signature b.queue.size(queueName, opts)
@@ -1064,6 +1307,7 @@ module.exports = {
1064
1307
  enqueue: enqueue,
1065
1308
  enqueueFlow: enqueueFlow,
1066
1309
  consume: consume,
1310
+ tick: tick,
1067
1311
  size: size,
1068
1312
  purge: purge,
1069
1313
  shutdown: shutdown,
@@ -1074,4 +1318,8 @@ module.exports = {
1074
1318
  PROTOCOLS: dispatcher.protocols,
1075
1319
  DEFERRED_PROTOCOLS: dispatcher.deferred,
1076
1320
  _resetForTest: _resetForTest,
1321
+ // Internal — the wrapped backend object, so a test can break one of its
1322
+ // lifecycle calls (an exhausted breaker, an unreachable store) and assert
1323
+ // what the drivers do about it. There is no other way to reach that path.
1324
+ _backendForTest: _backendFor,
1077
1325
  };