@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -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
- //try inline waiter (v14+: single condition handled without collator)
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 waitTopic = `${ns}.wfs.wait`;
177
- await (await this.getHotMeshClient(waitTopic, namespace)).signal(waitTopic, payload);
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 for wfs.wait (pre-v14 or Promise.all) — ignore
181
+ //no hook rule — ignore
181
182
  }
182
- //also signal collator path (Promise.all or pre-v14 single conditions)
183
183
  try {
184
- const signalTopic = `${ns}.wfs.signal`;
185
- return await (await this.getHotMeshClient(signalTopic, namespace)).signal(signalTopic, payload);
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 for wfs.signal — ignore
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
- //try inline waiter (v14+: single condition handled without collator)
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.wait`, payload);
112
+ await this.hotMesh.signal(`${this.hotMesh.appId}.wfs.signal`, payload);
112
113
  }
113
114
  catch {
114
- //no hook rule for wfs.wait (pre-v14 or Promise.all) — ignore
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.signal`, payload);
118
+ await this.hotMesh.signal(`${this.hotMesh.appId}.wfs.wait`, payload);
119
119
  }
120
120
  catch {
121
- //no hook rule for wfs.signal — ignore
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
- //try inline waiter (v14+: single condition handled without collator)
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.wait`, payload);
81
+ await hotMeshClient.signal(`${namespace}.wfs.signal`, payload);
81
82
  }
82
83
  catch {
83
- //no hook rule for wfs.wait (pre-v14 or Promise.all) — ignore
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.signal`, payload);
87
+ return await hotMeshClient.signal(`${namespace}.wfs.wait`, payload);
88
88
  }
89
89
  catch {
90
- //no hook rule for wfs.signal — ignore
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
- await this.kvsql(transaction).setnxex(fullKey, jobId, delay);
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
- //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
- }
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
- //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 };
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
- //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
- }
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 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]);
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 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",