@hotmeshio/hotmesh 0.22.7 → 0.23.0
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/durable/client.js +2 -2
- package/build/services/durable/exporter.js +8 -0
- package/build/services/durable/handle.d.ts +10 -1
- package/build/services/durable/handle.js +17 -2
- package/build/services/durable/schemas/factory.d.ts +1 -1
- package/build/services/durable/schemas/factory.js +46 -2
- package/build/services/durable/workflow/condition.d.ts +3 -2
- package/build/services/durable/workflow/condition.js +19 -3
- package/build/services/escalations/client.d.ts +14 -2
- package/build/services/escalations/client.js +26 -4
- package/build/services/store/index.d.ts +13 -0
- package/build/services/store/providers/postgres/exporter-sql.d.ts +5 -0
- package/build/services/store/providers/postgres/exporter-sql.js +11 -1
- package/build/services/store/providers/postgres/postgres.d.ts +8 -0
- package/build/services/store/providers/postgres/postgres.js +47 -19
- package/build/types/exporter.d.ts +24 -0
- package/build/types/job.d.ts +11 -1
- package/package.json +1 -1
package/build/package.json
CHANGED
|
@@ -167,7 +167,7 @@ class ClientService {
|
|
|
167
167
|
pending: options?.pending,
|
|
168
168
|
entity: options?.entity,
|
|
169
169
|
});
|
|
170
|
-
return new handle_1.WorkflowHandleService(hotMeshClient, workflowTopic, jobId);
|
|
170
|
+
return new handle_1.WorkflowHandleService(hotMeshClient, workflowTopic, jobId, this.escalations);
|
|
171
171
|
},
|
|
172
172
|
/**
|
|
173
173
|
* Sends a message payload to a running workflow that is paused and awaiting the signal.
|
|
@@ -267,7 +267,7 @@ class ClientService {
|
|
|
267
267
|
getHandle: async (taskQueue, workflowName, workflowId, namespace) => {
|
|
268
268
|
const workflowTopic = `${taskQueue}-${workflowName}`;
|
|
269
269
|
const hotMeshClient = await this.getHotMeshClient(taskQueue, namespace);
|
|
270
|
-
return new handle_1.WorkflowHandleService(hotMeshClient, workflowTopic, workflowId);
|
|
270
|
+
return new handle_1.WorkflowHandleService(hotMeshClient, workflowTopic, workflowId, this.escalations);
|
|
271
271
|
},
|
|
272
272
|
/**
|
|
273
273
|
* Provides direct access to the SEARCH backend
|
|
@@ -285,6 +285,14 @@ class ExporterService {
|
|
|
285
285
|
if (options.enrich_inputs) {
|
|
286
286
|
await this.enrichExecutionInputs(execution, jobId);
|
|
287
287
|
}
|
|
288
|
+
// Opt-in upward lineage pointer — one indexed lookup; surfaces the real
|
|
289
|
+
// spawning parent (not the collator $C job) and the root ancestor.
|
|
290
|
+
if (options.include_lineage && this.store.getJobLineage) {
|
|
291
|
+
const jobKey = `hmsh:${this.appId}:j:${jobId}`;
|
|
292
|
+
const lineage = await this.store.getJobLineage(jobKey);
|
|
293
|
+
execution.parent_workflow_id = lineage?.parent_id ?? null;
|
|
294
|
+
execution.origin_id = lineage?.origin_id ?? null;
|
|
295
|
+
}
|
|
288
296
|
return execution;
|
|
289
297
|
}
|
|
290
298
|
/**
|
|
@@ -3,6 +3,7 @@ import { DurableJobExport, ExportOptions, ExecutionExportOptions, WorkflowExecut
|
|
|
3
3
|
import { JobInterruptOptions } from '../../types/job';
|
|
4
4
|
import { StreamError } from '../../types/stream';
|
|
5
5
|
import { ExporterService } from './exporter';
|
|
6
|
+
import { EscalationClientService } from '../escalations/client';
|
|
6
7
|
/**
|
|
7
8
|
* Handle to a running or completed workflow execution. Returned by
|
|
8
9
|
* `client.workflow.start()` and `client.workflow.getHandle()`.
|
|
@@ -32,10 +33,12 @@ export declare class WorkflowHandleService {
|
|
|
32
33
|
hotMesh: HotMesh;
|
|
33
34
|
workflowTopic: string;
|
|
34
35
|
workflowId: string;
|
|
36
|
+
/** @private */
|
|
37
|
+
escalationClient?: EscalationClientService;
|
|
35
38
|
/**
|
|
36
39
|
* @private
|
|
37
40
|
*/
|
|
38
|
-
constructor(hotMesh: HotMesh, workflowTopic: string, workflowId: string);
|
|
41
|
+
constructor(hotMesh: HotMesh, workflowTopic: string, workflowId: string, escalationClient?: EscalationClientService);
|
|
39
42
|
/**
|
|
40
43
|
* Export the raw workflow state as a {@link DurableJobExport} with five sections:
|
|
41
44
|
*
|
|
@@ -126,6 +129,12 @@ export declare class WorkflowHandleService {
|
|
|
126
129
|
* subscribers are notified, and the job hash is expired. Unlike
|
|
127
130
|
* {@link cancel}, this does **not** give the workflow a chance to
|
|
128
131
|
* run cleanup code.
|
|
132
|
+
*
|
|
133
|
+
* Any pending escalations for this workflow are cancelled in the same
|
|
134
|
+
* Postgres transaction that decrements the job semaphore — one atomic
|
|
135
|
+
* write, no TOCTOU. A `system.escalation.*.cancelled` event is emitted
|
|
136
|
+
* locally for each cancelled row via the configured `events.publish`
|
|
137
|
+
* sink — instance-local only, never broadcast.
|
|
129
138
|
*/
|
|
130
139
|
terminate(options?: JobInterruptOptions): Promise<string>;
|
|
131
140
|
/**
|
|
@@ -27,10 +27,11 @@ class WorkflowHandleService {
|
|
|
27
27
|
/**
|
|
28
28
|
* @private
|
|
29
29
|
*/
|
|
30
|
-
constructor(hotMesh, workflowTopic, workflowId) {
|
|
30
|
+
constructor(hotMesh, workflowTopic, workflowId, escalationClient) {
|
|
31
31
|
this.workflowTopic = workflowTopic;
|
|
32
32
|
this.workflowId = workflowId;
|
|
33
33
|
this.hotMesh = hotMesh;
|
|
34
|
+
this.escalationClient = escalationClient;
|
|
34
35
|
this.exporter = new exporter_1.ExporterService(this.hotMesh.appId, this.hotMesh.engine.store, this.hotMesh.engine.logger);
|
|
35
36
|
}
|
|
36
37
|
/**
|
|
@@ -157,9 +158,23 @@ class WorkflowHandleService {
|
|
|
157
158
|
* subscribers are notified, and the job hash is expired. Unlike
|
|
158
159
|
* {@link cancel}, this does **not** give the workflow a chance to
|
|
159
160
|
* run cleanup code.
|
|
161
|
+
*
|
|
162
|
+
* Any pending escalations for this workflow are cancelled in the same
|
|
163
|
+
* Postgres transaction that decrements the job semaphore — one atomic
|
|
164
|
+
* write, no TOCTOU. A `system.escalation.*.cancelled` event is emitted
|
|
165
|
+
* locally for each cancelled row via the configured `events.publish`
|
|
166
|
+
* sink — instance-local only, never broadcast.
|
|
160
167
|
*/
|
|
161
168
|
async terminate(options) {
|
|
162
|
-
|
|
169
|
+
let cancelledEntries = [];
|
|
170
|
+
const result = await this.hotMesh.interrupt(`${this.hotMesh.appId}.execute`, this.workflowId, {
|
|
171
|
+
...options,
|
|
172
|
+
onEscalationsCancelled: (entries) => { cancelledEntries = entries; },
|
|
173
|
+
});
|
|
174
|
+
if (this.escalationClient && cancelledEntries.length > 0) {
|
|
175
|
+
this.escalationClient.emitCancelledBatch(cancelledEntries);
|
|
176
|
+
}
|
|
177
|
+
return result;
|
|
163
178
|
}
|
|
164
179
|
/**
|
|
165
180
|
* Requests cooperative cancellation of the workflow. Unlike
|
|
@@ -6,8 +6,9 @@ exports.APP_ID = exports.APP_VERSION = exports.getWorkflowYAML = void 0;
|
|
|
6
6
|
// schema upgrade and hot-swap to it (see WorkerService.activateWorkflow and
|
|
7
7
|
// ClientService.deployAndActivate). Changing the YAML without a bump leaves
|
|
8
8
|
// every already-deployed database on the old schema forever. Numeric string —
|
|
9
|
-
// compared with `Number()` for ordering. (
|
|
10
|
-
|
|
9
|
+
// compared with `Number()` for ordering. (v17: collator_waiter escalation block —
|
|
10
|
+
// escalation-bearing condition() inside Promise.all writes its hmsh_escalations row.)
|
|
11
|
+
const APP_VERSION = '17';
|
|
11
12
|
exports.APP_VERSION = APP_VERSION;
|
|
12
13
|
const APP_ID = 'durable';
|
|
13
14
|
exports.APP_ID = APP_ID;
|
|
@@ -1944,6 +1945,49 @@ const getWorkflowYAML = (app, version) => {
|
|
|
1944
1945
|
- ['{collator_trigger.output.data.items}', '{collator_cycle_hook.output.data.cur_index}']
|
|
1945
1946
|
- ['{@array.get}', duration]
|
|
1946
1947
|
- ['{@object.get}']
|
|
1948
|
+
# Flatten the CURRENT item's escalation config into this activity's own
|
|
1949
|
+
# output so the escalation block below can address it with flat symbols.
|
|
1950
|
+
# mapOutputData runs before the escalation INSERT in Leg1, so these are
|
|
1951
|
+
# resolved in time. For a plain (non-escalation) collated condition these
|
|
1952
|
+
# resolve to undefined and the INSERT is skipped, just like the inline waiter.
|
|
1953
|
+
output:
|
|
1954
|
+
maps:
|
|
1955
|
+
queueConfig:
|
|
1956
|
+
'@pipe':
|
|
1957
|
+
- ['{collator_trigger.output.data.items}', '{collator_cycle_hook.output.data.cur_index}']
|
|
1958
|
+
- ['{@array.get}', queueConfig]
|
|
1959
|
+
- ['{@object.get}']
|
|
1960
|
+
taskQueue:
|
|
1961
|
+
'@pipe':
|
|
1962
|
+
- ['{collator_trigger.output.data.items}', '{collator_cycle_hook.output.data.cur_index}']
|
|
1963
|
+
- ['{@array.get}', taskQueue]
|
|
1964
|
+
- ['{@object.get}']
|
|
1965
|
+
workflowName:
|
|
1966
|
+
'@pipe':
|
|
1967
|
+
- ['{collator_trigger.output.data.items}', '{collator_cycle_hook.output.data.cur_index}']
|
|
1968
|
+
- ['{@array.get}', workflowName]
|
|
1969
|
+
- ['{@object.get}']
|
|
1970
|
+
# Identical in principle to the inline waiter escalation block, but sourced
|
|
1971
|
+
# from the collated item. The escalation signal_key is auto-derived from this
|
|
1972
|
+
# activity's reentry hook rule (wfs.signal, matching item.signalId), so resolve()
|
|
1973
|
+
# and signal() — which double-fire wfs.signal + wfs.wait — reach it correctly.
|
|
1974
|
+
escalation:
|
|
1975
|
+
type: '{$self.output.data.queueConfig.type}'
|
|
1976
|
+
subtype: '{$self.output.data.queueConfig.subtype}'
|
|
1977
|
+
entity: '{$self.output.data.queueConfig.entity}'
|
|
1978
|
+
description: '{$self.output.data.queueConfig.description}'
|
|
1979
|
+
role: '{$self.output.data.queueConfig.role}'
|
|
1980
|
+
priority: '{$self.output.data.queueConfig.priority}'
|
|
1981
|
+
metadata: '{$self.output.data.queueConfig.metadata}'
|
|
1982
|
+
envelope: '{$self.output.data.queueConfig.envelope}'
|
|
1983
|
+
originId: '{$self.output.data.queueConfig.originId}'
|
|
1984
|
+
parentId: '{$self.output.data.queueConfig.parentId}'
|
|
1985
|
+
initiatedBy: '{$self.output.data.queueConfig.initiatedBy}'
|
|
1986
|
+
traceId: '{$self.output.data.queueConfig.traceId}'
|
|
1987
|
+
spanId: '{$self.output.data.queueConfig.spanId}'
|
|
1988
|
+
expiresAt: '{$self.output.data.queueConfig.expiresAt}'
|
|
1989
|
+
taskQueue: '{$self.output.data.taskQueue}'
|
|
1990
|
+
workflowType: '{$self.output.data.workflowName}'
|
|
1947
1991
|
hook:
|
|
1948
1992
|
type: object
|
|
1949
1993
|
properties:
|
|
@@ -97,6 +97,7 @@ import { ConditionQueueConfig } from '../../../types/hmsh_escalations';
|
|
|
97
97
|
* {@link ConditionQueueConfig} that writes one row to `public.hmsh_escalations`
|
|
98
98
|
* atomically at suspension time. Cannot specify both; use the config object's
|
|
99
99
|
* `expiresAt` field for deadline enforcement when an escalation is involved.
|
|
100
|
-
* @returns The signal payload,
|
|
100
|
+
* @returns The signal payload, `false` if a timeout string was given and it expired,
|
|
101
|
+
* or `null` if the escalation was cancelled via `client.escalations.cancel()`.
|
|
101
102
|
*/
|
|
102
|
-
export declare function condition<T>(signalId: string, timeoutOrConfig?: string | ConditionQueueConfig): Promise<T | false>;
|
|
103
|
+
export declare function condition<T>(signalId: string, timeoutOrConfig?: string | ConditionQueueConfig): Promise<T | false | null>;
|
|
@@ -102,7 +102,8 @@ const didRun_1 = require("./didRun");
|
|
|
102
102
|
* {@link ConditionQueueConfig} that writes one row to `public.hmsh_escalations`
|
|
103
103
|
* atomically at suspension time. Cannot specify both; use the config object's
|
|
104
104
|
* `expiresAt` field for deadline enforcement when an escalation is involved.
|
|
105
|
-
* @returns The signal payload,
|
|
105
|
+
* @returns The signal payload, `false` if a timeout string was given and it expired,
|
|
106
|
+
* or `null` if the escalation was cancelled via `client.escalations.cancel()`.
|
|
106
107
|
*/
|
|
107
108
|
async function condition(signalId, timeoutOrConfig) {
|
|
108
109
|
const timeout = typeof timeoutOrConfig === 'string' ? timeoutOrConfig : undefined;
|
|
@@ -127,7 +128,13 @@ async function condition(signalId, timeoutOrConfig) {
|
|
|
127
128
|
if (result?.timedOut) {
|
|
128
129
|
return false;
|
|
129
130
|
}
|
|
130
|
-
|
|
131
|
+
const signalData = result.data?.data;
|
|
132
|
+
// If the escalation was cancelled via cancel(), the signal carries this marker.
|
|
133
|
+
// Return null so the workflow can distinguish cancellation from a real resolution.
|
|
134
|
+
if (signalData && typeof signalData === 'object' && signalData.__escalation_cancelled === true) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return signalData;
|
|
131
138
|
}
|
|
132
139
|
const store = common_1.asyncLocalStorage.getStore();
|
|
133
140
|
// Emit DISPATCH span in debug mode
|
|
@@ -153,7 +160,16 @@ async function condition(signalId, timeoutOrConfig) {
|
|
|
153
160
|
type: 'DurableWaitForError',
|
|
154
161
|
code: common_1.HMSH_CODE_DURABLE_WAIT,
|
|
155
162
|
...(timeout ? { duration: (0, common_1.s)(timeout) } : {}),
|
|
156
|
-
|
|
163
|
+
// taskQueue + workflowName ride along so a collated wait (Promise.all) can
|
|
164
|
+
// write a faithful hmsh_escalations row from inside the collator flow —
|
|
165
|
+
// matching the inline waiter, which sources these from its trigger.
|
|
166
|
+
...(queueConfig
|
|
167
|
+
? {
|
|
168
|
+
queueConfig,
|
|
169
|
+
taskQueue: store.get('taskQueue'),
|
|
170
|
+
workflowName: store.get('workflowName'),
|
|
171
|
+
}
|
|
172
|
+
: {}),
|
|
157
173
|
};
|
|
158
174
|
interruptionRegistry.push(interruptionMessage);
|
|
159
175
|
await (0, common_1.sleepImmediate)();
|
|
@@ -120,10 +120,22 @@ export declare class EscalationClientService {
|
|
|
120
120
|
*/
|
|
121
121
|
escalateToRole(params: EscalateToRoleParams): Promise<EscalationEntry | null>;
|
|
122
122
|
/**
|
|
123
|
-
* Cancels a pending escalation
|
|
124
|
-
*
|
|
123
|
+
* Cancels a pending escalation and delivers a cancellation signal to the
|
|
124
|
+
* waiting workflow so that `condition()` returns `null`. Terminal rows
|
|
125
|
+
* return `already-terminal`. Signal delivery is best-effort post-commit —
|
|
126
|
+
* the committed cancelled row is the durable record; any missed delivery
|
|
127
|
+
* can be detected via a sweep of rows with `status = 'cancelled'` and a
|
|
128
|
+
* non-null `signal_key`.
|
|
125
129
|
*/
|
|
126
130
|
cancel(id: string, namespace?: string): Promise<CancelEscalationResult>;
|
|
131
|
+
/**
|
|
132
|
+
* Emits local `cancelled` events for a batch of already-cancelled escalation
|
|
133
|
+
* entries. Called by `WorkflowHandleService.terminate()` after the single
|
|
134
|
+
* atomic transaction that interrupts the workflow and cancels its escalations
|
|
135
|
+
* has committed. Fire-and-forget via the configured `events.publish` sink
|
|
136
|
+
* (e.g. NATS) — instance-local, never broadcast via Postgres LISTEN/NOTIFY.
|
|
137
|
+
*/
|
|
138
|
+
emitCancelledBatch(entries: EscalationEntry[]): void;
|
|
127
139
|
/**
|
|
128
140
|
* Resolves a pending escalation by UUID. Uses an explicit Postgres transaction
|
|
129
141
|
* with FOR UPDATE + WHERE guard: only one concurrent caller can commit the
|
|
@@ -242,16 +242,38 @@ class EscalationClientService {
|
|
|
242
242
|
return entry;
|
|
243
243
|
}
|
|
244
244
|
/**
|
|
245
|
-
* Cancels a pending escalation
|
|
246
|
-
*
|
|
245
|
+
* Cancels a pending escalation and delivers a cancellation signal to the
|
|
246
|
+
* waiting workflow so that `condition()` returns `null`. Terminal rows
|
|
247
|
+
* return `already-terminal`. Signal delivery is best-effort post-commit —
|
|
248
|
+
* the committed cancelled row is the durable record; any missed delivery
|
|
249
|
+
* can be detected via a sweep of rows with `status = 'cancelled'` and a
|
|
250
|
+
* non-null `signal_key`.
|
|
247
251
|
*/
|
|
248
252
|
async cancel(id, namespace) {
|
|
249
|
-
const
|
|
253
|
+
const ns = namespace ?? factory_1.APP_ID;
|
|
254
|
+
const hm = await this._engine(null, ns);
|
|
250
255
|
const result = await hm.engine.store.cancelEscalation(id, namespace);
|
|
251
|
-
if (result.ok === true)
|
|
256
|
+
if (result.ok === true) {
|
|
252
257
|
this._emit('cancelled', result.entry);
|
|
258
|
+
if (result.entry.signal_key) {
|
|
259
|
+
await this._deliverEscalationSignal(ns, result.entry.topic, {
|
|
260
|
+
id: result.entry.signal_key,
|
|
261
|
+
data: { __escalation_cancelled: true },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
253
265
|
return result;
|
|
254
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* Emits local `cancelled` events for a batch of already-cancelled escalation
|
|
269
|
+
* entries. Called by `WorkflowHandleService.terminate()` after the single
|
|
270
|
+
* atomic transaction that interrupts the workflow and cancels its escalations
|
|
271
|
+
* has committed. Fire-and-forget via the configured `events.publish` sink
|
|
272
|
+
* (e.g. NATS) — instance-local, never broadcast via Postgres LISTEN/NOTIFY.
|
|
273
|
+
*/
|
|
274
|
+
emitCancelledBatch(entries) {
|
|
275
|
+
this._emitMany('cancelled', entries);
|
|
276
|
+
}
|
|
255
277
|
/**
|
|
256
278
|
* Resolves a pending escalation by UUID. Uses an explicit Postgres transaction
|
|
257
279
|
* with FOR UPDATE + WHERE guard: only one concurrent caller can commit the
|
|
@@ -157,5 +157,18 @@ declare abstract class StoreService<Provider extends ProviderClient, Transaction
|
|
|
157
157
|
};
|
|
158
158
|
attributes: Record<string, string>;
|
|
159
159
|
}>;
|
|
160
|
+
/**
|
|
161
|
+
* Fetch just the lineage columns (`parent_id`, `origin_id`) for a job via a
|
|
162
|
+
* single indexed lookup on `key`. Powers the exporter's opt-in
|
|
163
|
+
* `include_lineage` pointer — `parent_id` is the real spawning workflow (never
|
|
164
|
+
* the synthetic collator `$C` job), `origin_id` is the root ancestor.
|
|
165
|
+
*
|
|
166
|
+
* @param jobKey - The job key (e.g., "hmsh:durable:j:workflowId")
|
|
167
|
+
* @returns parent/origin ids (null when absent — a null `parent_id` marks a root)
|
|
168
|
+
*/
|
|
169
|
+
getJobLineage?(jobKey: string): Promise<{
|
|
170
|
+
parent_id: string | null;
|
|
171
|
+
origin_id: string | null;
|
|
172
|
+
} | null>;
|
|
160
173
|
}
|
|
161
174
|
export { StoreService };
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* Fetch job record by key.
|
|
7
7
|
*/
|
|
8
8
|
export declare const GET_JOB_BY_KEY = "\n SELECT id, key, status, created_at, updated_at, expired_at, is_live\n FROM {schema}.jobs\n WHERE key = $1\n LIMIT 1\n";
|
|
9
|
+
/**
|
|
10
|
+
* Fetch just the lineage columns for a job — a single indexed lookup on `key`.
|
|
11
|
+
* Backs the exporter's opt-in `include_lineage` pointer.
|
|
12
|
+
*/
|
|
13
|
+
export declare const GET_JOB_LINEAGE = "\n SELECT parent_id, origin_id\n FROM {schema}.jobs\n WHERE key = $1\n LIMIT 1\n";
|
|
9
14
|
/**
|
|
10
15
|
* Fetch all attributes for a job.
|
|
11
16
|
*/
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* These queries support the exporter's input enrichment and direct query features.
|
|
5
5
|
*/
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
-
exports.buildChildWorkflowInputsQuery = exports.GET_STREAM_HISTORY_BY_JID_AND_AID = exports.GET_STREAM_HISTORY_BY_JID_AND_TYPE = exports.GET_STREAM_HISTORY_BY_JID = exports.GET_PROXYER_STREAM_INPUTS = exports.GET_ACTIVITY_INPUTS = exports.GET_JOB_ATTRIBUTES = exports.GET_JOB_BY_KEY = void 0;
|
|
7
|
+
exports.buildChildWorkflowInputsQuery = exports.GET_STREAM_HISTORY_BY_JID_AND_AID = exports.GET_STREAM_HISTORY_BY_JID_AND_TYPE = exports.GET_STREAM_HISTORY_BY_JID = exports.GET_PROXYER_STREAM_INPUTS = exports.GET_ACTIVITY_INPUTS = exports.GET_JOB_ATTRIBUTES = exports.GET_JOB_LINEAGE = exports.GET_JOB_BY_KEY = void 0;
|
|
8
8
|
/**
|
|
9
9
|
* Fetch job record by key.
|
|
10
10
|
*/
|
|
@@ -14,6 +14,16 @@ exports.GET_JOB_BY_KEY = `
|
|
|
14
14
|
WHERE key = $1
|
|
15
15
|
LIMIT 1
|
|
16
16
|
`;
|
|
17
|
+
/**
|
|
18
|
+
* Fetch just the lineage columns for a job — a single indexed lookup on `key`.
|
|
19
|
+
* Backs the exporter's opt-in `include_lineage` pointer.
|
|
20
|
+
*/
|
|
21
|
+
exports.GET_JOB_LINEAGE = `
|
|
22
|
+
SELECT parent_id, origin_id
|
|
23
|
+
FROM {schema}.jobs
|
|
24
|
+
WHERE key = $1
|
|
25
|
+
LIMIT 1
|
|
26
|
+
`;
|
|
17
27
|
/**
|
|
18
28
|
* Fetch all attributes for a job.
|
|
19
29
|
*/
|
|
@@ -216,6 +216,14 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
|
|
|
216
216
|
};
|
|
217
217
|
attributes: Record<string, string>;
|
|
218
218
|
}>;
|
|
219
|
+
/**
|
|
220
|
+
* Single indexed lookup of the lineage columns for a job key. Returns the real
|
|
221
|
+
* spawning parent (never the synthetic collator `$C` job) and the root ancestor.
|
|
222
|
+
*/
|
|
223
|
+
getJobLineage(jobKey: string): Promise<{
|
|
224
|
+
parent_id: string | null;
|
|
225
|
+
origin_id: string | null;
|
|
226
|
+
} | null>;
|
|
219
227
|
/**
|
|
220
228
|
* Fetch stream message history for a job from worker_streams.
|
|
221
229
|
* Returns raw activity input/output data from soft-deleted messages.
|
|
@@ -1009,32 +1009,23 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
1009
1009
|
*/
|
|
1010
1010
|
async interrupt(topic, jobId, options = {}) {
|
|
1011
1011
|
try {
|
|
1012
|
-
//
|
|
1012
|
+
//pre-flight: bail early if already inactive (optimization; hincrbyfloat is the real guard)
|
|
1013
1013
|
const status = await this.getStatus(jobId, this.appId);
|
|
1014
1014
|
if (status <= 0) {
|
|
1015
|
-
//verify still active; job already completed
|
|
1016
1015
|
throw new Error(`Job ${jobId} already completed`);
|
|
1017
1016
|
}
|
|
1018
|
-
//decrement job status (:) by 1bil
|
|
1019
1017
|
const amount = -1000000000;
|
|
1020
|
-
const jobKey = this.mintKey(key_1.KeyType.JOB_STATE, {
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
const result = await this.kvsql().hincrbyfloat(jobKey, ':', amount);
|
|
1025
|
-
if (result <= amount) {
|
|
1026
|
-
//verify active state; job already interrupted
|
|
1027
|
-
throw new Error(`Job ${jobId} already completed`);
|
|
1028
|
-
}
|
|
1029
|
-
//persist the error unless specifically told not to
|
|
1018
|
+
const jobKey = this.mintKey(key_1.KeyType.JOB_STATE, { appId: this.appId, jobId });
|
|
1019
|
+
//build error symbol BEFORE opening the transaction — symbol lookup is read-only
|
|
1020
|
+
let errSymbol;
|
|
1021
|
+
let err;
|
|
1030
1022
|
if (options.throw !== false) {
|
|
1031
|
-
const errKey = `metadata/err`;
|
|
1032
|
-
const symbolNames = [`$${topic}`];
|
|
1023
|
+
const errKey = `metadata/err`;
|
|
1024
|
+
const symbolNames = [`$${topic}`];
|
|
1033
1025
|
const symKeys = await this.getSymbolKeys(symbolNames);
|
|
1034
1026
|
const symVals = await this.getSymbolValues();
|
|
1035
1027
|
this.serializer.resetSymbols(symKeys, symVals, {});
|
|
1036
|
-
|
|
1037
|
-
const err = JSON.stringify({
|
|
1028
|
+
err = JSON.stringify({
|
|
1038
1029
|
code: options.code ?? enums_1.HMSH_CODE_INTERRUPT,
|
|
1039
1030
|
message: options.reason ?? `job [${jobId}] interrupted`,
|
|
1040
1031
|
stack: options.stack ?? '',
|
|
@@ -1042,9 +1033,32 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
1042
1033
|
});
|
|
1043
1034
|
const payload = { [errKey]: amount.toString() };
|
|
1044
1035
|
const hashData = this.serializer.package(payload, symbolNames);
|
|
1045
|
-
|
|
1046
|
-
|
|
1036
|
+
errSymbol = Object.keys(hashData)[0];
|
|
1037
|
+
}
|
|
1038
|
+
//single transaction: status decrement + optional error write + escalation cancel.
|
|
1039
|
+
//WHERE guard on the escalation UPDATE prevents double-cancel;
|
|
1040
|
+
//hincrbyfloat is the atomic idempotency proof checked post-commit.
|
|
1041
|
+
const txn = this.kvsql(this.transact());
|
|
1042
|
+
txn.hincrbyfloat(jobKey, ':', amount);
|
|
1043
|
+
if (errSymbol && err) {
|
|
1044
|
+
txn.hset(jobKey, { [errSymbol]: err });
|
|
1047
1045
|
}
|
|
1046
|
+
txn.addCommand(`UPDATE public.hmsh_escalations
|
|
1047
|
+
SET status = 'cancelled', updated_at = NOW()
|
|
1048
|
+
WHERE workflow_id = $1
|
|
1049
|
+
AND app_id = $2
|
|
1050
|
+
AND status = 'pending'
|
|
1051
|
+
RETURNING *`, [jobId, this.appId], 'array', (rows) => rows);
|
|
1052
|
+
const results = await txn.exec();
|
|
1053
|
+
//results[0] = new status after hincrbyfloat — the atomic idempotency guard
|
|
1054
|
+
const newStatus = results[0];
|
|
1055
|
+
if (newStatus <= amount) {
|
|
1056
|
+
throw new Error(`Job ${jobId} already completed`);
|
|
1057
|
+
}
|
|
1058
|
+
//fire the callback with any escalation rows cancelled in this same transaction;
|
|
1059
|
+
//no second query, no second transaction
|
|
1060
|
+
const cancelledEntries = (results[results.length - 1] || []);
|
|
1061
|
+
options.onEscalationsCancelled?.(cancelledEntries);
|
|
1048
1062
|
}
|
|
1049
1063
|
catch (e) {
|
|
1050
1064
|
if (!options.suppress) {
|
|
@@ -1324,6 +1338,20 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
1324
1338
|
}
|
|
1325
1339
|
return { job, attributes };
|
|
1326
1340
|
}
|
|
1341
|
+
/**
|
|
1342
|
+
* Single indexed lookup of the lineage columns for a job key. Returns the real
|
|
1343
|
+
* spawning parent (never the synthetic collator `$C` job) and the root ancestor.
|
|
1344
|
+
*/
|
|
1345
|
+
async getJobLineage(jobKey) {
|
|
1346
|
+
const { GET_JOB_LINEAGE } = await Promise.resolve().then(() => __importStar(require('./exporter-sql')));
|
|
1347
|
+
const schemaName = this.kvsql().safeName(this.appId);
|
|
1348
|
+
const sql = GET_JOB_LINEAGE.replace(/{schema}/g, schemaName);
|
|
1349
|
+
const result = await this.pgClient.query(sql, [jobKey]);
|
|
1350
|
+
if (result.rows.length === 0)
|
|
1351
|
+
return null;
|
|
1352
|
+
const { parent_id, origin_id } = result.rows[0];
|
|
1353
|
+
return { parent_id: parent_id ?? null, origin_id: origin_id ?? null };
|
|
1354
|
+
}
|
|
1327
1355
|
/**
|
|
1328
1356
|
* Fetch stream message history for a job from worker_streams.
|
|
1329
1357
|
* Returns raw activity input/output data from soft-deleted messages.
|
|
@@ -265,6 +265,19 @@ export interface WorkflowExecution {
|
|
|
265
265
|
summary: WorkflowExecutionSummary;
|
|
266
266
|
children?: WorkflowExecution[];
|
|
267
267
|
stream_history?: StreamHistoryEntry[];
|
|
268
|
+
/**
|
|
269
|
+
* Upward lineage pointer to the real spawning workflow — never the synthetic
|
|
270
|
+
* collator `$C` job. `null` marks a root. Only present when the export was
|
|
271
|
+
* requested with `include_lineage: true`.
|
|
272
|
+
*/
|
|
273
|
+
parent_workflow_id?: string | null;
|
|
274
|
+
/**
|
|
275
|
+
* Root ancestor of this execution; `null` for a root itself (identify roots
|
|
276
|
+
* via a null `parent_workflow_id`). Every descendant carries the same
|
|
277
|
+
* `origin_id`, so a subtree is recoverable in one query. Only present when the
|
|
278
|
+
* export was requested with `include_lineage: true`.
|
|
279
|
+
*/
|
|
280
|
+
origin_id?: string | null;
|
|
268
281
|
}
|
|
269
282
|
/**
|
|
270
283
|
* Options for `handle.exportExecution()`. Controls event filtering, enrichment,
|
|
@@ -310,6 +323,17 @@ export interface ExecutionExportOptions {
|
|
|
310
323
|
* @default false
|
|
311
324
|
*/
|
|
312
325
|
allow_direct_query?: boolean;
|
|
326
|
+
/**
|
|
327
|
+
* When true, populates the export's `parent_workflow_id` and `origin_id` from
|
|
328
|
+
* the jobs table via a single indexed lookup. `parent_workflow_id` is the real
|
|
329
|
+
* spawning workflow (never the synthetic collator `$C` job); `origin_id` is the
|
|
330
|
+
* root ancestor (a null `parent_workflow_id` marks a root). Opt-in so the extra
|
|
331
|
+
* query is only paid when a UI needs upward lineage. Requires a provider that
|
|
332
|
+
* implements `getJobLineage` (e.g., Postgres); a no-op otherwise.
|
|
333
|
+
*
|
|
334
|
+
* @default false
|
|
335
|
+
*/
|
|
336
|
+
include_lineage?: boolean;
|
|
313
337
|
/**
|
|
314
338
|
* When true, fetches the full stream message history for this workflow
|
|
315
339
|
* from the worker_streams table and attaches it as `stream_history`.
|
package/build/types/job.d.ts
CHANGED
|
@@ -130,7 +130,10 @@ type JobInterruptOptions = {
|
|
|
130
130
|
*/
|
|
131
131
|
throw?: boolean;
|
|
132
132
|
/**
|
|
133
|
-
* interrupt child/descendant jobs
|
|
133
|
+
* Reserved for future use: interrupt child/descendant jobs.
|
|
134
|
+
* This flag is parsed and threaded through the engine call chain
|
|
135
|
+
* but is **not yet implemented** — child workflows are not interrupted
|
|
136
|
+
* when this is set. Do not rely on it.
|
|
134
137
|
* @default false
|
|
135
138
|
*/
|
|
136
139
|
descend?: boolean;
|
|
@@ -154,6 +157,13 @@ type JobInterruptOptions = {
|
|
|
154
157
|
* Optional stack trace
|
|
155
158
|
*/
|
|
156
159
|
stack?: string;
|
|
160
|
+
/**
|
|
161
|
+
* Fires synchronously inside `store.interrupt()` after the single
|
|
162
|
+
* transaction that decrements the job semaphore AND cancels pending
|
|
163
|
+
* escalations commits. Only used by `WorkflowHandleService.terminate()`
|
|
164
|
+
* to emit local events without a second transaction or a separate query.
|
|
165
|
+
*/
|
|
166
|
+
onEscalationsCancelled?: (entries: any[]) => void;
|
|
157
167
|
};
|
|
158
168
|
/**
|
|
159
169
|
* format when publishing job meta/data on the wire when it completes
|