@iworkflow/temporal 0.4.0 → 0.6.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/README.md +10 -2
- package/dist/connection.d.ts +4 -1
- package/dist/index.d.ts +10 -2
- package/dist/index.js +96 -35
- package/dist/scope.d.ts +2 -0
- package/dist/workflow.js +11 -4
- package/package.json +2 -2
- package/src/connection.ts +4 -1
- package/src/failure.ts +5 -0
- package/src/index.ts +95 -31
- package/src/scope.ts +8 -0
- package/src/workflow.ts +10 -3
package/README.md
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
# @iworkflow/temporal
|
|
2
2
|
|
|
3
|
-
Temporal для `@iworkflow/core`. Peer: `@temporalio/{client,worker,workflow,activity}` ^1.24.
|
|
3
|
+
Temporal для `@iworkflow/core`. Peer: `@temporalio/{client,common,worker,workflow,activity}` ^1.24.
|
|
4
|
+
|
|
5
|
+
Модуль объявляет в своих зависимостях **все пять** peer, даже если сам импортирует не все: сборка
|
|
6
|
+
сервера с `external: ["@temporalio/*"]` их не вкладывает, и без `@temporalio/common` под падает
|
|
7
|
+
на старте с `Cannot find module`.
|
|
4
8
|
|
|
5
9
|
- `temporalRuntime(workflowsPath, opts?).start(def)` — воркер на task queue `def.app`; `opts.workflowBundlePath` — готовый бандл прод-сборки.
|
|
6
10
|
- `temporalClient(opts?)` — старт, сигнал, query по id.
|
|
11
|
+
- `temporalActivities(activities)` — шаги порта для своего `Worker.create` (слив старой очереди, тестовый
|
|
12
|
+
воркер): `Failure` → `ApplicationFailure`, работают `heartbeat` и `stepAttempt`. Шаги без неё
|
|
13
|
+
в своём воркере `heartbeat`/`stepAttempt` не видят — с 0.6.0 рантайм шага ставится на время шага.
|
|
7
14
|
- `@iworkflow/temporal/workflow` — только для входа песочницы модуля:
|
|
8
15
|
`export const outreach = temporalFlow(flows.outreach, ["send-twice"])`.
|
|
9
16
|
|
|
10
|
-
`opts` (`TemporalOptions`): `address`, `namespace`, `tls`, `apiKey` — источник токена, меняется каждые `apiKeyRenewMs
|
|
17
|
+
`opts` (`TemporalOptions`): `address`, `namespace`, `tls`, `apiKey` — источник токена, меняется каждые `apiKeyRenewMs`;
|
|
18
|
+
`plugins` — плагины SDK воркерам и клиенту; `temporalClient({ lazy: true })` — соединение при первом вызове.
|
|
11
19
|
Без них — переменные `TEMPORAL_URL` (по умолчанию `127.0.0.1:17233`), `TEMPORAL_NAMESPACE`.
|
package/dist/connection.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { TLSConfig } from "@temporalio/client";
|
|
1
|
+
import type { ConnectionPlugin, TLSConfig } from "@temporalio/client";
|
|
2
|
+
import type { WorkerPlugin } from "@temporalio/worker";
|
|
2
3
|
/** Подключение к Temporal; не задано — TEMPORAL_URL и TEMPORAL_NAMESPACE из окружения. */
|
|
3
4
|
export interface TemporalOptions {
|
|
4
5
|
address?: string;
|
|
@@ -8,6 +9,8 @@ export interface TemporalOptions {
|
|
|
8
9
|
/** Источник токена; соединение берёт свежий каждые apiKeyRenewMs (по умолчанию 60 с). */
|
|
9
10
|
apiKey?: () => Promise<string>;
|
|
10
11
|
apiKeyRenewMs?: number;
|
|
12
|
+
/** Плагины SDK (OpenTelemetry и т. п.) — воркерам и клиенту. */
|
|
13
|
+
plugins?: (ConnectionPlugin & WorkerPlugin)[];
|
|
11
14
|
}
|
|
12
15
|
export declare const address: (o: TemporalOptions) => string;
|
|
13
16
|
export declare const namespace: (o: TemporalOptions) => string;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { type EngineClient, type EngineRuntime } from "@iworkflow/core";
|
|
1
|
+
import { type Activity, type EngineClient, type EngineRuntime } from "@iworkflow/core";
|
|
2
2
|
import { type TemporalOptions } from "./connection";
|
|
3
3
|
export type { TemporalOptions };
|
|
4
|
+
/**
|
|
5
|
+
* Шаги порта для своего Worker.create (слив старой очереди, тестовый воркер): Failure доезжает
|
|
6
|
+
* как ApplicationFailure, heartbeat и stepAttempt работают. temporalRuntime делает это сам.
|
|
7
|
+
*/
|
|
8
|
+
export declare function temporalActivities<T extends Record<string, Activity>>(activities: T): T;
|
|
4
9
|
/**
|
|
5
10
|
* workflowsPath — вход песочницы модуля: temporalFlow(...) на каждый процесс, имя экспорта = имя процесса.
|
|
6
11
|
* workflowBundlePath — готовый бандл того же входа (прод-сборка); с ним воркер не собирает код сам.
|
|
@@ -8,4 +13,7 @@ export type { TemporalOptions };
|
|
|
8
13
|
export declare function temporalRuntime(workflowsPath: string, opts?: TemporalOptions & {
|
|
9
14
|
workflowBundlePath?: string;
|
|
10
15
|
}): EngineRuntime;
|
|
11
|
-
|
|
16
|
+
/** lazy — соединение при первом вызове: модуль поднимается и без Temporal. */
|
|
17
|
+
export declare function temporalClient(opts?: TemporalOptions & {
|
|
18
|
+
lazy?: boolean;
|
|
19
|
+
}): Promise<EngineClient>;
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,9 @@ import { Client, Connection, WorkflowFailedError, WorkflowNotFoundError } from "
|
|
|
3
3
|
import { Context } from "@temporalio/activity";
|
|
4
4
|
import { NativeConnection, Worker } from "@temporalio/worker";
|
|
5
5
|
import {
|
|
6
|
-
|
|
6
|
+
stepScope,
|
|
7
|
+
checkPriority,
|
|
8
|
+
DEFAULT_PRIORITY,
|
|
7
9
|
exitSignal,
|
|
8
10
|
FlowError as FlowError2
|
|
9
11
|
} from "@iworkflow/core";
|
|
@@ -39,6 +41,7 @@ var connectOptions = async (o) => ({
|
|
|
39
41
|
import {
|
|
40
42
|
ActivityFailure,
|
|
41
43
|
ApplicationFailure,
|
|
44
|
+
ApplicationFailureCategory,
|
|
42
45
|
ChildWorkflowFailure,
|
|
43
46
|
TerminatedFailure,
|
|
44
47
|
TimeoutFailure,
|
|
@@ -74,13 +77,22 @@ function toTemporal(e, nonRetryable) {
|
|
|
74
77
|
message: e.message,
|
|
75
78
|
type: e.type,
|
|
76
79
|
details: [e.details],
|
|
77
|
-
nonRetryable: nonRetryable || e.nonRetryable
|
|
80
|
+
nonRetryable: nonRetryable || e.nonRetryable,
|
|
81
|
+
...e.retryAfterMs !== undefined ? { nextRetryDelay: Math.max(e.retryAfterMs, 1) } : {},
|
|
82
|
+
...e.benign && !nonRetryable ? { category: ApplicationFailureCategory.BENIGN } : {}
|
|
78
83
|
});
|
|
79
84
|
if (isFlowError(e))
|
|
80
85
|
return ApplicationFailure.create({ message: e.message, type: e.type ?? e.reason, details: [e.details], nonRetryable });
|
|
81
86
|
return e;
|
|
82
87
|
}
|
|
83
88
|
|
|
89
|
+
// src/scope.ts
|
|
90
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
91
|
+
var alsScope = () => {
|
|
92
|
+
const als = new AsyncLocalStorage;
|
|
93
|
+
return { run: (rt, fn) => als.run(rt, fn), current: () => als.getStore() };
|
|
94
|
+
};
|
|
95
|
+
|
|
84
96
|
// src/index.ts
|
|
85
97
|
var SIGNAL = "iwf.signal";
|
|
86
98
|
var QUERY = "iwf.query";
|
|
@@ -99,29 +111,50 @@ var notFound = (id) => (e) => {
|
|
|
99
111
|
throw new FlowError2(`процесса ${id} нет`, { reason: "not-found" });
|
|
100
112
|
throw e;
|
|
101
113
|
};
|
|
102
|
-
var wrap = (h) => ({
|
|
114
|
+
var wrap = (h, ready = async () => {}) => ({
|
|
103
115
|
id: h.workflowId,
|
|
104
|
-
result:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
116
|
+
result: async () => {
|
|
117
|
+
await ready();
|
|
118
|
+
return h.result().catch((e) => {
|
|
119
|
+
throw e instanceof WorkflowFailedError && cause(e.cause) || e;
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
signal: async (topic, value) => {
|
|
123
|
+
await ready();
|
|
124
|
+
await h.signal(SIGNAL, { topic, value });
|
|
125
|
+
},
|
|
126
|
+
query: async (key) => {
|
|
127
|
+
await ready();
|
|
128
|
+
return await h.query(QUERY, key) ?? undefined;
|
|
129
|
+
},
|
|
109
130
|
async describe() {
|
|
131
|
+
await ready();
|
|
110
132
|
const d = await h.describe().catch(notFound(h.workflowId));
|
|
111
133
|
return { id: h.workflowId, status: STATUS[d.status.name] ?? "running", startedAt: d.startTime };
|
|
112
134
|
},
|
|
113
|
-
|
|
135
|
+
async terminate(reason) {
|
|
136
|
+
await ready();
|
|
137
|
+
await h.terminate(reason).catch(notFound(h.workflowId));
|
|
138
|
+
}
|
|
114
139
|
});
|
|
115
|
-
var
|
|
116
|
-
|
|
117
|
-
async (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
140
|
+
var TEMPORAL_STEP = {
|
|
141
|
+
heartbeat: async (details) => Context.current().heartbeat(details),
|
|
142
|
+
lastHeartbeat: async () => Context.current().info.heartbeatDetails,
|
|
143
|
+
attempt: () => Context.current().info.attempt
|
|
144
|
+
};
|
|
145
|
+
function temporalActivities(activities) {
|
|
146
|
+
const scope = stepScope(alsScope);
|
|
147
|
+
return Object.fromEntries(Object.entries(activities).map(([name, fn]) => [
|
|
148
|
+
name,
|
|
149
|
+
async (...args) => {
|
|
150
|
+
try {
|
|
151
|
+
return await scope.run(TEMPORAL_STEP, () => fn(...args));
|
|
152
|
+
} catch (e) {
|
|
153
|
+
throw toTemporal(e, false);
|
|
154
|
+
}
|
|
122
155
|
}
|
|
123
|
-
|
|
124
|
-
|
|
156
|
+
]));
|
|
157
|
+
}
|
|
125
158
|
function temporalRuntime(workflowsPath, opts = {}) {
|
|
126
159
|
let workers = [];
|
|
127
160
|
let running = [];
|
|
@@ -137,10 +170,6 @@ function temporalRuntime(workflowsPath, opts = {}) {
|
|
|
137
170
|
starting = true;
|
|
138
171
|
exit.arm();
|
|
139
172
|
stopping = false;
|
|
140
|
-
bindStepRuntime({
|
|
141
|
-
heartbeat: async (details) => Context.current().heartbeat(details),
|
|
142
|
-
lastHeartbeat: async () => Context.current().info.heartbeatDetails
|
|
143
|
-
});
|
|
144
173
|
let c;
|
|
145
174
|
try {
|
|
146
175
|
c = conn = await NativeConnection.connect(await connectOptions(opts));
|
|
@@ -151,8 +180,8 @@ function temporalRuntime(workflowsPath, opts = {}) {
|
|
|
151
180
|
}
|
|
152
181
|
stopRenew = renewApiKey(opts, (t) => c.setApiKey(t));
|
|
153
182
|
try {
|
|
154
|
-
const activities =
|
|
155
|
-
const base = { connection: c, namespace: namespace(opts) };
|
|
183
|
+
const activities = temporalActivities(def.activities);
|
|
184
|
+
const base = { connection: c, namespace: namespace(opts), ...opts.plugins ? { plugins: opts.plugins } : {} };
|
|
156
185
|
const code = opts.workflowBundlePath ? { workflowBundle: { codePath: opts.workflowBundlePath } } : { workflowsPath };
|
|
157
186
|
workers = [await Worker.create({ ...base, taskQueue: def.app, ...code, activities })];
|
|
158
187
|
for (const [pool, limit] of Object.entries(def.pools ?? {}))
|
|
@@ -205,25 +234,57 @@ function temporalRuntime(workflowsPath, opts = {}) {
|
|
|
205
234
|
};
|
|
206
235
|
}
|
|
207
236
|
async function temporalClient(opts = {}) {
|
|
208
|
-
|
|
209
|
-
|
|
237
|
+
let connection;
|
|
238
|
+
let stopRenew;
|
|
239
|
+
let ready = async () => {
|
|
240
|
+
return;
|
|
241
|
+
};
|
|
242
|
+
const plugins = opts.plugins ? { plugins: opts.plugins } : {};
|
|
243
|
+
if (opts.lazy) {
|
|
244
|
+
let token = "";
|
|
245
|
+
const apiKey = opts.apiKey;
|
|
246
|
+
let pending;
|
|
247
|
+
if (apiKey)
|
|
248
|
+
ready = async () => token || (token = await (pending ??= apiKey().finally(() => pending = undefined)));
|
|
249
|
+
connection = Connection.lazy({
|
|
250
|
+
...await connectOptions({ ...opts, apiKey: undefined }),
|
|
251
|
+
...apiKey ? { apiKey: () => token } : {},
|
|
252
|
+
...plugins
|
|
253
|
+
});
|
|
254
|
+
stopRenew = renewApiKey(opts, (t) => token = t);
|
|
255
|
+
} else {
|
|
256
|
+
connection = await Connection.connect({ ...await connectOptions(opts), ...plugins });
|
|
257
|
+
stopRenew = renewApiKey(opts, (t) => connection.setApiKey(t));
|
|
258
|
+
}
|
|
210
259
|
const client = new Client({ connection, namespace: namespace(opts) });
|
|
260
|
+
const startOptions = (target, args, o) => ({
|
|
261
|
+
taskQueue: target.app,
|
|
262
|
+
workflowId: o.id,
|
|
263
|
+
args,
|
|
264
|
+
...o.timeoutMs ? { workflowExecutionTimeout: o.timeoutMs } : {},
|
|
265
|
+
priority: { priorityKey: checkPriority(o.priority) ?? DEFAULT_PRIORITY }
|
|
266
|
+
});
|
|
211
267
|
return {
|
|
212
|
-
async start(target, args,
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
268
|
+
async start(target, args, o) {
|
|
269
|
+
await ready();
|
|
270
|
+
const h = await client.workflow.start(target.flow, { ...startOptions(target, args, o), workflowIdConflictPolicy: "USE_EXISTING" });
|
|
271
|
+
return wrap(h, ready);
|
|
272
|
+
},
|
|
273
|
+
async signalWithStart(target, args, o, topic, value) {
|
|
274
|
+
await ready();
|
|
275
|
+
const h = await client.workflow.signalWithStart(target.flow, {
|
|
276
|
+
...startOptions(target, args, o),
|
|
277
|
+
signal: SIGNAL,
|
|
278
|
+
signalArgs: [{ topic, value }]
|
|
219
279
|
});
|
|
220
|
-
return wrap(h);
|
|
280
|
+
return wrap(h, ready);
|
|
221
281
|
},
|
|
222
|
-
handle: (id) => wrap(client.workflow.getHandle(id)),
|
|
282
|
+
handle: (id) => wrap(client.workflow.getHandle(id), ready),
|
|
223
283
|
close: () => (stopRenew(), connection.close())
|
|
224
284
|
};
|
|
225
285
|
}
|
|
226
286
|
export {
|
|
287
|
+
temporalActivities,
|
|
227
288
|
temporalClient,
|
|
228
289
|
temporalRuntime
|
|
229
290
|
};
|
package/dist/scope.d.ts
ADDED
package/dist/workflow.js
CHANGED
|
@@ -15,12 +15,13 @@ import {
|
|
|
15
15
|
TemporalFailure,
|
|
16
16
|
workflowInfo
|
|
17
17
|
} from "@temporalio/workflow";
|
|
18
|
-
import { FlowError as FlowError2 } from "@iworkflow/core";
|
|
18
|
+
import { checkPriority, DEFAULT_PRIORITY, FlowError as FlowError2 } from "@iworkflow/core";
|
|
19
19
|
|
|
20
20
|
// src/failure.ts
|
|
21
21
|
import {
|
|
22
22
|
ActivityFailure,
|
|
23
23
|
ApplicationFailure,
|
|
24
|
+
ApplicationFailureCategory,
|
|
24
25
|
ChildWorkflowFailure,
|
|
25
26
|
TerminatedFailure,
|
|
26
27
|
TimeoutFailure,
|
|
@@ -56,7 +57,9 @@ function toTemporal(e, nonRetryable) {
|
|
|
56
57
|
message: e.message,
|
|
57
58
|
type: e.type,
|
|
58
59
|
details: [e.details],
|
|
59
|
-
nonRetryable: nonRetryable || e.nonRetryable
|
|
60
|
+
nonRetryable: nonRetryable || e.nonRetryable,
|
|
61
|
+
...e.retryAfterMs !== undefined ? { nextRetryDelay: Math.max(e.retryAfterMs, 1) } : {},
|
|
62
|
+
...e.benign && !nonRetryable ? { category: ApplicationFailureCategory.BENIGN } : {}
|
|
60
63
|
});
|
|
61
64
|
if (isFlowError(e))
|
|
62
65
|
return ApplicationFailure.create({ message: e.message, type: e.type ?? e.reason, details: [e.details], nonRetryable });
|
|
@@ -71,7 +74,8 @@ var queryDef = defineQuery(QUERY);
|
|
|
71
74
|
var poolQueue = (app, pool) => `${app}@${pool}`;
|
|
72
75
|
var proxies = new Map;
|
|
73
76
|
function activitiesWith(opts = {}) {
|
|
74
|
-
const
|
|
77
|
+
const priority = checkPriority(opts.priority) ?? workflowInfo().priority?.priorityKey;
|
|
78
|
+
const key = JSON.stringify({ ...opts, priority });
|
|
75
79
|
let p = proxies.get(key);
|
|
76
80
|
if (!p) {
|
|
77
81
|
p = proxyActivities({
|
|
@@ -79,9 +83,10 @@ function activitiesWith(opts = {}) {
|
|
|
79
83
|
startToCloseTimeout: opts.timeoutMs ?? 60000,
|
|
80
84
|
...opts.totalTimeoutMs ? { scheduleToCloseTimeout: opts.totalTimeoutMs } : {},
|
|
81
85
|
...opts.heartbeatTimeoutMs ? { heartbeatTimeout: opts.heartbeatTimeoutMs } : {},
|
|
86
|
+
...priority ? { priority: { priorityKey: priority } } : {},
|
|
82
87
|
retry: {
|
|
83
88
|
maximumAttempts: opts.attempts ?? 3,
|
|
84
|
-
initialInterval: opts.retryIntervalMs ?? 200,
|
|
89
|
+
initialInterval: Math.max(opts.retryIntervalMs ?? 200, 1),
|
|
85
90
|
backoffCoefficient: opts.backoff ?? 1,
|
|
86
91
|
...opts.maxRetryIntervalMs ? { maximumInterval: opts.maxRetryIntervalMs } : {},
|
|
87
92
|
...opts.nonRetryableTypes ? { nonRetryableErrorTypes: opts.nonRetryableTypes } : {}
|
|
@@ -116,6 +121,8 @@ function temporalFlow(flow, patches = []) {
|
|
|
116
121
|
workflowId: info.workflowId,
|
|
117
122
|
parentId: info.parent?.workflowId,
|
|
118
123
|
startedAt: info.startTime,
|
|
124
|
+
priority: info.priority?.priorityKey || DEFAULT_PRIORITY,
|
|
125
|
+
now: async () => Date.now(),
|
|
119
126
|
step: (name, stepArgs, opts) => activitiesWith(opts)[name](...stepArgs).catch(rethrow),
|
|
120
127
|
child: (target, childArgs, opts) => executeChild(target.flow, childOptions(target.app, childArgs, opts)).catch(rethrow),
|
|
121
128
|
async startChild(target, childArgs, opts) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iworkflow/temporal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Temporal adapter for @iworkflow/core",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"workflow",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"prepack": "bun run build"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@iworkflow/core": "^0.
|
|
44
|
+
"@iworkflow/core": "^0.6.0"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"@temporalio/activity": "^1.24.0",
|
package/src/connection.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Подключение к Temporal: адрес, TLS, токен с заменой на месте — общее для воркера и клиента.
|
|
2
|
-
import type { TLSConfig } from "@temporalio/client";
|
|
2
|
+
import type { ConnectionPlugin, TLSConfig } from "@temporalio/client";
|
|
3
|
+
import type { WorkerPlugin } from "@temporalio/worker";
|
|
3
4
|
|
|
4
5
|
/** Подключение к Temporal; не задано — TEMPORAL_URL и TEMPORAL_NAMESPACE из окружения. */
|
|
5
6
|
export interface TemporalOptions {
|
|
@@ -10,6 +11,8 @@ export interface TemporalOptions {
|
|
|
10
11
|
/** Источник токена; соединение берёт свежий каждые apiKeyRenewMs (по умолчанию 60 с). */
|
|
11
12
|
apiKey?: () => Promise<string>;
|
|
12
13
|
apiKeyRenewMs?: number;
|
|
14
|
+
/** Плагины SDK (OpenTelemetry и т. п.) — воркерам и клиенту. */
|
|
15
|
+
plugins?: (ConnectionPlugin & WorkerPlugin)[];
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
export const address = (o: TemporalOptions) => o.address ?? process.env.TEMPORAL_URL ?? "127.0.0.1:17233";
|
package/src/failure.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import {
|
|
3
3
|
ActivityFailure,
|
|
4
4
|
ApplicationFailure,
|
|
5
|
+
ApplicationFailureCategory,
|
|
5
6
|
ChildWorkflowFailure,
|
|
6
7
|
TerminatedFailure,
|
|
7
8
|
TimeoutFailure,
|
|
@@ -41,6 +42,10 @@ export function toTemporal(e: unknown, nonRetryable: boolean): unknown {
|
|
|
41
42
|
type: e.type,
|
|
42
43
|
details: [e.details],
|
|
43
44
|
nonRetryable: nonRetryable || e.nonRetryable,
|
|
45
|
+
// 0 сервер считает «не задано» и берёт паузу политики; 1 мс — «сразу», как у DBOS.
|
|
46
|
+
...(e.retryAfterMs !== undefined ? { nextRetryDelay: Math.max(e.retryAfterMs, 1) } : {}),
|
|
47
|
+
// BENIGN — только отказу шага: отказ процесса целиком штатным не бывает.
|
|
48
|
+
...(e.benign && !nonRetryable ? { category: ApplicationFailureCategory.BENIGN } : {}),
|
|
44
49
|
});
|
|
45
50
|
if (isFlowError(e))
|
|
46
51
|
return ApplicationFailure.create({ message: e.message, type: e.type ?? e.reason, details: [e.details], nonRetryable });
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,10 @@ import { Client, Connection, WorkflowFailedError, WorkflowNotFoundError, type Wo
|
|
|
3
3
|
import { Context } from "@temporalio/activity";
|
|
4
4
|
import { NativeConnection, Worker } from "@temporalio/worker";
|
|
5
5
|
import {
|
|
6
|
-
|
|
6
|
+
stepScope,
|
|
7
|
+
type StepRuntime,
|
|
8
|
+
checkPriority,
|
|
9
|
+
DEFAULT_PRIORITY,
|
|
7
10
|
exitSignal,
|
|
8
11
|
FlowError,
|
|
9
12
|
type Activity,
|
|
@@ -11,10 +14,13 @@ import {
|
|
|
11
14
|
type EngineClient,
|
|
12
15
|
type EngineRuntime,
|
|
13
16
|
type FlowHandle,
|
|
17
|
+
type FlowRef,
|
|
14
18
|
type FlowStatus,
|
|
19
|
+
type StartOptions,
|
|
15
20
|
} from "@iworkflow/core";
|
|
16
21
|
import { address, connectOptions, namespace, renewApiKey, type TemporalOptions } from "./connection";
|
|
17
22
|
import { cause, toTemporal } from "./failure";
|
|
23
|
+
import { alsScope } from "./scope";
|
|
18
24
|
|
|
19
25
|
export type { TemporalOptions };
|
|
20
26
|
|
|
@@ -38,35 +44,60 @@ const notFound = (id: string) => (e: unknown): never => {
|
|
|
38
44
|
throw e;
|
|
39
45
|
};
|
|
40
46
|
|
|
41
|
-
|
|
47
|
+
/** ready — токен ленивого клиента: без него SDK уйдёт с пустым ключом. */
|
|
48
|
+
const wrap = <T>(h: WorkflowHandle, ready: () => Promise<unknown> = async () => {}): FlowHandle<T> => ({
|
|
42
49
|
id: h.workflowId,
|
|
43
|
-
result: () =>
|
|
44
|
-
|
|
50
|
+
result: async () => {
|
|
51
|
+
await ready();
|
|
52
|
+
return h.result().catch((e) => {
|
|
45
53
|
throw (e instanceof WorkflowFailedError && cause(e.cause)) || e;
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
signal: async (topic, value) => {
|
|
57
|
+
await ready();
|
|
58
|
+
await h.signal(SIGNAL, { topic, value });
|
|
59
|
+
},
|
|
60
|
+
query: async <V>(key: string) => {
|
|
61
|
+
await ready();
|
|
62
|
+
return ((await h.query(QUERY, key)) ?? undefined) as V | undefined;
|
|
63
|
+
},
|
|
49
64
|
async describe() {
|
|
65
|
+
await ready();
|
|
50
66
|
const d = await h.describe().catch(notFound(h.workflowId));
|
|
51
67
|
return { id: h.workflowId, status: STATUS[d.status.name] ?? "running", startedAt: d.startTime };
|
|
52
68
|
},
|
|
53
|
-
|
|
69
|
+
async terminate(reason) {
|
|
70
|
+
await ready();
|
|
71
|
+
await h.terminate(reason).catch(notFound(h.workflowId));
|
|
72
|
+
},
|
|
54
73
|
});
|
|
55
74
|
|
|
56
|
-
/**
|
|
57
|
-
const
|
|
58
|
-
|
|
75
|
+
/** Рантайм шага Temporal: всё из контекста активности. */
|
|
76
|
+
const TEMPORAL_STEP: StepRuntime = {
|
|
77
|
+
heartbeat: async (details) => Context.current().heartbeat(details),
|
|
78
|
+
lastHeartbeat: async () => Context.current().info.heartbeatDetails,
|
|
79
|
+
attempt: () => Context.current().info.attempt,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Шаги порта для своего Worker.create (слив старой очереди, тестовый воркер): Failure доезжает
|
|
84
|
+
* как ApplicationFailure, heartbeat и stepAttempt работают. temporalRuntime делает это сам.
|
|
85
|
+
*/
|
|
86
|
+
export function temporalActivities<T extends Record<string, Activity>>(activities: T): T {
|
|
87
|
+
const scope = stepScope(alsScope);
|
|
88
|
+
return Object.fromEntries(
|
|
59
89
|
Object.entries(activities).map(([name, fn]) => [
|
|
60
90
|
name,
|
|
61
91
|
async (...args: unknown[]) => {
|
|
62
92
|
try {
|
|
63
|
-
return await fn(...args);
|
|
93
|
+
return await scope.run(TEMPORAL_STEP, () => fn(...args));
|
|
64
94
|
} catch (e) {
|
|
65
95
|
throw toTemporal(e, false);
|
|
66
96
|
}
|
|
67
97
|
},
|
|
68
98
|
]),
|
|
69
|
-
);
|
|
99
|
+
) as T;
|
|
100
|
+
}
|
|
70
101
|
|
|
71
102
|
/**
|
|
72
103
|
* workflowsPath — вход песочницы модуля: temporalFlow(...) на каждый процесс, имя экспорта = имя процесса.
|
|
@@ -89,10 +120,6 @@ export function temporalRuntime(
|
|
|
89
120
|
starting = true;
|
|
90
121
|
exit.arm();
|
|
91
122
|
stopping = false;
|
|
92
|
-
bindStepRuntime({
|
|
93
|
-
heartbeat: async (details) => Context.current().heartbeat(details),
|
|
94
|
-
lastHeartbeat: async () => Context.current().info.heartbeatDetails,
|
|
95
|
-
});
|
|
96
123
|
let c: NativeConnection;
|
|
97
124
|
try {
|
|
98
125
|
c = conn = await NativeConnection.connect(await connectOptions(opts));
|
|
@@ -104,8 +131,8 @@ export function temporalRuntime(
|
|
|
104
131
|
stopRenew = renewApiKey(opts, (t) => c.setApiKey(t));
|
|
105
132
|
// Воркер не создался (нет бандла, namespace) — соединение и таймер токена закрываются.
|
|
106
133
|
try {
|
|
107
|
-
const activities =
|
|
108
|
-
const base = { connection: c, namespace: namespace(opts) };
|
|
134
|
+
const activities = temporalActivities(def.activities);
|
|
135
|
+
const base = { connection: c, namespace: namespace(opts), ...(opts.plugins ? { plugins: opts.plugins } : {}) };
|
|
109
136
|
const code = opts.workflowBundlePath ? { workflowBundle: { codePath: opts.workflowBundlePath } } : { workflowsPath };
|
|
110
137
|
workers = [await Worker.create({ ...base, taskQueue: def.app, ...code, activities })];
|
|
111
138
|
// Пул — своя очередь с пределом одновременных шагов в этом процессе.
|
|
@@ -162,22 +189,59 @@ export function temporalRuntime(
|
|
|
162
189
|
};
|
|
163
190
|
}
|
|
164
191
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
192
|
+
/** lazy — соединение при первом вызове: модуль поднимается и без Temporal. */
|
|
193
|
+
export async function temporalClient(opts: TemporalOptions & { lazy?: boolean } = {}): Promise<EngineClient> {
|
|
194
|
+
let connection: Connection;
|
|
195
|
+
let stopRenew: () => void;
|
|
196
|
+
let ready = async (): Promise<unknown> => undefined;
|
|
197
|
+
const plugins = opts.plugins ? { plugins: opts.plugins } : {};
|
|
198
|
+
if (opts.lazy) {
|
|
199
|
+
// Ни Temporal, ни источник токена не нужны для старта: ключ — функцией, берётся при первом вызове.
|
|
200
|
+
let token = "";
|
|
201
|
+
const apiKey = opts.apiKey;
|
|
202
|
+
// Неудача не залипает: следующий вызов спросит токен снова.
|
|
203
|
+
// Один запрос на всех ждущих; неудача не залипает: следующий вызов спросит снова.
|
|
204
|
+
let pending: Promise<string> | undefined;
|
|
205
|
+
if (apiKey)
|
|
206
|
+
ready = async () =>
|
|
207
|
+
token ||
|
|
208
|
+
(token = await (pending ??= apiKey().finally(() => (pending = undefined))));
|
|
209
|
+
connection = Connection.lazy({
|
|
210
|
+
...(await connectOptions({ ...opts, apiKey: undefined })),
|
|
211
|
+
...(apiKey ? { apiKey: () => token } : {}),
|
|
212
|
+
...plugins,
|
|
213
|
+
});
|
|
214
|
+
stopRenew = renewApiKey(opts, (t) => (token = t));
|
|
215
|
+
} else {
|
|
216
|
+
connection = await Connection.connect({ ...(await connectOptions(opts)), ...plugins });
|
|
217
|
+
stopRenew = renewApiKey(opts, (t) => connection.setApiKey(t));
|
|
218
|
+
}
|
|
219
|
+
// Плагины — у соединения: оно само передаёт их клиенту.
|
|
168
220
|
const client = new Client({ connection, namespace: namespace(opts) });
|
|
221
|
+
const startOptions = (target: FlowRef, args: unknown[], o: StartOptions) => ({
|
|
222
|
+
taskQueue: target.app,
|
|
223
|
+
workflowId: o.id,
|
|
224
|
+
args,
|
|
225
|
+
...(o.timeoutMs ? { workflowExecutionTimeout: o.timeoutMs } : {}),
|
|
226
|
+
// Явно и по умолчанию: ctx.priority и очередь видят одно и то же, как у DBOS.
|
|
227
|
+
priority: { priorityKey: checkPriority(o.priority) ?? DEFAULT_PRIORITY },
|
|
228
|
+
});
|
|
169
229
|
return {
|
|
170
|
-
async start(target, args,
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
230
|
+
async start(target, args, o) {
|
|
231
|
+
await ready();
|
|
232
|
+
const h = await client.workflow.start(target.flow, { ...startOptions(target, args, o), workflowIdConflictPolicy: "USE_EXISTING" });
|
|
233
|
+
return wrap(h, ready);
|
|
234
|
+
},
|
|
235
|
+
async signalWithStart(target, args, o, topic, value) {
|
|
236
|
+
await ready();
|
|
237
|
+
const h = await client.workflow.signalWithStart(target.flow, {
|
|
238
|
+
...startOptions(target, args, o),
|
|
239
|
+
signal: SIGNAL,
|
|
240
|
+
signalArgs: [{ topic, value }],
|
|
177
241
|
});
|
|
178
|
-
return wrap(h);
|
|
242
|
+
return wrap(h, ready);
|
|
179
243
|
},
|
|
180
|
-
handle: (id) => wrap(client.workflow.getHandle(id)),
|
|
244
|
+
handle: (id) => wrap(client.workflow.getHandle(id), ready),
|
|
181
245
|
close: () => (stopRenew(), connection.close()),
|
|
182
246
|
};
|
|
183
247
|
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Область шага на AsyncLocalStorage: stepScope ядра берёт её у первого адаптера процесса.
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import type { StepRuntime, StepScope } from "@iworkflow/core";
|
|
4
|
+
|
|
5
|
+
export const alsScope = (): StepScope => {
|
|
6
|
+
const als = new AsyncLocalStorage<StepRuntime>();
|
|
7
|
+
return { run: (rt, fn) => als.run(rt, fn), current: () => als.getStore() };
|
|
8
|
+
};
|
package/src/workflow.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
workflowInfo,
|
|
19
19
|
} from "@temporalio/workflow";
|
|
20
20
|
|
|
21
|
-
import { FlowError, type ChildOptions, type Flow, type FlowContext, type StepOptions } from "@iworkflow/core";
|
|
21
|
+
import { checkPriority, DEFAULT_PRIORITY, FlowError, type ChildOptions, type Flow, type FlowContext, type StepOptions } from "@iworkflow/core";
|
|
22
22
|
import { fromTemporal, toTemporal } from "./failure";
|
|
23
23
|
|
|
24
24
|
export const SIGNAL = "iwf.signal";
|
|
@@ -32,7 +32,9 @@ export const poolQueue = (app: string, pool: string) => `${app}@${pool}`;
|
|
|
32
32
|
const proxies = new Map<string, Record<string, (...a: unknown[]) => Promise<unknown>>>();
|
|
33
33
|
|
|
34
34
|
function activitiesWith(opts: StepOptions = {}) {
|
|
35
|
-
|
|
35
|
+
// Шаг без своего приоритета — с приоритетом процесса, как у Temporal по умолчанию.
|
|
36
|
+
const priority = checkPriority(opts.priority) ?? workflowInfo().priority?.priorityKey;
|
|
37
|
+
const key = JSON.stringify({ ...opts, priority });
|
|
36
38
|
let p = proxies.get(key);
|
|
37
39
|
if (!p) {
|
|
38
40
|
p = proxyActivities({
|
|
@@ -40,9 +42,11 @@ function activitiesWith(opts: StepOptions = {}) {
|
|
|
40
42
|
startToCloseTimeout: opts.timeoutMs ?? 60_000,
|
|
41
43
|
...(opts.totalTimeoutMs ? { scheduleToCloseTimeout: opts.totalTimeoutMs } : {}),
|
|
42
44
|
...(opts.heartbeatTimeoutMs ? { heartbeatTimeout: opts.heartbeatTimeoutMs } : {}),
|
|
45
|
+
...(priority ? { priority: { priorityKey: priority } } : {}),
|
|
43
46
|
retry: {
|
|
44
47
|
maximumAttempts: opts.attempts ?? 3,
|
|
45
|
-
|
|
48
|
+
// 0 Temporal не принимает (ValueError — вечный повтор задачи процесса): 1 мс — «сразу», как у DBOS.
|
|
49
|
+
initialInterval: Math.max(opts.retryIntervalMs ?? 200, 1),
|
|
46
50
|
backoffCoefficient: opts.backoff ?? 1,
|
|
47
51
|
...(opts.maxRetryIntervalMs ? { maximumInterval: opts.maxRetryIntervalMs } : {}),
|
|
48
52
|
...(opts.nonRetryableTypes ? { nonRetryableErrorTypes: opts.nonRetryableTypes } : {}),
|
|
@@ -83,6 +87,9 @@ export function temporalFlow(flow: Flow, patches: string[] = []) {
|
|
|
83
87
|
workflowId: info.workflowId,
|
|
84
88
|
parentId: info.parent?.workflowId,
|
|
85
89
|
startedAt: info.startTime,
|
|
90
|
+
priority: info.priority?.priorityKey || DEFAULT_PRIORITY,
|
|
91
|
+
// Date.now() в песочнице Temporal детерминирован: время задачи процесса.
|
|
92
|
+
now: async () => Date.now(),
|
|
86
93
|
step: (name, stepArgs, opts) => activitiesWith(opts)[name]!(...stepArgs).catch(rethrow) as Promise<never>,
|
|
87
94
|
child: (target, childArgs, opts) =>
|
|
88
95
|
executeChild(target.flow, childOptions(target.app, childArgs, opts)).catch(rethrow) as Promise<never>,
|