@hotmeshio/hotmesh 0.25.1 → 0.25.4

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.25.1",
3
+ "version": "0.25.4",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -496,7 +496,16 @@ class Hook extends activity_1.Activity {
496
496
  }
497
497
  async getHookRule(topic) {
498
498
  const rules = await this.store.getHookRules();
499
- return rules?.[topic]?.[0];
499
+ const forTopic = rules?.[topic];
500
+ if (!forTopic?.length)
501
+ return undefined;
502
+ // A topic may carry one rule per waiter activity — the durable factory
503
+ // registers `waiter` and `signaler_waiter` on the same wait topic. Select
504
+ // the rule that targets THIS activity: the positional first rule addresses
505
+ // a sibling branch for every activity but its own, so its expected-pipe
506
+ // resolves against output that does not exist in this flow (which is how
507
+ // execHook-context waits were writing signal_key-less escalation rows).
508
+ return (forTopic.find((rule) => rule.to === this.metadata.aid) ?? forTopic[0]);
500
509
  }
501
510
  /**
502
511
  * Register the time hook (sleep) inside the Leg1 transaction.
@@ -654,10 +663,20 @@ class Hook extends activity_1.Activity {
654
663
  const store = this.store;
655
664
  if (typeof store.expireEscalationBySignalKey !== 'function')
656
665
  return;
666
+ // Hydration here is THROWAWAY. getState() REPLACES this.context with
667
+ // restored job state, which does not carry the per-message dimensional
668
+ // address (dad) — the stream message is its only carrier for this leg.
669
+ // processEvent must receive the pristine dispatch state (verifyReentry
670
+ // hydrates for itself, reading dad from context.metadata), so snapshot
671
+ // everything getState mutates — the context reference, metadata.dad,
672
+ // and leg — and restore them on every exit path. Without this, a wait
673
+ // executing below the root (any dimension) crashes Leg2 notarization.
674
+ const dispatchContext = this.context;
675
+ const dispatchDad = this.metadata.dad;
676
+ const dispatchLeg = this.leg;
657
677
  try {
658
- // The TIMEHOOK dispatch context carries metadata only hydrate job
659
- // state so the signal-key pipe expression resolves to the same value
660
- // Leg1 stored on the row.
678
+ // Hydrate job state so the signal-key pipe expression resolves to the
679
+ // same value Leg1 stored on the row.
661
680
  this.setLeg(2);
662
681
  await this.getState();
663
682
  const appId = this.engine.appId;
@@ -689,6 +708,11 @@ class Hook extends activity_1.Activity {
689
708
  error: e.message,
690
709
  });
691
710
  }
711
+ finally {
712
+ this.context = dispatchContext;
713
+ this.metadata.dad = dispatchDad;
714
+ this.setLeg(dispatchLeg);
715
+ }
692
716
  }
693
717
  }
694
718
  exports.Hook = Hook;
@@ -72,6 +72,15 @@ import { ConditionQueueConfig } from '../../../types/hmsh_escalations';
72
72
  * await client.escalations.resolve({ id: item.id, resolverPayload: { approved: true } });
73
73
  * ```
74
74
  *
75
+ * ## Placement: call escalation-bearing waits from main workflow code
76
+ *
77
+ * The resolve/signal delivery pipeline routes to the main flow's waiter.
78
+ * Inside a hook function (`execHook`), an escalation-bearing wait writes its
79
+ * row and honors `timeout` (the row expires and the hook resumes with
80
+ * `false`), while resolution delivery targets the main flow — so structure
81
+ * SLA-gated human waits in the workflow body and let hook functions report
82
+ * back via `signal()`.
83
+ *
75
84
  * ## Fan-in: wait for multiple signals in parallel
76
85
  *
77
86
  * ```typescript
@@ -77,6 +77,15 @@ const didRun_1 = require("./didRun");
77
77
  * await client.escalations.resolve({ id: item.id, resolverPayload: { approved: true } });
78
78
  * ```
79
79
  *
80
+ * ## Placement: call escalation-bearing waits from main workflow code
81
+ *
82
+ * The resolve/signal delivery pipeline routes to the main flow's waiter.
83
+ * Inside a hook function (`execHook`), an escalation-bearing wait writes its
84
+ * row and honors `timeout` (the row expires and the hook resumes with
85
+ * `false`), while resolution delivery targets the main flow — so structure
86
+ * SLA-gated human waits in the workflow body and let hook functions report
87
+ * back via `signal()`.
88
+ *
80
89
  * ## Fan-in: wait for multiple signals in parallel
81
90
  *
82
91
  * ```typescript
@@ -68,6 +68,7 @@ function resolveError(metadata) {
68
68
  exports.resolveError = resolveError;
69
69
  async function interrupt(instance, topic, jobId, options = {}) {
70
70
  await instance.store.interrupt(topic, jobId, options);
71
+ await expireStreamMessages(instance, jobId);
71
72
  const context = (await instance.getState(topic, jobId));
72
73
  const completionOpts = {
73
74
  interrupt: options.descend,
@@ -77,9 +78,34 @@ async function interrupt(instance, topic, jobId, options = {}) {
77
78
  }
78
79
  exports.interrupt = interrupt;
79
80
  async function scrub(instance, jobId) {
81
+ await expireStreamMessages(instance, jobId);
80
82
  await instance.store.scrub(jobId);
81
83
  }
82
84
  exports.scrub = scrub;
85
+ /**
86
+ * Soft-deletes the job's stream messages (queued, reserved, and
87
+ * scheduled retries) so no message belonging to a dead job is ever
88
+ * delivered again. Interrupting the job wins or throws before this
89
+ * runs; failures here are logged and absorbed because the delivery
90
+ * liveness guard drops any surviving message at claim time.
91
+ */
92
+ async function expireStreamMessages(instance, jobId) {
93
+ try {
94
+ const expired = await instance.stream.expireJobMessages?.(jobId);
95
+ if (expired) {
96
+ instance.logger.info('engine-job-stream-messages-expired', {
97
+ jobId,
98
+ expired,
99
+ });
100
+ }
101
+ }
102
+ catch (error) {
103
+ instance.logger.error('engine-job-stream-expire-error', {
104
+ jobId,
105
+ error,
106
+ });
107
+ }
108
+ }
83
109
  /**
84
110
  * Orchestrates all post-completion work for a finished job:
85
111
  * notify parent, publish to subscribers, schedule cleanup.
@@ -30,6 +30,7 @@ export declare class ConsumptionManager<S extends StreamService<ProviderClient,
30
30
  private duressManager?;
31
31
  private onDuressChange?;
32
32
  private messagesSinceLastEval;
33
+ private canExtendReservations;
33
34
  private adaptiveReservationTimeout;
34
35
  private adaptiveBatchSize;
35
36
  private lastDepthCheckAt;
@@ -52,7 +53,12 @@ export declare class ConsumptionManager<S extends StreamService<ProviderClient,
52
53
  consumeMessages(stream: string, group: string, consumer: string, callback: (streamData: StreamData) => Promise<StreamDataResponse | void>): Promise<void>;
53
54
  private consumeWithNotifications;
54
55
  private consumeWithPolling;
55
- consumeOne(stream: string, group: string, id: string, input: StreamData, callback: (streamData: StreamData) => Promise<StreamDataResponse | void>): Promise<void>;
56
+ /**
57
+ * Whether reservation heartbeats are available: the provider must
58
+ * implement extendReservation and advertise the capability.
59
+ */
60
+ private supportsHeartbeat;
61
+ consumeOne(stream: string, group: string, id: string, input: StreamData, callback: (streamData: StreamData) => Promise<StreamDataResponse | void>, consumer?: string): Promise<void>;
56
62
  execStreamLeg(input: StreamData, stream: string, id: string, callback: (streamData: StreamData) => Promise<StreamDataResponse | void>): Promise<StreamDataResponse>;
57
63
  ackAndDelete(stream: string, group: string, id: string): Promise<void>;
58
64
  ackAndDeleteBatch(stream: string, group: string, ids: string[]): Promise<void>;
@@ -195,7 +195,7 @@ class ConsumptionManager {
195
195
  if (this.lifecycleManager.isStopped(group, consumer, stream)) {
196
196
  return;
197
197
  }
198
- return this.consumeOne(stream, group, message.id, message.data, callback);
198
+ return this.consumeOne(stream, group, message.id, message.data, callback, consumer);
199
199
  });
200
200
  // Process all messages in parallel
201
201
  await Promise.allSettled(processingPromises);
@@ -212,7 +212,7 @@ class ConsumptionManager {
212
212
  if (this.lifecycleManager.isStopped(group, consumer, stream)) {
213
213
  return;
214
214
  }
215
- await this.consumeOne(stream, group, message.id, message.data, callback);
215
+ await this.consumeOne(stream, group, message.id, message.data, callback, consumer);
216
216
  }
217
217
  }
218
218
  // Check for pending messages if supported and enough time has passed
@@ -231,7 +231,7 @@ class ConsumptionManager {
231
231
  if (this.lifecycleManager.isStopped(group, consumer, stream)) {
232
232
  return;
233
233
  }
234
- return this.consumeOne(stream, group, message.id, message.data, callback);
234
+ return this.consumeOne(stream, group, message.id, message.data, callback, consumer);
235
235
  });
236
236
  await Promise.allSettled(reclaimPromises);
237
237
  }
@@ -240,7 +240,7 @@ class ConsumptionManager {
240
240
  if (this.lifecycleManager.isStopped(group, consumer, stream)) {
241
241
  return;
242
242
  }
243
- await this.consumeOne(stream, group, message.id, message.data, callback);
243
+ await this.consumeOne(stream, group, message.id, message.data, callback, consumer);
244
244
  }
245
245
  }
246
246
  }
@@ -349,7 +349,7 @@ class ConsumptionManager {
349
349
  if (this.lifecycleManager.isStopped(group, consumer, stream)) {
350
350
  return;
351
351
  }
352
- return this.consumeOne(stream, group, message.id, message.data, callback);
352
+ return this.consumeOne(stream, group, message.id, message.data, callback, consumer);
353
353
  });
354
354
  // Process all messages in parallel
355
355
  await Promise.allSettled(processingPromises);
@@ -366,7 +366,7 @@ class ConsumptionManager {
366
366
  if (this.lifecycleManager.isStopped(group, consumer, stream)) {
367
367
  return;
368
368
  }
369
- await this.consumeOne(stream, group, message.id, message.data, callback);
369
+ await this.consumeOne(stream, group, message.id, message.data, callback, consumer);
370
370
  }
371
371
  }
372
372
  // Check for pending messages if supported and enough time has passed
@@ -385,7 +385,7 @@ class ConsumptionManager {
385
385
  if (this.lifecycleManager.isStopped(group, consumer, stream)) {
386
386
  return;
387
387
  }
388
- return this.consumeOne(stream, group, message.id, message.data, callback);
388
+ return this.consumeOne(stream, group, message.id, message.data, callback, consumer);
389
389
  });
390
390
  await Promise.allSettled(reclaimPromises);
391
391
  }
@@ -394,7 +394,7 @@ class ConsumptionManager {
394
394
  if (this.lifecycleManager.isStopped(group, consumer, stream)) {
395
395
  return;
396
396
  }
397
- await this.consumeOne(stream, group, message.id, message.data, callback);
397
+ await this.consumeOne(stream, group, message.id, message.data, callback, consumer);
398
398
  }
399
399
  }
400
400
  }
@@ -434,7 +434,20 @@ class ConsumptionManager {
434
434
  };
435
435
  consume.call(this);
436
436
  }
437
- async consumeOne(stream, group, id, input, callback) {
437
+ /**
438
+ * Whether reservation heartbeats are available: the provider must
439
+ * implement extendReservation and advertise the capability.
440
+ */
441
+ supportsHeartbeat() {
442
+ if (this.canExtendReservations === undefined) {
443
+ this.canExtendReservations =
444
+ typeof this.stream.extendReservation === 'function' &&
445
+ this.stream.getProviderSpecificFeatures()
446
+ .supportsReservationExtension === true;
447
+ }
448
+ return this.canExtendReservations;
449
+ }
450
+ async consumeOne(stream, group, id, input, callback, consumer) {
438
451
  this.logger.debug(`stream-read-one`, { group, stream, id });
439
452
  // Poison message circuit breaker. This is a SAFETY NET that sits above
440
453
  // the normal retry mechanism (ErrorHandler.handleRetry / shouldRetry).
@@ -498,20 +511,67 @@ class ConsumptionManager {
498
511
  }
499
512
  return;
500
513
  }
501
- // Lease deadline: the full configured reservation timeout (N).
502
- // The reclaim interval is N+5s, so the deadline always fires
503
- // before a reclaimant can pick up the message. This preserves
504
- // the user's contract if they set 30s, the function gets 30s.
505
- const deadlineMs = this.adaptiveReservationTimeout * 1000;
514
+ // Lease strategy. With heartbeat support (provider capability +
515
+ // consumer identity), the reservation is refreshed at half the base
516
+ // window while the callback runs, so an activity that outlives the
517
+ // window stays leased and executes exactly once; the deadline then
518
+ // acts as a runaway cap (HMSH_RESERVATION_TIMEOUT_MAX_S). A crashed
519
+ // consumer stops heartbeating and its message is rescued within one
520
+ // window through the normal claim path. Providers without extension
521
+ // keep the hard deadline at the reservation timeout (N), with
522
+ // reclaim at N+5s.
523
+ const canHeartbeat = Boolean(consumer) && this.supportsHeartbeat();
524
+ const deadlineS = canHeartbeat
525
+ ? config_1.HMSH_RESERVATION_TIMEOUT_MAX_S
526
+ : this.adaptiveReservationTimeout;
527
+ const deadlineMs = deadlineS * 1000;
506
528
  let output;
507
529
  const telemetry = new telemetry_1.RouterTelemetry(this.appId);
508
530
  const processingStart = Date.now();
509
531
  try {
510
532
  telemetry.startStreamSpan(input, this.role);
511
533
  let deadlineTimer;
534
+ let rejectLease;
512
535
  const deadlinePromise = new Promise((_, reject) => {
513
- deadlineTimer = setTimeout(() => reject(new errors_1.LeaseExpiredError(deadlineMs, this.adaptiveReservationTimeout)), deadlineMs);
536
+ rejectLease = reject;
537
+ deadlineTimer = setTimeout(() => reject(new errors_1.LeaseExpiredError(deadlineMs, deadlineS)), deadlineMs);
514
538
  });
539
+ // Heartbeat at half the BASE window (adaptive scaling only grows
540
+ // the claim window, so the base cadence always outpaces reclaim
541
+ // eligibility). A beat that reports 0 means the lease is gone —
542
+ // reclaimed by another consumer, acked, or the job was
543
+ // interrupted — so this execution abandons without acking. A beat
544
+ // that errors is transient and retries on the next tick. Fast
545
+ // callbacks settle before the first beat ever fires.
546
+ let heartbeatTimer;
547
+ if (canHeartbeat) {
548
+ const intervalMs = Math.max(1000, Math.floor((config_1.HMSH_RESERVATION_TIMEOUT_S * 1000) / 2));
549
+ heartbeatTimer = setInterval(() => {
550
+ this.stream
551
+ .extendReservation(stream, id, consumer)
552
+ .then((extended) => {
553
+ if (extended === 0) {
554
+ this.logger.warn('stream-lease-lost', {
555
+ group,
556
+ stream,
557
+ id,
558
+ consumer,
559
+ topic: input.metadata?.topic,
560
+ jobId: input.metadata?.jid,
561
+ });
562
+ rejectLease(new errors_1.LeaseExpiredError(deadlineMs, deadlineS));
563
+ }
564
+ })
565
+ .catch((error) => {
566
+ this.logger.warn('stream-lease-extend-error', {
567
+ group,
568
+ stream,
569
+ id,
570
+ error,
571
+ });
572
+ });
573
+ }, intervalMs);
574
+ }
515
575
  try {
516
576
  output = await Promise.race([
517
577
  this.execStreamLeg(input, stream, id, callback.bind(this)),
@@ -520,6 +580,9 @@ class ConsumptionManager {
520
580
  }
521
581
  finally {
522
582
  clearTimeout(deadlineTimer);
583
+ if (heartbeatTimer) {
584
+ clearInterval(heartbeatTimer);
585
+ }
523
586
  }
524
587
  telemetry.setStreamErrorFromOutput(output);
525
588
  this.errorCount = 0;
@@ -536,7 +599,7 @@ class ConsumptionManager {
536
599
  stream,
537
600
  id,
538
601
  deadlineMs,
539
- reservationTimeoutS: this.adaptiveReservationTimeout,
602
+ reservationTimeoutS: deadlineS,
540
603
  topic: input.metadata?.topic,
541
604
  activityId: input.metadata?.aid,
542
605
  jobId: input.metadata?.jid,
@@ -66,10 +66,13 @@ export declare abstract class StreamService<ClientProvider extends ProviderClien
66
66
  supportsRetry: boolean;
67
67
  supportsNotifications?: boolean;
68
68
  supportsParallelProcessing?: boolean;
69
+ supportsReservationExtension?: boolean;
69
70
  maxMessageSize: number;
70
71
  maxBatchSize: number;
71
72
  };
72
73
  deadLetterMessages?(streamName: string, groupName: string, messageIds: string[]): Promise<number>;
74
+ expireJobMessages?(jid: string): Promise<number>;
75
+ extendReservation?(streamName: string, messageId: string, consumerName: string): Promise<number>;
73
76
  stopNotificationConsumer?(streamName: string, groupName: string): Promise<void>;
74
77
  cleanup?(): Promise<void>;
75
78
  }
@@ -19,6 +19,31 @@ export declare function buildPublishSQL(tableName: string, streamName: string, i
19
19
  sql: string;
20
20
  params: any[];
21
21
  };
22
+ /**
23
+ * Job-liveness context for the delivery guard. `keyPrefix` is the minted
24
+ * job-key prefix (`hmsh:<app>:j:`) so `keyPrefix || jid` addresses the
25
+ * jobs row. `enabled` is mutated to false (self-disable) when the jobs
26
+ * table is not visible from the stream connection.
27
+ */
28
+ export interface JobLivenessContext {
29
+ jobsTable: string;
30
+ keyPrefix: string;
31
+ enabled: boolean;
32
+ }
33
+ /**
34
+ * Soft-delete every live stream row that belongs to a job. Called when a
35
+ * job is interrupted so its queued, reserved, and scheduled-retry
36
+ * messages are never delivered again. Uses the partial jid indexes
37
+ * (idx_*_streams_jid_created); runs once per interrupt.
38
+ */
39
+ export declare function expireJobMessages(client: PostgresClientType & ProviderClient, tableNames: string[], jid: string, logger: ILogger): Promise<number>;
40
+ /**
41
+ * Refresh an owned reservation (heartbeat). Scoped to the owning
42
+ * consumer and to live rows: a message that was reclaimed by another
43
+ * consumer, acked, or expired (job interrupted) reports 0 so the
44
+ * stale consumer can abandon its execution.
45
+ */
46
+ export declare function extendReservation(client: PostgresClientType & ProviderClient, tableName: string, streamName: string, messageId: string, consumerName: string, logger: ILogger): Promise<number>;
22
47
  /**
23
48
  * Fetch messages from the stream with optional exponential backoff.
24
49
  * Uses SKIP LOCKED for high-concurrency consumption.
@@ -33,7 +58,7 @@ export declare function fetchMessages(client: PostgresClientType & ProviderClien
33
58
  initialBackoff?: number;
34
59
  maxBackoff?: number;
35
60
  maxRetries?: number;
36
- }, logger: ILogger): Promise<StreamMessage[]>;
61
+ }, logger: ILogger, liveness?: JobLivenessContext): Promise<StreamMessage[]>;
37
62
  /**
38
63
  * Acknowledge messages (no-op for PostgreSQL - uses soft delete pattern).
39
64
  */
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.retryMessages = exports.deadLetterMessages = exports.ackAndDelete = exports.deleteMessages = exports.acknowledgeMessages = exports.fetchMessages = exports.buildPublishSQL = exports.publishMessages = exports.getMessagePriority = void 0;
3
+ exports.retryMessages = exports.deadLetterMessages = exports.ackAndDelete = exports.deleteMessages = exports.acknowledgeMessages = exports.fetchMessages = exports.extendReservation = exports.expireJobMessages = exports.buildPublishSQL = exports.publishMessages = exports.getMessagePriority = void 0;
4
4
  const enums_1 = require("../../../../modules/enums");
5
5
  const utils_1 = require("../../../../modules/utils");
6
6
  const stream_1 = require("../../../../types/stream");
@@ -203,12 +203,121 @@ function buildPublishSQL(tableName, streamName, isEngine, messages, options) {
203
203
  };
204
204
  }
205
205
  exports.buildPublishSQL = buildPublishSQL;
206
+ /**
207
+ * Soft-delete every live stream row that belongs to a job. Called when a
208
+ * job is interrupted so its queued, reserved, and scheduled-retry
209
+ * messages are never delivered again. Uses the partial jid indexes
210
+ * (idx_*_streams_jid_created); runs once per interrupt.
211
+ */
212
+ async function expireJobMessages(client, tableNames, jid, logger) {
213
+ if (!jid)
214
+ return 0;
215
+ let total = 0;
216
+ for (const tableName of tableNames) {
217
+ try {
218
+ const res = await client.query(`UPDATE ${tableName}
219
+ SET expired_at = NOW()
220
+ WHERE jid = $1 AND expired_at IS NULL`, [jid]);
221
+ total += res.rowCount ?? 0;
222
+ }
223
+ catch (error) {
224
+ logger.error(`postgres-stream-expire-job-error`, {
225
+ tableName,
226
+ jid,
227
+ error,
228
+ });
229
+ throw error;
230
+ }
231
+ }
232
+ return total;
233
+ }
234
+ exports.expireJobMessages = expireJobMessages;
235
+ /**
236
+ * Refresh an owned reservation (heartbeat). Scoped to the owning
237
+ * consumer and to live rows: a message that was reclaimed by another
238
+ * consumer, acked, or expired (job interrupted) reports 0 so the
239
+ * stale consumer can abandon its execution.
240
+ */
241
+ async function extendReservation(client, tableName, streamName, messageId, consumerName, logger) {
242
+ try {
243
+ const res = await client.query(`UPDATE ${tableName}
244
+ SET reserved_at = NOW()
245
+ WHERE stream_name = $1 AND id = $2
246
+ AND reserved_by = $3 AND expired_at IS NULL`, [streamName, parseInt(messageId), consumerName]);
247
+ return res.rowCount ?? 0;
248
+ }
249
+ catch (error) {
250
+ logger.error(`postgres-stream-extend-error-${streamName}`, {
251
+ messageId,
252
+ error,
253
+ });
254
+ throw error;
255
+ }
256
+ }
257
+ exports.extendReservation = extendReservation;
258
+ /**
259
+ * Delivery liveness guard. Scoped to messages that are REDELIVERIES
260
+ * (a prior reservation lapsed) or RETRIES (retry_attempt > 0) — zombie
261
+ * messages of interrupted jobs only resurface through those paths, so
262
+ * first deliveries (the steady-state hot path) pay zero extra cost.
263
+ * A suspect whose job exists, is live, and has status <= 0 is expired
264
+ * in place and dropped from the batch. A missing job row still delivers
265
+ * (job-creating messages precede the row). Fails open on any error;
266
+ * self-disables when the jobs table is not visible (42P01).
267
+ */
268
+ async function dropDeadJobMessages(client, tableName, streamName, rows, liveness, logger) {
269
+ const deadIds = new Set();
270
+ const suspects = rows.filter((row) => row.jid && (row.redelivered || row.retry_attempt > 0));
271
+ if (suspects.length === 0) {
272
+ return deadIds;
273
+ }
274
+ try {
275
+ const res = await client.query(`UPDATE ${tableName} t
276
+ SET expired_at = NOW()
277
+ FROM ${liveness.jobsTable} j
278
+ WHERE t.stream_name = $1
279
+ AND t.id = ANY($2::bigint[])
280
+ AND j.key = $3 || t.jid
281
+ AND j.is_live
282
+ AND j.status <= 0
283
+ RETURNING t.id`, [streamName, suspects.map((row) => row.id), liveness.keyPrefix]);
284
+ for (const row of res.rows) {
285
+ deadIds.add(row.id.toString());
286
+ }
287
+ if (deadIds.size > 0) {
288
+ logger.warn(`postgres-stream-zombie-dropped-${streamName}`, {
289
+ count: deadIds.size,
290
+ jids: [
291
+ ...new Set(suspects
292
+ .filter((row) => deadIds.has(row.id.toString()))
293
+ .map((row) => row.jid)),
294
+ ],
295
+ });
296
+ }
297
+ }
298
+ catch (error) {
299
+ if (error?.code === '42P01') {
300
+ //jobs table is not visible from this connection; the guard cannot
301
+ //run here — interrupt-time purging (expireJobMessages) still applies
302
+ liveness.enabled = false;
303
+ logger.info('postgres-stream-liveness-guard-disabled', {
304
+ jobsTable: liveness.jobsTable,
305
+ });
306
+ }
307
+ else {
308
+ logger.error(`postgres-stream-liveness-guard-error-${streamName}`, {
309
+ error,
310
+ });
311
+ }
312
+ }
313
+ return deadIds;
314
+ }
206
315
  /**
207
316
  * Fetch messages from the stream with optional exponential backoff.
208
317
  * Uses SKIP LOCKED for high-concurrency consumption.
209
318
  * No group_name filter needed - the table itself determines engine vs worker.
210
319
  */
211
- async function fetchMessages(client, tableName, streamName, isEngine, consumerName, options = {}, logger) {
320
+ async function fetchMessages(client, tableName, streamName, isEngine, consumerName, options = {}, logger, liveness) {
212
321
  const enableBackoff = options?.enableBackoff ?? false;
213
322
  const initialBackoff = options?.initialBackoff ?? 100;
214
323
  const maxBackoff = options?.maxBackoff ?? 3000;
@@ -217,10 +326,11 @@ async function fetchMessages(client, tableName, streamName, isEngine, consumerNa
217
326
  let retries = 0;
218
327
  // Include workflow_name in RETURNING for worker streams. Columns are
219
328
  // qualified with the update target's alias because the claim UPDATE
220
- // joins a CTE that also exposes an id column.
329
+ // joins a CTE that also exposes an id column. Worker streams also
330
+ // return jid and the pre-claim redelivery flag for the liveness guard.
221
331
  const returningClause = isEngine
222
332
  ? 't.id, t.message, t.max_retry_attempts, t.backoff_coefficient, t.maximum_interval_seconds, t.retry_attempt'
223
- : 't.id, t.message, t.workflow_name, t.max_retry_attempts, t.backoff_coefficient, t.maximum_interval_seconds, t.retry_attempt';
333
+ : 't.id, t.message, t.workflow_name, t.max_retry_attempts, t.backoff_coefficient, t.maximum_interval_seconds, t.retry_attempt, t.jid, candidates.redelivered';
224
334
  try {
225
335
  while (retries < maxRetries) {
226
336
  retries++;
@@ -232,8 +342,11 @@ async function fetchMessages(client, tableName, streamName, isEngine, consumerNa
232
342
  // reserves MORE rows than LIMIT. The UPDATE repeats stream_name so
233
343
  // the planner prunes to a single hash partition and joins on the
234
344
  // (stream_name, id) primary key.
345
+ // candidates exposes the PRE-claim reservation state: a non-null
346
+ // reserved_at at claim time means a prior reservation lapsed
347
+ // (redelivery) — the trigger condition for the liveness guard.
235
348
  const res = await client.query(`WITH candidates AS MATERIALIZED (
236
- SELECT id FROM ${tableName}
349
+ SELECT id, (reserved_at IS NOT NULL) AS redelivered FROM ${tableName}
237
350
  WHERE stream_name = $1
238
351
  AND (reserved_at IS NULL OR reserved_at < NOW() - INTERVAL '${reservationTimeout} seconds')
239
352
  AND expired_at IS NULL
@@ -247,7 +360,14 @@ async function fetchMessages(client, tableName, streamName, isEngine, consumerNa
247
360
  FROM candidates
248
361
  WHERE t.stream_name = $1 AND t.id = candidates.id
249
362
  RETURNING ${returningClause}`, [streamName, batchSize, consumerName]);
250
- const messages = res.rows.map((row) => {
363
+ let rows = res.rows;
364
+ if (!isEngine && liveness?.enabled && rows.length > 0) {
365
+ const deadIds = await dropDeadJobMessages(client, tableName, streamName, rows, liveness, logger);
366
+ if (deadIds.size > 0) {
367
+ rows = rows.filter((row) => !deadIds.has(row.id.toString()));
368
+ }
369
+ }
370
+ const messages = rows.map((row) => {
251
371
  const data = (0, utils_1.parseStreamMessage)(row.message);
252
372
  const hasDefaultRetryPolicy = (row.max_retry_attempts === 3 || row.max_retry_attempts === 5) &&
253
373
  parseFloat(row.backoff_coefficient) === 10 &&
@@ -31,6 +31,7 @@ declare class PostgresStreamService extends StreamService<PostgresClientType & P
31
31
  */
32
32
  securedMode: boolean;
33
33
  private scoutManager;
34
+ private liveness;
34
35
  private notificationManager;
35
36
  constructor(streamClient: PostgresClientType & ProviderClient, storeClient: ProviderClient, config?: StreamConfig);
36
37
  init(namespace: string, appId: string, logger: ILogger): Promise<void>;
@@ -91,6 +92,20 @@ declare class PostgresStreamService extends StreamService<PostgresClientType & P
91
92
  private setupNotificationConsumer;
92
93
  stopNotificationConsumer(streamName: string, groupName: string): Promise<void>;
93
94
  private fetchMessages;
95
+ /**
96
+ * Refreshes an owned reservation (heartbeat). Called by the consumer
97
+ * while an activity callback is still running so the message stays
98
+ * leased past the base reservation window. Returns 0 when the lease
99
+ * is no longer this consumer's to hold (reclaimed, acked, or expired).
100
+ */
101
+ extendReservation(streamName: string, messageId: string, consumerName: string): Promise<number>;
102
+ /**
103
+ * Soft-deletes every live stream row that belongs to a job, across the
104
+ * worker and engine stream tables. Called by the engine when a job is
105
+ * interrupted or scrubbed so its queued, reserved, and scheduled-retry
106
+ * messages are never delivered again.
107
+ */
108
+ expireJobMessages(jid: string): Promise<number>;
94
109
  ackAndDelete(streamName: string, groupName: string, messageIds: string[]): Promise<number>;
95
110
  deadLetterMessages(streamName: string, groupName: string, messageIds: string[]): Promise<number>;
96
111
  acknowledgeMessages(streamName: string, groupName: string, messageIds: string[], options?: StringAnyType): Promise<number>;
@@ -118,6 +133,7 @@ declare class PostgresStreamService extends StreamService<PostgresClientType & P
118
133
  exactLimit?: boolean;
119
134
  }): Promise<number>;
120
135
  getProviderSpecificFeatures(): {
136
+ supportsReservationExtension: boolean;
121
137
  supportsBatching: boolean;
122
138
  supportsDeadLetterQueue: boolean;
123
139
  supportsOrdering: boolean;
@@ -62,6 +62,14 @@ class PostgresStreamService extends index_1.StreamService {
62
62
  // and never need direct table access.
63
63
  if (!this.securedMode) {
64
64
  await (0, kvtables_1.deploySchema)(this.streamClient, this.appId, this.logger);
65
+ //delivery liveness guard: drops redelivered/retried worker messages
66
+ //whose job is dead. Secured workers cannot read the jobs table —
67
+ //interrupt-time purging (expireJobMessages) covers them instead.
68
+ this.liveness = {
69
+ jobsTable: `${this.safeName(this.appId)}.jobs`,
70
+ keyPrefix: this.mintKey(key_1.KeyType.JOB_STATE, { jobId: '' }),
71
+ enabled: true,
72
+ };
65
73
  }
66
74
  // Initialize scout manager (skipped in secured mode — roles table inaccessible)
67
75
  this.scoutManager = this.securedMode ? null : new scout_1.ScoutManager(this.streamClient, this.appId, this.getEngineTableName.bind(this), this.mintKey.bind(this), this.logger);
@@ -285,7 +293,32 @@ class PostgresStreamService extends index_1.StreamService {
285
293
  return Secured.fetchMessagesSecured(this.streamClient, this.safeName(this.appId), target.streamName, consumerName, options || {}, this.logger);
286
294
  }
287
295
  const target = this.resolveStreamTarget(streamName);
288
- return Messages.fetchMessages(this.streamClient, target.tableName, target.streamName, target.isEngine, consumerName, options || {}, this.logger);
296
+ return Messages.fetchMessages(this.streamClient, target.tableName, target.streamName, target.isEngine, consumerName, options || {}, this.logger, this.liveness);
297
+ }
298
+ /**
299
+ * Refreshes an owned reservation (heartbeat). Called by the consumer
300
+ * while an activity callback is still running so the message stays
301
+ * leased past the base reservation window. Returns 0 when the lease
302
+ * is no longer this consumer's to hold (reclaimed, acked, or expired).
303
+ */
304
+ async extendReservation(streamName, messageId, consumerName) {
305
+ if (this.securedMode) {
306
+ return 0;
307
+ }
308
+ const target = this.resolveStreamTarget(streamName);
309
+ return Messages.extendReservation(this.streamClient, target.tableName, target.streamName, messageId, consumerName, this.logger);
310
+ }
311
+ /**
312
+ * Soft-deletes every live stream row that belongs to a job, across the
313
+ * worker and engine stream tables. Called by the engine when a job is
314
+ * interrupted or scrubbed so its queued, reserved, and scheduled-retry
315
+ * messages are never delivered again.
316
+ */
317
+ async expireJobMessages(jid) {
318
+ if (this.securedMode) {
319
+ return 0;
320
+ }
321
+ return Messages.expireJobMessages(this.streamClient, [this.getEngineTableName(), this.getWorkerTableName()], jid, this.logger);
289
322
  }
290
323
  async ackAndDelete(streamName, groupName, messageIds) {
291
324
  if (this.securedMode) {
@@ -355,7 +388,12 @@ class PostgresStreamService extends index_1.StreamService {
355
388
  return Stats.trimStream(this.streamClient, target.tableName, target.streamName, options, this.logger);
356
389
  }
357
390
  getProviderSpecificFeatures() {
358
- return Stats.getProviderSpecificFeatures(this.config);
391
+ return {
392
+ ...Stats.getProviderSpecificFeatures(this.config),
393
+ //heartbeat lease extension uses direct table access; secured
394
+ //workers use stored procedures and keep hard-deadline leases
395
+ supportsReservationExtension: !this.securedMode,
396
+ };
359
397
  }
360
398
  async cleanup() {
361
399
  if (this.scoutManager) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.25.1",
3
+ "version": "0.25.4",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",