@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.
- package/build/package.json +1 -1
- package/build/services/activities/activity/process.js +24 -6
- package/build/services/store/providers/postgres/kvtables.js +8 -47
- package/build/services/store/providers/postgres/kvtypes/hash/basic.js +21 -18
- package/build/services/store/providers/postgres/kvtypes/zset.js +0 -11
- package/build/services/store/providers/postgres/postgres.d.ts +15 -7
- package/build/services/store/providers/postgres/postgres.js +76 -65
- package/build/services/stream/providers/postgres/kvtables.js +12 -26
- package/build/services/stream/providers/postgres/messages.js +2 -23
- package/build/services/stream/providers/postgres/procedures.js +1 -23
- package/package.json +1 -1
package/build/package.json
CHANGED
|
@@ -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("
|
|
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
|
-
|
|
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
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
307
|
-
WHERE
|
|
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
|
|
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
|
|
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):
|
|
122
|
-
*
|
|
123
|
-
*
|
|
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
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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):
|
|
759
|
-
*
|
|
760
|
-
*
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
SELECT
|
|
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
|
-
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
if (
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
pendingData
|
|
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
|
-
|
|
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
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
*
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
SELECT
|
|
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
|
-
|
|
867
|
-
const
|
|
868
|
-
|
|
869
|
-
if (
|
|
870
|
-
|
|
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
|
|
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
|
|
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
|
|
166
|
-
ON ${engineTable} (stream_name,
|
|
167
|
-
WHERE
|
|
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
|
|
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
|
|
239
|
-
ON ${workerTable} (stream_name,
|
|
240
|
-
WHERE
|
|
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
|
-
|
|
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 --
|