@hotmeshio/hotmesh 0.16.6 → 0.17.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.
- package/build/package.json +1 -1
- package/build/services/activities/hook.js +17 -0
- package/build/services/compiler/deployer.js +8 -7
- package/build/services/durable/client.js +19 -3
- package/build/services/durable/handle.js +16 -2
- package/build/services/durable/schemas/factory.d.ts +1 -1
- package/build/services/durable/schemas/factory.js +130 -14
- package/build/services/durable/worker.js +26 -3
- package/build/services/durable/workflow/signal.js +16 -2
- package/build/services/store/providers/postgres/kvtables.js +7 -0
- package/build/services/store/providers/postgres/postgres.js +60 -56
- package/package.json +1 -1
package/build/package.json
CHANGED
|
@@ -358,6 +358,23 @@ class Hook extends activity_1.Activity {
|
|
|
358
358
|
await this.processEvent(status, code, 'hook');
|
|
359
359
|
if (code === 200) {
|
|
360
360
|
await taskService.deleteWebHookSignal(this.config.hook.topic, data);
|
|
361
|
+
//clean up orphan pending on the sibling signal topic
|
|
362
|
+
// wfs.wait delivered → remove wfs.signal pending
|
|
363
|
+
// wfs.signal delivered → remove wfs.wait pending
|
|
364
|
+
const topic = this.config.hook.topic;
|
|
365
|
+
const siblingTopic = topic.includes('.wfs.wait')
|
|
366
|
+
? topic.replace('.wfs.wait', '.wfs.signal')
|
|
367
|
+
: topic.includes('.wfs.signal')
|
|
368
|
+
? topic.replace('.wfs.signal', '.wfs.wait')
|
|
369
|
+
: null;
|
|
370
|
+
if (siblingTopic) {
|
|
371
|
+
try {
|
|
372
|
+
await taskService.deleteWebHookSignal(siblingTopic, data);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
//sibling entry may not exist — ignore
|
|
376
|
+
}
|
|
377
|
+
}
|
|
361
378
|
}
|
|
362
379
|
return;
|
|
363
380
|
}
|
|
@@ -442,14 +442,15 @@ class Deployer {
|
|
|
442
442
|
if (graph.hooks) {
|
|
443
443
|
for (const topic in graph.hooks) {
|
|
444
444
|
hookRules[topic] = graph.hooks[topic];
|
|
445
|
-
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
if (
|
|
449
|
-
targetActivity.hook
|
|
445
|
+
//create back-reference to the hook topic for ALL target activities
|
|
446
|
+
for (const rule of graph.hooks[topic]) {
|
|
447
|
+
const targetActivity = graph.activities[rule.to];
|
|
448
|
+
if (targetActivity) {
|
|
449
|
+
if (!targetActivity.hook) {
|
|
450
|
+
targetActivity.hook = {};
|
|
451
|
+
}
|
|
452
|
+
targetActivity.hook.topic = topic;
|
|
450
453
|
}
|
|
451
|
-
//create back-reference to the hook topic
|
|
452
|
-
targetActivity.hook.topic = topic;
|
|
453
454
|
}
|
|
454
455
|
}
|
|
455
456
|
}
|
|
@@ -165,12 +165,28 @@ class ClientService {
|
|
|
165
165
|
* hours before the workflow starts).
|
|
166
166
|
*/
|
|
167
167
|
signal: async (signalId, data, namespace, expire) => {
|
|
168
|
-
const
|
|
169
|
-
|
|
168
|
+
const ns = namespace ?? factory_1.APP_ID;
|
|
169
|
+
const payload = {
|
|
170
170
|
id: signalId,
|
|
171
171
|
data,
|
|
172
172
|
...(expire ? { $expire: expire } : {}),
|
|
173
|
-
}
|
|
173
|
+
};
|
|
174
|
+
//send collator topic first (creates pending if no collator),
|
|
175
|
+
//then inline waiter topic (delivers and cleans up collator pending)
|
|
176
|
+
try {
|
|
177
|
+
const signalTopic = `${ns}.wfs.signal`;
|
|
178
|
+
await (await this.getHotMeshClient(signalTopic, namespace)).signal(signalTopic, payload);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
//no hook rule — ignore
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const waitTopic = `${ns}.wfs.wait`;
|
|
185
|
+
return await (await this.getHotMeshClient(waitTopic, namespace)).signal(waitTopic, payload);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
//no hook rule — ignore
|
|
189
|
+
}
|
|
174
190
|
},
|
|
175
191
|
/**
|
|
176
192
|
* Spawns an a new, isolated execution cycle within the same job.
|
|
@@ -101,11 +101,25 @@ class WorkflowHandleService {
|
|
|
101
101
|
* @param expire - Optional pending signal TTL (e.g., '1h', '30d'). Default '10m'.
|
|
102
102
|
*/
|
|
103
103
|
async signal(signalId, data, expire) {
|
|
104
|
-
|
|
104
|
+
const payload = {
|
|
105
105
|
id: signalId,
|
|
106
106
|
data,
|
|
107
107
|
...(expire ? { $expire: expire } : {}),
|
|
108
|
-
}
|
|
108
|
+
};
|
|
109
|
+
//send collator topic first (creates pending if no collator),
|
|
110
|
+
//then inline waiter topic (delivers and cleans up collator pending)
|
|
111
|
+
try {
|
|
112
|
+
await this.hotMesh.signal(`${this.hotMesh.appId}.wfs.signal`, payload);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
//no hook rule — ignore
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
await this.hotMesh.signal(`${this.hotMesh.appId}.wfs.wait`, payload);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
//no hook rule — ignore
|
|
122
|
+
}
|
|
109
123
|
}
|
|
110
124
|
/**
|
|
111
125
|
* Returns the current workflow state. For a completed workflow this
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.APP_ID = exports.APP_VERSION = exports.getWorkflowYAML = void 0;
|
|
4
|
-
const APP_VERSION = '
|
|
4
|
+
const APP_VERSION = '14';
|
|
5
5
|
exports.APP_VERSION = APP_VERSION;
|
|
6
6
|
const APP_ID = 'durable';
|
|
7
7
|
exports.APP_ID = APP_ID;
|
|
@@ -320,14 +320,22 @@ const getWorkflowYAML = (app, version) => {
|
|
|
320
320
|
schema:
|
|
321
321
|
type: object
|
|
322
322
|
properties:
|
|
323
|
+
signalId:
|
|
324
|
+
type: string
|
|
325
|
+
description: the signal identifier to wait for
|
|
323
326
|
index:
|
|
324
327
|
type: number
|
|
325
|
-
description: the index
|
|
326
|
-
|
|
327
|
-
type:
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
328
|
+
description: the replay index (COUNTER++)
|
|
329
|
+
workflowDimension:
|
|
330
|
+
type: string
|
|
331
|
+
description: empty string or dimensional path (,0,0,1)
|
|
332
|
+
duration:
|
|
333
|
+
type: number
|
|
334
|
+
description: optional timeout in seconds
|
|
335
|
+
workflowId:
|
|
336
|
+
type: string
|
|
337
|
+
originJobId:
|
|
338
|
+
type: string
|
|
331
339
|
job:
|
|
332
340
|
maps:
|
|
333
341
|
response: '{$self.output.data.response}'
|
|
@@ -359,6 +367,47 @@ const getWorkflowYAML = (app, version) => {
|
|
|
359
367
|
continueGeneration: '{cycle_hook.output.data.continueGeneration}'
|
|
360
368
|
continueArgs: '{cycle_hook.output.data.continueArgs}'
|
|
361
369
|
|
|
370
|
+
waiter:
|
|
371
|
+
title: Waits for a matching signal or optional timeout (single condition)
|
|
372
|
+
type: hook
|
|
373
|
+
sleep: '{worker.output.data.duration}'
|
|
374
|
+
hook:
|
|
375
|
+
type: object
|
|
376
|
+
properties:
|
|
377
|
+
signalData:
|
|
378
|
+
type: object
|
|
379
|
+
output:
|
|
380
|
+
maps:
|
|
381
|
+
signalId: '{worker.output.data.signalId}'
|
|
382
|
+
job:
|
|
383
|
+
maps:
|
|
384
|
+
idempotentcy-marker[-]:
|
|
385
|
+
'@pipe':
|
|
386
|
+
- '@pipe':
|
|
387
|
+
- ['-wait', '{worker.output.data.workflowDimension}', '-', '{worker.output.data.index}', '-']
|
|
388
|
+
- ['{@string.concat}']
|
|
389
|
+
- '@pipe':
|
|
390
|
+
- '@pipe':
|
|
391
|
+
- ['{$self.hook.data.id}']
|
|
392
|
+
- '@pipe':
|
|
393
|
+
- [type, wait, data, '{$self.hook.data}', ac, '{$job.metadata.jc}', au, '{$self.output.metadata.au}']
|
|
394
|
+
- ['{@object.create}']
|
|
395
|
+
- '@pipe':
|
|
396
|
+
- [timedOut, true, ac, '{$self.output.metadata.ac}', au, '{$self.output.metadata.au}']
|
|
397
|
+
- ['{@object.create}']
|
|
398
|
+
- ['{@conditional.ternary}']
|
|
399
|
+
- ['{@object.create}']
|
|
400
|
+
|
|
401
|
+
wait_cycler:
|
|
402
|
+
title: Cycles back to the cycle_hook after signal wait
|
|
403
|
+
type: cycle
|
|
404
|
+
ancestor: cycle_hook
|
|
405
|
+
input:
|
|
406
|
+
maps:
|
|
407
|
+
retryCount: 0
|
|
408
|
+
continueGeneration: '{cycle_hook.output.data.continueGeneration}'
|
|
409
|
+
continueArgs: '{cycle_hook.output.data.continueArgs}'
|
|
410
|
+
|
|
362
411
|
childer:
|
|
363
412
|
title: Awaits a child flow to be executed/started
|
|
364
413
|
type: await
|
|
@@ -1072,14 +1121,22 @@ const getWorkflowYAML = (app, version) => {
|
|
|
1072
1121
|
schema:
|
|
1073
1122
|
type: object
|
|
1074
1123
|
properties:
|
|
1124
|
+
signalId:
|
|
1125
|
+
type: string
|
|
1126
|
+
description: the signal identifier to wait for
|
|
1075
1127
|
index:
|
|
1076
1128
|
type: number
|
|
1077
|
-
description: the index
|
|
1078
|
-
|
|
1079
|
-
type:
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1129
|
+
description: the replay index (COUNTER++)
|
|
1130
|
+
workflowDimension:
|
|
1131
|
+
type: string
|
|
1132
|
+
description: empty string or dimensional path (,0,0,1)
|
|
1133
|
+
duration:
|
|
1134
|
+
type: number
|
|
1135
|
+
description: optional timeout in seconds
|
|
1136
|
+
workflowId:
|
|
1137
|
+
type: string
|
|
1138
|
+
originJobId:
|
|
1139
|
+
type: string
|
|
1083
1140
|
|
|
1084
1141
|
signaler_sleeper:
|
|
1085
1142
|
title: Pauses a single thread within the worker for a set amount of seconds while the main flow thread and all other subthreads remain active
|
|
@@ -1105,6 +1162,45 @@ const getWorkflowYAML = (app, version) => {
|
|
|
1105
1162
|
maps:
|
|
1106
1163
|
retryCount: 0
|
|
1107
1164
|
|
|
1165
|
+
signaler_waiter:
|
|
1166
|
+
title: Waits for a matching signal or optional timeout (single condition in signal-in path)
|
|
1167
|
+
type: hook
|
|
1168
|
+
sleep: '{signaler_worker.output.data.duration}'
|
|
1169
|
+
hook:
|
|
1170
|
+
type: object
|
|
1171
|
+
properties:
|
|
1172
|
+
signalData:
|
|
1173
|
+
type: object
|
|
1174
|
+
output:
|
|
1175
|
+
maps:
|
|
1176
|
+
signalId: '{signaler_worker.output.data.signalId}'
|
|
1177
|
+
job:
|
|
1178
|
+
maps:
|
|
1179
|
+
idempotentcy-marker[-]:
|
|
1180
|
+
'@pipe':
|
|
1181
|
+
- '@pipe':
|
|
1182
|
+
- ['-wait', '{signaler_worker.output.data.workflowDimension}', '-', '{signaler_worker.output.data.index}', '-']
|
|
1183
|
+
- ['{@string.concat}']
|
|
1184
|
+
- '@pipe':
|
|
1185
|
+
- '@pipe':
|
|
1186
|
+
- ['{$self.hook.data.id}']
|
|
1187
|
+
- '@pipe':
|
|
1188
|
+
- [type, wait, data, '{$self.hook.data}', ac, '{$job.metadata.jc}', au, '{$self.output.metadata.au}']
|
|
1189
|
+
- ['{@object.create}']
|
|
1190
|
+
- '@pipe':
|
|
1191
|
+
- [timedOut, true, ac, '{$self.output.metadata.ac}', au, '{$self.output.metadata.au}']
|
|
1192
|
+
- ['{@object.create}']
|
|
1193
|
+
- ['{@conditional.ternary}']
|
|
1194
|
+
- ['{@object.create}']
|
|
1195
|
+
|
|
1196
|
+
signaler_wait_cycler:
|
|
1197
|
+
title: Cycles back to signaler_cycle_hook after signal wait
|
|
1198
|
+
type: cycle
|
|
1199
|
+
ancestor: signaler_cycle_hook
|
|
1200
|
+
input:
|
|
1201
|
+
maps:
|
|
1202
|
+
retryCount: 0
|
|
1203
|
+
|
|
1108
1204
|
signaler_childer:
|
|
1109
1205
|
title: Awaits a child flow to be executed/started
|
|
1110
1206
|
type: await
|
|
@@ -1546,6 +1642,9 @@ const getWorkflowYAML = (app, version) => {
|
|
|
1546
1642
|
- to: sleeper
|
|
1547
1643
|
conditions:
|
|
1548
1644
|
code: 588
|
|
1645
|
+
- to: waiter
|
|
1646
|
+
conditions:
|
|
1647
|
+
code: 595
|
|
1549
1648
|
- to: collator
|
|
1550
1649
|
conditions:
|
|
1551
1650
|
code: 589
|
|
@@ -1589,6 +1688,8 @@ const getWorkflowYAML = (app, version) => {
|
|
|
1589
1688
|
- to: proxy_cycler
|
|
1590
1689
|
sleeper:
|
|
1591
1690
|
- to: sleep_cycler
|
|
1691
|
+
waiter:
|
|
1692
|
+
- to: wait_cycler
|
|
1592
1693
|
### SUBPROCESS TRANSITIONS (REENTRY) ###
|
|
1593
1694
|
signaler:
|
|
1594
1695
|
- to: signaler_cycle_hook
|
|
@@ -1600,6 +1701,9 @@ const getWorkflowYAML = (app, version) => {
|
|
|
1600
1701
|
- to: signaler_sleeper
|
|
1601
1702
|
conditions:
|
|
1602
1703
|
code: 588
|
|
1704
|
+
- to: signaler_waiter
|
|
1705
|
+
conditions:
|
|
1706
|
+
code: 595
|
|
1603
1707
|
- to: signaler_collator
|
|
1604
1708
|
conditions:
|
|
1605
1709
|
code: 589
|
|
@@ -1627,6 +1731,8 @@ const getWorkflowYAML = (app, version) => {
|
|
|
1627
1731
|
- to: signaler_proxy_cycler
|
|
1628
1732
|
signaler_sleeper:
|
|
1629
1733
|
- to: signaler_sleep_cycler
|
|
1734
|
+
signaler_waiter:
|
|
1735
|
+
- to: signaler_wait_cycler
|
|
1630
1736
|
|
|
1631
1737
|
hooks:
|
|
1632
1738
|
${app}.flow.signal:
|
|
@@ -1635,7 +1741,17 @@ const getWorkflowYAML = (app, version) => {
|
|
|
1635
1741
|
match:
|
|
1636
1742
|
- expected: '{trigger.output.data.workflowId}'
|
|
1637
1743
|
actual: '{$self.hook.data.id}'
|
|
1638
|
-
|
|
1744
|
+
${app}.wfs.wait:
|
|
1745
|
+
- to: waiter
|
|
1746
|
+
conditions:
|
|
1747
|
+
match:
|
|
1748
|
+
- expected: '{worker.output.data.signalId}'
|
|
1749
|
+
actual: '{$self.hook.data.id}'
|
|
1750
|
+
- to: signaler_waiter
|
|
1751
|
+
conditions:
|
|
1752
|
+
match:
|
|
1753
|
+
- expected: '{signaler_worker.output.data.signalId}'
|
|
1754
|
+
actual: '{$self.hook.data.id}'
|
|
1639
1755
|
|
|
1640
1756
|
|
|
1641
1757
|
###################################################
|
|
@@ -799,10 +799,33 @@ class WorkerService {
|
|
|
799
799
|
if (isProcessing) {
|
|
800
800
|
return;
|
|
801
801
|
}
|
|
802
|
-
if (err instanceof errors_1.DurableWaitForError
|
|
803
|
-
interruptionRegistry.length
|
|
802
|
+
if (err instanceof errors_1.DurableWaitForError &&
|
|
803
|
+
interruptionRegistry.length === 1) {
|
|
804
|
+
//single condition() — handle inline, no collator needed
|
|
804
805
|
isProcessing = true;
|
|
805
|
-
|
|
806
|
+
const workflowInput = data.data;
|
|
807
|
+
const execIndex = counter.counter;
|
|
808
|
+
const { workflowId, workflowDimension, originJobId } = workflowInput;
|
|
809
|
+
return withPatchMarkers({
|
|
810
|
+
status: stream_1.StreamStatus.SUCCESS,
|
|
811
|
+
code: enums_1.HMSH_CODE_DURABLE_WAIT,
|
|
812
|
+
metadata: { ...data.metadata },
|
|
813
|
+
data: {
|
|
814
|
+
code: enums_1.HMSH_CODE_DURABLE_WAIT,
|
|
815
|
+
signalId: interruptionRegistry[0].signalId,
|
|
816
|
+
index: execIndex,
|
|
817
|
+
workflowDimension: interruptionRegistry[0].workflowDimension ||
|
|
818
|
+
workflowDimension ||
|
|
819
|
+
'',
|
|
820
|
+
duration: interruptionRegistry[0].duration,
|
|
821
|
+
workflowId,
|
|
822
|
+
originJobId: originJobId || workflowId,
|
|
823
|
+
},
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
else if (interruptionRegistry.length > 1) {
|
|
827
|
+
isProcessing = true;
|
|
828
|
+
//NOTE: this type is spawned when `Promise.all` is used (multiple items to collate)
|
|
806
829
|
const workflowInput = data.data;
|
|
807
830
|
const execIndex = counter.counter - interruptionRegistry.length + 1;
|
|
808
831
|
const { workflowId, workflowTopic, workflowDimension, originJobId, expire, } = workflowInput;
|
|
@@ -70,11 +70,25 @@ async function signal(signalId, data, expire) {
|
|
|
70
70
|
namespace,
|
|
71
71
|
});
|
|
72
72
|
if (await (0, isSideEffectAllowed_1.isSideEffectAllowed)(hotMeshClient, 'signal')) {
|
|
73
|
-
|
|
73
|
+
const payload = {
|
|
74
74
|
id: signalId,
|
|
75
75
|
data,
|
|
76
76
|
...(expire ? { $expire: expire } : {}),
|
|
77
|
-
}
|
|
77
|
+
};
|
|
78
|
+
//send collator topic first (creates pending if no collator),
|
|
79
|
+
//then inline waiter topic (delivers and cleans up collator pending)
|
|
80
|
+
try {
|
|
81
|
+
await hotMeshClient.signal(`${namespace}.wfs.signal`, payload);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
//no hook rule — ignore
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
return await hotMeshClient.signal(`${namespace}.wfs.wait`, payload);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
//no hook rule — ignore
|
|
91
|
+
}
|
|
78
92
|
}
|
|
79
93
|
}
|
|
80
94
|
exports.signal = signal;
|
|
@@ -204,6 +204,13 @@ const KVTables = (context) => ({
|
|
|
204
204
|
expiry TIMESTAMP WITH TIME ZONE
|
|
205
205
|
);
|
|
206
206
|
`);
|
|
207
|
+
if (tableDef.name === 'signal_registry') {
|
|
208
|
+
await client.query(`
|
|
209
|
+
CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_expiry
|
|
210
|
+
ON ${fullTableName} (expiry)
|
|
211
|
+
WHERE expiry IS NOT NULL;
|
|
212
|
+
`);
|
|
213
|
+
}
|
|
207
214
|
break;
|
|
208
215
|
case 'hash':
|
|
209
216
|
await client.query(`
|
|
@@ -775,36 +775,46 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
775
775
|
const fullKey = `${key}:${signalKey}`;
|
|
776
776
|
const delay = Math.max(hook.expire, enums_1.HMSH_SIGNAL_EXPIRE);
|
|
777
777
|
if (transaction) {
|
|
778
|
-
|
|
778
|
+
//in-transaction: unconditional upsert (overwrites $pending)
|
|
779
|
+
const kv = this.kvsql();
|
|
780
|
+
const tableName = kv.tableForKey(fullKey);
|
|
781
|
+
const storedKey = kv.storageKey(fullKey);
|
|
782
|
+
transaction.addCommand(`INSERT INTO ${tableName} (key, value, expiry)
|
|
783
|
+
VALUES ($1, $2, NOW() + INTERVAL '${delay} seconds')
|
|
784
|
+
ON CONFLICT (key) DO UPDATE
|
|
785
|
+
SET value = EXCLUDED.value, expiry = EXCLUDED.expiry`, [storedKey, jobId], 'boolean');
|
|
779
786
|
return { success: true };
|
|
780
787
|
}
|
|
788
|
+
//standalone: atomic CTE — read prior value + upsert in one statement.
|
|
789
|
+
//eliminates the advisory lock TOCTOU race (reentrant on same session).
|
|
781
790
|
const kv = this.kvsql();
|
|
782
791
|
const tableName = kv.tableForKey(fullKey);
|
|
783
792
|
const storedKey = kv.storageKey(fullKey);
|
|
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]);
|
|
787
793
|
try {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
794
|
+
const result = await this.pgClient.query(`WITH prior AS (
|
|
795
|
+
SELECT value FROM ${tableName}
|
|
796
|
+
WHERE key = $1 AND (expiry IS NULL OR expiry > NOW())
|
|
797
|
+
)
|
|
798
|
+
INSERT INTO ${tableName} (key, value, expiry)
|
|
799
|
+
VALUES ($1, $2, NOW() + INTERVAL '${delay} seconds')
|
|
800
|
+
ON CONFLICT (key) DO UPDATE
|
|
801
|
+
SET value = EXCLUDED.value, expiry = EXCLUDED.expiry
|
|
802
|
+
RETURNING (SELECT value FROM prior) as prior_value`, [storedKey, jobId]);
|
|
803
|
+
const priorValue = result.rows[0]?.prior_value;
|
|
804
|
+
if (priorValue?.startsWith('$pending::')) {
|
|
805
|
+
this.logger.debug('hook-signal-pending-consumed', {
|
|
806
|
+
key: signalKey,
|
|
807
|
+
});
|
|
808
|
+
return {
|
|
809
|
+
success: true,
|
|
810
|
+
pendingData: priorValue.slice('$pending::'.length),
|
|
811
|
+
};
|
|
801
812
|
}
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
return { success: true, pendingData };
|
|
813
|
+
if (priorValue && !priorValue.startsWith('$pending::')) {
|
|
814
|
+
//hook already set by a previous Leg1 (idempotent)
|
|
815
|
+
return { success: false };
|
|
816
|
+
}
|
|
817
|
+
return { success: true };
|
|
808
818
|
}
|
|
809
819
|
catch (error) {
|
|
810
820
|
if (error?.message?.includes('closed') ||
|
|
@@ -813,14 +823,6 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
813
823
|
}
|
|
814
824
|
throw error;
|
|
815
825
|
}
|
|
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
|
-
}
|
|
824
826
|
}
|
|
825
827
|
/**
|
|
826
828
|
* Leg2: get hook signal OR atomically set a pending signal.
|
|
@@ -849,30 +851,40 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
849
851
|
return undefined;
|
|
850
852
|
return value;
|
|
851
853
|
}
|
|
854
|
+
//atomic CTE: check for hook, store $pending if not found.
|
|
855
|
+
//eliminates the advisory lock TOCTOU race (reentrant on same session).
|
|
852
856
|
const kv = this.kvsql();
|
|
853
857
|
const tableName = kv.tableForKey(fullKey);
|
|
854
858
|
const storedKey = kv.storageKey(fullKey);
|
|
855
859
|
const expire = pendingExpire || enums_1.HMSH_PENDING_SIGNAL_EXPIRE;
|
|
856
860
|
const pendingValue = `$pending::${pendingData}`;
|
|
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]);
|
|
860
861
|
try {
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
862
|
+
const result = await this.pgClient.query(`WITH prior AS (
|
|
863
|
+
SELECT value FROM ${tableName}
|
|
864
|
+
WHERE key = $1 AND (expiry IS NULL OR expiry > NOW())
|
|
865
|
+
)
|
|
866
|
+
INSERT INTO ${tableName} (key, value, expiry)
|
|
867
|
+
VALUES ($1, $2, NOW() + INTERVAL '${expire} seconds')
|
|
868
|
+
ON CONFLICT (key) DO UPDATE
|
|
869
|
+
SET value = CASE
|
|
870
|
+
WHEN ${tableName}.value LIKE '$pending::%' THEN EXCLUDED.value
|
|
871
|
+
ELSE ${tableName}.value
|
|
872
|
+
END,
|
|
873
|
+
expiry = CASE
|
|
874
|
+
WHEN ${tableName}.value LIKE '$pending::%' THEN EXCLUDED.expiry
|
|
875
|
+
ELSE ${tableName}.expiry
|
|
876
|
+
END
|
|
877
|
+
RETURNING (SELECT value FROM prior) as prior_value`, [storedKey, pendingValue]);
|
|
878
|
+
const priorValue = result.rows[0]?.prior_value;
|
|
879
|
+
if (priorValue && !priorValue.startsWith('$pending::')) {
|
|
880
|
+
//hook found — return the composite job key
|
|
881
|
+
return priorValue;
|
|
870
882
|
}
|
|
871
|
-
//no hook
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
883
|
+
//no hook — $pending was stored (or updated existing pending)
|
|
884
|
+
this.logger.debug('hook-signal-pending-stored', {
|
|
885
|
+
topic,
|
|
886
|
+
resolved,
|
|
887
|
+
});
|
|
876
888
|
return undefined;
|
|
877
889
|
}
|
|
878
890
|
catch (error) {
|
|
@@ -882,14 +894,6 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
882
894
|
}
|
|
883
895
|
throw error;
|
|
884
896
|
}
|
|
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
|
-
}
|
|
893
897
|
}
|
|
894
898
|
async deleteHookSignal(topic, resolved) {
|
|
895
899
|
const key = this.mintKey(key_1.KeyType.SIGNALS, { appId: this.appId });
|