@iworkflow/temporal 0.1.0 → 0.2.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/package.json +4 -2
- package/src/failure.ts +48 -0
- package/src/index.ts +71 -15
- package/src/workflow.ts +56 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iworkflow/temporal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Temporal adapter for @iworkflow/core",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"workflow",
|
|
@@ -29,17 +29,19 @@
|
|
|
29
29
|
"test": "bun test"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@iworkflow/core": "^0.
|
|
32
|
+
"@iworkflow/core": "^0.2.0"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
35
|
"@temporalio/activity": "^1.24.0",
|
|
36
36
|
"@temporalio/client": "^1.24.0",
|
|
37
|
+
"@temporalio/common": "^1.24.0",
|
|
37
38
|
"@temporalio/worker": "^1.24.0",
|
|
38
39
|
"@temporalio/workflow": "^1.24.0"
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
|
41
42
|
"@temporalio/activity": "^1.24.0",
|
|
42
43
|
"@temporalio/client": "^1.24.0",
|
|
44
|
+
"@temporalio/common": "^1.24.0",
|
|
43
45
|
"@temporalio/worker": "^1.24.0",
|
|
44
46
|
"@temporalio/workflow": "^1.24.0"
|
|
45
47
|
}
|
package/src/failure.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Отказы Temporal ↔ ошибки порта. Общее для песочницы и клиента: только @temporalio/common.
|
|
2
|
+
import {
|
|
3
|
+
ActivityFailure,
|
|
4
|
+
ApplicationFailure,
|
|
5
|
+
ChildWorkflowFailure,
|
|
6
|
+
TerminatedFailure,
|
|
7
|
+
TimeoutFailure,
|
|
8
|
+
WorkflowExecutionAlreadyStartedError,
|
|
9
|
+
} from "@temporalio/common";
|
|
10
|
+
import { Failure, FlowError, isFlowError } from "@iworkflow/core";
|
|
11
|
+
|
|
12
|
+
/** Отказ шага, дочернего процесса или процесса целиком — FlowError; прочее (отмена) — как есть. */
|
|
13
|
+
export function fromTemporal(e: unknown): unknown {
|
|
14
|
+
if (e instanceof WorkflowExecutionAlreadyStartedError)
|
|
15
|
+
return new FlowError(`процесс ${e.workflowId} уже есть`, { reason: "already-started" });
|
|
16
|
+
if (e instanceof ActivityFailure) {
|
|
17
|
+
const exhausted = e.retryState === "MAXIMUM_ATTEMPTS_REACHED";
|
|
18
|
+
const f = cause(e.cause, exhausted);
|
|
19
|
+
// Срок шага целиком (scheduleToClose) Temporal отдаёт последней ошибкой попытки, а не TimeoutFailure.
|
|
20
|
+
if (f && e.retryState === "TIMEOUT") return new FlowError(f.message, { reason: "timeout", type: f.type, details: f.details });
|
|
21
|
+
return f ?? e;
|
|
22
|
+
}
|
|
23
|
+
if (e instanceof ChildWorkflowFailure) return cause(e.cause, false) ?? e;
|
|
24
|
+
return e;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Причина отказа процесса или шага — FlowError; undefined — причина не наша (отмена и т. п.). */
|
|
28
|
+
export function cause(c: Error | undefined, exhausted = false): FlowError | undefined {
|
|
29
|
+
if (c instanceof TimeoutFailure) return new FlowError(c.message || "срок вышел", { reason: "timeout", exhausted });
|
|
30
|
+
if (c instanceof TerminatedFailure) return new FlowError(c.message || "процесс прерван", { reason: "terminated" });
|
|
31
|
+
if (c instanceof ApplicationFailure)
|
|
32
|
+
return new FlowError(c.message, { reason: "failed", type: c.type ?? undefined, details: c.details?.[0], exhausted });
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Отказ из кода порта — ApplicationFailure: обычная ошибка в песочнице не роняет процесс, а повторяет задачу. */
|
|
37
|
+
export function toTemporal(e: unknown, nonRetryable: boolean): unknown {
|
|
38
|
+
if (e instanceof Failure)
|
|
39
|
+
return ApplicationFailure.create({
|
|
40
|
+
message: e.message,
|
|
41
|
+
type: e.type,
|
|
42
|
+
details: [e.details],
|
|
43
|
+
nonRetryable: nonRetryable || e.nonRetryable,
|
|
44
|
+
});
|
|
45
|
+
if (isFlowError(e))
|
|
46
|
+
return ApplicationFailure.create({ message: e.message, type: e.type ?? e.reason, details: [e.details], nonRetryable });
|
|
47
|
+
return e;
|
|
48
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,27 +1,76 @@
|
|
|
1
1
|
// Temporal: воркер на очереди модуля (task queue = имя модуля), клиент — по имени процесса.
|
|
2
|
-
import { Client, Connection, type WorkflowHandle } from "@temporalio/client";
|
|
2
|
+
import { Client, Connection, WorkflowFailedError, WorkflowNotFoundError, type WorkflowHandle } from "@temporalio/client";
|
|
3
3
|
import { Context } from "@temporalio/activity";
|
|
4
4
|
import { NativeConnection, Worker } from "@temporalio/worker";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
bindStepRuntime,
|
|
7
|
+
FlowError,
|
|
8
|
+
type Activity,
|
|
9
|
+
type AppDefinition,
|
|
10
|
+
type EngineClient,
|
|
11
|
+
type EngineRuntime,
|
|
12
|
+
type FlowHandle,
|
|
13
|
+
type FlowStatus,
|
|
14
|
+
} from "@iworkflow/core";
|
|
15
|
+
import { cause, toTemporal } from "./failure";
|
|
6
16
|
|
|
7
17
|
// Те же имена, что в workflow.ts: его нельзя импортировать вне песочницы.
|
|
8
18
|
const SIGNAL = "iwf.signal";
|
|
9
19
|
const QUERY = "iwf.query";
|
|
20
|
+
const poolQueue = (app: string, pool: string) => `${app}@${pool}`;
|
|
21
|
+
|
|
22
|
+
const STATUS: Record<string, FlowStatus> = {
|
|
23
|
+
RUNNING: "running",
|
|
24
|
+
COMPLETED: "completed",
|
|
25
|
+
FAILED: "failed",
|
|
26
|
+
CANCELLED: "terminated",
|
|
27
|
+
TERMINATED: "terminated",
|
|
28
|
+
TIMED_OUT: "timed-out",
|
|
29
|
+
CONTINUED_AS_NEW: "running",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const notFound = (id: string) => (e: unknown): never => {
|
|
33
|
+
if (e instanceof WorkflowNotFoundError) throw new FlowError(`процесса ${id} нет`, { reason: "not-found" });
|
|
34
|
+
throw e;
|
|
35
|
+
};
|
|
10
36
|
|
|
11
37
|
const wrap = <T>(h: WorkflowHandle): FlowHandle<T> => ({
|
|
12
38
|
id: h.workflowId,
|
|
13
|
-
result: () =>
|
|
39
|
+
result: () =>
|
|
40
|
+
h.result().catch((e) => {
|
|
41
|
+
throw (e instanceof WorkflowFailedError && cause(e.cause)) || e;
|
|
42
|
+
}),
|
|
14
43
|
signal: (topic, value) => h.signal(SIGNAL, { topic, value }),
|
|
15
44
|
query: async <V>(key: string) => ((await h.query(QUERY, key)) ?? undefined) as V | undefined,
|
|
45
|
+
async describe() {
|
|
46
|
+
const d = await h.describe().catch(notFound(h.workflowId));
|
|
47
|
+
return { id: h.workflowId, status: STATUS[d.status.name] ?? "running", startedAt: d.startTime };
|
|
48
|
+
},
|
|
49
|
+
terminate: async (reason) => void (await h.terminate(reason).catch(notFound(h.workflowId))),
|
|
16
50
|
});
|
|
17
51
|
|
|
18
52
|
const address = () => process.env.TEMPORAL_URL ?? "127.0.0.1:17233";
|
|
19
53
|
const namespace = () => process.env.TEMPORAL_NAMESPACE ?? "default";
|
|
20
54
|
|
|
55
|
+
/** Шаги отдают Failure как ApplicationFailure: тип, детали и nonRetryable доезжают до процесса. */
|
|
56
|
+
const withFailures = (activities: Record<string, Activity>) =>
|
|
57
|
+
Object.fromEntries(
|
|
58
|
+
Object.entries(activities).map(([name, fn]) => [
|
|
59
|
+
name,
|
|
60
|
+
async (...args: unknown[]) => {
|
|
61
|
+
try {
|
|
62
|
+
return await fn(...args);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
throw toTemporal(e, false);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
]),
|
|
68
|
+
);
|
|
69
|
+
|
|
21
70
|
/** workflowsPath — вход песочницы модуля: temporalFlow(...) на каждый процесс, имя экспорта = имя процесса. */
|
|
22
71
|
export function temporalRuntime(workflowsPath: string): EngineRuntime {
|
|
23
|
-
let
|
|
24
|
-
let running: Promise<void>
|
|
72
|
+
let workers: Worker[] = [];
|
|
73
|
+
let running: Promise<void>[] = [];
|
|
25
74
|
let conn: NativeConnection | undefined;
|
|
26
75
|
return {
|
|
27
76
|
async start(def: AppDefinition) {
|
|
@@ -30,18 +79,24 @@ export function temporalRuntime(workflowsPath: string): EngineRuntime {
|
|
|
30
79
|
lastHeartbeat: async () => Context.current().info.heartbeatDetails,
|
|
31
80
|
});
|
|
32
81
|
conn = await NativeConnection.connect({ address: address() });
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
82
|
+
const activities = withFailures(def.activities);
|
|
83
|
+
const base = { connection: conn, namespace: namespace() };
|
|
84
|
+
workers = [await Worker.create({ ...base, taskQueue: def.app, workflowsPath, activities })];
|
|
85
|
+
// Пул — своя очередь с пределом одновременных шагов в этом процессе.
|
|
86
|
+
for (const [pool, limit] of Object.entries(def.pools ?? {}))
|
|
87
|
+
workers.push(
|
|
88
|
+
await Worker.create({
|
|
89
|
+
...base,
|
|
90
|
+
taskQueue: poolQueue(def.app, pool),
|
|
91
|
+
activities,
|
|
92
|
+
maxConcurrentActivityTaskExecutions: limit,
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
running = workers.map((w) => w.run());
|
|
41
96
|
},
|
|
42
97
|
async stop() {
|
|
43
|
-
|
|
44
|
-
await running;
|
|
98
|
+
for (const w of workers) w.shutdown();
|
|
99
|
+
await Promise.all(running);
|
|
45
100
|
await conn?.close();
|
|
46
101
|
},
|
|
47
102
|
};
|
|
@@ -57,6 +112,7 @@ export async function temporalClient(): Promise<EngineClient> {
|
|
|
57
112
|
workflowId: opts.id,
|
|
58
113
|
args,
|
|
59
114
|
workflowIdConflictPolicy: "USE_EXISTING",
|
|
115
|
+
...(opts.timeoutMs ? { workflowExecutionTimeout: opts.timeoutMs } : {}),
|
|
60
116
|
});
|
|
61
117
|
return wrap(h);
|
|
62
118
|
},
|
package/src/workflow.ts
CHANGED
|
@@ -8,36 +8,63 @@ import {
|
|
|
8
8
|
deprecatePatch,
|
|
9
9
|
executeChild,
|
|
10
10
|
getExternalWorkflowHandle,
|
|
11
|
+
ParentClosePolicy,
|
|
11
12
|
patched,
|
|
12
13
|
proxyActivities,
|
|
13
14
|
setHandler,
|
|
14
15
|
sleep,
|
|
16
|
+
startChild,
|
|
17
|
+
TemporalFailure,
|
|
15
18
|
workflowInfo,
|
|
16
19
|
} from "@temporalio/workflow";
|
|
17
20
|
|
|
18
|
-
import type
|
|
21
|
+
import { FlowError, type ChildOptions, type Flow, type FlowContext, type StepOptions } from "@iworkflow/core";
|
|
22
|
+
import { fromTemporal, toTemporal } from "./failure";
|
|
19
23
|
|
|
20
24
|
export const SIGNAL = "iwf.signal";
|
|
21
25
|
export const QUERY = "iwf.query";
|
|
22
26
|
const signalDef = defineSignal<[{ topic: string; value: unknown }]>(SIGNAL);
|
|
23
27
|
const queryDef = defineQuery<unknown, [string]>(QUERY);
|
|
24
28
|
|
|
29
|
+
/** Очередь пула шагов модуля; воркер пула поднимает temporalRuntime. */
|
|
30
|
+
export const poolQueue = (app: string, pool: string) => `${app}@${pool}`;
|
|
31
|
+
|
|
25
32
|
const proxies = new Map<string, Record<string, (...a: unknown[]) => Promise<unknown>>>();
|
|
26
33
|
|
|
27
34
|
function activitiesWith(opts: StepOptions = {}) {
|
|
28
|
-
const key =
|
|
35
|
+
const key = JSON.stringify(opts);
|
|
29
36
|
let p = proxies.get(key);
|
|
30
37
|
if (!p) {
|
|
31
38
|
p = proxyActivities({
|
|
39
|
+
...(opts.pool ? { taskQueue: poolQueue(workflowInfo().taskQueue, opts.pool) } : {}),
|
|
32
40
|
startToCloseTimeout: opts.timeoutMs ?? 60_000,
|
|
41
|
+
...(opts.totalTimeoutMs ? { scheduleToCloseTimeout: opts.totalTimeoutMs } : {}),
|
|
33
42
|
...(opts.heartbeatTimeoutMs ? { heartbeatTimeout: opts.heartbeatTimeoutMs } : {}),
|
|
34
|
-
retry: {
|
|
43
|
+
retry: {
|
|
44
|
+
maximumAttempts: opts.attempts ?? 3,
|
|
45
|
+
initialInterval: opts.retryIntervalMs ?? 200,
|
|
46
|
+
backoffCoefficient: opts.backoff ?? 1,
|
|
47
|
+
...(opts.maxRetryIntervalMs ? { maximumInterval: opts.maxRetryIntervalMs } : {}),
|
|
48
|
+
...(opts.nonRetryableTypes ? { nonRetryableErrorTypes: opts.nonRetryableTypes } : {}),
|
|
49
|
+
},
|
|
35
50
|
});
|
|
36
51
|
proxies.set(key, p);
|
|
37
52
|
}
|
|
38
53
|
return p;
|
|
39
54
|
}
|
|
40
55
|
|
|
56
|
+
const childOptions = (app: string, args: unknown[], opts: ChildOptions) => ({
|
|
57
|
+
taskQueue: app,
|
|
58
|
+
workflowId: opts.id,
|
|
59
|
+
args,
|
|
60
|
+
...(opts.timeoutMs ? { workflowExecutionTimeout: opts.timeoutMs } : {}),
|
|
61
|
+
...(opts.onExisting === "reject" ? { workflowIdReusePolicy: "REJECT_DUPLICATE" as const } : {}),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const rethrow = (e: unknown): never => {
|
|
65
|
+
throw fromTemporal(e);
|
|
66
|
+
};
|
|
67
|
+
|
|
41
68
|
/** patches — как AppDefinition.patches: необъявленный id — ошибка, как у DBOS. */
|
|
42
69
|
export function temporalFlow(flow: Flow, patches: string[] = []) {
|
|
43
70
|
return async (...args: unknown[]) => {
|
|
@@ -51,17 +78,34 @@ export function temporalFlow(flow: Flow, patches: string[] = []) {
|
|
|
51
78
|
});
|
|
52
79
|
setHandler(queryDef, (key) => state.get(key));
|
|
53
80
|
|
|
81
|
+
const info = workflowInfo();
|
|
54
82
|
const ctx: FlowContext = {
|
|
55
|
-
workflowId:
|
|
56
|
-
|
|
83
|
+
workflowId: info.workflowId,
|
|
84
|
+
parentId: info.parent?.workflowId,
|
|
85
|
+
startedAt: info.startTime,
|
|
86
|
+
step: (name, stepArgs, opts) => activitiesWith(opts)[name]!(...stepArgs).catch(rethrow) as Promise<never>,
|
|
57
87
|
child: (target, childArgs, opts) =>
|
|
58
|
-
executeChild(target.flow,
|
|
88
|
+
executeChild(target.flow, childOptions(target.app, childArgs, opts)).catch(rethrow) as Promise<never>,
|
|
89
|
+
async startChild(target, childArgs, opts) {
|
|
90
|
+
// REJECT_DUPLICATE: завершённый id тоже занят — как у DBOS, где id не переиспользуется.
|
|
91
|
+
const h = await startChild(target.flow, {
|
|
92
|
+
...childOptions(target.app, childArgs, { ...opts, onExisting: "reject" }),
|
|
93
|
+
parentClosePolicy: ParentClosePolicy.ABANDON,
|
|
94
|
+
}).catch(rethrow);
|
|
95
|
+
return { id: opts.id, result: () => h.result().catch(rethrow) as Promise<never> };
|
|
96
|
+
},
|
|
59
97
|
sleep: (ms) => sleep(ms),
|
|
60
98
|
async recv<T>(topic: string, timeoutMs: number) {
|
|
61
99
|
const ready = await condition(() => (inbox.get(topic)?.length ?? 0) > 0, timeoutMs);
|
|
62
100
|
return ready ? (inbox.get(topic)!.shift() as T) : null;
|
|
63
101
|
},
|
|
64
|
-
signal: (workflowId, topic, value) =>
|
|
102
|
+
signal: (workflowId, topic, value) =>
|
|
103
|
+
getExternalWorkflowHandle(workflowId)
|
|
104
|
+
.signal(signalDef, { topic, value })
|
|
105
|
+
.catch((e) => {
|
|
106
|
+
if (!(e instanceof TemporalFailure)) throw e;
|
|
107
|
+
throw new FlowError(`процесса ${workflowId} нет или он завершён`, { reason: "not-found" });
|
|
108
|
+
}),
|
|
65
109
|
async setState(key, value) {
|
|
66
110
|
state.set(key, value);
|
|
67
111
|
},
|
|
@@ -71,6 +115,10 @@ export function temporalFlow(flow: Flow, patches: string[] = []) {
|
|
|
71
115
|
},
|
|
72
116
|
deprecatePatch: async (id) => deprecatePatch(id),
|
|
73
117
|
};
|
|
74
|
-
|
|
118
|
+
try {
|
|
119
|
+
return await flow(ctx, ...args);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
throw toTemporal(e, true);
|
|
122
|
+
}
|
|
75
123
|
};
|
|
76
124
|
}
|