@hotmeshio/hotmesh 0.14.10 → 0.15.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.14.10",
3
+ "version": "0.15.1",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -140,21 +140,31 @@ 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)
143
144
  await client.query(`
144
145
  CREATE TABLE IF NOT EXISTS ${schemaName}.engine_streams_part_${i}
145
146
  PARTITION OF ${engineTable}
146
- FOR VALUES WITH (modulus 8, remainder ${i});
147
+ FOR VALUES WITH (modulus 8, remainder ${i})
148
+ WITH (fillfactor = 70);
147
149
  `);
148
150
  }
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).
149
156
  await client.query(`
150
- CREATE INDEX IF NOT EXISTS idx_engine_streams_active_messages
151
- ON ${engineTable} (stream_name, reserved_at, visible_at, id)
157
+ CREATE INDEX IF NOT EXISTS idx_engine_streams_dequeue
158
+ ON ${engineTable} (stream_name, visible_at, id)
152
159
  WHERE reserved_at IS NULL AND expired_at IS NULL;
153
160
  `);
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.
154
164
  await client.query(`
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;
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;
158
168
  `);
159
169
  await client.query(`
160
170
  CREATE INDEX IF NOT EXISTS idx_engine_streams_expired_at
@@ -209,21 +219,25 @@ async function createTables(client, schemaName) {
209
219
  ) PARTITION BY HASH (stream_name);
210
220
  `);
211
221
  for (let i = 0; i < 8; i++) {
222
+ // fillfactor 70: reserves 30% page space for HOT updates (reserve/ack cycle)
212
223
  await client.query(`
213
224
  CREATE TABLE IF NOT EXISTS ${schemaName}.worker_streams_part_${i}
214
225
  PARTITION OF ${workerTable}
215
- FOR VALUES WITH (modulus 8, remainder ${i});
226
+ FOR VALUES WITH (modulus 8, remainder ${i})
227
+ WITH (fillfactor = 70);
216
228
  `);
217
229
  }
230
+ // Dedicated dequeue index (see engine_streams comments above)
218
231
  await client.query(`
219
- CREATE INDEX IF NOT EXISTS idx_worker_streams_active_messages
220
- ON ${workerTable} (stream_name, reserved_at, visible_at, id)
232
+ CREATE INDEX IF NOT EXISTS idx_worker_streams_dequeue
233
+ ON ${workerTable} (stream_name, visible_at, id)
221
234
  WHERE reserved_at IS NULL AND expired_at IS NULL;
222
235
  `);
236
+ // Stale-reservation recovery (see engine_streams comments above)
223
237
  await client.query(`
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;
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;
227
241
  `);
228
242
  await client.query(`
229
243
  CREATE INDEX IF NOT EXISTS idx_worker_streams_expired_at
@@ -207,12 +207,17 @@ 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
- const res = await client.query(`UPDATE ${tableName}
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}
211
216
  SET reserved_at = NOW(), reserved_by = $3
212
217
  WHERE id IN (
213
218
  SELECT id FROM ${tableName}
214
219
  WHERE stream_name = $1
215
- AND (reserved_at IS NULL OR reserved_at < NOW() - INTERVAL '${reservationTimeout} seconds')
220
+ AND reserved_at < NOW() - INTERVAL '${reservationTimeout} seconds'
216
221
  AND expired_at IS NULL
217
222
  AND visible_at <= NOW()
218
223
  ORDER BY id
@@ -220,6 +225,22 @@ async function fetchMessages(client, tableName, streamName, isEngine, consumerNa
220
225
  FOR UPDATE SKIP LOCKED
221
226
  )
222
227
  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
+ }
223
244
  const messages = res.rows.map((row) => {
224
245
  const data = (0, utils_1.parseStreamMessage)(row.message);
225
246
  const hasDefaultRetryPolicy = (row.max_retry_attempts === 3 || row.max_retry_attempts === 5) &&
@@ -54,13 +54,16 @@ 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.
57
60
  RETURN QUERY
58
61
  UPDATE ${workerTable} ws
59
62
  SET reserved_at = NOW(), reserved_by = p_consumer_id
60
63
  WHERE ws.id IN (
61
64
  SELECT ws2.id FROM ${workerTable} ws2
62
65
  WHERE ws2.stream_name = p_stream_name
63
- AND (ws2.reserved_at IS NULL OR ws2.reserved_at < NOW() - (p_reservation_timeout_sec || ' seconds')::INTERVAL)
66
+ AND ws2.reserved_at < NOW() - (p_reservation_timeout_sec || ' seconds')::INTERVAL
64
67
  AND ws2.expired_at IS NULL
65
68
  AND ws2.visible_at <= NOW()
66
69
  ORDER BY ws2.id
@@ -69,6 +72,25 @@ function getCreateProceduresSQL(schemaName) {
69
72
  )
70
73
  RETURNING ws.id, ws.message, ws.workflow_name, ws.max_retry_attempts,
71
74
  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;
72
94
  END;
73
95
  $$;`,
74
96
  // -- worker_ack --
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.14.10",
3
+ "version": "0.15.1",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",