@hotmeshio/hotmesh 0.14.3 → 0.14.5
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/modules/enums.d.ts +6 -0
- package/build/modules/enums.js +8 -2
- package/build/package.json +4 -3
- package/build/services/activities/hook.d.ts +10 -1
- package/build/services/activities/hook.js +45 -6
- package/build/services/dba/index.d.ts +1 -0
- package/build/services/dba/index.js +20 -3
- package/build/services/durable/client.js +13 -3
- package/build/services/durable/handle.d.ts +8 -1
- package/build/services/durable/handle.js +9 -1
- package/build/services/durable/worker.js +4 -0
- package/build/services/durable/workflow/signal.d.ts +1 -1
- package/build/services/durable/workflow/signal.js +2 -1
- package/build/services/mapper/index.d.ts +57 -2
- package/build/services/mapper/index.js +57 -2
- package/build/services/pipe/index.d.ts +444 -10
- package/build/services/pipe/index.js +444 -10
- package/build/services/store/index.d.ts +15 -2
- package/build/services/store/providers/postgres/kvtables.d.ts +1 -0
- package/build/services/store/providers/postgres/kvtables.js +46 -1
- package/build/services/store/providers/postgres/postgres.d.ts +25 -2
- package/build/services/store/providers/postgres/postgres.js +121 -4
- package/build/services/stream/registry.d.ts +1 -0
- package/build/services/stream/registry.js +12 -8
- package/build/services/task/index.d.ts +4 -1
- package/build/services/task/index.js +34 -6
- package/build/services/worker/index.js +2 -0
- package/build/types/dba.d.ts +11 -0
- package/build/types/hotmesh.d.ts +8 -0
- package/package.json +4 -3
- package/vitest.config.mts +1 -1
|
@@ -754,16 +754,133 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
754
754
|
return patterns;
|
|
755
755
|
}
|
|
756
756
|
}
|
|
757
|
+
/**
|
|
758
|
+
* Leg1: set hook signal, atomically detecting a pending signal.
|
|
759
|
+
*
|
|
760
|
+
* Standalone (no transaction): single CTE query that reads any existing
|
|
761
|
+
* pending value, then inserts the hook signal (overwriting pending or
|
|
762
|
+
* expired entries). Returns `{success, pendingData}` in one round trip.
|
|
763
|
+
*
|
|
764
|
+
* In a transaction: queues the setnxex; pending detection deferred.
|
|
765
|
+
*/
|
|
757
766
|
async setHookSignal(hook, transaction) {
|
|
758
767
|
const key = this.mintKey(key_1.KeyType.SIGNALS, { appId: this.appId });
|
|
759
768
|
const { topic, resolved, jobId } = hook;
|
|
760
769
|
const signalKey = `${topic}:${resolved}`;
|
|
761
|
-
|
|
770
|
+
const fullKey = `${key}:${signalKey}`;
|
|
771
|
+
const delay = Math.max(hook.expire, enums_1.HMSH_SIGNAL_EXPIRE);
|
|
772
|
+
if (transaction) {
|
|
773
|
+
await this.kvsql(transaction).setnxex(fullKey, jobId, delay);
|
|
774
|
+
return { success: true };
|
|
775
|
+
}
|
|
776
|
+
const kv = this.kvsql();
|
|
777
|
+
const tableName = kv.tableForKey(fullKey);
|
|
778
|
+
const storedKey = kv.storageKey(fullKey);
|
|
779
|
+
const sql = `
|
|
780
|
+
WITH pre AS (
|
|
781
|
+
SELECT value FROM ${tableName}
|
|
782
|
+
WHERE key = $1 AND (expiry IS NULL OR expiry > NOW())
|
|
783
|
+
),
|
|
784
|
+
ins AS (
|
|
785
|
+
INSERT INTO ${tableName} (key, value, expiry)
|
|
786
|
+
VALUES ($1, $2, NOW() + INTERVAL '${delay} seconds')
|
|
787
|
+
ON CONFLICT (key) DO UPDATE
|
|
788
|
+
SET value = EXCLUDED.value, expiry = EXCLUDED.expiry
|
|
789
|
+
WHERE ${tableName}.expiry IS NULL
|
|
790
|
+
OR ${tableName}.expiry <= NOW()
|
|
791
|
+
OR ${tableName}.value LIKE '$pending::%'
|
|
792
|
+
RETURNING true as success
|
|
793
|
+
)
|
|
794
|
+
SELECT
|
|
795
|
+
COALESCE((SELECT success FROM ins), false) as success,
|
|
796
|
+
(SELECT value FROM pre) as existing_value
|
|
797
|
+
`;
|
|
798
|
+
try {
|
|
799
|
+
const res = await this.pgClient.query(sql, [storedKey, jobId]);
|
|
800
|
+
const row = res.rows[0] || {};
|
|
801
|
+
const success = row.success === true;
|
|
802
|
+
const existing = row.existing_value;
|
|
803
|
+
if (success && existing?.startsWith('$pending::')) {
|
|
804
|
+
return {
|
|
805
|
+
success: true,
|
|
806
|
+
pendingData: existing.slice('$pending::'.length),
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
return { success };
|
|
810
|
+
}
|
|
811
|
+
catch (error) {
|
|
812
|
+
if (error?.message?.includes('closed') ||
|
|
813
|
+
error?.message?.includes('queryable')) {
|
|
814
|
+
return { success: false };
|
|
815
|
+
}
|
|
816
|
+
throw error;
|
|
817
|
+
}
|
|
762
818
|
}
|
|
763
|
-
|
|
819
|
+
/**
|
|
820
|
+
* Leg2: get hook signal OR atomically set a pending signal.
|
|
821
|
+
*
|
|
822
|
+
* When `pendingData` is provided and no hook signal exists, the
|
|
823
|
+
* pending value is inserted in the SAME SQL statement — no second
|
|
824
|
+
* round trip. This is the transactional edge that prevents the
|
|
825
|
+
* signal from being lost: by the time the query returns, the
|
|
826
|
+
* pending key is already visible to leg1's setnxex.
|
|
827
|
+
*
|
|
828
|
+
* When `pendingData` is omitted, behaves as a plain read.
|
|
829
|
+
*/
|
|
830
|
+
async getHookSignal(topic, resolved, pendingData, pendingExpire) {
|
|
764
831
|
const key = this.mintKey(key_1.KeyType.SIGNALS, { appId: this.appId });
|
|
765
|
-
const
|
|
766
|
-
|
|
832
|
+
const fullKey = `${key}:${topic}:${resolved}`;
|
|
833
|
+
if (!pendingData) {
|
|
834
|
+
//plain read (used by deleteWebHookSignal path, tests, etc.)
|
|
835
|
+
const response = await this.kvsql().get(fullKey);
|
|
836
|
+
if (!response)
|
|
837
|
+
return undefined;
|
|
838
|
+
const value = response.toString();
|
|
839
|
+
if (value.startsWith('$pending::'))
|
|
840
|
+
return undefined;
|
|
841
|
+
return value;
|
|
842
|
+
}
|
|
843
|
+
//atomic get-or-set-pending: one round trip
|
|
844
|
+
const kv = this.kvsql();
|
|
845
|
+
const tableName = kv.tableForKey(fullKey);
|
|
846
|
+
const storedKey = kv.storageKey(fullKey);
|
|
847
|
+
const expire = pendingExpire || enums_1.HMSH_PENDING_SIGNAL_EXPIRE;
|
|
848
|
+
const pendingValue = `$pending::${pendingData}`;
|
|
849
|
+
const sql = `
|
|
850
|
+
WITH existing AS (
|
|
851
|
+
SELECT value FROM ${tableName}
|
|
852
|
+
WHERE key = $1 AND (expiry IS NULL OR expiry > NOW())
|
|
853
|
+
),
|
|
854
|
+
pending AS (
|
|
855
|
+
INSERT INTO ${tableName} (key, value, expiry)
|
|
856
|
+
SELECT $1, $2, NOW() + INTERVAL '${expire} seconds'
|
|
857
|
+
WHERE NOT EXISTS (SELECT 1 FROM existing)
|
|
858
|
+
ON CONFLICT (key) DO UPDATE
|
|
859
|
+
SET value = EXCLUDED.value, expiry = EXCLUDED.expiry
|
|
860
|
+
WHERE ${tableName}.expiry IS NULL OR ${tableName}.expiry <= NOW()
|
|
861
|
+
RETURNING true as inserted
|
|
862
|
+
)
|
|
863
|
+
SELECT
|
|
864
|
+
(SELECT value FROM existing) as hook_value,
|
|
865
|
+
(SELECT inserted FROM pending) as pending_inserted
|
|
866
|
+
`;
|
|
867
|
+
try {
|
|
868
|
+
const res = await this.pgClient.query(sql, [storedKey, pendingValue]);
|
|
869
|
+
const row = res.rows[0] || {};
|
|
870
|
+
const hookValue = row.hook_value;
|
|
871
|
+
if (hookValue && !hookValue.startsWith('$pending::')) {
|
|
872
|
+
return hookValue;
|
|
873
|
+
}
|
|
874
|
+
//no hook signal; pending was inserted (or already existed)
|
|
875
|
+
return undefined;
|
|
876
|
+
}
|
|
877
|
+
catch (error) {
|
|
878
|
+
if (error?.message?.includes('closed') ||
|
|
879
|
+
error?.message?.includes('queryable')) {
|
|
880
|
+
return undefined;
|
|
881
|
+
}
|
|
882
|
+
throw error;
|
|
883
|
+
}
|
|
767
884
|
}
|
|
768
885
|
async deleteHookSignal(topic, resolved) {
|
|
769
886
|
const key = this.mintKey(key_1.KeyType.SIGNALS, { appId: this.appId });
|
|
@@ -29,6 +29,7 @@ class StreamConsumerRegistry {
|
|
|
29
29
|
topic: taskQueue,
|
|
30
30
|
reclaimDelay: config?.reclaimDelay,
|
|
31
31
|
reclaimCount: config?.reclaimCount,
|
|
32
|
+
readonly: config?.readonly || false,
|
|
32
33
|
throttle,
|
|
33
34
|
retry: config?.retry,
|
|
34
35
|
}, stream, logger);
|
|
@@ -39,14 +40,17 @@ class StreamConsumerRegistry {
|
|
|
39
40
|
logger,
|
|
40
41
|
};
|
|
41
42
|
StreamConsumerRegistry.workerConsumers.set(key, entry);
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
43
|
+
// Only start consuming if not readonly
|
|
44
|
+
if (!config?.readonly) {
|
|
45
|
+
// Create the dispatch callback that routes by workflow_name
|
|
46
|
+
const dispatchCallback = StreamConsumerRegistry.createWorkerDispatcher(key);
|
|
47
|
+
// Start consuming from the task queue stream
|
|
48
|
+
const streamKey = stream.mintKey(key_1.KeyType.STREAMS, {
|
|
49
|
+
appId,
|
|
50
|
+
topic: taskQueue,
|
|
51
|
+
});
|
|
52
|
+
router.consumeMessages(streamKey, 'WORKER', guid, dispatchCallback);
|
|
53
|
+
}
|
|
50
54
|
}
|
|
51
55
|
// Register the callback for this workflow name
|
|
52
56
|
entry.callbacks.set(workflowName, callback);
|
|
@@ -29,7 +29,10 @@ declare class TaskService {
|
|
|
29
29
|
processTimeHooks(timeEventCallback: (jobId: string, gId: string, activityId: string, type: WorkListTaskType) => Promise<void>, listKey?: string): Promise<void>;
|
|
30
30
|
cancelCleanup(): void;
|
|
31
31
|
getHookRule(topic: string): Promise<HookRule | undefined>;
|
|
32
|
-
registerWebHook(topic: string, context: JobState, dad: string, expire: number
|
|
32
|
+
registerWebHook(topic: string, context: JobState, dad: string, expire: number): Promise<{
|
|
33
|
+
jobId: string;
|
|
34
|
+
pending?: string;
|
|
35
|
+
}>;
|
|
33
36
|
processWebHookSignal(topic: string, data: Record<string, unknown>): Promise<[string, string, string, string] | undefined>;
|
|
34
37
|
deleteWebHookSignal(topic: string, data: Record<string, unknown>): Promise<number>;
|
|
35
38
|
/**
|
|
@@ -134,7 +134,7 @@ class TaskService {
|
|
|
134
134
|
const rules = await this.store.getHookRules();
|
|
135
135
|
return rules?.[topic]?.[0];
|
|
136
136
|
}
|
|
137
|
-
async registerWebHook(topic, context, dad, expire
|
|
137
|
+
async registerWebHook(topic, context, dad, expire) {
|
|
138
138
|
const hookRule = await this.getHookRule(topic);
|
|
139
139
|
if (hookRule) {
|
|
140
140
|
const mapExpression = hookRule.conditions.match[0].expected;
|
|
@@ -150,8 +150,27 @@ class TaskService {
|
|
|
150
150
|
jobId: compositeJobKey,
|
|
151
151
|
expire,
|
|
152
152
|
};
|
|
153
|
-
|
|
154
|
-
return
|
|
153
|
+
//called standalone (no transaction) so the single CTE query can
|
|
154
|
+
//atomically detect and return pending signal data on collision
|
|
155
|
+
const result = await this.store.setHookSignal(hook);
|
|
156
|
+
if (result.pendingData) {
|
|
157
|
+
this.logger.warn('task-signal-race-pending-consumed', {
|
|
158
|
+
topic,
|
|
159
|
+
resolved,
|
|
160
|
+
jobId,
|
|
161
|
+
});
|
|
162
|
+
return { jobId, pending: result.pendingData };
|
|
163
|
+
}
|
|
164
|
+
if (!result.success) {
|
|
165
|
+
//setnxex failed but no pending signal; likely a retry where
|
|
166
|
+
//our own hook signal was already set. continue normally.
|
|
167
|
+
this.logger.debug('task-signal-hook-already-set', {
|
|
168
|
+
topic,
|
|
169
|
+
resolved,
|
|
170
|
+
jobId,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return { jobId };
|
|
155
174
|
}
|
|
156
175
|
else {
|
|
157
176
|
throw new Error('signaler.registerWebHook:error: hook rule not found');
|
|
@@ -165,10 +184,19 @@ class TaskService {
|
|
|
165
184
|
const context = { $self: { hook: { data } }, $hook: { data } };
|
|
166
185
|
const mapExpression = hookRule.conditions.match[0].actual;
|
|
167
186
|
const resolved = pipe_1.Pipe.resolve(mapExpression, context);
|
|
168
|
-
|
|
187
|
+
//resolve $expire override from the signal data (e.g., '1h', '30d')
|
|
188
|
+
const pendingExpire = typeof data.$expire === 'string'
|
|
189
|
+
? (0, utils_1.s)(data.$expire)
|
|
190
|
+
: enums_1.HMSH_PENDING_SIGNAL_EXPIRE;
|
|
191
|
+
//atomic: returns the hook signal, or stores a pending signal
|
|
192
|
+
//in the same SQL statement if no hook is registered yet
|
|
193
|
+
const hookSignalId = await this.store.getHookSignal(topic, resolved, JSON.stringify(data), pendingExpire);
|
|
169
194
|
if (!hookSignalId) {
|
|
170
|
-
|
|
171
|
-
|
|
195
|
+
this.logger.warn('task-signal-race-pending-stored', {
|
|
196
|
+
topic,
|
|
197
|
+
resolved,
|
|
198
|
+
expire: pendingExpire,
|
|
199
|
+
});
|
|
172
200
|
return undefined;
|
|
173
201
|
}
|
|
174
202
|
//`aid` is part of composite key, but the hook `topic` is its public interface;
|
|
@@ -51,6 +51,7 @@ class WorkerService {
|
|
|
51
51
|
await registry_1.StreamConsumerRegistry.registerWorker(namespace, appId, guid, worker.topic, worker.workflowName, worker.callback, service.stream, service.store, logger, {
|
|
52
52
|
reclaimDelay: worker.reclaimDelay,
|
|
53
53
|
reclaimCount: worker.reclaimCount,
|
|
54
|
+
readonly: worker.readonly,
|
|
54
55
|
retry: worker.retry,
|
|
55
56
|
});
|
|
56
57
|
// Still need a router for publishing responses back to engine
|
|
@@ -114,6 +115,7 @@ class WorkerService {
|
|
|
114
115
|
reclaimDelay: worker.reclaimDelay,
|
|
115
116
|
reclaimCount: worker.reclaimCount,
|
|
116
117
|
throttle,
|
|
118
|
+
readonly: worker.readonly || false,
|
|
117
119
|
retry: worker.retry,
|
|
118
120
|
}, this.stream, logger);
|
|
119
121
|
}
|
package/build/types/dba.d.ts
CHANGED
|
@@ -103,6 +103,15 @@ export interface PruneOptions {
|
|
|
103
103
|
* @default false
|
|
104
104
|
*/
|
|
105
105
|
keepHmark?: boolean;
|
|
106
|
+
/**
|
|
107
|
+
* If true, hard-deletes expired rows from `signal_registry`.
|
|
108
|
+
* These include consumed hook signals and stale pending signals
|
|
109
|
+
* (signals that arrived before hook registration but were never
|
|
110
|
+
* claimed). All signal_registry entries have a natural `expiry`
|
|
111
|
+
* column; this operation removes rows whose expiry has passed.
|
|
112
|
+
* @default true
|
|
113
|
+
*/
|
|
114
|
+
signals?: boolean;
|
|
106
115
|
}
|
|
107
116
|
/**
|
|
108
117
|
* Result returned by `DBA.prune()`, providing deletion
|
|
@@ -123,4 +132,6 @@ export interface PruneResult {
|
|
|
123
132
|
transient: number;
|
|
124
133
|
/** Number of jobs marked as pruned (pruned_at set) */
|
|
125
134
|
marked: number;
|
|
135
|
+
/** Number of expired signal_registry rows hard-deleted */
|
|
136
|
+
signals: number;
|
|
126
137
|
}
|
package/build/types/hotmesh.d.ts
CHANGED
|
@@ -284,6 +284,14 @@ type HotMeshWorker = {
|
|
|
284
284
|
user: string;
|
|
285
285
|
password: string;
|
|
286
286
|
};
|
|
287
|
+
/**
|
|
288
|
+
* If true, the worker's router will not consume messages from the
|
|
289
|
+
* stream. The worker can still publish responses but will never
|
|
290
|
+
* dequeue or process messages. This is inherited from the
|
|
291
|
+
* connection's `readonly` flag by the Durable layer.
|
|
292
|
+
* @default false
|
|
293
|
+
*/
|
|
294
|
+
readonly?: boolean;
|
|
287
295
|
};
|
|
288
296
|
type HotMeshConfig = {
|
|
289
297
|
appId: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hotmeshio/hotmesh",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.5",
|
|
4
4
|
"description": "Durable Workflow",
|
|
5
5
|
"main": "./build/index.js",
|
|
6
6
|
"types": "./build/index.d.ts",
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"obfuscate": "ts-node scripts/obfuscate.ts",
|
|
15
15
|
"clean-build": "npm run clean && npm run build",
|
|
16
16
|
"clean-build-obfuscate": "npm run clean-build && npm run obfuscate",
|
|
17
|
-
"docs": "typedoc",
|
|
18
|
-
"docs:clean": "rimraf ./docs/hotmesh && typedoc",
|
|
17
|
+
"docs": "typedoc && cp -R docs/hotmesh/* docs/ && rm -rf docs/hotmesh",
|
|
18
|
+
"docs:clean": "rimraf ./docs/hotmesh && typedoc && cp -R docs/hotmesh/* docs/ && rm -rf docs/hotmesh",
|
|
19
19
|
"lint": "eslint . --ext .ts",
|
|
20
20
|
"lint:fix": "eslint . --fix --ext .ts",
|
|
21
21
|
"start": "ts-node src/index.ts",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"test:durable:retrypolicy": "vitest run tests/durable/retry-policy",
|
|
48
48
|
"test:durable:sleep": "vitest run tests/durable/sleep/postgres.test.ts",
|
|
49
49
|
"test:durable:signal": "vitest run tests/durable/signal/postgres.test.ts",
|
|
50
|
+
"test:durable:readonly": "docker compose --profile readonly up -d --build && docker compose exec hotmesh-readonly npx vitest run --config tests/durable/readonly/vitest.config.mts",
|
|
50
51
|
"test:durable:unknown": "vitest run tests/durable/unknown/postgres.test.ts",
|
|
51
52
|
"test:durable:exporter": "HMSH_LOGLEVEL=info vitest run tests/durable/exporter",
|
|
52
53
|
"test:durable:exporter:debug": "EXPORT_DEBUG=1 HMSH_LOGLEVEL=error vitest run tests/durable/basic/postgres.test.ts",
|
package/vitest.config.mts
CHANGED
|
@@ -5,7 +5,7 @@ export default defineConfig({
|
|
|
5
5
|
globals: true,
|
|
6
6
|
environment: 'node',
|
|
7
7
|
include: ['tests/**/*.test.ts'],
|
|
8
|
-
exclude: ['node_modules', 'build', 'config'],
|
|
8
|
+
exclude: ['node_modules', 'build', 'config', 'tests/durable/readonly/**'],
|
|
9
9
|
testTimeout: 60_000,
|
|
10
10
|
hookTimeout: 120_000,
|
|
11
11
|
fileParallelism: false,
|