@hotmeshio/hotmesh 0.15.1 → 0.16.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/modules/storage.d.ts +1 -0
- package/build/modules/storage.js +2 -1
- package/build/package.json +1 -1
- package/build/services/activities/activity/state.d.ts +1 -1
- package/build/services/activities/activity/state.js +3 -2
- package/build/services/durable/exporter.js +16 -2
- package/build/services/pipe/functions/cron.js +3 -0
- package/build/services/serializer/index.js +1 -1
- package/build/services/store/providers/postgres/kvtables.js +53 -14
- package/build/services/store/providers/postgres/kvtypes/hash/basic.js +18 -21
- package/build/services/store/providers/postgres/kvtypes/zset.js +11 -0
- package/build/services/store/providers/postgres/postgres.js +4 -6
- package/build/services/task/index.js +1 -1
- package/build/services/virtual/index.d.ts +22 -1
- package/build/services/virtual/index.js +54 -6
- package/build/types/index.d.ts +1 -1
- package/build/types/virtual.d.ts +69 -1
- package/package.json +1 -1
|
@@ -2,3 +2,4 @@
|
|
|
2
2
|
import { AsyncLocalStorage } from 'async_hooks';
|
|
3
3
|
export declare const asyncLocalStorage: AsyncLocalStorage<Map<string, any>>;
|
|
4
4
|
export declare const activityAsyncLocalStorage: AsyncLocalStorage<Map<string, any>>;
|
|
5
|
+
export declare const virtualAsyncLocalStorage: AsyncLocalStorage<Map<string, any>>;
|
package/build/modules/storage.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.activityAsyncLocalStorage = exports.asyncLocalStorage = void 0;
|
|
3
|
+
exports.virtualAsyncLocalStorage = exports.activityAsyncLocalStorage = exports.asyncLocalStorage = void 0;
|
|
4
4
|
const async_hooks_1 = require("async_hooks");
|
|
5
5
|
exports.asyncLocalStorage = new async_hooks_1.AsyncLocalStorage();
|
|
6
6
|
exports.activityAsyncLocalStorage = new async_hooks_1.AsyncLocalStorage();
|
|
7
|
+
exports.virtualAsyncLocalStorage = new async_hooks_1.AsyncLocalStorage();
|
package/build/package.json
CHANGED
|
@@ -30,7 +30,7 @@ interface StateContext {
|
|
|
30
30
|
export declare function setState(instance: StateContext, transaction?: ProviderTransaction): Promise<string>;
|
|
31
31
|
export declare function getState(instance: StateContext): Promise<void>;
|
|
32
32
|
export declare function setStatus(instance: StateContext, amount: number, transaction?: ProviderTransaction): Promise<void | any>;
|
|
33
|
-
export declare function bindJobMetadata(
|
|
33
|
+
export declare function bindJobMetadata(_instance: StateContext): void;
|
|
34
34
|
export declare function bindActivityMetadata(instance: StateContext): void;
|
|
35
35
|
export declare function bindJobState(instance: StateContext, state: StringAnyType): Promise<void>;
|
|
36
36
|
export declare function bindActivityState(instance: StateContext, state: StringAnyType): void;
|
|
@@ -68,8 +68,9 @@ async function setStatus(instance, amount, transaction) {
|
|
|
68
68
|
}
|
|
69
69
|
exports.setStatus = setStatus;
|
|
70
70
|
//─── metadata binding ────────────────────────────────────────────────
|
|
71
|
-
function bindJobMetadata(
|
|
72
|
-
|
|
71
|
+
function bindJobMetadata(_instance) {
|
|
72
|
+
// ju (job_updated) is maintained by the jobs.updated_at trigger —
|
|
73
|
+
// no need to serialize it into jobs_attributes on every activity.
|
|
73
74
|
}
|
|
74
75
|
exports.bindJobMetadata = bindJobMetadata;
|
|
75
76
|
function bindActivityMetadata(instance) {
|
|
@@ -676,9 +676,23 @@ class ExporterService {
|
|
|
676
676
|
const metadata = state?.output?.metadata ?? state?.metadata;
|
|
677
677
|
const stateData = state?.output?.data ?? state?.data;
|
|
678
678
|
const jobCreated = metadata?.jc ?? stateData?.jc ?? metadata?.ac;
|
|
679
|
-
const jobUpdated = metadata?.ju ?? stateData?.ju ?? metadata?.au;
|
|
680
679
|
const startTime = parseTimestamp(jobCreated);
|
|
681
|
-
|
|
680
|
+
// Derive close time from the latest activity update (au) in the
|
|
681
|
+
// timeline, falling back to metadata.au. ju is no longer updated
|
|
682
|
+
// after creation — jobs.updated_at is the authoritative source,
|
|
683
|
+
// but it's not available in the DurableJobExport format.
|
|
684
|
+
let latestAu = null;
|
|
685
|
+
for (const entry of raw.timeline || []) {
|
|
686
|
+
const val = entry.value;
|
|
687
|
+
const au = val?.au;
|
|
688
|
+
if (au) {
|
|
689
|
+
const parsed = parseTimestamp(au);
|
|
690
|
+
if (parsed && (!latestAu || parsed > latestAu)) {
|
|
691
|
+
latestAu = parsed;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const closeTime = latestAu ?? parseTimestamp(metadata?.au);
|
|
682
696
|
// ── Synthetic workflow_execution_started ─────────────────────────
|
|
683
697
|
if (startTime) {
|
|
684
698
|
events.push(makeEvent(nextId++, 'workflow_execution_started', 'workflow', startTime, null, false, {
|
|
@@ -30,6 +30,9 @@ class CronHandler {
|
|
|
30
30
|
* ```
|
|
31
31
|
*/
|
|
32
32
|
nextDelay(cronExpression) {
|
|
33
|
+
if (cronExpression == null || typeof cronExpression !== 'string') {
|
|
34
|
+
return -1;
|
|
35
|
+
}
|
|
33
36
|
if (!(0, utils_1.isValidCron)(cronExpression)) {
|
|
34
37
|
throw new Error(`Invalid cron expression: ${cronExpression}`);
|
|
35
38
|
}
|
|
@@ -153,6 +153,20 @@ 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
|
+
`);
|
|
156
170
|
},
|
|
157
171
|
async createTables(client, appName) {
|
|
158
172
|
try {
|
|
@@ -251,27 +265,40 @@ const KVTables = (context) => ({
|
|
|
251
265
|
CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_context_gin
|
|
252
266
|
ON ${fullTableName} USING GIN (context);
|
|
253
267
|
`);
|
|
254
|
-
// Create partitions
|
|
268
|
+
// Create partitions with fillfactor 70: reserves 30% page space
|
|
269
|
+
// for HOT updates (status update cycle in setStatusAndCollateGuid)
|
|
255
270
|
await client.query(`
|
|
256
271
|
DO $$
|
|
257
272
|
BEGIN
|
|
258
273
|
FOR i IN 0..7 LOOP
|
|
259
274
|
EXECUTE format(
|
|
260
|
-
'CREATE TABLE IF NOT EXISTS ${fullTableName}_part_%s PARTITION OF ${fullTableName}
|
|
261
|
-
FOR VALUES WITH (modulus 8, remainder %s)
|
|
275
|
+
'CREATE TABLE IF NOT EXISTS ${fullTableName}_part_%s PARTITION OF ${fullTableName}
|
|
276
|
+
FOR VALUES WITH (modulus 8, remainder %s)
|
|
277
|
+
WITH (fillfactor = 70)',
|
|
262
278
|
i, i
|
|
263
279
|
);
|
|
264
280
|
END LOOP;
|
|
265
281
|
END$$;
|
|
266
282
|
`);
|
|
267
|
-
//
|
|
283
|
+
// Original index for queries that filter on expired_at directly
|
|
284
|
+
// (e.g. _hmget's WHERE expired_at IS NULL OR expired_at > NOW())
|
|
268
285
|
await client.query(`
|
|
269
|
-
CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_expired_at
|
|
286
|
+
CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_expired_at
|
|
270
287
|
ON ${fullTableName} (key, expired_at) INCLUDE (is_live);
|
|
271
288
|
`);
|
|
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.
|
|
272
292
|
await client.query(`
|
|
273
|
-
CREATE INDEX IF NOT EXISTS idx_${tableDef.name}
|
|
274
|
-
ON ${fullTableName} (
|
|
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
|
+
await client.query(`
|
|
300
|
+
CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_entity_status
|
|
301
|
+
ON ${fullTableName} (entity) INCLUDE (status);
|
|
275
302
|
`);
|
|
276
303
|
await client.query(`
|
|
277
304
|
CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_expired_at
|
|
@@ -303,7 +330,9 @@ const KVTables = (context) => ({
|
|
|
303
330
|
BEFORE INSERT OR UPDATE ON ${fullTableName}
|
|
304
331
|
FOR EACH ROW EXECUTE PROCEDURE ${schemaName}.update_is_live();
|
|
305
332
|
`);
|
|
306
|
-
//
|
|
333
|
+
// Enforce uniqueness of live jobs via trigger.
|
|
334
|
+
// Uses is_live partial index for the EXISTS check (existing rows
|
|
335
|
+
// have is_live maintained by trg_update_is_live).
|
|
307
336
|
await client.query(`
|
|
308
337
|
CREATE OR REPLACE FUNCTION ${schemaName}.enforce_live_job_uniqueness()
|
|
309
338
|
RETURNS TRIGGER AS $$
|
|
@@ -312,8 +341,8 @@ const KVTables = (context) => ({
|
|
|
312
341
|
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.key, 0));
|
|
313
342
|
IF EXISTS (
|
|
314
343
|
SELECT 1 FROM ${fullTableName}
|
|
315
|
-
WHERE key = NEW.key
|
|
316
|
-
AND
|
|
344
|
+
WHERE key = NEW.key
|
|
345
|
+
AND is_live
|
|
317
346
|
AND id <> NEW.id
|
|
318
347
|
) THEN
|
|
319
348
|
RAISE EXCEPTION 'A live job with key % already exists.', NEW.key;
|
|
@@ -384,14 +413,16 @@ const KVTables = (context) => ({
|
|
|
384
413
|
FOR EACH ROW
|
|
385
414
|
EXECUTE FUNCTION ${schemaName}.update_attributes_updated_at();
|
|
386
415
|
`);
|
|
387
|
-
// Create partitions
|
|
416
|
+
// Create partitions with fillfactor 70: reserves 30% page space
|
|
417
|
+
// for HOT updates (frequent upserts in hincrbyfloat/collateLeg2Entry)
|
|
388
418
|
await client.query(`
|
|
389
419
|
DO $$
|
|
390
420
|
BEGIN
|
|
391
421
|
FOR i IN 0..7 LOOP
|
|
392
422
|
EXECUTE format(
|
|
393
|
-
'CREATE TABLE IF NOT EXISTS ${attributesTableName}_part_%s PARTITION OF ${attributesTableName}
|
|
394
|
-
FOR VALUES WITH (modulus 8, remainder %s)
|
|
423
|
+
'CREATE TABLE IF NOT EXISTS ${attributesTableName}_part_%s PARTITION OF ${attributesTableName}
|
|
424
|
+
FOR VALUES WITH (modulus 8, remainder %s)
|
|
425
|
+
WITH (fillfactor = 70)',
|
|
395
426
|
i, i
|
|
396
427
|
);
|
|
397
428
|
END LOOP;
|
|
@@ -433,8 +464,16 @@ const KVTables = (context) => ({
|
|
|
433
464
|
);
|
|
434
465
|
`);
|
|
435
466
|
await client.query(`
|
|
436
|
-
CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_score_member
|
|
467
|
+
CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_score_member
|
|
437
468
|
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;
|
|
438
477
|
`);
|
|
439
478
|
break;
|
|
440
479
|
default:
|
|
@@ -292,11 +292,19 @@ function _hset(context, key, fields, options) {
|
|
|
292
292
|
params.push(key, fields[':'], options?.entity ?? null);
|
|
293
293
|
}
|
|
294
294
|
else {
|
|
295
|
-
//
|
|
295
|
+
// Two-pass upsert: UPDATE existing live job, INSERT only if no
|
|
296
|
+
// live job exists. Avoids ON CONFLICT seq_scan on partitioned
|
|
297
|
+
// tables that lack a unique partial index.
|
|
296
298
|
sql = `
|
|
299
|
+
WITH existing AS (
|
|
300
|
+
UPDATE ${targetTable}
|
|
301
|
+
SET status = $2
|
|
302
|
+
WHERE key = $1 AND is_live
|
|
303
|
+
RETURNING 1
|
|
304
|
+
)
|
|
297
305
|
INSERT INTO ${targetTable} (id, key, status, entity)
|
|
298
|
-
|
|
299
|
-
|
|
306
|
+
SELECT gen_random_uuid(), $1, $2, $3
|
|
307
|
+
WHERE NOT EXISTS (SELECT 1 FROM existing)
|
|
300
308
|
RETURNING 1 as count
|
|
301
309
|
`;
|
|
302
310
|
params.push(key, fields[':'], options?.entity ?? null);
|
|
@@ -503,31 +511,20 @@ function _hgetall(context, key) {
|
|
|
503
511
|
const tableName = context.tableForKey(key, 'hash');
|
|
504
512
|
const isJobsTableResult = (0, utils_1.isJobsTable)(tableName);
|
|
505
513
|
if (isJobsTableResult) {
|
|
514
|
+
// Single CTE: reads jobs row once, then joins attributes
|
|
506
515
|
const sql = `
|
|
507
516
|
WITH valid_job AS (
|
|
508
517
|
SELECT id, status, context
|
|
509
518
|
FROM ${tableName}
|
|
510
519
|
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)
|
|
527
520
|
)
|
|
528
|
-
SELECT
|
|
521
|
+
SELECT 'status' AS field, status::text AS value FROM valid_job
|
|
522
|
+
UNION ALL
|
|
523
|
+
SELECT 'context' AS field, context::text AS value FROM valid_job
|
|
529
524
|
UNION ALL
|
|
530
|
-
SELECT
|
|
525
|
+
SELECT symbol || dimension AS field, value
|
|
526
|
+
FROM ${tableName}_attributes
|
|
527
|
+
WHERE job_id = (SELECT id FROM valid_job);
|
|
531
528
|
`;
|
|
532
529
|
return { sql, params: [key] };
|
|
533
530
|
}
|
|
@@ -83,6 +83,17 @@ 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
|
|
86
97
|
const sql = `
|
|
87
98
|
WITH total_entries AS (
|
|
88
99
|
SELECT COUNT(*) - 1 AS max_index FROM ${tableName}
|
|
@@ -475,12 +475,10 @@ 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
|
-
|
|
482
|
-
delete hashData[':'];
|
|
483
|
-
}
|
|
478
|
+
// ':' (status) is NOT written to jobs_attributes — jobs.status is
|
|
479
|
+
// maintained by setStatus/setStatusAndCollateGuid. The attribute row
|
|
480
|
+
// was redundant, never read, and contended on every activity.
|
|
481
|
+
delete hashData[':'];
|
|
484
482
|
await this.kvsql(transaction).hset(hashKey, hashData);
|
|
485
483
|
return jobId;
|
|
486
484
|
}
|
|
@@ -42,7 +42,7 @@ class TaskService {
|
|
|
42
42
|
async registerTimeHook(jobId, gId, activityId, type, inSeconds = enums_1.HMSH_FIDELITY_SECONDS, dad, transaction) {
|
|
43
43
|
const fromNow = Date.now() + inSeconds * 1000;
|
|
44
44
|
const fidelityMS = enums_1.HMSH_FIDELITY_SECONDS * 1000;
|
|
45
|
-
const awakenTimeSlot = Math.
|
|
45
|
+
const awakenTimeSlot = Math.ceil(fromNow / fidelityMS) * fidelityMS;
|
|
46
46
|
await this.store.registerTimeHook(jobId, gId, activityId, type, awakenTimeSlot, dad, transaction);
|
|
47
47
|
}
|
|
48
48
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { HotMesh } from '../hotmesh';
|
|
2
|
-
import { VirtualConnectParams, VirtualCronParams, VirtualExecParams, VirtualFlushParams, VirtualInstanceOptions, VirtualInterruptParams } from '../../types/virtual';
|
|
2
|
+
import { VirtualConnectParams, VirtualContext, VirtualCronParams, VirtualExecParams, VirtualFlushParams, VirtualInstanceOptions, VirtualInterruptParams } from '../../types/virtual';
|
|
3
3
|
import { ProviderConfig, ProvidersConfig } from '../../types/provider';
|
|
4
4
|
/**
|
|
5
5
|
* Virtual creates a virtual network of functions, connecting any
|
|
@@ -185,6 +185,27 @@ declare class Virtual {
|
|
|
185
185
|
* ```
|
|
186
186
|
*/
|
|
187
187
|
static interrupt(params: VirtualInterruptParams): Promise<boolean>;
|
|
188
|
+
/**
|
|
189
|
+
* Returns the execution context for the current Virtual callback.
|
|
190
|
+
* Must be called from inside a `Virtual.cron` or `Virtual.exec`
|
|
191
|
+
* callback — throws if called outside that scope.
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```typescript
|
|
195
|
+
* await Virtual.cron({
|
|
196
|
+
* topic: 'my.cron',
|
|
197
|
+
* connection,
|
|
198
|
+
* args: [],
|
|
199
|
+
* options: { id: 'daily', interval: '0 0 * * *' },
|
|
200
|
+
* callback: async () => {
|
|
201
|
+
* const ctx = Virtual.getContext();
|
|
202
|
+
* console.log(ctx.workflowId); // 'daily'
|
|
203
|
+
* console.log(ctx.attempt); // 1
|
|
204
|
+
* },
|
|
205
|
+
* });
|
|
206
|
+
* ```
|
|
207
|
+
*/
|
|
208
|
+
static getContext(): VirtualContext;
|
|
188
209
|
/**
|
|
189
210
|
* Shuts down all virtual instances. Call this method
|
|
190
211
|
* from the SIGTERM handler in your application.
|
|
@@ -6,8 +6,14 @@ const hotmesh_1 = require("../hotmesh");
|
|
|
6
6
|
const enums_1 = require("../../modules/enums");
|
|
7
7
|
const utils_1 = require("../../modules/utils");
|
|
8
8
|
const key_1 = require("../../modules/key");
|
|
9
|
+
const storage_1 = require("../../modules/storage");
|
|
9
10
|
const cron_1 = require("../pipe/functions/cron");
|
|
10
11
|
const factory_1 = require("./schemas/factory");
|
|
12
|
+
const DEFAULT_RETRY_POLICY = {
|
|
13
|
+
maximumAttempts: 3,
|
|
14
|
+
backoffCoefficient: 2,
|
|
15
|
+
maximumInterval: 30,
|
|
16
|
+
};
|
|
11
17
|
/**
|
|
12
18
|
* Virtual creates a virtual network of functions, connecting any
|
|
13
19
|
* function as an idempotent, cacheable endpoint. Call functions
|
|
@@ -173,13 +179,19 @@ class Virtual {
|
|
|
173
179
|
{
|
|
174
180
|
topic: params.topic,
|
|
175
181
|
connection,
|
|
176
|
-
retry: params.retry ??
|
|
177
|
-
maximumAttempts: 3,
|
|
178
|
-
backoffCoefficient: 2,
|
|
179
|
-
maximumInterval: 30,
|
|
180
|
-
},
|
|
182
|
+
retry: params.retry ?? DEFAULT_RETRY_POLICY,
|
|
181
183
|
callback: async function (input) {
|
|
182
|
-
const
|
|
184
|
+
const context = new Map([
|
|
185
|
+
['topic', params.topic],
|
|
186
|
+
['workflowId', input.metadata.jid ?? ''],
|
|
187
|
+
['workflowName', input.metadata.wfn ?? ''],
|
|
188
|
+
['dimension', input.metadata.dad ?? ''],
|
|
189
|
+
['attempt', input.metadata.try ?? 1],
|
|
190
|
+
['guid', input.metadata.guid],
|
|
191
|
+
['traceId', input.metadata.trc ?? ''],
|
|
192
|
+
['spanId', input.metadata.spn ?? ''],
|
|
193
|
+
]);
|
|
194
|
+
const response = await storage_1.virtualAsyncLocalStorage.run(context, () => params.callback.apply(this, input.data.args));
|
|
183
195
|
return {
|
|
184
196
|
metadata: { ...input.metadata },
|
|
185
197
|
data: { response },
|
|
@@ -386,6 +398,42 @@ class Virtual {
|
|
|
386
398
|
}
|
|
387
399
|
return true;
|
|
388
400
|
}
|
|
401
|
+
/**
|
|
402
|
+
* Returns the execution context for the current Virtual callback.
|
|
403
|
+
* Must be called from inside a `Virtual.cron` or `Virtual.exec`
|
|
404
|
+
* callback — throws if called outside that scope.
|
|
405
|
+
*
|
|
406
|
+
* @example
|
|
407
|
+
* ```typescript
|
|
408
|
+
* await Virtual.cron({
|
|
409
|
+
* topic: 'my.cron',
|
|
410
|
+
* connection,
|
|
411
|
+
* args: [],
|
|
412
|
+
* options: { id: 'daily', interval: '0 0 * * *' },
|
|
413
|
+
* callback: async () => {
|
|
414
|
+
* const ctx = Virtual.getContext();
|
|
415
|
+
* console.log(ctx.workflowId); // 'daily'
|
|
416
|
+
* console.log(ctx.attempt); // 1
|
|
417
|
+
* },
|
|
418
|
+
* });
|
|
419
|
+
* ```
|
|
420
|
+
*/
|
|
421
|
+
static getContext() {
|
|
422
|
+
const store = storage_1.virtualAsyncLocalStorage.getStore();
|
|
423
|
+
if (!store) {
|
|
424
|
+
throw new Error('Virtual.getContext() called outside of a Virtual callback execution context');
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
topic: store.get('topic'),
|
|
428
|
+
workflowId: store.get('workflowId'),
|
|
429
|
+
workflowName: store.get('workflowName'),
|
|
430
|
+
dimension: store.get('dimension'),
|
|
431
|
+
attempt: store.get('attempt'),
|
|
432
|
+
guid: store.get('guid'),
|
|
433
|
+
traceId: store.get('traceId'),
|
|
434
|
+
spanId: store.get('spanId'),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
389
437
|
/**
|
|
390
438
|
* Shuts down all virtual instances. Call this method
|
|
391
439
|
* from the SIGTERM handler in your application.
|
package/build/types/index.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export { ExtensionType, JobCompletionOptions, JobData, JobsData, JobInterruptOpt
|
|
|
15
15
|
export { MappingStatements } from './map';
|
|
16
16
|
export { Pipe, PipeContext, PipeItem, PipeItems, PipeObject, ReduceObject, } from './pipe';
|
|
17
17
|
export { ProviderClass, ProviderClient, ProviderConfig, ProviderTransaction, Providers, TransactionResultList, ProviderNativeClient, ProviderOptions, } from './provider';
|
|
18
|
-
export { VirtualConnectParams, VirtualExecParams, VirtualCronParams, VirtualExecOptions, VirtualCronOptions, VirtualInterruptOptions, VirtualInterruptParams, VirtualFlushParams, } from './virtual';
|
|
18
|
+
export { VirtualConnectParams, VirtualContext, VirtualExecParams, VirtualCronParams, VirtualExecOptions, VirtualCronOptions, VirtualInterruptOptions, VirtualInterruptParams, VirtualFlushParams, } from './virtual';
|
|
19
19
|
export { PostgresClassType, PostgresClientOptions, PostgresClientType, PostgresConsumerGroup, PostgresPendingMessage, PostgresPoolClientType, PostgresQueryConfigType, PostgresQueryResultType, PostgresStreamMessage, PostgresStreamOptions, PostgresTransaction, } from './postgres';
|
|
20
20
|
export { ActivateMessage, CronMessage, JobMessage, JobMessageCallback, PingMessage, PongMessage, QuorumMessage, QuorumMessageCallback, QuorumProfile, RollCallMessage, RollCallOptions, SubscriptionCallback, SubscriptionOptions, SystemHealth, ThrottleMessage, ThrottleOptions, WorkMessage, } from './quorum';
|
|
21
21
|
export { NatsAckPolicy, NatsAckPolicyExplicitType, NatsClassType, NatsClientType, NatsClientOptions, NatsConsumerConfigType, NatsJetStreamManager, NatsConnection, NatsJetStreamType, NatsConnectionOptions, NatsConsumerConfig, NatsConsumerInfo, NatsConsumerManager, NatsDeliveryInfo, NatsJetStreamOptions, NatsError, NatsErrorType, NatsJetStreamClient, NatsJsMsg, NatsMessageType, NatsMsgExpect, NatsPubAck, NatsPubAckType, NatsPublishOptions, NatsRetentionPolicy, NatsRetentionPolicyWorkqueueType, NatsSequenceInfo, NatsStorageMemoryType, NatsStorageType, NatsStreamConfig, NatsStreamInfo, NatsStreamManager, NatsStreamConfigType, NatsStreamInfoType, NatsStreamOptions, NatsStreamState, NatsTransaction, } from './nats';
|
package/build/types/virtual.d.ts
CHANGED
|
@@ -208,4 +208,72 @@ interface VirtualInterruptParams {
|
|
|
208
208
|
*/
|
|
209
209
|
options: VirtualInterruptOptions;
|
|
210
210
|
}
|
|
211
|
-
|
|
211
|
+
/**
|
|
212
|
+
* Execution context available inside Virtual callbacks via
|
|
213
|
+
* `Virtual.getContext()`. Populated automatically by the
|
|
214
|
+
* AsyncLocalStorage wrapper in `Virtual.connect()`.
|
|
215
|
+
*
|
|
216
|
+
* @example
|
|
217
|
+
* ```typescript
|
|
218
|
+
* await Virtual.cron({
|
|
219
|
+
* topic: 'billing.daily',
|
|
220
|
+
* connection,
|
|
221
|
+
* args: [],
|
|
222
|
+
* options: { id: 'billing-run', interval: '0 0 * * *' },
|
|
223
|
+
* callback: async () => {
|
|
224
|
+
* const ctx = Virtual.getContext();
|
|
225
|
+
* // ctx.workflowId === 'billing-run'
|
|
226
|
+
* // ctx.topic === 'billing.daily'
|
|
227
|
+
* // ctx.guid === unique per invocation
|
|
228
|
+
* },
|
|
229
|
+
* });
|
|
230
|
+
* ```
|
|
231
|
+
*/
|
|
232
|
+
interface VirtualContext {
|
|
233
|
+
/**
|
|
234
|
+
* The worker topic that routed this invocation.
|
|
235
|
+
* Matches the `topic` passed to `Virtual.cron()` or
|
|
236
|
+
* `Virtual.connect()`.
|
|
237
|
+
*/
|
|
238
|
+
topic: string;
|
|
239
|
+
/**
|
|
240
|
+
* Workflow / job ID. For cron callbacks this is the `id`
|
|
241
|
+
* from `options.id`. For `Virtual.exec` calls this is the
|
|
242
|
+
* auto-generated or user-supplied job identifier.
|
|
243
|
+
*/
|
|
244
|
+
workflowId: string;
|
|
245
|
+
/**
|
|
246
|
+
* Internal workflow name (the graph subscription topic,
|
|
247
|
+
* e.g. `hmsh.cron` or `hmsh.call`).
|
|
248
|
+
*/
|
|
249
|
+
workflowName: string;
|
|
250
|
+
/**
|
|
251
|
+
* Dimensional address for this execution within the
|
|
252
|
+
* workflow's DAG. Encodes the cycle iteration and
|
|
253
|
+
* parallel branch position.
|
|
254
|
+
*/
|
|
255
|
+
dimension: string;
|
|
256
|
+
/**
|
|
257
|
+
* Current retry attempt (1-based). `1` on the first try,
|
|
258
|
+
* incremented on each retry per the `RetryPolicy`.
|
|
259
|
+
*/
|
|
260
|
+
attempt: number;
|
|
261
|
+
/**
|
|
262
|
+
* Globally unique identifier for the stream message that
|
|
263
|
+
* triggered this callback. A new GUID is minted for every
|
|
264
|
+
* invocation, including retries and cycle iterations, so it
|
|
265
|
+
* serves as an idempotency key for exactly-once processing.
|
|
266
|
+
*/
|
|
267
|
+
guid: string;
|
|
268
|
+
/**
|
|
269
|
+
* OpenTelemetry trace ID propagated from the originating
|
|
270
|
+
* workflow. Empty string when tracing is not configured.
|
|
271
|
+
*/
|
|
272
|
+
traceId: string;
|
|
273
|
+
/**
|
|
274
|
+
* OpenTelemetry span ID propagated from the parent activity.
|
|
275
|
+
* Empty string when tracing is not configured.
|
|
276
|
+
*/
|
|
277
|
+
spanId: string;
|
|
278
|
+
}
|
|
279
|
+
export { VirtualConnectParams, VirtualContext, VirtualExecParams, VirtualCronParams, VirtualExecOptions, VirtualCronOptions, VirtualInterruptOptions, VirtualInterruptParams, VirtualFlushOptions, VirtualFlushParams, VirtualInstanceOptions, };
|