@hotmeshio/hotmesh 0.16.1 → 0.16.3

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.16.1",
3
+ "version": "0.16.3",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -3,7 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.processEvent = void 0;
4
4
  const enums_1 = require("../../../modules/enums");
5
5
  const errors_1 = require("../../../modules/errors");
6
- const collator_1 = require("../../collator");
6
+ const collator_1 = require("../../../types/collator");
7
+ const collator_2 = require("../../collator");
7
8
  const telemetry_1 = require("../../telemetry");
8
9
  const stream_1 = require("../../../types/stream");
9
10
  // Per-instance collation error tracking for reservation timeout detection
@@ -34,7 +35,7 @@ async function processEvent(instance, status = stream_1.StreamStatus.SUCCESS, co
34
35
  try {
35
36
  const collationKey = await instance.verifyReentry();
36
37
  instance.adjacentIndex =
37
- collator_1.CollatorService.getDimensionalIndex(collationKey);
38
+ collator_2.CollatorService.getDimensionalIndex(collationKey);
38
39
  telemetry = new telemetry_1.TelemetryService(instance.engine.appId, instance.config, instance.metadata, instance.context);
39
40
  telemetry.startActivitySpan(instance.leg);
40
41
  //bind data per status type
@@ -71,10 +72,27 @@ async function processEvent(instance, status = stream_1.StreamStatus.SUCCESS, co
71
72
  }
72
73
  catch (error) {
73
74
  if (error instanceof errors_1.CollationError) {
74
- // INACTIVE is legitimate duplicate detection the Postgres atomic
75
- // CTE (collateLeg2Entry) serializes via row locks, so the GUID
76
- // ledger value is correct. Silent ack is the right behavior:
77
- // the work was already done by a prior delivery of this message.
75
+ //FORBIDDEN: Leg1 not complete signal arrived in the window
76
+ //between registerHook (standalone) and Leg1 transaction commit.
77
+ //Rethrow so the stream message is retried with backoff; by then
78
+ //Leg1 will have committed and Leg2 processing will succeed.
79
+ //The GUID marker was already committed by notarizeLeg2Entry;
80
+ //on retry, collateLeg2Entry's SETNX is a no-op for the same
81
+ //GUID, and verifySyntheticInteger sees no steps done → allowed.
82
+ if (error.fault === collator_1.CollationFaultType.FORBIDDEN) {
83
+ instance.logger.warn('process-event-forbidden-retry', {
84
+ jid: instance.context.metadata.jid,
85
+ aid: instance.metadata.aid,
86
+ message: 'Leg1 not committed yet; rethrowing for stream retry',
87
+ error,
88
+ });
89
+ throw error;
90
+ }
91
+ // INACTIVE/DUPLICATE: legitimate duplicate detection — the
92
+ // Postgres atomic CTE (collateLeg2Entry) serializes via row
93
+ // locks, so the GUID ledger value is correct. Silent ack is
94
+ // the right behavior: the work was already done by a prior
95
+ // delivery of this message.
78
96
  const now = Date.now();
79
97
  if (now - collationWindowStart > COLLATION_WINDOW_MS) {
80
98
  collationErrorCount = 0;
@@ -153,20 +153,6 @@ const KVTables = (context) => ({
153
153
  FOR EACH ROW EXECUTE FUNCTION ${schemaName}.update_jobs_updated_at();
154
154
  `);
155
155
  }
156
- // v0.15.2: add dedicated is_live partial index for hot-path queries
157
- const { rows: idxRows } = await client.query(`SELECT 1 FROM pg_indexes WHERE indexname = 'idx_jobs_key_live' AND schemaname = $1 LIMIT 1`, [schemaName]);
158
- if (idxRows.length === 0) {
159
- await client.query(`
160
- CREATE INDEX IF NOT EXISTS idx_jobs_key_live
161
- ON ${jobsTable} (key) WHERE is_live;
162
- `);
163
- }
164
- // v0.15.2: partial index for sorted_set scheduler hot path
165
- await client.query(`
166
- CREATE INDEX IF NOT EXISTS idx_task_schedules_active
167
- ON ${schemaName}.task_schedules (key, score)
168
- WHERE expiry IS NULL;
169
- `);
170
156
  },
171
157
  async createTables(client, appName) {
172
158
  try {
@@ -265,40 +251,27 @@ const KVTables = (context) => ({
265
251
  CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_context_gin
266
252
  ON ${fullTableName} USING GIN (context);
267
253
  `);
268
- // Create partitions with fillfactor 70: reserves 30% page space
269
- // for HOT updates (status update cycle in setStatusAndCollateGuid)
254
+ // Create partitions using a DO block
270
255
  await client.query(`
271
256
  DO $$
272
257
  BEGIN
273
258
  FOR i IN 0..7 LOOP
274
259
  EXECUTE format(
275
260
  'CREATE TABLE IF NOT EXISTS ${fullTableName}_part_%s PARTITION OF ${fullTableName}
276
- FOR VALUES WITH (modulus 8, remainder %s)
277
- WITH (fillfactor = 70)',
261
+ FOR VALUES WITH (modulus 8, remainder %s)',
278
262
  i, i
279
263
  );
280
264
  END LOOP;
281
265
  END$$;
282
266
  `);
283
- // Original index for queries that filter on expired_at directly
284
- // (e.g. _hmget's WHERE expired_at IS NULL OR expired_at > NOW())
267
+ // Create optimized indexes
285
268
  await client.query(`
286
269
  CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_expired_at
287
270
  ON ${fullTableName} (key, expired_at) INCLUDE (is_live);
288
271
  `);
289
- // Dedicated partial index for the hot-path (WHERE key = $1 AND is_live).
290
- // Covers the uniqueness trigger, hget, hgetall, hincrbyfloat, and
291
- // the two-pass upsert — all of which use AND is_live directly.
292
- await client.query(`
293
- CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_live
294
- ON ${fullTableName} (key)
295
- WHERE is_live;
296
- `);
297
- // status in INCLUDE (not key) so semaphore increments don't
298
- // force index key updates — allows HOT on the hottest write path.
299
272
  await client.query(`
300
273
  CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_entity_status
301
- ON ${fullTableName} (entity) INCLUDE (status);
274
+ ON ${fullTableName} (entity, status);
302
275
  `);
303
276
  await client.query(`
304
277
  CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_expired_at
@@ -330,9 +303,7 @@ const KVTables = (context) => ({
330
303
  BEFORE INSERT OR UPDATE ON ${fullTableName}
331
304
  FOR EACH ROW EXECUTE PROCEDURE ${schemaName}.update_is_live();
332
305
  `);
333
- // Enforce uniqueness of live jobs via trigger.
334
- // Uses is_live partial index for the EXISTS check (existing rows
335
- // have is_live maintained by trg_update_is_live).
306
+ // Create function to enforce uniqueness of live jobs
336
307
  await client.query(`
337
308
  CREATE OR REPLACE FUNCTION ${schemaName}.enforce_live_job_uniqueness()
338
309
  RETURNS TRIGGER AS $$
@@ -342,7 +313,7 @@ const KVTables = (context) => ({
342
313
  IF EXISTS (
343
314
  SELECT 1 FROM ${fullTableName}
344
315
  WHERE key = NEW.key
345
- AND is_live
316
+ AND (expired_at IS NULL OR expired_at > NOW())
346
317
  AND id <> NEW.id
347
318
  ) THEN
348
319
  RAISE EXCEPTION 'A live job with key % already exists.', NEW.key;
@@ -413,16 +384,14 @@ const KVTables = (context) => ({
413
384
  FOR EACH ROW
414
385
  EXECUTE FUNCTION ${schemaName}.update_attributes_updated_at();
415
386
  `);
416
- // Create partitions with fillfactor 70: reserves 30% page space
417
- // for HOT updates (frequent upserts in hincrbyfloat/collateLeg2Entry)
387
+ // Create partitions for attributes table
418
388
  await client.query(`
419
389
  DO $$
420
390
  BEGIN
421
391
  FOR i IN 0..7 LOOP
422
392
  EXECUTE format(
423
393
  'CREATE TABLE IF NOT EXISTS ${attributesTableName}_part_%s PARTITION OF ${attributesTableName}
424
- FOR VALUES WITH (modulus 8, remainder %s)
425
- WITH (fillfactor = 70)',
394
+ FOR VALUES WITH (modulus 8, remainder %s)',
426
395
  i, i
427
396
  );
428
397
  END LOOP;
@@ -466,14 +435,6 @@ const KVTables = (context) => ({
466
435
  await client.query(`
467
436
  CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_score_member
468
437
  ON ${fullTableName} (key, score, member);
469
- `);
470
- // Partial index for the scheduler hot path (zrangebyscore).
471
- // Most entries have no expiry; this avoids the OR filter that
472
- // prevents clean index-only scans.
473
- await client.query(`
474
- CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_active
475
- ON ${fullTableName} (key, score)
476
- WHERE expiry IS NULL;
477
438
  `);
478
439
  break;
479
440
  default:
@@ -292,19 +292,11 @@ function _hset(context, key, fields, options) {
292
292
  params.push(key, fields[':'], options?.entity ?? null);
293
293
  }
294
294
  else {
295
- // Two-pass upsert: UPDATE existing live job, INSERT only if no
296
- // live job exists. Avoids ON CONFLICT seq_scan on partitioned
297
- // tables that lack a unique partial index.
295
+ // Update existing job or insert new one
298
296
  sql = `
299
- WITH existing AS (
300
- UPDATE ${targetTable}
301
- SET status = $2
302
- WHERE key = $1 AND is_live
303
- RETURNING 1
304
- )
305
297
  INSERT INTO ${targetTable} (id, key, status, entity)
306
- SELECT gen_random_uuid(), $1, $2, $3
307
- WHERE NOT EXISTS (SELECT 1 FROM existing)
298
+ VALUES (gen_random_uuid(), $1, $2, $3)
299
+ ON CONFLICT (key) WHERE is_live DO UPDATE SET status = EXCLUDED.status
308
300
  RETURNING 1 as count
309
301
  `;
310
302
  params.push(key, fields[':'], options?.entity ?? null);
@@ -511,20 +503,31 @@ function _hgetall(context, key) {
511
503
  const tableName = context.tableForKey(key, 'hash');
512
504
  const isJobsTableResult = (0, utils_1.isJobsTable)(tableName);
513
505
  if (isJobsTableResult) {
514
- // Single CTE: reads jobs row once, then joins attributes
515
506
  const sql = `
516
507
  WITH valid_job AS (
517
508
  SELECT id, status, context
518
509
  FROM ${tableName}
519
510
  WHERE key = $1 AND is_live
511
+ ),
512
+ job_data AS (
513
+ SELECT 'status' AS field, status::text AS value
514
+ FROM ${tableName}
515
+ WHERE key = $1 AND is_live
516
+
517
+ UNION ALL
518
+
519
+ SELECT 'context' AS field, context::text AS value
520
+ FROM ${tableName}
521
+ WHERE key = $1 AND is_live
522
+ ),
523
+ attribute_data AS (
524
+ SELECT symbol || dimension AS field, value
525
+ FROM ${tableName}_attributes
526
+ WHERE job_id IN (SELECT id FROM valid_job)
520
527
  )
521
- SELECT 'status' AS field, status::text AS value FROM valid_job
522
- UNION ALL
523
- SELECT 'context' AS field, context::text AS value FROM valid_job
528
+ SELECT * FROM job_data
524
529
  UNION ALL
525
- SELECT symbol || dimension AS field, value
526
- FROM ${tableName}_attributes
527
- WHERE job_id = (SELECT id FROM valid_job);
530
+ SELECT * FROM attribute_data;
528
531
  `;
529
532
  return { sql, params: [key] };
530
533
  }
@@ -83,17 +83,6 @@ const zsetModule = (context) => ({
83
83
  _zrange(key, start, stop, facet) {
84
84
  const tableName = context.tableForKey(key, 'sorted_set');
85
85
  const selectColumns = facet === 'WITHSCORES' ? 'member, score' : 'member';
86
- // Fast path: (0, -1) means "get all" — skip COUNT/ROW_NUMBER entirely
87
- if (start === 0 && stop === -1) {
88
- const sql = `
89
- SELECT ${selectColumns}
90
- FROM ${tableName}
91
- WHERE key = $1 AND (expiry IS NULL OR expiry > NOW())
92
- ORDER BY score ASC, member ASC;
93
- `;
94
- return { sql, params: [context.storageKey(key)] };
95
- }
96
- // General case: negative indices require COUNT for resolution
97
86
  const sql = `
98
87
  WITH total_entries AS (
99
88
  SELECT COUNT(*) - 1 AS max_index FROM ${tableName}
@@ -118,9 +118,14 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
118
118
  /**
119
119
  * Leg1: set hook signal, atomically detecting a pending signal.
120
120
  *
121
- * Standalone (no transaction): single CTE query that reads any existing
122
- * pending value, then inserts the hook signal (overwriting pending or
123
- * expired entries). Returns `{success, pendingData}` in one round trip.
121
+ * Standalone (no transaction): acquires a per-key advisory lock to
122
+ * serialize with concurrent getHookSignal calls, then reads any
123
+ * existing pending value and inserts the hook signal.
124
+ *
125
+ * The advisory lock prevents a race where the CTE's read snapshot
126
+ * misses a concurrently inserted pending signal — under READ
127
+ * COMMITTED, ON CONFLICT sees committed writes but the SELECT CTE
128
+ * does not, causing the pending data to be silently overwritten.
124
129
  *
125
130
  * In a transaction: queues the setnxex; pending detection deferred.
126
131
  */
@@ -132,10 +137,13 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
132
137
  * Leg2: get hook signal OR atomically set a pending signal.
133
138
  *
134
139
  * When `pendingData` is provided and no hook signal exists, the
135
- * pending value is inserted in the SAME SQL statement — no second
136
- * round trip. This is the transactional edge that prevents the
137
- * signal from being lost: by the time the query returns, the
138
- * pending key is already visible to leg1's setnxex.
140
+ * pending value is stored so leg1's setHookSignal can detect it.
141
+ *
142
+ * Uses a per-key advisory lock to serialize with concurrent
143
+ * setHookSignal calls. Without the lock, a CTE race exists where
144
+ * the read snapshot misses a concurrently inserted hook signal AND
145
+ * the pending INSERT fails on conflict (the hook has valid expiry),
146
+ * silently losing the signal.
139
147
  *
140
148
  * When `pendingData` is omitted, behaves as a plain read.
141
149
  */
@@ -475,10 +475,12 @@ class PostgresStoreService extends __1.StoreService {
475
475
  const symVals = await this.getSymbolValues();
476
476
  this.serializer.resetSymbols(symKeys, symVals, dIds);
477
477
  const hashData = this.serializer.package(state, symbolNames);
478
- // ':' (status) is NOT written to jobs_attributes — jobs.status is
479
- // maintained by setStatus/setStatusAndCollateGuid. The attribute row
480
- // was redundant, never read, and contended on every activity.
481
- delete hashData[':'];
478
+ if (status !== null) {
479
+ hashData[':'] = status.toString();
480
+ }
481
+ else {
482
+ delete hashData[':'];
483
+ }
482
484
  await this.kvsql(transaction).hset(hashKey, hashData);
483
485
  return jobId;
484
486
  }
@@ -755,9 +757,14 @@ class PostgresStoreService extends __1.StoreService {
755
757
  /**
756
758
  * Leg1: set hook signal, atomically detecting a pending signal.
757
759
  *
758
- * Standalone (no transaction): single CTE query that reads any existing
759
- * pending value, then inserts the hook signal (overwriting pending or
760
- * expired entries). Returns `{success, pendingData}` in one round trip.
760
+ * Standalone (no transaction): acquires a per-key advisory lock to
761
+ * serialize with concurrent getHookSignal calls, then reads any
762
+ * existing pending value and inserts the hook signal.
763
+ *
764
+ * The advisory lock prevents a race where the CTE's read snapshot
765
+ * misses a concurrently inserted pending signal — under READ
766
+ * COMMITTED, ON CONFLICT sees committed writes but the SELECT CTE
767
+ * does not, causing the pending data to be silently overwritten.
761
768
  *
762
769
  * In a transaction: queues the setnxex; pending detection deferred.
763
770
  */
@@ -774,37 +781,30 @@ class PostgresStoreService extends __1.StoreService {
774
781
  const kv = this.kvsql();
775
782
  const tableName = kv.tableForKey(fullKey);
776
783
  const storedKey = kv.storageKey(fullKey);
777
- const sql = `
778
- WITH pre AS (
779
- SELECT value FROM ${tableName}
780
- WHERE key = $1 AND (expiry IS NULL OR expiry > NOW())
781
- ),
782
- ins AS (
783
- INSERT INTO ${tableName} (key, value, expiry)
784
- VALUES ($1, $2, NOW() + INTERVAL '${delay} seconds')
785
- ON CONFLICT (key) DO UPDATE
786
- SET value = EXCLUDED.value, expiry = EXCLUDED.expiry
787
- WHERE ${tableName}.expiry IS NULL
788
- OR ${tableName}.expiry <= NOW()
789
- OR ${tableName}.value LIKE '$pending::%'
790
- RETURNING true as success
791
- )
792
- SELECT
793
- COALESCE((SELECT success FROM ins), false) as success,
794
- (SELECT value FROM pre) as existing_value
795
- `;
784
+ //acquire per-key advisory lock (session-level) to serialize
785
+ //with concurrent getHookSignal for the same signal key
786
+ await this.pgClient.query('SELECT pg_advisory_lock(901, hashtext($1))', [storedKey]);
796
787
  try {
797
- const res = await this.pgClient.query(sql, [storedKey, jobId]);
798
- const row = res.rows[0] || {};
799
- const success = row.success === true;
800
- const existing = row.existing_value;
801
- if (success && existing?.startsWith('$pending::')) {
802
- return {
803
- success: true,
804
- pendingData: existing.slice('$pending::'.length),
805
- };
788
+ //read existing value under lock
789
+ const readRes = await this.pgClient.query(`SELECT value FROM ${tableName}
790
+ WHERE key = $1 AND (expiry IS NULL OR expiry > NOW())`, [storedKey]);
791
+ let pendingData;
792
+ if (readRes.rows.length > 0) {
793
+ const existing = readRes.rows[0].value;
794
+ if (existing?.startsWith('$pending::')) {
795
+ pendingData = existing.slice('$pending::'.length);
796
+ }
797
+ else {
798
+ //hook already set (retry) — no change needed
799
+ return { success: false };
800
+ }
806
801
  }
807
- return { success };
802
+ //insert hook value (or overwrite pending)
803
+ await this.pgClient.query(`INSERT INTO ${tableName} (key, value, expiry)
804
+ VALUES ($1, $2, NOW() + INTERVAL '${delay} seconds')
805
+ ON CONFLICT (key) DO UPDATE
806
+ SET value = EXCLUDED.value, expiry = EXCLUDED.expiry`, [storedKey, jobId]);
807
+ return { success: true, pendingData };
808
808
  }
809
809
  catch (error) {
810
810
  if (error?.message?.includes('closed') ||
@@ -813,15 +813,26 @@ class PostgresStoreService extends __1.StoreService {
813
813
  }
814
814
  throw error;
815
815
  }
816
+ finally {
817
+ try {
818
+ await this.pgClient.query('SELECT pg_advisory_unlock(901, hashtext($1))', [storedKey]);
819
+ }
820
+ catch {
821
+ //lock auto-releases on session close
822
+ }
823
+ }
816
824
  }
817
825
  /**
818
826
  * Leg2: get hook signal OR atomically set a pending signal.
819
827
  *
820
828
  * When `pendingData` is provided and no hook signal exists, the
821
- * pending value is inserted in the SAME SQL statement — no second
822
- * round trip. This is the transactional edge that prevents the
823
- * signal from being lost: by the time the query returns, the
824
- * pending key is already visible to leg1's setnxex.
829
+ * pending value is stored so leg1's setHookSignal can detect it.
830
+ *
831
+ * Uses a per-key advisory lock to serialize with concurrent
832
+ * setHookSignal calls. Without the lock, a CTE race exists where
833
+ * the read snapshot misses a concurrently inserted hook signal AND
834
+ * the pending INSERT fails on conflict (the hook has valid expiry),
835
+ * silently losing the signal.
825
836
  *
826
837
  * When `pendingData` is omitted, behaves as a plain read.
827
838
  */
@@ -838,38 +849,30 @@ class PostgresStoreService extends __1.StoreService {
838
849
  return undefined;
839
850
  return value;
840
851
  }
841
- //atomic get-or-set-pending: one round trip
842
852
  const kv = this.kvsql();
843
853
  const tableName = kv.tableForKey(fullKey);
844
854
  const storedKey = kv.storageKey(fullKey);
845
855
  const expire = pendingExpire || enums_1.HMSH_PENDING_SIGNAL_EXPIRE;
846
856
  const pendingValue = `$pending::${pendingData}`;
847
- const sql = `
848
- WITH existing AS (
849
- SELECT value FROM ${tableName}
850
- WHERE key = $1 AND (expiry IS NULL OR expiry > NOW())
851
- ),
852
- pending AS (
853
- INSERT INTO ${tableName} (key, value, expiry)
854
- SELECT $1, $2, NOW() + INTERVAL '${expire} seconds'
855
- WHERE NOT EXISTS (SELECT 1 FROM existing)
856
- ON CONFLICT (key) DO UPDATE
857
- SET value = EXCLUDED.value, expiry = EXCLUDED.expiry
858
- WHERE ${tableName}.expiry IS NULL OR ${tableName}.expiry <= NOW()
859
- RETURNING true as inserted
860
- )
861
- SELECT
862
- (SELECT value FROM existing) as hook_value,
863
- (SELECT inserted FROM pending) as pending_inserted
864
- `;
857
+ //acquire per-key advisory lock (session-level) to serialize
858
+ //with concurrent setHookSignal for the same signal key
859
+ await this.pgClient.query('SELECT pg_advisory_lock(901, hashtext($1))', [storedKey]);
865
860
  try {
866
- const res = await this.pgClient.query(sql, [storedKey, pendingValue]);
867
- const row = res.rows[0] || {};
868
- const hookValue = row.hook_value;
869
- if (hookValue && !hookValue.startsWith('$pending::')) {
870
- return hookValue;
861
+ //read existing value under lock
862
+ const readRes = await this.pgClient.query(`SELECT value FROM ${tableName}
863
+ WHERE key = $1 AND (expiry IS NULL OR expiry > NOW())`, [storedKey]);
864
+ if (readRes.rows.length > 0) {
865
+ const value = readRes.rows[0].value;
866
+ if (value && !value.startsWith('$pending::')) {
867
+ //hook found — return it
868
+ return value;
869
+ }
871
870
  }
872
- //no hook signal; pending was inserted (or already existed)
871
+ //no hook signal store pending
872
+ await this.pgClient.query(`INSERT INTO ${tableName} (key, value, expiry)
873
+ VALUES ($1, $2, NOW() + INTERVAL '${expire} seconds')
874
+ ON CONFLICT (key) DO UPDATE
875
+ SET value = EXCLUDED.value, expiry = EXCLUDED.expiry`, [storedKey, pendingValue]);
873
876
  return undefined;
874
877
  }
875
878
  catch (error) {
@@ -879,6 +882,14 @@ class PostgresStoreService extends __1.StoreService {
879
882
  }
880
883
  throw error;
881
884
  }
885
+ finally {
886
+ try {
887
+ await this.pgClient.query('SELECT pg_advisory_unlock(901, hashtext($1))', [storedKey]);
888
+ }
889
+ catch {
890
+ //lock auto-releases on session close
891
+ }
892
+ }
882
893
  }
883
894
  async deleteHookSignal(topic, resolved) {
884
895
  const key = this.mintKey(key_1.KeyType.SIGNALS, { appId: this.appId });
@@ -140,31 +140,21 @@ async function createTables(client, schemaName) {
140
140
  ) PARTITION BY HASH (stream_name);
141
141
  `);
142
142
  for (let i = 0; i < 8; i++) {
143
- // fillfactor 70: reserves 30% page space for HOT updates (reserve/ack cycle)
144
143
  await client.query(`
145
144
  CREATE TABLE IF NOT EXISTS ${schemaName}.engine_streams_part_${i}
146
145
  PARTITION OF ${engineTable}
147
- FOR VALUES WITH (modulus 8, remainder ${i})
148
- WITH (fillfactor = 70);
146
+ FOR VALUES WITH (modulus 8, remainder ${i});
149
147
  `);
150
148
  }
151
- // Dedicated dequeue index: columns match the hot-path query exactly
152
- // (stream_name = $1, visible_at <= NOW(), ORDER BY id) with partial
153
- // filter on reserved_at IS NULL AND expired_at IS NULL.
154
- // Replaces the old active_messages index that had reserved_at as a
155
- // column (redundant with the WHERE filter, displacing visible_at).
156
149
  await client.query(`
157
- CREATE INDEX IF NOT EXISTS idx_engine_streams_dequeue
158
- ON ${engineTable} (stream_name, visible_at, id)
150
+ CREATE INDEX IF NOT EXISTS idx_engine_streams_active_messages
151
+ ON ${engineTable} (stream_name, reserved_at, visible_at, id)
159
152
  WHERE reserved_at IS NULL AND expired_at IS NULL;
160
153
  `);
161
- // Stale-reservation recovery: covers the timed-out reservation path
162
- // (reserved_at < NOW() - INTERVAL) separately from the hot path so
163
- // the planner can use each index cleanly without an OR.
164
154
  await client.query(`
165
- CREATE INDEX IF NOT EXISTS idx_engine_streams_stale_reservations
166
- ON ${engineTable} (stream_name, reserved_at, visible_at, id)
167
- WHERE reserved_at IS NOT NULL AND expired_at IS NULL;
155
+ CREATE INDEX IF NOT EXISTS idx_engine_streams_message_fetch
156
+ ON ${engineTable} (stream_name, visible_at, id)
157
+ WHERE expired_at IS NULL;
168
158
  `);
169
159
  await client.query(`
170
160
  CREATE INDEX IF NOT EXISTS idx_engine_streams_expired_at
@@ -219,25 +209,21 @@ async function createTables(client, schemaName) {
219
209
  ) PARTITION BY HASH (stream_name);
220
210
  `);
221
211
  for (let i = 0; i < 8; i++) {
222
- // fillfactor 70: reserves 30% page space for HOT updates (reserve/ack cycle)
223
212
  await client.query(`
224
213
  CREATE TABLE IF NOT EXISTS ${schemaName}.worker_streams_part_${i}
225
214
  PARTITION OF ${workerTable}
226
- FOR VALUES WITH (modulus 8, remainder ${i})
227
- WITH (fillfactor = 70);
215
+ FOR VALUES WITH (modulus 8, remainder ${i});
228
216
  `);
229
217
  }
230
- // Dedicated dequeue index (see engine_streams comments above)
231
218
  await client.query(`
232
- CREATE INDEX IF NOT EXISTS idx_worker_streams_dequeue
233
- ON ${workerTable} (stream_name, visible_at, id)
219
+ CREATE INDEX IF NOT EXISTS idx_worker_streams_active_messages
220
+ ON ${workerTable} (stream_name, reserved_at, visible_at, id)
234
221
  WHERE reserved_at IS NULL AND expired_at IS NULL;
235
222
  `);
236
- // Stale-reservation recovery (see engine_streams comments above)
237
223
  await client.query(`
238
- CREATE INDEX IF NOT EXISTS idx_worker_streams_stale_reservations
239
- ON ${workerTable} (stream_name, reserved_at, visible_at, id)
240
- WHERE reserved_at IS NOT NULL AND expired_at IS NULL;
224
+ CREATE INDEX IF NOT EXISTS idx_worker_streams_message_fetch
225
+ ON ${workerTable} (stream_name, visible_at, id)
226
+ WHERE expired_at IS NULL;
241
227
  `);
242
228
  await client.query(`
243
229
  CREATE INDEX IF NOT EXISTS idx_worker_streams_expired_at
@@ -207,17 +207,12 @@ async function fetchMessages(client, tableName, streamName, isEngine, consumerNa
207
207
  retries++;
208
208
  const batchSize = options?.batchSize || 1;
209
209
  const reservationTimeout = options?.reservationTimeout || enums_1.HMSH_RESERVATION_TIMEOUT_S;
210
- // Two-pass dequeue: stale reservations first (FIFO — they have
211
- // lower ids and have waited longest), then fresh messages. Split
212
- // avoids an OR that prevents the planner from using partial
213
- // indexes cleanly. Stale check is a fast no-op (empty index scan)
214
- // when there are no timed-out reservations.
215
- let res = await client.query(`UPDATE ${tableName}
210
+ const res = await client.query(`UPDATE ${tableName}
216
211
  SET reserved_at = NOW(), reserved_by = $3
217
212
  WHERE id IN (
218
213
  SELECT id FROM ${tableName}
219
214
  WHERE stream_name = $1
220
- AND reserved_at < NOW() - INTERVAL '${reservationTimeout} seconds'
215
+ AND (reserved_at IS NULL OR reserved_at < NOW() - INTERVAL '${reservationTimeout} seconds')
221
216
  AND expired_at IS NULL
222
217
  AND visible_at <= NOW()
223
218
  ORDER BY id
@@ -225,22 +220,6 @@ async function fetchMessages(client, tableName, streamName, isEngine, consumerNa
225
220
  FOR UPDATE SKIP LOCKED
226
221
  )
227
222
  RETURNING ${returningClause}`, [streamName, batchSize, consumerName]);
228
- // Fresh messages: unreserved, visible, not expired
229
- if (res.rows.length === 0) {
230
- res = await client.query(`UPDATE ${tableName}
231
- SET reserved_at = NOW(), reserved_by = $3
232
- WHERE id IN (
233
- SELECT id FROM ${tableName}
234
- WHERE stream_name = $1
235
- AND reserved_at IS NULL
236
- AND expired_at IS NULL
237
- AND visible_at <= NOW()
238
- ORDER BY id
239
- LIMIT $2
240
- FOR UPDATE SKIP LOCKED
241
- )
242
- RETURNING ${returningClause}`, [streamName, batchSize, consumerName]);
243
- }
244
223
  const messages = res.rows.map((row) => {
245
224
  const data = (0, utils_1.parseStreamMessage)(row.message);
246
225
  const hasDefaultRetryPolicy = (row.max_retry_attempts === 3 || row.max_retry_attempts === 5) &&
@@ -54,16 +54,13 @@ function getCreateProceduresSQL(schemaName) {
54
54
  SET search_path = ${schemaName}, pg_temp
55
55
  AS $$
56
56
  ${STREAM_ACCESS_CHECK}
57
- -- Two-pass dequeue: stale reservations first (FIFO — lower ids,
58
- -- waited longest), then fresh. Split avoids OR that prevents
59
- -- partial index usage. Stale check is a fast no-op when empty.
60
57
  RETURN QUERY
61
58
  UPDATE ${workerTable} ws
62
59
  SET reserved_at = NOW(), reserved_by = p_consumer_id
63
60
  WHERE ws.id IN (
64
61
  SELECT ws2.id FROM ${workerTable} ws2
65
62
  WHERE ws2.stream_name = p_stream_name
66
- AND ws2.reserved_at < NOW() - (p_reservation_timeout_sec || ' seconds')::INTERVAL
63
+ AND (ws2.reserved_at IS NULL OR ws2.reserved_at < NOW() - (p_reservation_timeout_sec || ' seconds')::INTERVAL)
67
64
  AND ws2.expired_at IS NULL
68
65
  AND ws2.visible_at <= NOW()
69
66
  ORDER BY ws2.id
@@ -72,25 +69,6 @@ function getCreateProceduresSQL(schemaName) {
72
69
  )
73
70
  RETURNING ws.id, ws.message, ws.workflow_name, ws.max_retry_attempts,
74
71
  ws.backoff_coefficient, ws.maximum_interval_seconds, ws.retry_attempt;
75
-
76
- -- Fresh messages: unreserved, visible, not expired
77
- IF NOT FOUND THEN
78
- RETURN QUERY
79
- UPDATE ${workerTable} ws
80
- SET reserved_at = NOW(), reserved_by = p_consumer_id
81
- WHERE ws.id IN (
82
- SELECT ws2.id FROM ${workerTable} ws2
83
- WHERE ws2.stream_name = p_stream_name
84
- AND ws2.reserved_at IS NULL
85
- AND ws2.expired_at IS NULL
86
- AND ws2.visible_at <= NOW()
87
- ORDER BY ws2.id
88
- LIMIT p_batch_size
89
- FOR UPDATE SKIP LOCKED
90
- )
91
- RETURNING ws.id, ws.message, ws.workflow_name, ws.max_retry_attempts,
92
- ws.backoff_coefficient, ws.maximum_interval_seconds, ws.retry_attempt;
93
- END IF;
94
72
  END;
95
73
  $$;`,
96
74
  // -- worker_ack --
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.16.1",
3
+ "version": "0.16.3",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",