@hotmeshio/hotmesh 0.19.5 → 0.20.1

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.19.5",
3
+ "version": "0.20.1",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -31,8 +31,10 @@ async function deploySchema(streamClient, appId, logger) {
31
31
  }
32
32
  await client.query('COMMIT');
33
33
  }
34
- // Always run index migrations under the lock
34
+ // Always run index, procedure, and trigger migrations under the lock
35
35
  await ensureIndexes(client, schemaName);
36
+ await ensureProcedures(client, schemaName);
37
+ await ensureStatementLevelTriggers(client, schemaName);
36
38
  }
37
39
  finally {
38
40
  await client.query('SELECT pg_advisory_unlock($1)', [lockId]);
@@ -129,7 +131,12 @@ async function waitForTablesCreation(streamClient, lockId, schemaName, logger) {
129
131
  async function ensureIndexes(client, schemaName) {
130
132
  const engineTable = `${schemaName}.engine_streams`;
131
133
  const workerTable = `${schemaName}.worker_streams`;
132
- // Drop legacy indexes that don't include the priority column
134
+ // Drop legacy indexes that don't include the priority column, plus
135
+ // redundant ones: idx_*_expired_at duplicates the partial
136
+ // idx_*_processed_volume for the retention purge, and
137
+ // idx_*_stream_name_expired_at duplicates the leading column and
138
+ // predicate of idx_*_message_fetch. Every index here is maintained on
139
+ // each message's INSERT plus two non-HOT UPDATEs (reserve, ack).
133
140
  for (const idx of [
134
141
  'idx_engine_streams_dequeue',
135
142
  'idx_engine_streams_stale_reservations',
@@ -139,6 +146,10 @@ async function ensureIndexes(client, schemaName) {
139
146
  'idx_engine_streams_message_fetch',
140
147
  'idx_worker_streams_active_messages',
141
148
  'idx_worker_streams_message_fetch',
149
+ 'idx_engine_streams_expired_at',
150
+ 'idx_engine_stream_name_expired_at',
151
+ 'idx_worker_streams_expired_at',
152
+ 'idx_worker_stream_name_expired_at',
142
153
  ]) {
143
154
  await client.query(`DROP INDEX IF EXISTS ${schemaName}.${idx}`);
144
155
  }
@@ -148,9 +159,13 @@ async function ensureIndexes(client, schemaName) {
148
159
  ON ${engineTable} (stream_name, priority DESC, visible_at, id)
149
160
  WHERE reserved_at IS NULL AND expired_at IS NULL;
150
161
  `);
162
+ // message_fetch must match the dequeue ORDER BY (priority DESC, id)
163
+ // exactly — placing visible_at between them forces the claim query to
164
+ // fetch and sort the entire pending backlog instead of stopping at
165
+ // LIMIT. visible_at and stale-reservation checks are scan filters.
151
166
  await client.query(`
152
167
  CREATE INDEX IF NOT EXISTS idx_engine_streams_message_fetch
153
- ON ${engineTable} (stream_name, priority DESC, visible_at, id)
168
+ ON ${engineTable} (stream_name, priority DESC, id)
154
169
  WHERE expired_at IS NULL;
155
170
  `);
156
171
  await client.query(`
@@ -160,7 +175,7 @@ async function ensureIndexes(client, schemaName) {
160
175
  `);
161
176
  await client.query(`
162
177
  CREATE INDEX IF NOT EXISTS idx_worker_streams_message_fetch
163
- ON ${workerTable} (stream_name, priority DESC, visible_at, id)
178
+ ON ${workerTable} (stream_name, priority DESC, id)
164
179
  WHERE expired_at IS NULL;
165
180
  `);
166
181
  // v0.18.0: add jid column to engine_streams for job tracing
@@ -171,6 +186,50 @@ async function ensureIndexes(client, schemaName) {
171
186
  WHERE jid != '';
172
187
  `);
173
188
  }
189
+ /**
190
+ * Re-deploy the SECURITY DEFINER stored procedures on existing
191
+ * databases so query changes (e.g., worker_dequeue) reach deployments
192
+ * created before the change. CREATE OR REPLACE preserves grants.
193
+ */
194
+ async function ensureProcedures(client, schemaName) {
195
+ for (const sql of (0, procedures_1.getCreateProceduresSQL)(schemaName)) {
196
+ await client.query(sql);
197
+ }
198
+ }
199
+ /**
200
+ * Migrate pre-existing row-level notification triggers to the
201
+ * statement-level form. Recreating a trigger takes an ACCESS EXCLUSIVE
202
+ * lock on the table, so only do it when the installed trigger is still
203
+ * row-level (tgtype bit 0 set); subsequent boots are a no-op.
204
+ *
205
+ * The function replacement and trigger swap MUST commit atomically:
206
+ * between them, the still-installed row-level trigger would invoke the
207
+ * statement-level function body, whose transition-table reference
208
+ * (new_rows) errors under FOR EACH ROW — failing every concurrent
209
+ * INSERT until the swap lands (or indefinitely, if the migrating
210
+ * process dies between the two statements).
211
+ */
212
+ async function ensureStatementLevelTriggers(client, schemaName) {
213
+ const result = await client.query(`SELECT count(*) AS row_level
214
+ FROM pg_trigger t
215
+ JOIN pg_class c ON c.oid = t.tgrelid
216
+ JOIN pg_namespace n ON n.oid = c.relnamespace
217
+ WHERE n.nspname = $1
218
+ AND c.relname IN ('engine_streams', 'worker_streams')
219
+ AND t.tgname IN ('notify_engine_stream_insert', 'notify_worker_stream_insert')
220
+ AND (t.tgtype & 1) = 1`, [schemaName]);
221
+ if (parseInt(result.rows[0].row_level, 10) > 0) {
222
+ await client.query('BEGIN');
223
+ try {
224
+ await createNotificationTriggers(client, schemaName);
225
+ await client.query('COMMIT');
226
+ }
227
+ catch (error) {
228
+ await client.query('ROLLBACK');
229
+ throw error;
230
+ }
231
+ }
232
+ }
174
233
  async function createTables(client, schemaName) {
175
234
  await client.query(`CREATE SCHEMA IF NOT EXISTS ${schemaName};`);
176
235
  // ---- ENGINE_STREAMS table ----
@@ -210,16 +269,7 @@ async function createTables(client, schemaName) {
210
269
  `);
211
270
  await client.query(`
212
271
  CREATE INDEX IF NOT EXISTS idx_engine_streams_message_fetch
213
- ON ${engineTable} (stream_name, priority DESC, visible_at, id)
214
- WHERE expired_at IS NULL;
215
- `);
216
- await client.query(`
217
- CREATE INDEX IF NOT EXISTS idx_engine_streams_expired_at
218
- ON ${engineTable} (expired_at);
219
- `);
220
- await client.query(`
221
- CREATE INDEX IF NOT EXISTS idx_engine_stream_name_expired_at
222
- ON ${engineTable} (stream_name)
272
+ ON ${engineTable} (stream_name, priority DESC, id)
223
273
  WHERE expired_at IS NULL;
224
274
  `);
225
275
  await client.query(`
@@ -280,16 +330,7 @@ async function createTables(client, schemaName) {
280
330
  `);
281
331
  await client.query(`
282
332
  CREATE INDEX IF NOT EXISTS idx_worker_streams_message_fetch
283
- ON ${workerTable} (stream_name, priority DESC, visible_at, id)
284
- WHERE expired_at IS NULL;
285
- `);
286
- await client.query(`
287
- CREATE INDEX IF NOT EXISTS idx_worker_streams_expired_at
288
- ON ${workerTable} (expired_at);
289
- `);
290
- await client.query(`
291
- CREATE INDEX IF NOT EXISTS idx_worker_stream_name_expired_at
292
- ON ${workerTable} (stream_name)
333
+ ON ${workerTable} (stream_name, priority DESC, id)
293
334
  WHERE expired_at IS NULL;
294
335
  `);
295
336
  await client.query(`
@@ -342,28 +383,35 @@ async function createNotificationTriggers(client, schemaName) {
342
383
  const engineTable = `${schemaName}.engine_streams`;
343
384
  const workerTable = `${schemaName}.worker_streams`;
344
385
  // ---- ENGINE notification trigger ----
386
+ // Statement-level with a transition table: one pg_notify per distinct
387
+ // stream_name per INSERT statement. Row-level triggers fire pg_notify
388
+ // per message, which both multiplies trigger overhead and serializes
389
+ // commits on the global notification queue lock at high insert rates.
345
390
  await client.query(`
346
391
  CREATE OR REPLACE FUNCTION ${schemaName}.notify_new_engine_stream_message()
347
392
  RETURNS TRIGGER AS $$
348
393
  DECLARE
394
+ rec RECORD;
349
395
  channel_name TEXT;
350
396
  payload JSON;
351
397
  BEGIN
352
- IF NEW.visible_at <= NOW() THEN
353
- channel_name := 'eng_' || NEW.stream_name;
398
+ FOR rec IN
399
+ SELECT DISTINCT stream_name FROM new_rows WHERE visible_at <= NOW()
400
+ LOOP
401
+ channel_name := 'eng_' || rec.stream_name;
354
402
  IF length(channel_name) > 63 THEN
355
403
  channel_name := left(channel_name, 63);
356
404
  END IF;
357
405
 
358
406
  payload := json_build_object(
359
- 'stream_name', NEW.stream_name,
407
+ 'stream_name', rec.stream_name,
360
408
  'table_type', 'engine'
361
409
  );
362
410
 
363
411
  PERFORM pg_notify(channel_name, payload::text);
364
- END IF;
412
+ END LOOP;
365
413
 
366
- RETURN NEW;
414
+ RETURN NULL;
367
415
  END;
368
416
  $$ LANGUAGE plpgsql;
369
417
  `);
@@ -371,7 +419,8 @@ async function createNotificationTriggers(client, schemaName) {
371
419
  DROP TRIGGER IF EXISTS notify_engine_stream_insert ON ${engineTable};
372
420
  CREATE TRIGGER notify_engine_stream_insert
373
421
  AFTER INSERT ON ${engineTable}
374
- FOR EACH ROW
422
+ REFERENCING NEW TABLE AS new_rows
423
+ FOR EACH STATEMENT
375
424
  EXECUTE FUNCTION ${schemaName}.notify_new_engine_stream_message();
376
425
  `);
377
426
  // ---- WORKER notification trigger ----
@@ -379,24 +428,27 @@ async function createNotificationTriggers(client, schemaName) {
379
428
  CREATE OR REPLACE FUNCTION ${schemaName}.notify_new_worker_stream_message()
380
429
  RETURNS TRIGGER AS $$
381
430
  DECLARE
431
+ rec RECORD;
382
432
  channel_name TEXT;
383
433
  payload JSON;
384
434
  BEGIN
385
- IF NEW.visible_at <= NOW() THEN
386
- channel_name := 'wrk_' || NEW.stream_name;
435
+ FOR rec IN
436
+ SELECT DISTINCT stream_name FROM new_rows WHERE visible_at <= NOW()
437
+ LOOP
438
+ channel_name := 'wrk_' || rec.stream_name;
387
439
  IF length(channel_name) > 63 THEN
388
440
  channel_name := left(channel_name, 63);
389
441
  END IF;
390
442
 
391
443
  payload := json_build_object(
392
- 'stream_name', NEW.stream_name,
444
+ 'stream_name', rec.stream_name,
393
445
  'table_type', 'worker'
394
446
  );
395
447
 
396
448
  PERFORM pg_notify(channel_name, payload::text);
397
- END IF;
449
+ END LOOP;
398
450
 
399
- RETURN NEW;
451
+ RETURN NULL;
400
452
  END;
401
453
  $$ LANGUAGE plpgsql;
402
454
  `);
@@ -404,7 +456,8 @@ async function createNotificationTriggers(client, schemaName) {
404
456
  DROP TRIGGER IF EXISTS notify_worker_stream_insert ON ${workerTable};
405
457
  CREATE TRIGGER notify_worker_stream_insert
406
458
  AFTER INSERT ON ${workerTable}
407
- FOR EACH ROW
459
+ REFERENCING NEW TABLE AS new_rows
460
+ FOR EACH STATEMENT
408
461
  EXECUTE FUNCTION ${schemaName}.notify_new_worker_stream_message();
409
462
  `);
410
463
  // ---- Visibility timeout notification function (queries both tables) ----
@@ -215,18 +215,24 @@ async function fetchMessages(client, tableName, streamName, isEngine, consumerNa
215
215
  const maxRetries = options?.maxRetries ?? 3;
216
216
  let backoff = initialBackoff;
217
217
  let retries = 0;
218
- // Include workflow_name in RETURNING for worker streams
218
+ // Include workflow_name in RETURNING for worker streams. Columns are
219
+ // qualified with the update target's alias because the claim UPDATE
220
+ // joins a CTE that also exposes an id column.
219
221
  const returningClause = isEngine
220
- ? 'id, message, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, retry_attempt'
221
- : 'id, message, workflow_name, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, retry_attempt';
222
+ ? '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';
222
224
  try {
223
225
  while (retries < maxRetries) {
224
226
  retries++;
225
227
  const batchSize = options?.batchSize || 1;
226
228
  const reservationTimeout = options?.reservationTimeout || (enums_1.HMSH_RESERVATION_TIMEOUT_S + 5);
227
- const res = await client.query(`UPDATE ${tableName}
228
- SET reserved_at = NOW(), reserved_by = $3
229
- WHERE id IN (
229
+ // The locking SELECT must live in a MATERIALIZED CTE: as a plain IN
230
+ // subquery the planner may re-execute it per outer row (rows updated
231
+ // earlier in the same command are skipped as lock candidates), which
232
+ // reserves MORE rows than LIMIT. The UPDATE repeats stream_name so
233
+ // the planner prunes to a single hash partition and joins on the
234
+ // (stream_name, id) primary key.
235
+ const res = await client.query(`WITH candidates AS MATERIALIZED (
230
236
  SELECT id FROM ${tableName}
231
237
  WHERE stream_name = $1
232
238
  AND (reserved_at IS NULL OR reserved_at < NOW() - INTERVAL '${reservationTimeout} seconds')
@@ -236,6 +242,10 @@ async function fetchMessages(client, tableName, streamName, isEngine, consumerNa
236
242
  LIMIT $2
237
243
  FOR UPDATE SKIP LOCKED
238
244
  )
245
+ UPDATE ${tableName} t
246
+ SET reserved_at = NOW(), reserved_by = $3
247
+ FROM candidates
248
+ WHERE t.stream_name = $1 AND t.id = candidates.id
239
249
  RETURNING ${returningClause}`, [streamName, batchSize, consumerName]);
240
250
  const messages = res.rows.map((row) => {
241
251
  const data = (0, utils_1.parseStreamMessage)(row.message);
@@ -36,6 +36,13 @@ declare class PostgresStreamService extends StreamService<PostgresClientType & P
36
36
  init(namespace: string, appId: string, logger: ILogger): Promise<void>;
37
37
  private isNotificationsEnabled;
38
38
  private checkForMissedMessages;
39
+ /**
40
+ * Notification-driven fetch with coalescing. NOTIFYs that arrive while
41
+ * a fetch is in flight set fetchPending instead of issuing concurrent
42
+ * claim queries (a burst of N inserts otherwise triggers N claims per
43
+ * consumer, most returning empty). The drain loop re-fetches while the
44
+ * batch came back full or a NOTIFY arrived mid-fetch.
45
+ */
39
46
  private fetchAndDeliverMessages;
40
47
  private getConsumerKey;
41
48
  /**
@@ -82,11 +82,31 @@ class PostgresStreamService extends index_1.StreamService {
82
82
  return await instance.fetchMessages(consumer.streamName, consumer.groupName, consumer.consumerName, { batchSize: 10, reservationTimeout: instance.reservationTimeout, enableBackoff: false, maxRetries: 1 });
83
83
  });
84
84
  }
85
+ /**
86
+ * Notification-driven fetch with coalescing. NOTIFYs that arrive while
87
+ * a fetch is in flight set fetchPending instead of issuing concurrent
88
+ * claim queries (a burst of N inserts otherwise triggers N claims per
89
+ * consumer, most returning empty). The drain loop re-fetches while the
90
+ * batch came back full or a NOTIFY arrived mid-fetch.
91
+ */
85
92
  async fetchAndDeliverMessages(consumer) {
93
+ if (consumer.fetchInFlight) {
94
+ consumer.fetchPending = true;
95
+ return;
96
+ }
97
+ consumer.fetchInFlight = true;
98
+ const batchSize = 10;
86
99
  try {
87
- const messages = await this.fetchMessages(consumer.streamName, consumer.groupName, consumer.consumerName, { batchSize: 10, reservationTimeout: this.reservationTimeout, enableBackoff: false, maxRetries: 1 });
88
- if (messages.length > 0) {
89
- consumer.callback(messages);
100
+ let drain = true;
101
+ while (drain && consumer.isListening !== false) {
102
+ consumer.fetchPending = false;
103
+ const messages = await this.fetchMessages(consumer.streamName, consumer.groupName, consumer.consumerName, { batchSize, reservationTimeout: this.reservationTimeout, enableBackoff: false, maxRetries: 1 });
104
+ if (messages.length > 0) {
105
+ consumer.callback(messages);
106
+ }
107
+ // Boolean() rather than === true: fetchPending is mutated by the
108
+ // notification handler across the await, which TS narrowing misses
109
+ drain = messages.length === batchSize || Boolean(consumer.fetchPending);
90
110
  }
91
111
  }
92
112
  catch (error) {
@@ -96,6 +116,9 @@ class PostgresStreamService extends index_1.StreamService {
96
116
  error,
97
117
  });
98
118
  }
119
+ finally {
120
+ consumer.fetchInFlight = false;
121
+ }
99
122
  }
100
123
  getConsumerKey(streamName, groupName) {
101
124
  return `${streamName}:${groupName}`;
@@ -54,10 +54,12 @@ function getCreateProceduresSQL(schemaName) {
54
54
  SET search_path = ${schemaName}, pg_temp
55
55
  AS $$
56
56
  ${STREAM_ACCESS_CHECK}
57
+ -- The locking SELECT must live in a MATERIALIZED CTE: as a plain IN
58
+ -- subquery the planner may re-execute it per outer row, reserving
59
+ -- MORE rows than p_batch_size. stream_name on the UPDATE prunes to
60
+ -- a single hash partition and joins on the primary key.
57
61
  RETURN QUERY
58
- UPDATE ${workerTable} ws
59
- SET reserved_at = NOW(), reserved_by = p_consumer_id
60
- WHERE ws.id IN (
62
+ WITH candidates AS MATERIALIZED (
61
63
  SELECT ws2.id FROM ${workerTable} ws2
62
64
  WHERE ws2.stream_name = p_stream_name
63
65
  AND (ws2.reserved_at IS NULL OR ws2.reserved_at < NOW() - (p_reservation_timeout_sec || ' seconds')::INTERVAL)
@@ -67,6 +69,11 @@ function getCreateProceduresSQL(schemaName) {
67
69
  LIMIT p_batch_size
68
70
  FOR UPDATE SKIP LOCKED
69
71
  )
72
+ UPDATE ${workerTable} ws
73
+ SET reserved_at = NOW(), reserved_by = p_consumer_id
74
+ FROM candidates
75
+ WHERE ws.stream_name = p_stream_name
76
+ AND ws.id = candidates.id
70
77
  RETURNING ws.id, ws.message, ws.workflow_name, ws.max_retry_attempts,
71
78
  ws.backoff_coefficient, ws.maximum_interval_seconds, ws.retry_attempt;
72
79
  END;
@@ -300,4 +300,8 @@ export interface NotificationConsumer {
300
300
  lastFallbackCheck: number;
301
301
  /** Service instance that owns this consumer (for fetchAndDeliverMessages dispatch) */
302
302
  serviceInstance?: any;
303
+ /** True while a notification-driven fetch is in flight (coalesces concurrent NOTIFYs) */
304
+ fetchInFlight?: boolean;
305
+ /** Set when a NOTIFY arrives mid-fetch; triggers one follow-up fetch */
306
+ fetchPending?: boolean;
303
307
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.19.5",
3
+ "version": "0.20.1",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",