@trigger.dev/redis-worker 4.5.0-rc.6 → 4.5.0

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/dist/index.d.cts CHANGED
@@ -64,6 +64,22 @@ declare class SimpleQueue<TMessageCatalog extends MessageCatalogSchema> {
64
64
  size({ includeFuture }?: {
65
65
  includeFuture?: boolean;
66
66
  }): Promise<number>;
67
+ /**
68
+ * Age (in ms) of the oldest *overdue* message — the oldest item whose scheduled
69
+ * time has already passed (score <= now). Returns 0 when the queue is empty or
70
+ * only holds future/delayed or in-flight (future-scored) items.
71
+ *
72
+ * Resolves the candidate against the `items` hash so orphaned `queue` entries
73
+ * (a member whose payload is missing — the same stale state `dequeueItems`
74
+ * cleans up) don't report a phantom stall for work that can't be dequeued. The
75
+ * Lua scans due items oldest-first and returns the first score whose payload
76
+ * still exists.
77
+ *
78
+ * This is the generic stall signal: it stays at 0 while a queue drains healthily
79
+ * and rises only when due work sits undrained (poison block, dead consumer,
80
+ * backpressure).
81
+ */
82
+ oldestMessageAge(): Promise<number>;
67
83
  getJob(id: string): Promise<QueueItem<TMessageCatalog> | null>;
68
84
  moveToDeadLetterQueue(id: string, errorMessage: string): Promise<void>;
69
85
  sizeOfDeadLetterQueue(): Promise<number>;
@@ -80,6 +96,7 @@ declare module "@internal/redis" {
80
96
  moveToDeadLetterQueue(queue: string, items: string, dlq: string, dlqItems: string, id: string, errorMessage: string, callback?: Callback<number>): Result<number, Context>;
81
97
  enqueueItemOnce(queue: string, items: string, id: string, score: number, serializedItem: string, callback?: Callback<number>): Result<number, Context>;
82
98
  getJob(queue: string, items: string, id: string, callback?: Callback<[string, string, string] | null>): Result<[string, string, string] | null, Context>;
99
+ getOldestDueScore(queue: string, items: string, now: number, callback?: Callback<string | number>): Result<string | number, Context>;
83
100
  }
84
101
  }
85
102
 
package/dist/index.d.ts CHANGED
@@ -64,6 +64,22 @@ declare class SimpleQueue<TMessageCatalog extends MessageCatalogSchema> {
64
64
  size({ includeFuture }?: {
65
65
  includeFuture?: boolean;
66
66
  }): Promise<number>;
67
+ /**
68
+ * Age (in ms) of the oldest *overdue* message — the oldest item whose scheduled
69
+ * time has already passed (score <= now). Returns 0 when the queue is empty or
70
+ * only holds future/delayed or in-flight (future-scored) items.
71
+ *
72
+ * Resolves the candidate against the `items` hash so orphaned `queue` entries
73
+ * (a member whose payload is missing — the same stale state `dequeueItems`
74
+ * cleans up) don't report a phantom stall for work that can't be dequeued. The
75
+ * Lua scans due items oldest-first and returns the first score whose payload
76
+ * still exists.
77
+ *
78
+ * This is the generic stall signal: it stays at 0 while a queue drains healthily
79
+ * and rises only when due work sits undrained (poison block, dead consumer,
80
+ * backpressure).
81
+ */
82
+ oldestMessageAge(): Promise<number>;
67
83
  getJob(id: string): Promise<QueueItem<TMessageCatalog> | null>;
68
84
  moveToDeadLetterQueue(id: string, errorMessage: string): Promise<void>;
69
85
  sizeOfDeadLetterQueue(): Promise<number>;
@@ -80,6 +96,7 @@ declare module "@internal/redis" {
80
96
  moveToDeadLetterQueue(queue: string, items: string, dlq: string, dlqItems: string, id: string, errorMessage: string, callback?: Callback<number>): Result<number, Context>;
81
97
  enqueueItemOnce(queue: string, items: string, id: string, score: number, serializedItem: string, callback?: Callback<number>): Result<number, Context>;
82
98
  getJob(queue: string, items: string, id: string, callback?: Callback<[string, string, string] | null>): Result<[string, string, string] | null, Context>;
99
+ getOldestDueScore(queue: string, items: string, now: number, callback?: Callback<string | number>): Result<string | number, Context>;
83
100
  }
84
101
  }
85
102
 
package/dist/index.js CHANGED
@@ -9985,6 +9985,37 @@ var SimpleQueue = class {
9985
9985
  throw e;
9986
9986
  }
9987
9987
  }
9988
+ /**
9989
+ * Age (in ms) of the oldest *overdue* message — the oldest item whose scheduled
9990
+ * time has already passed (score <= now). Returns 0 when the queue is empty or
9991
+ * only holds future/delayed or in-flight (future-scored) items.
9992
+ *
9993
+ * Resolves the candidate against the `items` hash so orphaned `queue` entries
9994
+ * (a member whose payload is missing — the same stale state `dequeueItems`
9995
+ * cleans up) don't report a phantom stall for work that can't be dequeued. The
9996
+ * Lua scans due items oldest-first and returns the first score whose payload
9997
+ * still exists.
9998
+ *
9999
+ * This is the generic stall signal: it stays at 0 while a queue drains healthily
10000
+ * and rises only when due work sits undrained (poison block, dead consumer,
10001
+ * backpressure).
10002
+ */
10003
+ async oldestMessageAge() {
10004
+ try {
10005
+ const now = Date.now();
10006
+ const score = Number(await this.redis.getOldestDueScore(`queue`, `items`, now));
10007
+ if (!Number.isFinite(score) || score < 0) {
10008
+ return 0;
10009
+ }
10010
+ return Math.max(0, now - score);
10011
+ } catch (e) {
10012
+ this.logger.error(`SimpleQueue ${this.name}.oldestMessageAge(): error getting oldest age`, {
10013
+ queue: this.name,
10014
+ error: e
10015
+ });
10016
+ return 0;
10017
+ }
10018
+ }
9988
10019
  async getJob(id) {
9989
10020
  const result = await this.redis.getJob(`queue`, `items`, id);
9990
10021
  if (!result) {
@@ -10151,6 +10182,29 @@ var SimpleQueue = class {
10151
10182
  return dequeued
10152
10183
  `
10153
10184
  });
10185
+ this.redis.defineCommand("getOldestDueScore", {
10186
+ numberOfKeys: 2,
10187
+ lua: `
10188
+ local queue = KEYS[1]
10189
+ local items = KEYS[2]
10190
+ local now = tonumber(ARGV[1])
10191
+
10192
+ -- Oldest-first scan of due items, bounded so a long prefix of orphans can't
10193
+ -- make this O(n). Orphans are rare (dequeueItems removes them), so in the
10194
+ -- common case this returns on the first iteration. Read-only: unlike
10195
+ -- dequeueItems we don't ZREM orphans here \u2014 a metric probe must not mutate.
10196
+ local result = redis.call('ZRANGEBYSCORE', queue, '-inf', now, 'WITHSCORES', 'LIMIT', 0, 100)
10197
+
10198
+ for i = 1, #result, 2 do
10199
+ local id = result[i]
10200
+ if redis.call('HEXISTS', items, id) == 1 then
10201
+ return result[i + 1]
10202
+ end
10203
+ end
10204
+
10205
+ return -1
10206
+ `
10207
+ });
10154
10208
  this.redis.defineCommand("getJob", {
10155
10209
  numberOfKeys: 2,
10156
10210
  lua: `
@@ -11395,6 +11449,15 @@ var Worker = class _Worker {
11395
11449
  concurrencyLimitPendingObservableGauge.addCallback(
11396
11450
  this.#updateConcurrencyLimitPendingMetric.bind(this)
11397
11451
  );
11452
+ const oldestMessageAgeObservableGauge = this.meter.createObservableGauge(
11453
+ "redis_worker.queue.oldest_message_age",
11454
+ {
11455
+ description: "Age of the oldest overdue message in the queue",
11456
+ unit: "ms",
11457
+ valueType: ValueType.INT
11458
+ }
11459
+ );
11460
+ oldestMessageAgeObservableGauge.addCallback(this.#updateOldestMessageAgeMetric.bind(this));
11398
11461
  }
11399
11462
  subscriber;
11400
11463
  tracer;
@@ -11423,6 +11486,12 @@ var Worker = class _Worker {
11423
11486
  worker_name: this.options.name
11424
11487
  });
11425
11488
  }
11489
+ async #updateOldestMessageAgeMetric(observableResult) {
11490
+ const oldestMessageAge = await this.queue.oldestMessageAge();
11491
+ observableResult.observe(oldestMessageAge, {
11492
+ worker_name: this.options.name
11493
+ });
11494
+ }
11426
11495
  async #updateConcurrencyLimitActiveMetric(observableResult) {
11427
11496
  for (const [workerId, limiter] of Object.entries(this.limiters)) {
11428
11497
  observableResult.observe(limiter.activeCount, {
@@ -11716,15 +11785,15 @@ var Worker = class _Worker {
11716
11785
  await this.flushBatch(queueItem.job, workerId, limiter);
11717
11786
  }
11718
11787
  } else {
11719
- limiter(
11720
- () => this.processItem(queueItem, items.length, workerId, limiter)
11721
- ).catch((err) => {
11722
- this.logger.error("Unhandled error in processItem:", {
11723
- error: err,
11724
- workerId,
11725
- item
11726
- });
11727
- });
11788
+ limiter(() => this.processItem(queueItem, items.length, workerId, limiter)).catch(
11789
+ (err) => {
11790
+ this.logger.error("Unhandled error in processItem:", {
11791
+ error: err,
11792
+ workerId,
11793
+ item
11794
+ });
11795
+ }
11796
+ );
11728
11797
  }
11729
11798
  }
11730
11799
  } catch (error) {
@@ -11874,13 +11943,25 @@ var Worker = class _Worker {
11874
11943
  attempt: newAttempt
11875
11944
  };
11876
11945
  if (!shouldLogError) {
11877
- this.logger.info(`Worker batch item reached max attempts. Moving to DLQ.`, dlqLogAttributes);
11946
+ this.logger.info(
11947
+ `Worker batch item reached max attempts. Moving to DLQ.`,
11948
+ dlqLogAttributes
11949
+ );
11878
11950
  } else if (errorLogLevel === "warn") {
11879
- this.logger.warn(`Worker batch item reached max attempts. Moving to DLQ.`, dlqLogAttributes);
11951
+ this.logger.warn(
11952
+ `Worker batch item reached max attempts. Moving to DLQ.`,
11953
+ dlqLogAttributes
11954
+ );
11880
11955
  } else if (errorLogLevel === "info") {
11881
- this.logger.info(`Worker batch item reached max attempts. Moving to DLQ.`, dlqLogAttributes);
11956
+ this.logger.info(
11957
+ `Worker batch item reached max attempts. Moving to DLQ.`,
11958
+ dlqLogAttributes
11959
+ );
11882
11960
  } else {
11883
- this.logger.error(`Worker batch item reached max attempts. Moving to DLQ.`, dlqLogAttributes);
11961
+ this.logger.error(
11962
+ `Worker batch item reached max attempts. Moving to DLQ.`,
11963
+ dlqLogAttributes
11964
+ );
11884
11965
  }
11885
11966
  await this.queue.moveToDeadLetterQueue(item.id, errorMessage);
11886
11967
  continue;
@@ -14389,9 +14470,7 @@ var DRRScheduler = class extends BaseScheduler {
14389
14470
  };
14390
14471
  })
14391
14472
  );
14392
- const eligibleTenants = tenantData.filter(
14393
- (t) => !t.isAtCapacity && t.deficit >= 1
14394
- );
14473
+ const eligibleTenants = tenantData.filter((t) => !t.isAtCapacity && t.deficit >= 1);
14395
14474
  const blockedTenants = tenantData.filter((t) => t.isAtCapacity);
14396
14475
  if (blockedTenants.length > 0) {
14397
14476
  this.logger.debug("DRR: tenants blocked by concurrency", {
@@ -14684,11 +14763,7 @@ var WeightedScheduler = class extends BaseScheduler {
14684
14763
  // FairScheduler Implementation
14685
14764
  // ============================================================================
14686
14765
  async selectQueues(masterQueueShard, consumerId, context2) {
14687
- const snapshot = await this.#getOrCreateSnapshot(
14688
- masterQueueShard,
14689
- consumerId,
14690
- context2
14691
- );
14766
+ const snapshot = await this.#getOrCreateSnapshot(masterQueueShard, consumerId, context2);
14692
14767
  if (snapshot.queues.length === 0) {
14693
14768
  return [];
14694
14769
  }
@@ -16061,10 +16136,7 @@ var FairQueue = class {
16061
16136
  if (this.concurrencyManager && storedMessage) {
16062
16137
  await this.concurrencyManager.release(descriptor, messageId);
16063
16138
  }
16064
- const { queueEmpty } = await this.#updateAllIndexesAfterDequeue(
16065
- queueId,
16066
- descriptor.tenantId
16067
- );
16139
+ const { queueEmpty } = await this.#updateAllIndexesAfterDequeue(queueId, descriptor.tenantId);
16068
16140
  if (queueEmpty) {
16069
16141
  this.queueDescriptorCache.delete(queueId);
16070
16142
  this.queueCooloffStates.delete(queueId);
@@ -16321,38 +16393,6 @@ var FairQueue = class {
16321
16393
  }
16322
16394
  return false;
16323
16395
  }
16324
- #incrementCooloff(queueId) {
16325
- if (this.queueCooloffStates.size >= this.maxCooloffStatesSize) {
16326
- this.logger.warn("Cooloff states cache hit size cap, clearing all entries", {
16327
- size: this.queueCooloffStates.size,
16328
- cap: this.maxCooloffStatesSize
16329
- });
16330
- this.queueCooloffStates.clear();
16331
- }
16332
- const state = this.queueCooloffStates.get(queueId) ?? {
16333
- tag: "normal",
16334
- consecutiveFailures: 0
16335
- };
16336
- if (state.tag === "normal") {
16337
- const newFailures = state.consecutiveFailures + 1;
16338
- if (newFailures >= this.cooloffThreshold) {
16339
- this.queueCooloffStates.set(queueId, {
16340
- tag: "cooloff",
16341
- expiresAt: Date.now() + this.cooloffPeriodMs
16342
- });
16343
- this.logger.debug("Queue entered cooloff", {
16344
- queueId,
16345
- cooloffPeriodMs: this.cooloffPeriodMs,
16346
- consecutiveFailures: newFailures
16347
- });
16348
- } else {
16349
- this.queueCooloffStates.set(queueId, {
16350
- tag: "normal",
16351
- consecutiveFailures: newFailures
16352
- });
16353
- }
16354
- }
16355
- }
16356
16396
  #resetCooloff(queueId) {
16357
16397
  this.queueCooloffStates.delete(queueId);
16358
16398
  }
@@ -16620,7 +16660,10 @@ var stringToError = z.string().transform((v, ctx) => {
16620
16660
  try {
16621
16661
  return BufferEntryError.parse(JSON.parse(v));
16622
16662
  } catch {
16623
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: "expected JSON-encoded BufferEntryError" });
16663
+ ctx.addIssue({
16664
+ code: z.ZodIssueCode.custom,
16665
+ message: "expected JSON-encoded BufferEntryError"
16666
+ });
16624
16667
  return z.NEVER;
16625
16668
  }
16626
16669
  });
@@ -16818,11 +16861,7 @@ var MollifierBuffer = class {
16818
16861
  // not an error.
16819
16862
  async listEntriesForEnv(envId, maxCount) {
16820
16863
  if (maxCount <= 0) return [];
16821
- const runIds = await this.redis.lrange(
16822
- `mollifier:queue:${envId}`,
16823
- 0,
16824
- maxCount - 1
16825
- );
16864
+ const runIds = await this.redis.lrange(`mollifier:queue:${envId}`, 0, maxCount - 1);
16826
16865
  if (runIds.length === 0) return [];
16827
16866
  const pipeline = this.redis.pipeline();
16828
16867
  for (const runId of runIds) {
@@ -16959,10 +16998,7 @@ var MollifierBuffer = class {
16959
16998
  // never wiped by a slow predecessor.
16960
16999
  async releaseClaim(input) {
16961
17000
  const claimKey = makeIdempotencyClaimKey(input);
16962
- await this.redis.releaseMollifierClaim(
16963
- claimKey,
16964
- `${PENDING_PREFIX}${input.token}`
16965
- );
17001
+ await this.redis.releaseMollifierClaim(claimKey, `${PENDING_PREFIX}${input.token}`);
16966
17002
  }
16967
17003
  // Read the current claim value, used by the wait/poll loop on losers
16968
17004
  // to detect "pending" → "resolved" transitions and timeouts.
@@ -17647,9 +17683,7 @@ var MollifierDrainer = class {
17647
17683
  const orgs = await this.buffer.listOrgs();
17648
17684
  if (orgs.length === 0) return { drained: 0, failed: 0 };
17649
17685
  const orgSlice = this.takeOrgSlice(orgs);
17650
- const envsByOrg = await Promise.all(
17651
- orgSlice.map((orgId) => this.buffer.listEnvsForOrg(orgId))
17652
- );
17686
+ const envsByOrg = await Promise.all(orgSlice.map((orgId) => this.buffer.listEnvsForOrg(orgId)));
17653
17687
  const targets = [];
17654
17688
  for (let i = 0; i < orgSlice.length; i++) {
17655
17689
  const orgId = orgSlice[i];
@@ -17862,14 +17896,11 @@ var MollifierDrainer = class {
17862
17896
  } catch (writeErr) {
17863
17897
  if (this.isRetryable(writeErr)) {
17864
17898
  await this.buffer.requeue(entry.runId);
17865
- this.logger.warn(
17866
- "MollifierDrainer: terminal-failure callback retryable; requeued",
17867
- {
17868
- runId: entry.runId,
17869
- attempts: nextAttempts,
17870
- writeErr
17871
- }
17872
- );
17899
+ this.logger.warn("MollifierDrainer: terminal-failure callback retryable; requeued", {
17900
+ runId: entry.runId,
17901
+ attempts: nextAttempts,
17902
+ writeErr
17903
+ });
17873
17904
  return "failed";
17874
17905
  }
17875
17906
  this.logger.error("MollifierDrainer: terminal-failure callback failed", {