@hotmeshio/hotmesh 0.14.10 → 0.16.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.
- package/build/package.json +1 -1
- package/build/services/activities/activity/state.d.ts +1 -1
- package/build/services/activities/activity/state.js +3 -2
- package/build/services/durable/exporter.js +16 -2
- package/build/services/pipe/functions/cron.js +1 -1
- package/build/services/serializer/index.js +1 -1
- package/build/services/store/providers/postgres/kvtables.js +53 -14
- package/build/services/store/providers/postgres/kvtypes/hash/basic.js +18 -21
- package/build/services/store/providers/postgres/kvtypes/zset.js +11 -0
- package/build/services/store/providers/postgres/postgres.js +4 -6
- package/build/services/stream/providers/postgres/kvtables.js +26 -12
- package/build/services/stream/providers/postgres/messages.js +23 -2
- package/build/services/stream/providers/postgres/procedures.js +23 -1
- package/package.json +1 -1
package/build/package.json
CHANGED
|
@@ -30,7 +30,7 @@ interface StateContext {
|
|
|
30
30
|
export declare function setState(instance: StateContext, transaction?: ProviderTransaction): Promise<string>;
|
|
31
31
|
export declare function getState(instance: StateContext): Promise<void>;
|
|
32
32
|
export declare function setStatus(instance: StateContext, amount: number, transaction?: ProviderTransaction): Promise<void | any>;
|
|
33
|
-
export declare function bindJobMetadata(
|
|
33
|
+
export declare function bindJobMetadata(_instance: StateContext): void;
|
|
34
34
|
export declare function bindActivityMetadata(instance: StateContext): void;
|
|
35
35
|
export declare function bindJobState(instance: StateContext, state: StringAnyType): Promise<void>;
|
|
36
36
|
export declare function bindActivityState(instance: StateContext, state: StringAnyType): void;
|
|
@@ -68,8 +68,9 @@ async function setStatus(instance, amount, transaction) {
|
|
|
68
68
|
}
|
|
69
69
|
exports.setStatus = setStatus;
|
|
70
70
|
//─── metadata binding ────────────────────────────────────────────────
|
|
71
|
-
function bindJobMetadata(
|
|
72
|
-
|
|
71
|
+
function bindJobMetadata(_instance) {
|
|
72
|
+
// ju (job_updated) is maintained by the jobs.updated_at trigger —
|
|
73
|
+
// no need to serialize it into jobs_attributes on every activity.
|
|
73
74
|
}
|
|
74
75
|
exports.bindJobMetadata = bindJobMetadata;
|
|
75
76
|
function bindActivityMetadata(instance) {
|
|
@@ -676,9 +676,23 @@ class ExporterService {
|
|
|
676
676
|
const metadata = state?.output?.metadata ?? state?.metadata;
|
|
677
677
|
const stateData = state?.output?.data ?? state?.data;
|
|
678
678
|
const jobCreated = metadata?.jc ?? stateData?.jc ?? metadata?.ac;
|
|
679
|
-
const jobUpdated = metadata?.ju ?? stateData?.ju ?? metadata?.au;
|
|
680
679
|
const startTime = parseTimestamp(jobCreated);
|
|
681
|
-
|
|
680
|
+
// Derive close time from the latest activity update (au) in the
|
|
681
|
+
// timeline, falling back to metadata.au. ju is no longer updated
|
|
682
|
+
// after creation — jobs.updated_at is the authoritative source,
|
|
683
|
+
// but it's not available in the DurableJobExport format.
|
|
684
|
+
let latestAu = null;
|
|
685
|
+
for (const entry of raw.timeline || []) {
|
|
686
|
+
const val = entry.value;
|
|
687
|
+
const au = val?.au;
|
|
688
|
+
if (au) {
|
|
689
|
+
const parsed = parseTimestamp(au);
|
|
690
|
+
if (parsed && (!latestAu || parsed > latestAu)) {
|
|
691
|
+
latestAu = parsed;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const closeTime = latestAu ?? parseTimestamp(metadata?.au);
|
|
682
696
|
// ── Synthetic workflow_execution_started ─────────────────────────
|
|
683
697
|
if (startTime) {
|
|
684
698
|
events.push(makeEvent(nextId++, 'workflow_execution_started', 'workflow', startTime, null, false, {
|
|
@@ -31,7 +31,7 @@ class CronHandler {
|
|
|
31
31
|
*/
|
|
32
32
|
nextDelay(cronExpression) {
|
|
33
33
|
if (!(0, utils_1.isValidCron)(cronExpression)) {
|
|
34
|
-
|
|
34
|
+
return -1;
|
|
35
35
|
}
|
|
36
36
|
const interval = (0, cron_parser_1.parseExpression)(cronExpression, { utc: true });
|
|
37
37
|
const nextDate = interval.next().toDate();
|
|
@@ -153,6 +153,20 @@ 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
|
+
`);
|
|
156
170
|
},
|
|
157
171
|
async createTables(client, appName) {
|
|
158
172
|
try {
|
|
@@ -251,27 +265,40 @@ const KVTables = (context) => ({
|
|
|
251
265
|
CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_context_gin
|
|
252
266
|
ON ${fullTableName} USING GIN (context);
|
|
253
267
|
`);
|
|
254
|
-
// Create partitions
|
|
268
|
+
// Create partitions with fillfactor 70: reserves 30% page space
|
|
269
|
+
// for HOT updates (status update cycle in setStatusAndCollateGuid)
|
|
255
270
|
await client.query(`
|
|
256
271
|
DO $$
|
|
257
272
|
BEGIN
|
|
258
273
|
FOR i IN 0..7 LOOP
|
|
259
274
|
EXECUTE format(
|
|
260
|
-
'CREATE TABLE IF NOT EXISTS ${fullTableName}_part_%s PARTITION OF ${fullTableName}
|
|
261
|
-
FOR VALUES WITH (modulus 8, remainder %s)
|
|
275
|
+
'CREATE TABLE IF NOT EXISTS ${fullTableName}_part_%s PARTITION OF ${fullTableName}
|
|
276
|
+
FOR VALUES WITH (modulus 8, remainder %s)
|
|
277
|
+
WITH (fillfactor = 70)',
|
|
262
278
|
i, i
|
|
263
279
|
);
|
|
264
280
|
END LOOP;
|
|
265
281
|
END$$;
|
|
266
282
|
`);
|
|
267
|
-
//
|
|
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())
|
|
268
285
|
await client.query(`
|
|
269
|
-
CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_expired_at
|
|
286
|
+
CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_expired_at
|
|
270
287
|
ON ${fullTableName} (key, expired_at) INCLUDE (is_live);
|
|
271
288
|
`);
|
|
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.
|
|
272
292
|
await client.query(`
|
|
273
|
-
CREATE INDEX IF NOT EXISTS idx_${tableDef.name}
|
|
274
|
-
ON ${fullTableName} (
|
|
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
|
+
await client.query(`
|
|
300
|
+
CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_entity_status
|
|
301
|
+
ON ${fullTableName} (entity) INCLUDE (status);
|
|
275
302
|
`);
|
|
276
303
|
await client.query(`
|
|
277
304
|
CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_expired_at
|
|
@@ -303,7 +330,9 @@ const KVTables = (context) => ({
|
|
|
303
330
|
BEFORE INSERT OR UPDATE ON ${fullTableName}
|
|
304
331
|
FOR EACH ROW EXECUTE PROCEDURE ${schemaName}.update_is_live();
|
|
305
332
|
`);
|
|
306
|
-
//
|
|
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).
|
|
307
336
|
await client.query(`
|
|
308
337
|
CREATE OR REPLACE FUNCTION ${schemaName}.enforce_live_job_uniqueness()
|
|
309
338
|
RETURNS TRIGGER AS $$
|
|
@@ -312,8 +341,8 @@ const KVTables = (context) => ({
|
|
|
312
341
|
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.key, 0));
|
|
313
342
|
IF EXISTS (
|
|
314
343
|
SELECT 1 FROM ${fullTableName}
|
|
315
|
-
WHERE key = NEW.key
|
|
316
|
-
AND
|
|
344
|
+
WHERE key = NEW.key
|
|
345
|
+
AND is_live
|
|
317
346
|
AND id <> NEW.id
|
|
318
347
|
) THEN
|
|
319
348
|
RAISE EXCEPTION 'A live job with key % already exists.', NEW.key;
|
|
@@ -384,14 +413,16 @@ const KVTables = (context) => ({
|
|
|
384
413
|
FOR EACH ROW
|
|
385
414
|
EXECUTE FUNCTION ${schemaName}.update_attributes_updated_at();
|
|
386
415
|
`);
|
|
387
|
-
// Create partitions
|
|
416
|
+
// Create partitions with fillfactor 70: reserves 30% page space
|
|
417
|
+
// for HOT updates (frequent upserts in hincrbyfloat/collateLeg2Entry)
|
|
388
418
|
await client.query(`
|
|
389
419
|
DO $$
|
|
390
420
|
BEGIN
|
|
391
421
|
FOR i IN 0..7 LOOP
|
|
392
422
|
EXECUTE format(
|
|
393
|
-
'CREATE TABLE IF NOT EXISTS ${attributesTableName}_part_%s PARTITION OF ${attributesTableName}
|
|
394
|
-
FOR VALUES WITH (modulus 8, remainder %s)
|
|
423
|
+
'CREATE TABLE IF NOT EXISTS ${attributesTableName}_part_%s PARTITION OF ${attributesTableName}
|
|
424
|
+
FOR VALUES WITH (modulus 8, remainder %s)
|
|
425
|
+
WITH (fillfactor = 70)',
|
|
395
426
|
i, i
|
|
396
427
|
);
|
|
397
428
|
END LOOP;
|
|
@@ -433,8 +464,16 @@ const KVTables = (context) => ({
|
|
|
433
464
|
);
|
|
434
465
|
`);
|
|
435
466
|
await client.query(`
|
|
436
|
-
CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_score_member
|
|
467
|
+
CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_score_member
|
|
437
468
|
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;
|
|
438
477
|
`);
|
|
439
478
|
break;
|
|
440
479
|
default:
|
|
@@ -292,11 +292,19 @@ function _hset(context, key, fields, options) {
|
|
|
292
292
|
params.push(key, fields[':'], options?.entity ?? null);
|
|
293
293
|
}
|
|
294
294
|
else {
|
|
295
|
-
//
|
|
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.
|
|
296
298
|
sql = `
|
|
299
|
+
WITH existing AS (
|
|
300
|
+
UPDATE ${targetTable}
|
|
301
|
+
SET status = $2
|
|
302
|
+
WHERE key = $1 AND is_live
|
|
303
|
+
RETURNING 1
|
|
304
|
+
)
|
|
297
305
|
INSERT INTO ${targetTable} (id, key, status, entity)
|
|
298
|
-
|
|
299
|
-
|
|
306
|
+
SELECT gen_random_uuid(), $1, $2, $3
|
|
307
|
+
WHERE NOT EXISTS (SELECT 1 FROM existing)
|
|
300
308
|
RETURNING 1 as count
|
|
301
309
|
`;
|
|
302
310
|
params.push(key, fields[':'], options?.entity ?? null);
|
|
@@ -503,31 +511,20 @@ function _hgetall(context, key) {
|
|
|
503
511
|
const tableName = context.tableForKey(key, 'hash');
|
|
504
512
|
const isJobsTableResult = (0, utils_1.isJobsTable)(tableName);
|
|
505
513
|
if (isJobsTableResult) {
|
|
514
|
+
// Single CTE: reads jobs row once, then joins attributes
|
|
506
515
|
const sql = `
|
|
507
516
|
WITH valid_job AS (
|
|
508
517
|
SELECT id, status, context
|
|
509
518
|
FROM ${tableName}
|
|
510
519
|
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)
|
|
527
520
|
)
|
|
528
|
-
SELECT
|
|
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
|
|
529
524
|
UNION ALL
|
|
530
|
-
SELECT
|
|
525
|
+
SELECT symbol || dimension AS field, value
|
|
526
|
+
FROM ${tableName}_attributes
|
|
527
|
+
WHERE job_id = (SELECT id FROM valid_job);
|
|
531
528
|
`;
|
|
532
529
|
return { sql, params: [key] };
|
|
533
530
|
}
|
|
@@ -83,6 +83,17 @@ 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
|
|
86
97
|
const sql = `
|
|
87
98
|
WITH total_entries AS (
|
|
88
99
|
SELECT COUNT(*) - 1 AS max_index FROM ${tableName}
|
|
@@ -475,12 +475,10 @@ 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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
delete hashData[':'];
|
|
483
|
-
}
|
|
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[':'];
|
|
484
482
|
await this.kvsql(transaction).hset(hashKey, hashData);
|
|
485
483
|
return jobId;
|
|
486
484
|
}
|
|
@@ -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
|
|
151
|
-
ON ${engineTable} (stream_name,
|
|
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
|
|
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
|
|
220
|
-
ON ${workerTable} (stream_name,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 --
|