@hotmeshio/hotmesh 0.17.0 → 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/durable/client.js +8 -8
- package/build/services/durable/handle.js +6 -6
- package/build/services/durable/workflow/signal.js +6 -6
- 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
|
}
|
|
@@ -171,21 +171,21 @@ class ClientService {
|
|
|
171
171
|
data,
|
|
172
172
|
...(expire ? { $expire: expire } : {}),
|
|
173
173
|
};
|
|
174
|
-
//
|
|
174
|
+
//send collator topic first (creates pending if no collator),
|
|
175
|
+
//then inline waiter topic (delivers and cleans up collator pending)
|
|
175
176
|
try {
|
|
176
|
-
const
|
|
177
|
-
await (await this.getHotMeshClient(
|
|
177
|
+
const signalTopic = `${ns}.wfs.signal`;
|
|
178
|
+
await (await this.getHotMeshClient(signalTopic, namespace)).signal(signalTopic, payload);
|
|
178
179
|
}
|
|
179
180
|
catch {
|
|
180
|
-
//no hook rule
|
|
181
|
+
//no hook rule — ignore
|
|
181
182
|
}
|
|
182
|
-
//also signal collator path (Promise.all or pre-v14 single conditions)
|
|
183
183
|
try {
|
|
184
|
-
const
|
|
185
|
-
return await (await this.getHotMeshClient(
|
|
184
|
+
const waitTopic = `${ns}.wfs.wait`;
|
|
185
|
+
return await (await this.getHotMeshClient(waitTopic, namespace)).signal(waitTopic, payload);
|
|
186
186
|
}
|
|
187
187
|
catch {
|
|
188
|
-
//no hook rule
|
|
188
|
+
//no hook rule — ignore
|
|
189
189
|
}
|
|
190
190
|
},
|
|
191
191
|
/**
|
|
@@ -106,19 +106,19 @@ class WorkflowHandleService {
|
|
|
106
106
|
data,
|
|
107
107
|
...(expire ? { $expire: expire } : {}),
|
|
108
108
|
};
|
|
109
|
-
//
|
|
109
|
+
//send collator topic first (creates pending if no collator),
|
|
110
|
+
//then inline waiter topic (delivers and cleans up collator pending)
|
|
110
111
|
try {
|
|
111
|
-
await this.hotMesh.signal(`${this.hotMesh.appId}.wfs.
|
|
112
|
+
await this.hotMesh.signal(`${this.hotMesh.appId}.wfs.signal`, payload);
|
|
112
113
|
}
|
|
113
114
|
catch {
|
|
114
|
-
//no hook rule
|
|
115
|
+
//no hook rule — ignore
|
|
115
116
|
}
|
|
116
|
-
//also signal collator path (Promise.all or pre-v14 single conditions)
|
|
117
117
|
try {
|
|
118
|
-
await this.hotMesh.signal(`${this.hotMesh.appId}.wfs.
|
|
118
|
+
await this.hotMesh.signal(`${this.hotMesh.appId}.wfs.wait`, payload);
|
|
119
119
|
}
|
|
120
120
|
catch {
|
|
121
|
-
//no hook rule
|
|
121
|
+
//no hook rule — ignore
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
/**
|
|
@@ -75,19 +75,19 @@ async function signal(signalId, data, expire) {
|
|
|
75
75
|
data,
|
|
76
76
|
...(expire ? { $expire: expire } : {}),
|
|
77
77
|
};
|
|
78
|
-
//
|
|
78
|
+
//send collator topic first (creates pending if no collator),
|
|
79
|
+
//then inline waiter topic (delivers and cleans up collator pending)
|
|
79
80
|
try {
|
|
80
|
-
await hotMeshClient.signal(`${namespace}.wfs.
|
|
81
|
+
await hotMeshClient.signal(`${namespace}.wfs.signal`, payload);
|
|
81
82
|
}
|
|
82
83
|
catch {
|
|
83
|
-
//no hook rule
|
|
84
|
+
//no hook rule — ignore
|
|
84
85
|
}
|
|
85
|
-
//also signal collator path (Promise.all or pre-v14 single conditions)
|
|
86
86
|
try {
|
|
87
|
-
return await hotMeshClient.signal(`${namespace}.wfs.
|
|
87
|
+
return await hotMeshClient.signal(`${namespace}.wfs.wait`, payload);
|
|
88
88
|
}
|
|
89
89
|
catch {
|
|
90
|
-
//no hook rule
|
|
90
|
+
//no hook rule — ignore
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
}
|
|
@@ -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 });
|