@iworkflow/temporal 0.3.0 → 0.5.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 +2 -1
- package/dist/connection.d.ts +23 -0
- package/dist/failure.d.ts +7 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +277 -0
- package/dist/workflow.d.ts +7 -0
- package/dist/workflow.js +165 -0
- package/package.json +21 -6
- package/src/connection.ts +4 -1
- package/src/failure.ts +2 -0
- package/src/index.ts +90 -20
- package/src/workflow.ts +10 -3
- package/src/connection.test.ts +0 -34
package/README.md
CHANGED
|
@@ -7,5 +7,6 @@ Temporal для `@iworkflow/core`. Peer: `@temporalio/{client,worker,workflow,ac
|
|
|
7
7
|
- `@iworkflow/temporal/workflow` — только для входа песочницы модуля:
|
|
8
8
|
`export const outreach = temporalFlow(flows.outreach, ["send-twice"])`.
|
|
9
9
|
|
|
10
|
-
`opts` (`TemporalOptions`): `address`, `namespace`, `tls`, `apiKey` — источник токена, меняется каждые `apiKeyRenewMs
|
|
10
|
+
`opts` (`TemporalOptions`): `address`, `namespace`, `tls`, `apiKey` — источник токена, меняется каждые `apiKeyRenewMs`;
|
|
11
|
+
`plugins` — плагины SDK воркерам и клиенту; `temporalClient({ lazy: true })` — соединение при первом вызове.
|
|
11
12
|
Без них — переменные `TEMPORAL_URL` (по умолчанию `127.0.0.1:17233`), `TEMPORAL_NAMESPACE`.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ConnectionPlugin, TLSConfig } from "@temporalio/client";
|
|
2
|
+
import type { WorkerPlugin } from "@temporalio/worker";
|
|
3
|
+
/** Подключение к Temporal; не задано — TEMPORAL_URL и TEMPORAL_NAMESPACE из окружения. */
|
|
4
|
+
export interface TemporalOptions {
|
|
5
|
+
address?: string;
|
|
6
|
+
namespace?: string;
|
|
7
|
+
/** TLSConfig SDK (serverNameOverride, свой CA, mTLS). С apiKey SDK включает TLS сам — без TLS только `false`. */
|
|
8
|
+
tls?: boolean | TLSConfig;
|
|
9
|
+
/** Источник токена; соединение берёт свежий каждые apiKeyRenewMs (по умолчанию 60 с). */
|
|
10
|
+
apiKey?: () => Promise<string>;
|
|
11
|
+
apiKeyRenewMs?: number;
|
|
12
|
+
/** Плагины SDK (OpenTelemetry и т. п.) — воркерам и клиенту. */
|
|
13
|
+
plugins?: (ConnectionPlugin & WorkerPlugin)[];
|
|
14
|
+
}
|
|
15
|
+
export declare const address: (o: TemporalOptions) => string;
|
|
16
|
+
export declare const namespace: (o: TemporalOptions) => string;
|
|
17
|
+
/** Токен меняется на месте: иначе воркер однажды молча перестанет брать задачи. */
|
|
18
|
+
export declare function renewApiKey(o: TemporalOptions, set: (token: string) => unknown): () => void;
|
|
19
|
+
export declare const connectOptions: (o: TemporalOptions) => Promise<{
|
|
20
|
+
address: string;
|
|
21
|
+
tls?: boolean | TLSConfig | undefined;
|
|
22
|
+
apiKey?: string | undefined;
|
|
23
|
+
}>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { FlowError } from "@iworkflow/core";
|
|
2
|
+
/** Отказ шага, дочернего процесса или процесса целиком — FlowError; прочее (отмена) — как есть. */
|
|
3
|
+
export declare function fromTemporal(e: unknown): unknown;
|
|
4
|
+
/** Причина отказа процесса или шага — FlowError; undefined — причина не наша (отмена и т. п.). */
|
|
5
|
+
export declare function cause(c: Error | undefined, exhausted?: boolean): FlowError | undefined;
|
|
6
|
+
/** Отказ из кода порта — ApplicationFailure: обычная ошибка в песочнице не роняет процесс, а повторяет задачу. */
|
|
7
|
+
export declare function toTemporal(e: unknown, nonRetryable: boolean): unknown;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type EngineClient, type EngineRuntime } from "@iworkflow/core";
|
|
2
|
+
import { type TemporalOptions } from "./connection";
|
|
3
|
+
export type { TemporalOptions };
|
|
4
|
+
/**
|
|
5
|
+
* workflowsPath — вход песочницы модуля: temporalFlow(...) на каждый процесс, имя экспорта = имя процесса.
|
|
6
|
+
* workflowBundlePath — готовый бандл того же входа (прод-сборка); с ним воркер не собирает код сам.
|
|
7
|
+
*/
|
|
8
|
+
export declare function temporalRuntime(workflowsPath: string, opts?: TemporalOptions & {
|
|
9
|
+
workflowBundlePath?: string;
|
|
10
|
+
}): EngineRuntime;
|
|
11
|
+
/** lazy — соединение при первом вызове: модуль поднимается и без Temporal. */
|
|
12
|
+
export declare function temporalClient(opts?: TemporalOptions & {
|
|
13
|
+
lazy?: boolean;
|
|
14
|
+
}): Promise<EngineClient>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { Client, Connection, WorkflowFailedError, WorkflowNotFoundError } from "@temporalio/client";
|
|
3
|
+
import { Context } from "@temporalio/activity";
|
|
4
|
+
import { NativeConnection, Worker } from "@temporalio/worker";
|
|
5
|
+
import {
|
|
6
|
+
bindStepRuntime,
|
|
7
|
+
checkPriority,
|
|
8
|
+
DEFAULT_PRIORITY,
|
|
9
|
+
exitSignal,
|
|
10
|
+
FlowError as FlowError2
|
|
11
|
+
} from "@iworkflow/core";
|
|
12
|
+
|
|
13
|
+
// src/connection.ts
|
|
14
|
+
var address = (o) => o.address ?? process.env.TEMPORAL_URL ?? "127.0.0.1:17233";
|
|
15
|
+
var namespace = (o) => o.namespace ?? process.env.TEMPORAL_NAMESPACE ?? "default";
|
|
16
|
+
function renewApiKey(o, set) {
|
|
17
|
+
if (!o.apiKey)
|
|
18
|
+
return () => {};
|
|
19
|
+
const apiKey = o.apiKey;
|
|
20
|
+
let busy = false;
|
|
21
|
+
let stopped = false;
|
|
22
|
+
const timer = setInterval(() => {
|
|
23
|
+
if (busy)
|
|
24
|
+
return;
|
|
25
|
+
busy = true;
|
|
26
|
+
apiKey().then((t) => stopped ? undefined : set(t)).catch((e) => stopped || console.error("токен Temporal не обновлён:", e)).finally(() => busy = false);
|
|
27
|
+
}, o.apiKeyRenewMs ?? 60000);
|
|
28
|
+
timer.unref?.();
|
|
29
|
+
return () => {
|
|
30
|
+
stopped = true;
|
|
31
|
+
clearInterval(timer);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
var connectOptions = async (o) => ({
|
|
35
|
+
address: address(o),
|
|
36
|
+
...o.tls !== undefined ? { tls: o.tls } : {},
|
|
37
|
+
...o.apiKey ? { apiKey: await o.apiKey() } : {}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// src/failure.ts
|
|
41
|
+
import {
|
|
42
|
+
ActivityFailure,
|
|
43
|
+
ApplicationFailure,
|
|
44
|
+
ChildWorkflowFailure,
|
|
45
|
+
TerminatedFailure,
|
|
46
|
+
TimeoutFailure,
|
|
47
|
+
WorkflowExecutionAlreadyStartedError
|
|
48
|
+
} from "@temporalio/common";
|
|
49
|
+
import { Failure, FlowError, isFlowError } from "@iworkflow/core";
|
|
50
|
+
function fromTemporal(e) {
|
|
51
|
+
if (e instanceof WorkflowExecutionAlreadyStartedError)
|
|
52
|
+
return new FlowError(`процесс ${e.workflowId} уже есть`, { reason: "already-started" });
|
|
53
|
+
if (e instanceof ActivityFailure) {
|
|
54
|
+
const exhausted = e.retryState === "MAXIMUM_ATTEMPTS_REACHED";
|
|
55
|
+
const f = cause(e.cause, exhausted);
|
|
56
|
+
if (f && e.retryState === "TIMEOUT")
|
|
57
|
+
return new FlowError(f.message, { reason: "timeout", type: f.type, details: f.details });
|
|
58
|
+
return f ?? e;
|
|
59
|
+
}
|
|
60
|
+
if (e instanceof ChildWorkflowFailure)
|
|
61
|
+
return cause(e.cause, false) ?? e;
|
|
62
|
+
return e;
|
|
63
|
+
}
|
|
64
|
+
function cause(c, exhausted = false) {
|
|
65
|
+
if (c instanceof TimeoutFailure)
|
|
66
|
+
return new FlowError(c.message || "срок вышел", { reason: "timeout", exhausted });
|
|
67
|
+
if (c instanceof TerminatedFailure)
|
|
68
|
+
return new FlowError(c.message || "процесс прерван", { reason: "terminated" });
|
|
69
|
+
if (c instanceof ApplicationFailure)
|
|
70
|
+
return new FlowError(c.message, { reason: "failed", type: c.type ?? undefined, details: c.details?.[0], exhausted });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
function toTemporal(e, nonRetryable) {
|
|
74
|
+
if (e instanceof Failure)
|
|
75
|
+
return ApplicationFailure.create({
|
|
76
|
+
message: e.message,
|
|
77
|
+
type: e.type,
|
|
78
|
+
details: [e.details],
|
|
79
|
+
nonRetryable: nonRetryable || e.nonRetryable,
|
|
80
|
+
...e.retryAfterMs !== undefined ? { nextRetryDelay: Math.max(e.retryAfterMs, 1) } : {}
|
|
81
|
+
});
|
|
82
|
+
if (isFlowError(e))
|
|
83
|
+
return ApplicationFailure.create({ message: e.message, type: e.type ?? e.reason, details: [e.details], nonRetryable });
|
|
84
|
+
return e;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/index.ts
|
|
88
|
+
var SIGNAL = "iwf.signal";
|
|
89
|
+
var QUERY = "iwf.query";
|
|
90
|
+
var poolQueue = (app, pool) => `${app}@${pool}`;
|
|
91
|
+
var STATUS = {
|
|
92
|
+
RUNNING: "running",
|
|
93
|
+
COMPLETED: "completed",
|
|
94
|
+
FAILED: "failed",
|
|
95
|
+
CANCELLED: "terminated",
|
|
96
|
+
TERMINATED: "terminated",
|
|
97
|
+
TIMED_OUT: "timed-out",
|
|
98
|
+
CONTINUED_AS_NEW: "running"
|
|
99
|
+
};
|
|
100
|
+
var notFound = (id) => (e) => {
|
|
101
|
+
if (e instanceof WorkflowNotFoundError)
|
|
102
|
+
throw new FlowError2(`процесса ${id} нет`, { reason: "not-found" });
|
|
103
|
+
throw e;
|
|
104
|
+
};
|
|
105
|
+
var wrap = (h, ready = async () => {}) => ({
|
|
106
|
+
id: h.workflowId,
|
|
107
|
+
result: async () => {
|
|
108
|
+
await ready();
|
|
109
|
+
return h.result().catch((e) => {
|
|
110
|
+
throw e instanceof WorkflowFailedError && cause(e.cause) || e;
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
signal: async (topic, value) => {
|
|
114
|
+
await ready();
|
|
115
|
+
await h.signal(SIGNAL, { topic, value });
|
|
116
|
+
},
|
|
117
|
+
query: async (key) => {
|
|
118
|
+
await ready();
|
|
119
|
+
return await h.query(QUERY, key) ?? undefined;
|
|
120
|
+
},
|
|
121
|
+
async describe() {
|
|
122
|
+
await ready();
|
|
123
|
+
const d = await h.describe().catch(notFound(h.workflowId));
|
|
124
|
+
return { id: h.workflowId, status: STATUS[d.status.name] ?? "running", startedAt: d.startTime };
|
|
125
|
+
},
|
|
126
|
+
async terminate(reason) {
|
|
127
|
+
await ready();
|
|
128
|
+
await h.terminate(reason).catch(notFound(h.workflowId));
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
var withFailures = (activities) => Object.fromEntries(Object.entries(activities).map(([name, fn]) => [
|
|
132
|
+
name,
|
|
133
|
+
async (...args) => {
|
|
134
|
+
try {
|
|
135
|
+
return await fn(...args);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
throw toTemporal(e, false);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
]));
|
|
141
|
+
function temporalRuntime(workflowsPath, opts = {}) {
|
|
142
|
+
let workers = [];
|
|
143
|
+
let running = [];
|
|
144
|
+
let conn;
|
|
145
|
+
let starting = false;
|
|
146
|
+
let stopping = false;
|
|
147
|
+
const exit = exitSignal();
|
|
148
|
+
let stopRenew = () => {};
|
|
149
|
+
return {
|
|
150
|
+
async start(def) {
|
|
151
|
+
if (starting)
|
|
152
|
+
throw new Error("воркер уже запущен");
|
|
153
|
+
starting = true;
|
|
154
|
+
exit.arm();
|
|
155
|
+
stopping = false;
|
|
156
|
+
bindStepRuntime({
|
|
157
|
+
heartbeat: async (details) => Context.current().heartbeat(details),
|
|
158
|
+
lastHeartbeat: async () => Context.current().info.heartbeatDetails,
|
|
159
|
+
attempt: () => Context.current().info.attempt
|
|
160
|
+
});
|
|
161
|
+
let c;
|
|
162
|
+
try {
|
|
163
|
+
c = conn = await NativeConnection.connect(await connectOptions(opts));
|
|
164
|
+
} catch (e) {
|
|
165
|
+
starting = false;
|
|
166
|
+
exit.reject(e);
|
|
167
|
+
throw e;
|
|
168
|
+
}
|
|
169
|
+
stopRenew = renewApiKey(opts, (t) => c.setApiKey(t));
|
|
170
|
+
try {
|
|
171
|
+
const activities = withFailures(def.activities);
|
|
172
|
+
const base = { connection: c, namespace: namespace(opts), ...opts.plugins ? { plugins: opts.plugins } : {} };
|
|
173
|
+
const code = opts.workflowBundlePath ? { workflowBundle: { codePath: opts.workflowBundlePath } } : { workflowsPath };
|
|
174
|
+
workers = [await Worker.create({ ...base, taskQueue: def.app, ...code, activities })];
|
|
175
|
+
for (const [pool, limit] of Object.entries(def.pools ?? {}))
|
|
176
|
+
workers.push(await Worker.create({
|
|
177
|
+
...base,
|
|
178
|
+
taskQueue: poolQueue(def.app, pool),
|
|
179
|
+
activities,
|
|
180
|
+
maxConcurrentActivityTaskExecutions: limit
|
|
181
|
+
}));
|
|
182
|
+
} catch (e) {
|
|
183
|
+
await Promise.allSettled(workers.map(async (w) => {
|
|
184
|
+
const r = w.run();
|
|
185
|
+
w.shutdown();
|
|
186
|
+
await r;
|
|
187
|
+
}));
|
|
188
|
+
stopRenew();
|
|
189
|
+
await c.close().catch(() => {});
|
|
190
|
+
conn = undefined;
|
|
191
|
+
workers = [];
|
|
192
|
+
starting = false;
|
|
193
|
+
exit.reject(e);
|
|
194
|
+
throw e;
|
|
195
|
+
}
|
|
196
|
+
running = workers.map((w) => w.run());
|
|
197
|
+
Promise.all(running).then(() => stopping || exit.resolve(), () => {});
|
|
198
|
+
for (const r of running)
|
|
199
|
+
r.catch((e) => stopping || exit.reject(e));
|
|
200
|
+
},
|
|
201
|
+
done: () => exit.promise(),
|
|
202
|
+
async stop() {
|
|
203
|
+
stopping = true;
|
|
204
|
+
for (const w of workers)
|
|
205
|
+
if (w.getState() === "RUNNING")
|
|
206
|
+
w.shutdown();
|
|
207
|
+
const done = await Promise.allSettled(running);
|
|
208
|
+
stopRenew();
|
|
209
|
+
try {
|
|
210
|
+
await conn?.close();
|
|
211
|
+
} finally {
|
|
212
|
+
conn = undefined;
|
|
213
|
+
workers = [];
|
|
214
|
+
running = [];
|
|
215
|
+
starting = false;
|
|
216
|
+
exit.resolve();
|
|
217
|
+
}
|
|
218
|
+
const failed = done.find((r) => r.status === "rejected");
|
|
219
|
+
if (failed)
|
|
220
|
+
throw failed.reason;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
async function temporalClient(opts = {}) {
|
|
225
|
+
let connection;
|
|
226
|
+
let stopRenew;
|
|
227
|
+
let ready = async () => {
|
|
228
|
+
return;
|
|
229
|
+
};
|
|
230
|
+
const plugins = opts.plugins ? { plugins: opts.plugins } : {};
|
|
231
|
+
if (opts.lazy) {
|
|
232
|
+
let token = "";
|
|
233
|
+
const apiKey = opts.apiKey;
|
|
234
|
+
let pending;
|
|
235
|
+
if (apiKey)
|
|
236
|
+
ready = async () => token || (token = await (pending ??= apiKey().finally(() => pending = undefined)));
|
|
237
|
+
connection = Connection.lazy({
|
|
238
|
+
...await connectOptions({ ...opts, apiKey: undefined }),
|
|
239
|
+
...apiKey ? { apiKey: () => token } : {},
|
|
240
|
+
...plugins
|
|
241
|
+
});
|
|
242
|
+
stopRenew = renewApiKey(opts, (t) => token = t);
|
|
243
|
+
} else {
|
|
244
|
+
connection = await Connection.connect({ ...await connectOptions(opts), ...plugins });
|
|
245
|
+
stopRenew = renewApiKey(opts, (t) => connection.setApiKey(t));
|
|
246
|
+
}
|
|
247
|
+
const client = new Client({ connection, namespace: namespace(opts) });
|
|
248
|
+
const startOptions = (target, args, o) => ({
|
|
249
|
+
taskQueue: target.app,
|
|
250
|
+
workflowId: o.id,
|
|
251
|
+
args,
|
|
252
|
+
...o.timeoutMs ? { workflowExecutionTimeout: o.timeoutMs } : {},
|
|
253
|
+
priority: { priorityKey: checkPriority(o.priority) ?? DEFAULT_PRIORITY }
|
|
254
|
+
});
|
|
255
|
+
return {
|
|
256
|
+
async start(target, args, o) {
|
|
257
|
+
await ready();
|
|
258
|
+
const h = await client.workflow.start(target.flow, { ...startOptions(target, args, o), workflowIdConflictPolicy: "USE_EXISTING" });
|
|
259
|
+
return wrap(h, ready);
|
|
260
|
+
},
|
|
261
|
+
async signalWithStart(target, args, o, topic, value) {
|
|
262
|
+
await ready();
|
|
263
|
+
const h = await client.workflow.signalWithStart(target.flow, {
|
|
264
|
+
...startOptions(target, args, o),
|
|
265
|
+
signal: SIGNAL,
|
|
266
|
+
signalArgs: [{ topic, value }]
|
|
267
|
+
});
|
|
268
|
+
return wrap(h, ready);
|
|
269
|
+
},
|
|
270
|
+
handle: (id) => wrap(client.workflow.getHandle(id), ready),
|
|
271
|
+
close: () => (stopRenew(), connection.close())
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
export {
|
|
275
|
+
temporalClient,
|
|
276
|
+
temporalRuntime
|
|
277
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type Flow } from "@iworkflow/core";
|
|
2
|
+
export declare const SIGNAL = "iwf.signal";
|
|
3
|
+
export declare const QUERY = "iwf.query";
|
|
4
|
+
/** Очередь пула шагов модуля; воркер пула поднимает temporalRuntime. */
|
|
5
|
+
export declare const poolQueue: (app: string, pool: string) => string;
|
|
6
|
+
/** patches — как AppDefinition.patches: необъявленный id — ошибка, как у DBOS. */
|
|
7
|
+
export declare function temporalFlow(flow: Flow, patches?: string[]): (...args: unknown[]) => Promise<unknown>;
|
package/dist/workflow.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// src/workflow.ts
|
|
2
|
+
import {
|
|
3
|
+
condition,
|
|
4
|
+
defineQuery,
|
|
5
|
+
defineSignal,
|
|
6
|
+
deprecatePatch,
|
|
7
|
+
executeChild,
|
|
8
|
+
getExternalWorkflowHandle,
|
|
9
|
+
ParentClosePolicy,
|
|
10
|
+
patched,
|
|
11
|
+
proxyActivities,
|
|
12
|
+
setHandler,
|
|
13
|
+
sleep,
|
|
14
|
+
startChild,
|
|
15
|
+
TemporalFailure,
|
|
16
|
+
workflowInfo
|
|
17
|
+
} from "@temporalio/workflow";
|
|
18
|
+
import { checkPriority, DEFAULT_PRIORITY, FlowError as FlowError2 } from "@iworkflow/core";
|
|
19
|
+
|
|
20
|
+
// src/failure.ts
|
|
21
|
+
import {
|
|
22
|
+
ActivityFailure,
|
|
23
|
+
ApplicationFailure,
|
|
24
|
+
ChildWorkflowFailure,
|
|
25
|
+
TerminatedFailure,
|
|
26
|
+
TimeoutFailure,
|
|
27
|
+
WorkflowExecutionAlreadyStartedError
|
|
28
|
+
} from "@temporalio/common";
|
|
29
|
+
import { Failure, FlowError, isFlowError } from "@iworkflow/core";
|
|
30
|
+
function fromTemporal(e) {
|
|
31
|
+
if (e instanceof WorkflowExecutionAlreadyStartedError)
|
|
32
|
+
return new FlowError(`процесс ${e.workflowId} уже есть`, { reason: "already-started" });
|
|
33
|
+
if (e instanceof ActivityFailure) {
|
|
34
|
+
const exhausted = e.retryState === "MAXIMUM_ATTEMPTS_REACHED";
|
|
35
|
+
const f = cause(e.cause, exhausted);
|
|
36
|
+
if (f && e.retryState === "TIMEOUT")
|
|
37
|
+
return new FlowError(f.message, { reason: "timeout", type: f.type, details: f.details });
|
|
38
|
+
return f ?? e;
|
|
39
|
+
}
|
|
40
|
+
if (e instanceof ChildWorkflowFailure)
|
|
41
|
+
return cause(e.cause, false) ?? e;
|
|
42
|
+
return e;
|
|
43
|
+
}
|
|
44
|
+
function cause(c, exhausted = false) {
|
|
45
|
+
if (c instanceof TimeoutFailure)
|
|
46
|
+
return new FlowError(c.message || "срок вышел", { reason: "timeout", exhausted });
|
|
47
|
+
if (c instanceof TerminatedFailure)
|
|
48
|
+
return new FlowError(c.message || "процесс прерван", { reason: "terminated" });
|
|
49
|
+
if (c instanceof ApplicationFailure)
|
|
50
|
+
return new FlowError(c.message, { reason: "failed", type: c.type ?? undefined, details: c.details?.[0], exhausted });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
function toTemporal(e, nonRetryable) {
|
|
54
|
+
if (e instanceof Failure)
|
|
55
|
+
return ApplicationFailure.create({
|
|
56
|
+
message: e.message,
|
|
57
|
+
type: e.type,
|
|
58
|
+
details: [e.details],
|
|
59
|
+
nonRetryable: nonRetryable || e.nonRetryable,
|
|
60
|
+
...e.retryAfterMs !== undefined ? { nextRetryDelay: Math.max(e.retryAfterMs, 1) } : {}
|
|
61
|
+
});
|
|
62
|
+
if (isFlowError(e))
|
|
63
|
+
return ApplicationFailure.create({ message: e.message, type: e.type ?? e.reason, details: [e.details], nonRetryable });
|
|
64
|
+
return e;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/workflow.ts
|
|
68
|
+
var SIGNAL = "iwf.signal";
|
|
69
|
+
var QUERY = "iwf.query";
|
|
70
|
+
var signalDef = defineSignal(SIGNAL);
|
|
71
|
+
var queryDef = defineQuery(QUERY);
|
|
72
|
+
var poolQueue = (app, pool) => `${app}@${pool}`;
|
|
73
|
+
var proxies = new Map;
|
|
74
|
+
function activitiesWith(opts = {}) {
|
|
75
|
+
const priority = checkPriority(opts.priority) ?? workflowInfo().priority?.priorityKey;
|
|
76
|
+
const key = JSON.stringify({ ...opts, priority });
|
|
77
|
+
let p = proxies.get(key);
|
|
78
|
+
if (!p) {
|
|
79
|
+
p = proxyActivities({
|
|
80
|
+
...opts.pool ? { taskQueue: poolQueue(workflowInfo().taskQueue, opts.pool) } : {},
|
|
81
|
+
startToCloseTimeout: opts.timeoutMs ?? 60000,
|
|
82
|
+
...opts.totalTimeoutMs ? { scheduleToCloseTimeout: opts.totalTimeoutMs } : {},
|
|
83
|
+
...opts.heartbeatTimeoutMs ? { heartbeatTimeout: opts.heartbeatTimeoutMs } : {},
|
|
84
|
+
...priority ? { priority: { priorityKey: priority } } : {},
|
|
85
|
+
retry: {
|
|
86
|
+
maximumAttempts: opts.attempts ?? 3,
|
|
87
|
+
initialInterval: Math.max(opts.retryIntervalMs ?? 200, 1),
|
|
88
|
+
backoffCoefficient: opts.backoff ?? 1,
|
|
89
|
+
...opts.maxRetryIntervalMs ? { maximumInterval: opts.maxRetryIntervalMs } : {},
|
|
90
|
+
...opts.nonRetryableTypes ? { nonRetryableErrorTypes: opts.nonRetryableTypes } : {}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
proxies.set(key, p);
|
|
94
|
+
}
|
|
95
|
+
return p;
|
|
96
|
+
}
|
|
97
|
+
var childOptions = (app, args, opts) => ({
|
|
98
|
+
taskQueue: app,
|
|
99
|
+
workflowId: opts.id,
|
|
100
|
+
args,
|
|
101
|
+
...opts.timeoutMs ? { workflowExecutionTimeout: opts.timeoutMs } : {},
|
|
102
|
+
...opts.onExisting === "reject" ? { workflowIdReusePolicy: "REJECT_DUPLICATE" } : {}
|
|
103
|
+
});
|
|
104
|
+
var rethrow = (e) => {
|
|
105
|
+
throw fromTemporal(e);
|
|
106
|
+
};
|
|
107
|
+
function temporalFlow(flow, patches = []) {
|
|
108
|
+
return async (...args) => {
|
|
109
|
+
const inbox = new Map;
|
|
110
|
+
const state = new Map;
|
|
111
|
+
setHandler(signalDef, ({ topic, value }) => {
|
|
112
|
+
const q = inbox.get(topic) ?? [];
|
|
113
|
+
q.push(value);
|
|
114
|
+
inbox.set(topic, q);
|
|
115
|
+
});
|
|
116
|
+
setHandler(queryDef, (key) => state.get(key));
|
|
117
|
+
const info = workflowInfo();
|
|
118
|
+
const ctx = {
|
|
119
|
+
workflowId: info.workflowId,
|
|
120
|
+
parentId: info.parent?.workflowId,
|
|
121
|
+
startedAt: info.startTime,
|
|
122
|
+
priority: info.priority?.priorityKey || DEFAULT_PRIORITY,
|
|
123
|
+
now: async () => Date.now(),
|
|
124
|
+
step: (name, stepArgs, opts) => activitiesWith(opts)[name](...stepArgs).catch(rethrow),
|
|
125
|
+
child: (target, childArgs, opts) => executeChild(target.flow, childOptions(target.app, childArgs, opts)).catch(rethrow),
|
|
126
|
+
async startChild(target, childArgs, opts) {
|
|
127
|
+
const h = await startChild(target.flow, {
|
|
128
|
+
...childOptions(target.app, childArgs, { ...opts, onExisting: "reject" }),
|
|
129
|
+
parentClosePolicy: ParentClosePolicy.ABANDON
|
|
130
|
+
}).catch(rethrow);
|
|
131
|
+
return { id: opts.id, result: () => h.result().catch(rethrow) };
|
|
132
|
+
},
|
|
133
|
+
sleep: (ms) => sleep(ms),
|
|
134
|
+
async recv(topic, timeoutMs) {
|
|
135
|
+
const ready = await condition(() => (inbox.get(topic)?.length ?? 0) > 0, timeoutMs);
|
|
136
|
+
return ready ? inbox.get(topic).shift() : null;
|
|
137
|
+
},
|
|
138
|
+
signal: (workflowId, topic, value) => getExternalWorkflowHandle(workflowId).signal(signalDef, { topic, value }).catch((e) => {
|
|
139
|
+
if (!(e instanceof TemporalFailure))
|
|
140
|
+
throw e;
|
|
141
|
+
throw new FlowError2(`процесса ${workflowId} нет или он завершён`, { reason: "not-found" });
|
|
142
|
+
}),
|
|
143
|
+
async setState(key, value) {
|
|
144
|
+
state.set(key, value);
|
|
145
|
+
},
|
|
146
|
+
patched: async (id) => {
|
|
147
|
+
if (!patches.includes(id))
|
|
148
|
+
throw new Error(`патч "${id}" не объявлен в patches процесса`);
|
|
149
|
+
return patched(id);
|
|
150
|
+
},
|
|
151
|
+
deprecatePatch: async (id) => deprecatePatch(id)
|
|
152
|
+
};
|
|
153
|
+
try {
|
|
154
|
+
return await flow(ctx, ...args);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
throw toTemporal(e, true);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
export {
|
|
161
|
+
QUERY,
|
|
162
|
+
SIGNAL,
|
|
163
|
+
poolQueue,
|
|
164
|
+
temporalFlow
|
|
165
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iworkflow/temporal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Temporal adapter for @iworkflow/core",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"workflow",
|
|
@@ -13,23 +13,35 @@
|
|
|
13
13
|
"directory": "packages/temporal"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
|
-
"src"
|
|
16
|
+
"src",
|
|
17
|
+
"!src/**/*.test.ts",
|
|
18
|
+
"dist"
|
|
17
19
|
],
|
|
18
20
|
"type": "module",
|
|
19
21
|
"sideEffects": false,
|
|
20
22
|
"exports": {
|
|
21
|
-
".":
|
|
22
|
-
|
|
23
|
+
".": {
|
|
24
|
+
"bun": "./src/index.ts",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./workflow": {
|
|
29
|
+
"bun": "./src/workflow.ts",
|
|
30
|
+
"types": "./dist/workflow.d.ts",
|
|
31
|
+
"default": "./dist/workflow.js"
|
|
32
|
+
}
|
|
23
33
|
},
|
|
24
34
|
"publishConfig": {
|
|
25
35
|
"access": "public"
|
|
26
36
|
},
|
|
27
37
|
"scripts": {
|
|
38
|
+
"build": "rm -rf dist && bun build src/index.ts src/workflow.ts --outdir dist --target node --format esm --packages external && tsc -p tsconfig.build.json",
|
|
28
39
|
"type-check": "tsc --noEmit -p tsconfig.json",
|
|
29
|
-
"test": "bun test"
|
|
40
|
+
"test": "bun test",
|
|
41
|
+
"prepack": "bun run build"
|
|
30
42
|
},
|
|
31
43
|
"dependencies": {
|
|
32
|
-
"@iworkflow/core": "^0.
|
|
44
|
+
"@iworkflow/core": "^0.5.0"
|
|
33
45
|
},
|
|
34
46
|
"peerDependencies": {
|
|
35
47
|
"@temporalio/activity": "^1.24.0",
|
|
@@ -44,5 +56,8 @@
|
|
|
44
56
|
"@temporalio/common": "^1.24.0",
|
|
45
57
|
"@temporalio/worker": "^1.24.0",
|
|
46
58
|
"@temporalio/workflow": "^1.24.0"
|
|
59
|
+
},
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": ">=22"
|
|
47
62
|
}
|
|
48
63
|
}
|
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
|
@@ -41,6 +41,8 @@ export function toTemporal(e: unknown, nonRetryable: boolean): unknown {
|
|
|
41
41
|
type: e.type,
|
|
42
42
|
details: [e.details],
|
|
43
43
|
nonRetryable: nonRetryable || e.nonRetryable,
|
|
44
|
+
// 0 сервер считает «не задано» и берёт паузу политики; 1 мс — «сразу», как у DBOS.
|
|
45
|
+
...(e.retryAfterMs !== undefined ? { nextRetryDelay: Math.max(e.retryAfterMs, 1) } : {}),
|
|
44
46
|
});
|
|
45
47
|
if (isFlowError(e))
|
|
46
48
|
return ApplicationFailure.create({ message: e.message, type: e.type ?? e.reason, details: [e.details], nonRetryable });
|
package/src/index.ts
CHANGED
|
@@ -4,13 +4,18 @@ import { Context } from "@temporalio/activity";
|
|
|
4
4
|
import { NativeConnection, Worker } from "@temporalio/worker";
|
|
5
5
|
import {
|
|
6
6
|
bindStepRuntime,
|
|
7
|
+
checkPriority,
|
|
8
|
+
DEFAULT_PRIORITY,
|
|
9
|
+
exitSignal,
|
|
7
10
|
FlowError,
|
|
8
11
|
type Activity,
|
|
9
12
|
type AppDefinition,
|
|
10
13
|
type EngineClient,
|
|
11
14
|
type EngineRuntime,
|
|
12
15
|
type FlowHandle,
|
|
16
|
+
type FlowRef,
|
|
13
17
|
type FlowStatus,
|
|
18
|
+
type StartOptions,
|
|
14
19
|
} from "@iworkflow/core";
|
|
15
20
|
import { address, connectOptions, namespace, renewApiKey, type TemporalOptions } from "./connection";
|
|
16
21
|
import { cause, toTemporal } from "./failure";
|
|
@@ -37,19 +42,32 @@ const notFound = (id: string) => (e: unknown): never => {
|
|
|
37
42
|
throw e;
|
|
38
43
|
};
|
|
39
44
|
|
|
40
|
-
|
|
45
|
+
/** ready — токен ленивого клиента: без него SDK уйдёт с пустым ключом. */
|
|
46
|
+
const wrap = <T>(h: WorkflowHandle, ready: () => Promise<unknown> = async () => {}): FlowHandle<T> => ({
|
|
41
47
|
id: h.workflowId,
|
|
42
|
-
result: () =>
|
|
43
|
-
|
|
48
|
+
result: async () => {
|
|
49
|
+
await ready();
|
|
50
|
+
return h.result().catch((e) => {
|
|
44
51
|
throw (e instanceof WorkflowFailedError && cause(e.cause)) || e;
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
signal: async (topic, value) => {
|
|
55
|
+
await ready();
|
|
56
|
+
await h.signal(SIGNAL, { topic, value });
|
|
57
|
+
},
|
|
58
|
+
query: async <V>(key: string) => {
|
|
59
|
+
await ready();
|
|
60
|
+
return ((await h.query(QUERY, key)) ?? undefined) as V | undefined;
|
|
61
|
+
},
|
|
48
62
|
async describe() {
|
|
63
|
+
await ready();
|
|
49
64
|
const d = await h.describe().catch(notFound(h.workflowId));
|
|
50
65
|
return { id: h.workflowId, status: STATUS[d.status.name] ?? "running", startedAt: d.startTime };
|
|
51
66
|
},
|
|
52
|
-
|
|
67
|
+
async terminate(reason) {
|
|
68
|
+
await ready();
|
|
69
|
+
await h.terminate(reason).catch(notFound(h.workflowId));
|
|
70
|
+
},
|
|
53
71
|
});
|
|
54
72
|
|
|
55
73
|
/** Шаги отдают Failure как ApplicationFailure: тип, детали и nonRetryable доезжают до процесса. */
|
|
@@ -79,27 +97,33 @@ export function temporalRuntime(
|
|
|
79
97
|
let running: Promise<void>[] = [];
|
|
80
98
|
let conn: NativeConnection | undefined;
|
|
81
99
|
let starting = false;
|
|
100
|
+
let stopping = false;
|
|
101
|
+
const exit = exitSignal();
|
|
82
102
|
let stopRenew = () => {};
|
|
83
103
|
return {
|
|
84
104
|
async start(def: AppDefinition) {
|
|
85
105
|
if (starting) throw new Error("воркер уже запущен");
|
|
86
106
|
starting = true;
|
|
107
|
+
exit.arm();
|
|
108
|
+
stopping = false;
|
|
87
109
|
bindStepRuntime({
|
|
88
110
|
heartbeat: async (details) => Context.current().heartbeat(details),
|
|
89
111
|
lastHeartbeat: async () => Context.current().info.heartbeatDetails,
|
|
112
|
+
attempt: () => Context.current().info.attempt,
|
|
90
113
|
});
|
|
91
114
|
let c: NativeConnection;
|
|
92
115
|
try {
|
|
93
116
|
c = conn = await NativeConnection.connect(await connectOptions(opts));
|
|
94
117
|
} catch (e) {
|
|
95
118
|
starting = false;
|
|
119
|
+
exit.reject(e);
|
|
96
120
|
throw e;
|
|
97
121
|
}
|
|
98
122
|
stopRenew = renewApiKey(opts, (t) => c.setApiKey(t));
|
|
99
123
|
// Воркер не создался (нет бандла, namespace) — соединение и таймер токена закрываются.
|
|
100
124
|
try {
|
|
101
125
|
const activities = withFailures(def.activities);
|
|
102
|
-
const base = { connection: c, namespace: namespace(opts) };
|
|
126
|
+
const base = { connection: c, namespace: namespace(opts), ...(opts.plugins ? { plugins: opts.plugins } : {}) };
|
|
103
127
|
const code = opts.workflowBundlePath ? { workflowBundle: { codePath: opts.workflowBundlePath } } : { workflowsPath };
|
|
104
128
|
workers = [await Worker.create({ ...base, taskQueue: def.app, ...code, activities })];
|
|
105
129
|
// Пул — своя очередь с пределом одновременных шагов в этом процессе.
|
|
@@ -124,11 +148,19 @@ export function temporalRuntime(
|
|
|
124
148
|
conn = undefined;
|
|
125
149
|
workers = [];
|
|
126
150
|
starting = false;
|
|
151
|
+
exit.reject(e);
|
|
127
152
|
throw e;
|
|
128
153
|
}
|
|
129
154
|
running = workers.map((w) => w.run());
|
|
155
|
+
// Воркер упал сам — done() отклоняется сразу, не дожидаясь stop.
|
|
156
|
+
// Отказ run() во время stop (срок дренажа) — не падение: done() решает stop.
|
|
157
|
+
// Воркеры погасил сам Runtime (SIGTERM) без stop — тоже конец цикла.
|
|
158
|
+
void Promise.all(running).then(() => stopping || exit.resolve(), () => {});
|
|
159
|
+
for (const r of running) r.catch((e) => stopping || exit.reject(e));
|
|
130
160
|
},
|
|
161
|
+
done: () => exit.promise(),
|
|
131
162
|
async stop() {
|
|
163
|
+
stopping = true;
|
|
132
164
|
for (const w of workers) if (w.getState() === "RUNNING") w.shutdown();
|
|
133
165
|
// Упавший воркер не мешает закрыть соединение и таймер токена.
|
|
134
166
|
const done = await Promise.allSettled(running);
|
|
@@ -140,6 +172,7 @@ export function temporalRuntime(
|
|
|
140
172
|
workers = [];
|
|
141
173
|
running = [];
|
|
142
174
|
starting = false;
|
|
175
|
+
exit.resolve();
|
|
143
176
|
}
|
|
144
177
|
const failed = done.find((r) => r.status === "rejected");
|
|
145
178
|
if (failed) throw (failed as PromiseRejectedResult).reason;
|
|
@@ -147,22 +180,59 @@ export function temporalRuntime(
|
|
|
147
180
|
};
|
|
148
181
|
}
|
|
149
182
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
183
|
+
/** lazy — соединение при первом вызове: модуль поднимается и без Temporal. */
|
|
184
|
+
export async function temporalClient(opts: TemporalOptions & { lazy?: boolean } = {}): Promise<EngineClient> {
|
|
185
|
+
let connection: Connection;
|
|
186
|
+
let stopRenew: () => void;
|
|
187
|
+
let ready = async (): Promise<unknown> => undefined;
|
|
188
|
+
const plugins = opts.plugins ? { plugins: opts.plugins } : {};
|
|
189
|
+
if (opts.lazy) {
|
|
190
|
+
// Ни Temporal, ни источник токена не нужны для старта: ключ — функцией, берётся при первом вызове.
|
|
191
|
+
let token = "";
|
|
192
|
+
const apiKey = opts.apiKey;
|
|
193
|
+
// Неудача не залипает: следующий вызов спросит токен снова.
|
|
194
|
+
// Один запрос на всех ждущих; неудача не залипает: следующий вызов спросит снова.
|
|
195
|
+
let pending: Promise<string> | undefined;
|
|
196
|
+
if (apiKey)
|
|
197
|
+
ready = async () =>
|
|
198
|
+
token ||
|
|
199
|
+
(token = await (pending ??= apiKey().finally(() => (pending = undefined))));
|
|
200
|
+
connection = Connection.lazy({
|
|
201
|
+
...(await connectOptions({ ...opts, apiKey: undefined })),
|
|
202
|
+
...(apiKey ? { apiKey: () => token } : {}),
|
|
203
|
+
...plugins,
|
|
204
|
+
});
|
|
205
|
+
stopRenew = renewApiKey(opts, (t) => (token = t));
|
|
206
|
+
} else {
|
|
207
|
+
connection = await Connection.connect({ ...(await connectOptions(opts)), ...plugins });
|
|
208
|
+
stopRenew = renewApiKey(opts, (t) => connection.setApiKey(t));
|
|
209
|
+
}
|
|
210
|
+
// Плагины — у соединения: оно само передаёт их клиенту.
|
|
153
211
|
const client = new Client({ connection, namespace: namespace(opts) });
|
|
212
|
+
const startOptions = (target: FlowRef, args: unknown[], o: StartOptions) => ({
|
|
213
|
+
taskQueue: target.app,
|
|
214
|
+
workflowId: o.id,
|
|
215
|
+
args,
|
|
216
|
+
...(o.timeoutMs ? { workflowExecutionTimeout: o.timeoutMs } : {}),
|
|
217
|
+
// Явно и по умолчанию: ctx.priority и очередь видят одно и то же, как у DBOS.
|
|
218
|
+
priority: { priorityKey: checkPriority(o.priority) ?? DEFAULT_PRIORITY },
|
|
219
|
+
});
|
|
154
220
|
return {
|
|
155
|
-
async start(target, args,
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
221
|
+
async start(target, args, o) {
|
|
222
|
+
await ready();
|
|
223
|
+
const h = await client.workflow.start(target.flow, { ...startOptions(target, args, o), workflowIdConflictPolicy: "USE_EXISTING" });
|
|
224
|
+
return wrap(h, ready);
|
|
225
|
+
},
|
|
226
|
+
async signalWithStart(target, args, o, topic, value) {
|
|
227
|
+
await ready();
|
|
228
|
+
const h = await client.workflow.signalWithStart(target.flow, {
|
|
229
|
+
...startOptions(target, args, o),
|
|
230
|
+
signal: SIGNAL,
|
|
231
|
+
signalArgs: [{ topic, value }],
|
|
162
232
|
});
|
|
163
|
-
return wrap(h);
|
|
233
|
+
return wrap(h, ready);
|
|
164
234
|
},
|
|
165
|
-
handle: (id) => wrap(client.workflow.getHandle(id)),
|
|
235
|
+
handle: (id) => wrap(client.workflow.getHandle(id), ready),
|
|
166
236
|
close: () => (stopRenew(), connection.close()),
|
|
167
237
|
};
|
|
168
238
|
}
|
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>,
|
package/src/connection.test.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { expect, test } from "bun:test";
|
|
2
|
-
import { connectOptions, renewApiKey } from "./connection";
|
|
3
|
-
|
|
4
|
-
test("токен берётся при подключении и меняется по таймеру", async () => {
|
|
5
|
-
let n = 0;
|
|
6
|
-
const apiKey = async () => `t${++n}`;
|
|
7
|
-
expect(await connectOptions({ address: "a:1", tls: true, apiKey })).toEqual({ address: "a:1", tls: true, apiKey: "t1" });
|
|
8
|
-
const got: string[] = [];
|
|
9
|
-
const stop = renewApiKey({ apiKey, apiKeyRenewMs: 10 }, (t) => got.push(t));
|
|
10
|
-
while (got.length < 2) await Bun.sleep(5);
|
|
11
|
-
stop();
|
|
12
|
-
const seen = got.length;
|
|
13
|
-
await Bun.sleep(25);
|
|
14
|
-
expect(seen).toBeGreaterThanOrEqual(2);
|
|
15
|
-
expect(got.length).toBe(seen);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
test("без токена и TLS — только адрес", async () => {
|
|
19
|
-
expect(await connectOptions({ address: "a:1" })).toEqual({ address: "a:1" });
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test("медленный запрос не затирает свежий токен, после остановки токен не ставится", async () => {
|
|
23
|
-
let release!: (t: string) => void;
|
|
24
|
-
let calls = 0;
|
|
25
|
-
const apiKey = () => (++calls === 1 ? new Promise<string>((r) => (release = r)) : Promise.resolve(`t${calls}`));
|
|
26
|
-
const got: string[] = [];
|
|
27
|
-
const stop = renewApiKey({ apiKey, apiKeyRenewMs: 5 }, (t) => got.push(t));
|
|
28
|
-
await Bun.sleep(40);
|
|
29
|
-
expect(calls).toBe(1);
|
|
30
|
-
stop();
|
|
31
|
-
release("old");
|
|
32
|
-
await Bun.sleep(5);
|
|
33
|
-
expect(got).toEqual([]);
|
|
34
|
-
});
|