@iworkflow/temporal 0.2.0 → 0.3.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 +4 -3
- package/package.json +2 -2
- package/src/connection.test.ts +34 -0
- package/src/connection.ts +45 -0
- package/src/index.ts +72 -26
package/README.md
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Temporal для `@iworkflow/core`. Peer: `@temporalio/{client,worker,workflow,activity}` ^1.24.
|
|
4
4
|
|
|
5
|
-
- `temporalRuntime(workflowsPath).start(def)` — воркер на task queue `def.app
|
|
6
|
-
- `temporalClient()` — старт, сигнал, query по id.
|
|
5
|
+
- `temporalRuntime(workflowsPath, opts?).start(def)` — воркер на task queue `def.app`; `opts.workflowBundlePath` — готовый бандл прод-сборки.
|
|
6
|
+
- `temporalClient(opts?)` — старт, сигнал, query по id.
|
|
7
7
|
- `@iworkflow/temporal/workflow` — только для входа песочницы модуля:
|
|
8
8
|
`export const outreach = temporalFlow(flows.outreach, ["send-twice"])`.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
`opts` (`TemporalOptions`): `address`, `namespace`, `tls`, `apiKey` — источник токена, меняется каждые `apiKeyRenewMs`.
|
|
11
|
+
Без них — переменные `TEMPORAL_URL` (по умолчанию `127.0.0.1:17233`), `TEMPORAL_NAMESPACE`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iworkflow/temporal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Temporal adapter for @iworkflow/core",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"workflow",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"test": "bun test"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@iworkflow/core": "^0.
|
|
32
|
+
"@iworkflow/core": "^0.3.0"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
35
|
"@temporalio/activity": "^1.24.0",
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Подключение к Temporal: адрес, TLS, токен с заменой на месте — общее для воркера и клиента.
|
|
2
|
+
import type { TLSConfig } from "@temporalio/client";
|
|
3
|
+
|
|
4
|
+
/** Подключение к Temporal; не задано — TEMPORAL_URL и TEMPORAL_NAMESPACE из окружения. */
|
|
5
|
+
export interface TemporalOptions {
|
|
6
|
+
address?: string;
|
|
7
|
+
namespace?: string;
|
|
8
|
+
/** TLSConfig SDK (serverNameOverride, свой CA, mTLS). С apiKey SDK включает TLS сам — без TLS только `false`. */
|
|
9
|
+
tls?: boolean | TLSConfig;
|
|
10
|
+
/** Источник токена; соединение берёт свежий каждые apiKeyRenewMs (по умолчанию 60 с). */
|
|
11
|
+
apiKey?: () => Promise<string>;
|
|
12
|
+
apiKeyRenewMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const address = (o: TemporalOptions) => o.address ?? process.env.TEMPORAL_URL ?? "127.0.0.1:17233";
|
|
16
|
+
export const namespace = (o: TemporalOptions) => o.namespace ?? process.env.TEMPORAL_NAMESPACE ?? "default";
|
|
17
|
+
|
|
18
|
+
/** Токен меняется на месте: иначе воркер однажды молча перестанет брать задачи. */
|
|
19
|
+
export function renewApiKey(o: TemporalOptions, set: (token: string) => unknown): () => void {
|
|
20
|
+
if (!o.apiKey) return () => {};
|
|
21
|
+
const apiKey = o.apiKey;
|
|
22
|
+
// По одному запросу за раз: поздний ответ не затрёт свежий токен, после остановки токен не ставится.
|
|
23
|
+
let busy = false;
|
|
24
|
+
let stopped = false;
|
|
25
|
+
const timer = setInterval(() => {
|
|
26
|
+
if (busy) return;
|
|
27
|
+
busy = true;
|
|
28
|
+
apiKey()
|
|
29
|
+
.then((t) => (stopped ? undefined : set(t)))
|
|
30
|
+
.catch((e: unknown) => stopped || console.error("токен Temporal не обновлён:", e))
|
|
31
|
+
.finally(() => (busy = false));
|
|
32
|
+
}, o.apiKeyRenewMs ?? 60_000);
|
|
33
|
+
timer.unref?.();
|
|
34
|
+
return () => {
|
|
35
|
+
stopped = true;
|
|
36
|
+
clearInterval(timer);
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const connectOptions = async (o: TemporalOptions) => ({
|
|
41
|
+
address: address(o),
|
|
42
|
+
...(o.tls !== undefined ? { tls: o.tls } : {}),
|
|
43
|
+
...(o.apiKey ? { apiKey: await o.apiKey() } : {}),
|
|
44
|
+
});
|
|
45
|
+
|
package/src/index.ts
CHANGED
|
@@ -12,8 +12,11 @@ import {
|
|
|
12
12
|
type FlowHandle,
|
|
13
13
|
type FlowStatus,
|
|
14
14
|
} from "@iworkflow/core";
|
|
15
|
+
import { address, connectOptions, namespace, renewApiKey, type TemporalOptions } from "./connection";
|
|
15
16
|
import { cause, toTemporal } from "./failure";
|
|
16
17
|
|
|
18
|
+
export type { TemporalOptions };
|
|
19
|
+
|
|
17
20
|
// Те же имена, что в workflow.ts: его нельзя импортировать вне песочницы.
|
|
18
21
|
const SIGNAL = "iwf.signal";
|
|
19
22
|
const QUERY = "iwf.query";
|
|
@@ -49,9 +52,6 @@ const wrap = <T>(h: WorkflowHandle): FlowHandle<T> => ({
|
|
|
49
52
|
terminate: async (reason) => void (await h.terminate(reason).catch(notFound(h.workflowId))),
|
|
50
53
|
});
|
|
51
54
|
|
|
52
|
-
const address = () => process.env.TEMPORAL_URL ?? "127.0.0.1:17233";
|
|
53
|
-
const namespace = () => process.env.TEMPORAL_NAMESPACE ?? "default";
|
|
54
|
-
|
|
55
55
|
/** Шаги отдают Failure как ApplicationFailure: тип, детали и nonRetryable доезжают до процесса. */
|
|
56
56
|
const withFailures = (activities: Record<string, Activity>) =>
|
|
57
57
|
Object.fromEntries(
|
|
@@ -67,44 +67,90 @@ const withFailures = (activities: Record<string, Activity>) =>
|
|
|
67
67
|
]),
|
|
68
68
|
);
|
|
69
69
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
70
|
+
/**
|
|
71
|
+
* workflowsPath — вход песочницы модуля: temporalFlow(...) на каждый процесс, имя экспорта = имя процесса.
|
|
72
|
+
* workflowBundlePath — готовый бандл того же входа (прод-сборка); с ним воркер не собирает код сам.
|
|
73
|
+
*/
|
|
74
|
+
export function temporalRuntime(
|
|
75
|
+
workflowsPath: string,
|
|
76
|
+
opts: TemporalOptions & { workflowBundlePath?: string } = {},
|
|
77
|
+
): EngineRuntime {
|
|
72
78
|
let workers: Worker[] = [];
|
|
73
79
|
let running: Promise<void>[] = [];
|
|
74
80
|
let conn: NativeConnection | undefined;
|
|
81
|
+
let starting = false;
|
|
82
|
+
let stopRenew = () => {};
|
|
75
83
|
return {
|
|
76
84
|
async start(def: AppDefinition) {
|
|
85
|
+
if (starting) throw new Error("воркер уже запущен");
|
|
86
|
+
starting = true;
|
|
77
87
|
bindStepRuntime({
|
|
78
88
|
heartbeat: async (details) => Context.current().heartbeat(details),
|
|
79
89
|
lastHeartbeat: async () => Context.current().info.heartbeatDetails,
|
|
80
90
|
});
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
);
|
|
91
|
+
let c: NativeConnection;
|
|
92
|
+
try {
|
|
93
|
+
c = conn = await NativeConnection.connect(await connectOptions(opts));
|
|
94
|
+
} catch (e) {
|
|
95
|
+
starting = false;
|
|
96
|
+
throw e;
|
|
97
|
+
}
|
|
98
|
+
stopRenew = renewApiKey(opts, (t) => c.setApiKey(t));
|
|
99
|
+
// Воркер не создался (нет бандла, namespace) — соединение и таймер токена закрываются.
|
|
100
|
+
try {
|
|
101
|
+
const activities = withFailures(def.activities);
|
|
102
|
+
const base = { connection: c, namespace: namespace(opts) };
|
|
103
|
+
const code = opts.workflowBundlePath ? { workflowBundle: { codePath: opts.workflowBundlePath } } : { workflowsPath };
|
|
104
|
+
workers = [await Worker.create({ ...base, taskQueue: def.app, ...code, activities })];
|
|
105
|
+
// Пул — своя очередь с пределом одновременных шагов в этом процессе.
|
|
106
|
+
for (const [pool, limit] of Object.entries(def.pools ?? {}))
|
|
107
|
+
workers.push(
|
|
108
|
+
await Worker.create({
|
|
109
|
+
...base,
|
|
110
|
+
taskQueue: poolQueue(def.app, pool),
|
|
111
|
+
activities,
|
|
112
|
+
maxConcurrentActivityTaskExecutions: limit,
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
// Созданный, но не запущенный воркер держит соединение: запустить и сразу погасить.
|
|
117
|
+
await Promise.allSettled(workers.map(async (w) => {
|
|
118
|
+
const r = w.run();
|
|
119
|
+
w.shutdown();
|
|
120
|
+
await r;
|
|
121
|
+
}));
|
|
122
|
+
stopRenew();
|
|
123
|
+
await c.close().catch(() => {});
|
|
124
|
+
conn = undefined;
|
|
125
|
+
workers = [];
|
|
126
|
+
starting = false;
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
95
129
|
running = workers.map((w) => w.run());
|
|
96
130
|
},
|
|
97
131
|
async stop() {
|
|
98
|
-
for (const w of workers) w.shutdown();
|
|
99
|
-
|
|
100
|
-
await
|
|
132
|
+
for (const w of workers) if (w.getState() === "RUNNING") w.shutdown();
|
|
133
|
+
// Упавший воркер не мешает закрыть соединение и таймер токена.
|
|
134
|
+
const done = await Promise.allSettled(running);
|
|
135
|
+
stopRenew();
|
|
136
|
+
try {
|
|
137
|
+
await conn?.close();
|
|
138
|
+
} finally {
|
|
139
|
+
conn = undefined;
|
|
140
|
+
workers = [];
|
|
141
|
+
running = [];
|
|
142
|
+
starting = false;
|
|
143
|
+
}
|
|
144
|
+
const failed = done.find((r) => r.status === "rejected");
|
|
145
|
+
if (failed) throw (failed as PromiseRejectedResult).reason;
|
|
101
146
|
},
|
|
102
147
|
};
|
|
103
148
|
}
|
|
104
149
|
|
|
105
|
-
export async function temporalClient(): Promise<EngineClient> {
|
|
106
|
-
const connection = await Connection.connect(
|
|
107
|
-
const
|
|
150
|
+
export async function temporalClient(opts: TemporalOptions = {}): Promise<EngineClient> {
|
|
151
|
+
const connection = await Connection.connect(await connectOptions(opts));
|
|
152
|
+
const stopRenew = renewApiKey(opts, (t) => connection.setApiKey(t));
|
|
153
|
+
const client = new Client({ connection, namespace: namespace(opts) });
|
|
108
154
|
return {
|
|
109
155
|
async start(target, args, opts) {
|
|
110
156
|
const h = await client.workflow.start(target.flow, {
|
|
@@ -117,6 +163,6 @@ export async function temporalClient(): Promise<EngineClient> {
|
|
|
117
163
|
return wrap(h);
|
|
118
164
|
},
|
|
119
165
|
handle: (id) => wrap(client.workflow.getHandle(id)),
|
|
120
|
-
close: () => connection.close(),
|
|
166
|
+
close: () => (stopRenew(), connection.close()),
|
|
121
167
|
};
|
|
122
168
|
}
|