@hotmeshio/hotmesh 0.25.5 → 0.26.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.
@@ -35,6 +35,10 @@ async function deploySchema(streamClient, appId, logger) {
35
35
  await ensureIndexes(client, schemaName);
36
36
  await ensureProcedures(client, schemaName);
37
37
  await ensureStatementLevelTriggers(client, schemaName);
38
+ // Re-deploy the fallback poller's discovery function so existing
39
+ // databases receive predicate changes (v0.25.6: stale-reservation
40
+ // reclaim — see getNotifyVisibleMessagesSQL)
41
+ await client.query(getNotifyVisibleMessagesSQL(schemaName));
38
42
  }
39
43
  finally {
40
44
  await client.query('SELECT pg_advisory_unlock($1)', [lockId]);
@@ -177,6 +181,19 @@ async function ensureIndexes(client, schemaName) {
177
181
  CREATE INDEX IF NOT EXISTS idx_worker_streams_message_fetch
178
182
  ON ${workerTable} (stream_name, priority DESC, id)
179
183
  WHERE expired_at IS NULL;
184
+ `);
185
+ // v0.25.6: in-flight partial indexes — bounded to currently reserved
186
+ // rows (≈ concurrent executions) — serve the fallback poller's
187
+ // stale-reservation reclaim branch (see getNotifyVisibleMessagesSQL)
188
+ await client.query(`
189
+ CREATE INDEX IF NOT EXISTS idx_engine_streams_inflight
190
+ ON ${engineTable} (stream_name, reserved_at)
191
+ WHERE expired_at IS NULL AND reserved_at IS NOT NULL;
192
+ `);
193
+ await client.query(`
194
+ CREATE INDEX IF NOT EXISTS idx_worker_streams_inflight
195
+ ON ${workerTable} (stream_name, reserved_at)
196
+ WHERE expired_at IS NULL AND reserved_at IS NOT NULL;
180
197
  `);
181
198
  // v0.18.0: add jid column to engine_streams for job tracing
182
199
  await client.query(`ALTER TABLE ${engineTable} ADD COLUMN IF NOT EXISTS jid TEXT NOT NULL DEFAULT ''`);
@@ -461,7 +478,34 @@ async function createNotificationTriggers(client, schemaName) {
461
478
  EXECUTE FUNCTION ${schemaName}.notify_new_worker_stream_message();
462
479
  `);
463
480
  // ---- Visibility timeout notification function (queries both tables) ----
464
- await client.query(`
481
+ await client.query(getNotifyVisibleMessagesSQL(schemaName));
482
+ }
483
+ /**
484
+ * The fallback poller's discovery function. Surfaces streams that have
485
+ * deliverable work: unreserved visible messages AND stale reservations
486
+ * whose holder died (heartbeats refresh reserved_at every ~15s while an
487
+ * activity runs, so any reservation older than 60s has a dead holder or
488
+ * is already claimable). Stale-reservation discovery is the ONLY path
489
+ * that reclaims orphaned in-flight work on a quiet stream — without it,
490
+ * a process death wedges its reserved messages forever. A premature
491
+ * surface (reservation window configured above 60s on a provider with
492
+ * static leases) costs one empty fetch; the claim query enforces the
493
+ * real window.
494
+ *
495
+ * Deployed on fresh schemas AND re-deployed on every boot (see
496
+ * deploySchema) so existing databases receive predicate changes.
497
+ */
498
+ function getNotifyVisibleMessagesSQL(schemaName) {
499
+ const engineTable = `${schemaName}.engine_streams`;
500
+ const workerTable = `${schemaName}.worker_streams`;
501
+ //stale threshold tracks the configured base window: heartbeats refresh
502
+ //reserved_at every base/2 (15s default), so live holders never look
503
+ //stale; 2x base keeps static-lease holders (secured workers, one
504
+ //adaptive doubling) from surfacing as false positives. Baked at
505
+ //deploy time from the deploying node's env — a premature surface
506
+ //costs one empty fetch (the claim query enforces the real window).
507
+ const staleSeconds = Math.max(60, enums_1.HMSH_RESERVATION_TIMEOUT_S * 2);
508
+ return `
465
509
  CREATE OR REPLACE FUNCTION ${schemaName}.notify_visible_messages()
466
510
  RETURNS INTEGER AS $$
467
511
  DECLARE
@@ -470,13 +514,23 @@ async function createNotificationTriggers(client, schemaName) {
470
514
  payload JSON;
471
515
  notification_count INTEGER := 0;
472
516
  BEGIN
473
- -- Engine streams
517
+ -- Engine streams: visible unreserved work (active_messages
518
+ -- partial index) UNION stale reservations whose holder died
519
+ -- (inflight partial index) — each branch index-matched
474
520
  FOR msg IN
475
- SELECT DISTINCT stream_name
476
- FROM ${engineTable}
477
- WHERE visible_at <= NOW()
478
- AND reserved_at IS NULL
479
- AND expired_at IS NULL
521
+ SELECT DISTINCT u.stream_name FROM (
522
+ (SELECT stream_name FROM ${engineTable}
523
+ WHERE visible_at <= NOW()
524
+ AND reserved_at IS NULL
525
+ AND expired_at IS NULL
526
+ LIMIT 50)
527
+ UNION ALL
528
+ (SELECT stream_name FROM ${engineTable}
529
+ WHERE expired_at IS NULL
530
+ AND reserved_at IS NOT NULL
531
+ AND reserved_at < NOW() - INTERVAL '${staleSeconds} seconds'
532
+ LIMIT 50)
533
+ ) u
480
534
  LIMIT 50
481
535
  LOOP
482
536
  channel_name := 'eng_' || msg.stream_name;
@@ -493,13 +547,21 @@ async function createNotificationTriggers(client, schemaName) {
493
547
  notification_count := notification_count + 1;
494
548
  END LOOP;
495
549
 
496
- -- Worker streams
550
+ -- Worker streams: same two index-matched branches
497
551
  FOR msg IN
498
- SELECT DISTINCT stream_name
499
- FROM ${workerTable}
500
- WHERE visible_at <= NOW()
501
- AND reserved_at IS NULL
502
- AND expired_at IS NULL
552
+ SELECT DISTINCT u.stream_name FROM (
553
+ (SELECT stream_name FROM ${workerTable}
554
+ WHERE visible_at <= NOW()
555
+ AND reserved_at IS NULL
556
+ AND expired_at IS NULL
557
+ LIMIT 50)
558
+ UNION ALL
559
+ (SELECT stream_name FROM ${workerTable}
560
+ WHERE expired_at IS NULL
561
+ AND reserved_at IS NOT NULL
562
+ AND reserved_at < NOW() - INTERVAL '${staleSeconds} seconds'
563
+ LIMIT 50)
564
+ ) u
503
565
  LIMIT 50
504
566
  LOOP
505
567
  channel_name := 'wrk_' || msg.stream_name;
@@ -519,7 +581,7 @@ async function createNotificationTriggers(client, schemaName) {
519
581
  RETURN notification_count;
520
582
  END;
521
583
  $$ LANGUAGE plpgsql;
522
- `);
584
+ `;
523
585
  }
524
586
  function getNotificationChannelName(streamName, isEngine) {
525
587
  const prefix = isEngine ? 'eng_' : 'wrk_';
@@ -296,12 +296,15 @@ async function dropDeadJobMessages(client, tableName, streamName, rows, liveness
296
296
  }
297
297
  }
298
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
299
+ if (error?.code === '42P01' || error?.code === '42501') {
300
+ //jobs table is not visible (42P01) or not readable (42501) from
301
+ //this connection; the guard cannot run here — interrupt-time
302
+ //purging (expireJobMessages) still applies. Self-disable so the
303
+ //hot path stops issuing a failing cross-table query per fetch.
302
304
  liveness.enabled = false;
303
305
  logger.info('postgres-stream-liveness-guard-disabled', {
304
306
  jobsTable: liveness.jobsTable,
307
+ code: error.code,
305
308
  });
306
309
  }
307
310
  else {
@@ -150,17 +150,19 @@ function getCreateProceduresSQL(schemaName) {
150
150
  -- Engine stream_name is the schema/appId name
151
151
  engine_stream_name := '${schemaName}';
152
152
 
153
+ -- jid stamped from the message metadata (parity with the raw
154
+ -- publish path) so job-scoped expiry can address these rows
153
155
  IF p_max_retry_attempts IS NOT NULL THEN
154
156
  INSERT INTO ${engineTable}
155
- (stream_name, message, priority, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, visible_at, retry_attempt)
157
+ (stream_name, jid, message, priority, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, visible_at, retry_attempt)
156
158
  VALUES
157
- (engine_stream_name, p_message, p_priority, p_max_retry_attempts, p_backoff_coefficient, p_maximum_interval_seconds, p_visible_at, p_retry_attempt)
159
+ (engine_stream_name, COALESCE(p_message::jsonb -> 'metadata' ->> 'jid', ''), p_message, p_priority, p_max_retry_attempts, p_backoff_coefficient, p_maximum_interval_seconds, p_visible_at, p_retry_attempt)
158
160
  RETURNING id INTO new_id;
159
161
  ELSE
160
162
  INSERT INTO ${engineTable}
161
- (stream_name, message, priority, visible_at, retry_attempt)
163
+ (stream_name, jid, message, priority, visible_at, retry_attempt)
162
164
  VALUES
163
- (engine_stream_name, p_message, p_priority, p_visible_at, p_retry_attempt)
165
+ (engine_stream_name, COALESCE(p_message::jsonb -> 'metadata' ->> 'jid', ''), p_message, p_priority, p_visible_at, p_retry_attempt)
164
166
  RETURNING id INTO new_id;
165
167
  END IF;
166
168
 
@@ -106,17 +106,16 @@ export type ResolveEscalationResult = {
106
106
  reason: 'not-found' | 'already-resolved' | 'already-cancelled' | 'already-expired';
107
107
  };
108
108
  /**
109
- * A pre-built wake publish, executed INSIDE the resolve transaction so the
110
- * awaiting workflow's wake commits atomically with `status='resolved'`. The
111
- * SQL is a stream INSERT produced by the stream provider; `forSignalKey`
112
- * pins the command to the row it was built for — the store executes it only
113
- * when the locked row's `signal_key` matches (a mismatched row falls back
114
- * to post-commit delivery).
109
+ * A pre-built wake message, committed INSIDE the resolve/cancel transaction
110
+ * so the awaiting workflow's wake is durable with the status change. The
111
+ * client composes the webhook message; the store owns the stream INSERT.
112
+ * `forSignalKey` pins the wake to the row it was built for — it is written
113
+ * only when the affected row's `signal_key` matches (a mismatched row falls
114
+ * back to post-commit delivery).
115
115
  */
116
116
  export interface EscalationWakeCommand {
117
117
  forSignalKey: string;
118
- sql: string;
119
- params: unknown[];
118
+ message: string;
120
119
  }
121
120
  export type ReleaseEscalationResult = {
122
121
  ok: true;
@@ -132,6 +131,29 @@ export type CancelEscalationResult = {
132
131
  ok: false;
133
132
  reason: 'not-found' | 'already-terminal';
134
133
  };
134
+ /**
135
+ * Retention parameters for `pruneEscalations`. Prunes only terminal rows
136
+ * (`resolved`/`cancelled`/`expired`) — the statuses every engine state
137
+ * transition treats as final — older than the given horizon.
138
+ */
139
+ export interface PruneEscalationsParams {
140
+ /**
141
+ * Age horizon as a Postgres interval string (e.g. `'90 days'`, `'12 hours'`).
142
+ * Rows qualify when `updated_at < NOW() - olderThan`.
143
+ */
144
+ olderThan: string;
145
+ /** Terminal statuses to prune. Defaults to all three; non-terminal values are ignored. */
146
+ statuses?: Array<'resolved' | 'cancelled' | 'expired'>;
147
+ namespace?: string;
148
+ /**
149
+ * Max rows deleted per call (bounds lock time and vacuum pressure).
150
+ * Default 10,000, capped at 100,000. Loop until `deleted` is 0 to drain.
151
+ */
152
+ limit?: number;
153
+ }
154
+ export interface PruneEscalationsResult {
155
+ deleted: number;
156
+ }
135
157
  export interface ListEscalationsParams {
136
158
  namespace?: string;
137
159
  role?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.25.5",
3
+ "version": "0.26.0",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
package/ISSUE.md DELETED
@@ -1,53 +0,0 @@
1
- # ADDENDUM — lost condition wakes are NOT restart-dependent (registration-window race)
2
-
3
- Follow-up to the earlier "Durable wakes lost across process restart" report filed
4
- tonight (sleep timers + condition signals). New evidence sharpens the condition case.
5
-
6
- ## Reproduced with no restart involved
7
-
8
- Same environment (hotmesh 0.25.4, Postgres, long-tail 0.7.3), process running
9
- continuously the whole time:
10
-
11
- - `13:59:36.204` — pill escalation created (conditionLT atomic park, child
12
- `...-winddown-gluer-0`).
13
- - `13:59:36.237` — row **resolved**, 33 ms after creation (a hot consumer was
14
- already scanning the pond and grabbed it instantly).
15
- - 15+ minutes later — the awaiting child is still `status > 0`, never woke;
16
- its parent's `Promise.all` wedged with it.
17
-
18
- ## The pattern across all three observed condition cases
19
-
20
- Every lost condition wake we have seen shares one property: **the resolve landed
21
- sub-second after the conditionLT park** (33 ms, 232 ms, ~600 ms). Slow resolves
22
- (seconds or minutes after park) have never lost a wake across hundreds of
23
- station-worker resolutions tonight.
24
-
25
- Hypothesis: a resolve that commits **inside the awaiting workflow's
26
- wake-registration window** — after the escalation row is visible to consumers
27
- but before the awaiter's subscription is fully registered — delivers to nobody,
28
- and nothing ever re-checks the persisted row against sleeping awaiters. The
29
- restart in the earlier case was likely incidental; the window is the bug.
30
-
31
- ## Suggested repro (tighter than the original)
32
-
33
- 1. Workflow A parks via `conditionLT` (the escalation-creating overload).
34
- 2. A hot loop polls for the row and resolves it the instant it appears
35
- (sub-100 ms). No process kill needed.
36
- 3. Observe whether A ever wakes. In our runs, a same-process consumer beating
37
- the registration window wedges A reliably enough to hit twice in one evening.
38
-
39
- ## Suggested direction (unchanged, reinforced)
40
-
41
- Boot-time AND periodic reconciliation of resolved-rows-with-sleeping-awaiters
42
- would heal both the restart loss and this race — the row carries the awaiting
43
- `workflow_id`; the join is cheap. Making the wake itself a stream message
44
- (liveness-redelivered since 0.25.4) also closes the window, since the message
45
- persists until reserved and acked.
46
-
47
- ## Our workaround (in production use as of tonight)
48
-
49
- Settlement guard in the watcher activity: after the row's outcome is known,
50
- poll `durable.jobs` for the awaiting child's status; if still running ~10s
51
- after its row resolved, terminate the child by id (terminate purges cleanly
52
- since 0.25.4) and treat the row's persisted resolver payload as authoritative.
53
- The parent catches the child's terminate rejection. Ugly but wedge-proof.