@voltro/workflow 0.24.0 → 0.26.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/CHANGELOG.md +670 -0
- package/dist/cluster-BQ0H5M9n.js +131 -0
- package/dist/cluster.d.ts +140 -13
- package/dist/cluster.js +2 -2
- package/dist/clusterTestSuite.d.ts +34 -0
- package/dist/clusterTestSuite.js +38 -30
- package/dist/index.d.ts +87 -29
- package/dist/index.js +2 -2
- package/dist/primitives-Dgu3O55Q.js +164 -0
- package/dist/primitives.d.ts +63 -28
- package/dist/primitives.js +1 -1
- package/dist/{src-ByK8t2Nv.js → src-KDx7NTsa.js} +111 -74
- package/package.json +3 -3
- package/dist/cluster-DuFweKq0.js +0 -58
- package/dist/primitives-CWy1iu5w.js +0 -111
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Context as e, Duration as t, Effect as n, Layer as r, Option as i } from "effect";
|
|
2
|
+
import { ClusterCron as a, ClusterSchema as o, ClusterWorkflowEngine as s, EntityAddress as c, EntityId as l, EntityType as u, MessageStorage as d, RunnerAddress as f, Sharding as p, SingleRunner as m } from "@effect/cluster";
|
|
3
|
+
import { SqlClient as h } from "@effect/sql";
|
|
4
|
+
//#region src/clusterLayer.ts
|
|
5
|
+
var g = 34e3, _ = (e) => {
|
|
6
|
+
let t = e.runnerListenHost ?? process.env.POD_IP ?? process.env.VOLTRO_WORKFLOW_RUNNER_HOST ?? "localhost";
|
|
7
|
+
return {
|
|
8
|
+
host: t,
|
|
9
|
+
port: e.runnerListenPort ?? 34e3,
|
|
10
|
+
localhostRisk: e.runnerStorage === "sql" && (t === "localhost" || t === "127.0.0.1")
|
|
11
|
+
};
|
|
12
|
+
}, v = (e) => {
|
|
13
|
+
let t = e.sqliteFamily ? "memory" : "sql", n = e.override ?? process.env.VOLTRO_WORKFLOW_RUNNER_STORAGE;
|
|
14
|
+
if (n === void 0 || n.trim() === "") return t;
|
|
15
|
+
let r = n.trim().toLowerCase();
|
|
16
|
+
if (r !== "memory" && r !== "sql") throw Error(`VOLTRO_WORKFLOW_RUNNER_STORAGE must be 'memory' or 'sql', got '${n}'.`);
|
|
17
|
+
if (r === "sql" && e.sqliteFamily) throw Error("VOLTRO_WORKFLOW_RUNNER_STORAGE='sql' is unsupported on sqlite / turso — @effect/cluster has no sqlite advisory-lock branch. Use 'memory'.");
|
|
18
|
+
return r;
|
|
19
|
+
}, y = (e = process.env.VOLTRO_WORKFLOW_SHARD_LOCK) => {
|
|
20
|
+
if (e === void 0 || e.trim() === "") return "auto";
|
|
21
|
+
let t = e.trim().toLowerCase();
|
|
22
|
+
if (t === "auto" || t === "row" || t === "advisory") return t;
|
|
23
|
+
throw Error(`VOLTRO_WORKFLOW_SHARD_LOCK must be 'auto', 'row', or 'advisory', got '${e}'.`);
|
|
24
|
+
}, b = n.gen(function* () {
|
|
25
|
+
let e = yield* (yield* h.SqlClient)`SELECT @@wsrep_on AS wsrep`.pipe(n.catchAll(() => n.succeed([])));
|
|
26
|
+
if (e.length === 0) return !1;
|
|
27
|
+
let t = e[0].wsrep;
|
|
28
|
+
return t === 1 || t === "1" || t === !0 || t === "ON" || t === "on";
|
|
29
|
+
}), x = (e) => n.gen(function* () {
|
|
30
|
+
return e.runnerStorage === "memory" ? !1 : e.mode === "row" ? !0 : e.mode === "advisory" || e.dialectId !== "mysql" && e.dialectId !== "mariadb" ? !1 : yield* b;
|
|
31
|
+
}), S = () => {
|
|
32
|
+
let e = (e) => {
|
|
33
|
+
if (e === void 0) return;
|
|
34
|
+
let t = Number(e);
|
|
35
|
+
return Number.isFinite(t) && t > 0 ? t : void 0;
|
|
36
|
+
}, t = e(process.env.VOLTRO_WORKFLOW_FAILOVER_LEASE), n = e(process.env.VOLTRO_WORKFLOW_FAILOVER_HEARTBEAT), r = e(process.env.VOLTRO_WORKFLOW_POLL_INTERVAL);
|
|
37
|
+
return {
|
|
38
|
+
...t === void 0 ? {} : { failoverLeaseSeconds: t },
|
|
39
|
+
...n === void 0 ? {} : { failoverHeartbeatSeconds: n },
|
|
40
|
+
...r === void 0 ? {} : { messagePollSeconds: r }
|
|
41
|
+
};
|
|
42
|
+
}, C = n.flatMap(p.Sharding, (e) => e.pollStorage), w = /* @__PURE__ */ new Set(), T = (e) => {
|
|
43
|
+
let a = _({
|
|
44
|
+
runnerStorage: e.runnerStorage,
|
|
45
|
+
runnerListenHost: e.runnerListenHost,
|
|
46
|
+
runnerListenPort: e.runnerListenPort
|
|
47
|
+
}), o = a.host, c = a.port;
|
|
48
|
+
a.localhostRisk && !w.has(o) && (w.add(o), process.stderr.write(`[voltro:workflow] runner host is '${o}' with SQL cluster storage — other pods cannot reach this runner, so a workflow cannot resume on another pod after a reschedule. Inject POD_IP via the K8s downward API (fieldRef: status.podIP) or set runnerListenHost / VOLTRO_WORKFLOW_RUNNER_HOST.
|
|
49
|
+
`));
|
|
50
|
+
let l = e.shardLockMode ?? y(), u = r.unwrapEffect(x({
|
|
51
|
+
dialectId: e.dialectId,
|
|
52
|
+
runnerStorage: e.runnerStorage,
|
|
53
|
+
mode: l
|
|
54
|
+
}).pipe(n.map((n) => (e.runnerStorage === "sql" && process.stderr.write(`[voltro:workflow] shard-lock coordination: ${n ? "row-based" : "advisory"} (dialect=${e.dialectId ?? "unknown"}, mode=${l}` + (n && l === "auto" ? ", wsrep/Galera cluster detected" : "") + ")\n"), m.layer({
|
|
55
|
+
runnerStorage: e.runnerStorage,
|
|
56
|
+
shardingConfig: {
|
|
57
|
+
runnerAddress: i.some(f.make(o, c)),
|
|
58
|
+
runnerListenAddress: i.some(f.make(o, c)),
|
|
59
|
+
...n ? { shardLockDisableAdvisory: !0 } : {},
|
|
60
|
+
...e.failoverLeaseSeconds === void 0 ? {} : { shardLockExpiration: t.seconds(e.failoverLeaseSeconds) },
|
|
61
|
+
...e.failoverHeartbeatSeconds === void 0 ? {} : { shardLockRefreshInterval: t.seconds(e.failoverHeartbeatSeconds) },
|
|
62
|
+
...e.messagePollSeconds === void 0 ? {} : { entityMessagePollInterval: t.seconds(e.messagePollSeconds) }
|
|
63
|
+
}
|
|
64
|
+
}))))).pipe(r.provide(e.sqlClientLayer)), d = e.extraShardingLayers ?? [];
|
|
65
|
+
return (d.length === 0 ? s.layer : r.mergeAll(s.layer, ...d)).pipe(r.provideMerge(u));
|
|
66
|
+
}, E = (e) => a.make({
|
|
67
|
+
name: e.name,
|
|
68
|
+
cron: e.cron,
|
|
69
|
+
execute: n.promise(() => e.execute()),
|
|
70
|
+
skipIfOlderThan: e.skipIfOlderThan ?? t.days(1)
|
|
71
|
+
}), D = (e, t) => `${e}/${t}`, O = (t, n, r) => {
|
|
72
|
+
let i = l.make(n), a = e.get(t.annotations, o.ShardGroup)(i);
|
|
73
|
+
return new c.EntityAddress({
|
|
74
|
+
entityType: u.EntityType.make(`Workflow/${t.name}`),
|
|
75
|
+
entityId: i,
|
|
76
|
+
shardId: r.getShardId(i, a)
|
|
77
|
+
});
|
|
78
|
+
}, k = (e) => {
|
|
79
|
+
let t = (e) => e && typeof e == "object" && "_tag" in e ? String(e._tag) : void 0, n = t(e);
|
|
80
|
+
if (n === "Failure" || n !== "Success") return "failed";
|
|
81
|
+
let r = e.value, i = t(r);
|
|
82
|
+
if (i === "Suspended") return "suspended";
|
|
83
|
+
if (i === "Complete") {
|
|
84
|
+
let e = r.exit;
|
|
85
|
+
return t(e) === "Success" ? "succeeded" : "failed";
|
|
86
|
+
}
|
|
87
|
+
return "failed";
|
|
88
|
+
}, A = (e) => n.gen(function* () {
|
|
89
|
+
let t = yield* p.Sharding, r = yield* d.MessageStorage, a = O(e.workflow, e.executionId, t), o = yield* r.requestIdForPrimaryKey({
|
|
90
|
+
address: a,
|
|
91
|
+
tag: "run",
|
|
92
|
+
id: ""
|
|
93
|
+
}).pipe(n.orElseSucceed(() => i.none()));
|
|
94
|
+
if (i.isNone(o)) return {
|
|
95
|
+
redriven: !1,
|
|
96
|
+
runReplyCleared: !1,
|
|
97
|
+
activitiesReset: 0,
|
|
98
|
+
reason: "no-journal"
|
|
99
|
+
};
|
|
100
|
+
let s = (yield* r.repliesForUnfiltered([o.value]).pipe(n.orElseSucceed(() => []))).filter((e) => e._tag === "WithExit").at(-1);
|
|
101
|
+
if (s === void 0) return {
|
|
102
|
+
redriven: !1,
|
|
103
|
+
runReplyCleared: !1,
|
|
104
|
+
activitiesReset: 0,
|
|
105
|
+
reason: "no-reply"
|
|
106
|
+
};
|
|
107
|
+
if (k(s.exit) === "succeeded") return {
|
|
108
|
+
redriven: !1,
|
|
109
|
+
runReplyCleared: !1,
|
|
110
|
+
activitiesReset: 0,
|
|
111
|
+
reason: "succeeded"
|
|
112
|
+
};
|
|
113
|
+
let c = 0;
|
|
114
|
+
for (let o of e.failedActivities) {
|
|
115
|
+
let e = yield* r.requestIdForPrimaryKey({
|
|
116
|
+
address: a,
|
|
117
|
+
tag: "activity",
|
|
118
|
+
id: D(o.name, o.attempt)
|
|
119
|
+
}).pipe(n.orElseSucceed(() => i.none()));
|
|
120
|
+
i.isSome(e) && (yield* t.reset(e.value)) && (c += 1);
|
|
121
|
+
}
|
|
122
|
+
let l = yield* t.reset(o.value);
|
|
123
|
+
return yield* t.pollStorage, {
|
|
124
|
+
redriven: !0,
|
|
125
|
+
runReplyCleared: l,
|
|
126
|
+
activitiesReset: c,
|
|
127
|
+
reason: null
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
//#endregion
|
|
131
|
+
export { b as a, S as c, x as d, T as f, g as i, _ as l, A as n, y as o, E as r, C as s, D as t, v as u };
|
package/dist/cluster.d.ts
CHANGED
|
@@ -3,11 +3,22 @@ import { Cron } from 'effect';
|
|
|
3
3
|
import { Duration } from 'effect';
|
|
4
4
|
import { Effect } from 'effect';
|
|
5
5
|
import { Layer } from 'effect';
|
|
6
|
+
import { MessageStorage } from '@effect/cluster';
|
|
6
7
|
import { Sharding } from '@effect/cluster';
|
|
7
8
|
import { SqlClient } from '@effect/sql';
|
|
8
9
|
import { SqlError } from '@effect/sql';
|
|
10
|
+
import { Workflow } from '@effect/workflow';
|
|
9
11
|
import { WorkflowEngine } from '@effect/workflow';
|
|
10
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The cluster's own activity primary-key format — an activity RPC's
|
|
15
|
+
* `primaryKey` is `` `${name}/${attempt}` `` (`ClusterWorkflowEngine`'s
|
|
16
|
+
* `activityPrimaryKey`). Mirrored here so we can address a specific
|
|
17
|
+
* failed attempt's stored reply; the contract test pins this against the
|
|
18
|
+
* real engine so a format change is caught, not silently missed.
|
|
19
|
+
*/
|
|
20
|
+
export declare const activityPrimaryKey: (activityName: string, attempt: number) => string;
|
|
21
|
+
|
|
11
22
|
export declare interface ClusterCronSpec {
|
|
12
23
|
/** Stable schedule name — used as the cluster cron's identity. */
|
|
13
24
|
readonly name: string;
|
|
@@ -34,6 +45,12 @@ export declare const DEFAULT_RUNNER_LISTEN_PORT: 34000;
|
|
|
34
45
|
* somehow can't be probed can still force `VOLTRO_WORKFLOW_SHARD_LOCK=row`. */
|
|
35
46
|
export declare const detectWsrepCluster: Effect.Effect<boolean, never, SqlClient.SqlClient>;
|
|
36
47
|
|
|
48
|
+
/** A step attempt to reset so it re-executes on the next drive. */
|
|
49
|
+
export declare interface FailedActivityRef {
|
|
50
|
+
readonly name: string;
|
|
51
|
+
readonly attempt: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
37
54
|
/**
|
|
38
55
|
* Build the cluster-cron layer for one schedule. Returns a
|
|
39
56
|
* `Layer<never, never, Sharding>` — the framework provides `Sharding`
|
|
@@ -47,6 +64,94 @@ export declare const makeClusterCronLayer: (spec: ClusterCronSpec) => Layer.Laye
|
|
|
47
64
|
* the advisory path that breaks cross-pod handoff on Galera. */
|
|
48
65
|
export declare const parseShardLockMode: (raw?: string | undefined) => ShardLockMode;
|
|
49
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Re-poll the cluster's durable storage NOW — the effect behind a cross-replica
|
|
69
|
+
* "wake". Run it on the workflow runtime (`workflowRuntime.runPromise`), which
|
|
70
|
+
* carries `Sharding`; it opens the storage-read latch so a message just enqueued
|
|
71
|
+
* by another replica is picked up immediately instead of on the next poll tick.
|
|
72
|
+
* Lives here so a boot path doesn't import `@effect/cluster` (and its ~420ms
|
|
73
|
+
* load) just to reach the `Sharding` tag.
|
|
74
|
+
*/
|
|
75
|
+
export declare const pollWorkflowStorage: Effect.Effect<void, never, Sharding.Sharding>;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Re-drive a terminally-`failed` workflow run from the step it died on.
|
|
79
|
+
*
|
|
80
|
+
* Clears the failed step attempts' journaled replies (so they re-execute)
|
|
81
|
+
* and the terminal `run` reply, then re-polls cluster storage so the
|
|
82
|
+
* engine re-delivers the `run` request. Completed steps replay from the
|
|
83
|
+
* journal; only the cleared (failed) steps run again.
|
|
84
|
+
*
|
|
85
|
+
* Idempotent-safe to REFUSE: if the run has no journal, is still running,
|
|
86
|
+
* or already succeeded, it returns `redriven: false` with a `reason` and
|
|
87
|
+
* touches nothing.
|
|
88
|
+
*
|
|
89
|
+
* Requires the SAME cluster instance's `Sharding` + `MessageStorage` that
|
|
90
|
+
* the workflow engine was built from (see `workflowEngineLayer`, which
|
|
91
|
+
* re-exports both for exactly this).
|
|
92
|
+
*/
|
|
93
|
+
export declare const redriveFailedRun: (target: RedriveTarget) => Effect.Effect<RedriveOutcome, never, Sharding.Sharding | MessageStorage.MessageStorage>;
|
|
94
|
+
|
|
95
|
+
export declare interface RedriveOutcome {
|
|
96
|
+
/** True iff the `run` reply was cleared and storage re-polled. */
|
|
97
|
+
readonly redriven: boolean;
|
|
98
|
+
/** True iff the terminal `run` reply was cleared. */
|
|
99
|
+
readonly runReplyCleared: boolean;
|
|
100
|
+
/** How many failed activity replies were cleared (so they re-execute). */
|
|
101
|
+
readonly activitiesReset: number;
|
|
102
|
+
/** Set when `redriven` is false. */
|
|
103
|
+
readonly reason: RedriveSkipReason | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Why a re-drive did nothing — surfaced so the caller reports honestly. */
|
|
107
|
+
export declare type RedriveSkipReason =
|
|
108
|
+
/** No `run` request found in the cluster journal — a memory engine, or
|
|
109
|
+
* a run that was never durably journaled. Nothing to re-drive. */
|
|
110
|
+
'no-journal'
|
|
111
|
+
/** The `run` request exists but carries no terminal reply — the run is
|
|
112
|
+
* still processing. Re-driving would race the live fiber; refuse. */
|
|
113
|
+
| 'no-reply'
|
|
114
|
+
/** The stored reply is a SUCCESS — the run completed cleanly. Nothing
|
|
115
|
+
* to re-drive; refuse rather than resurrect a finished run. */
|
|
116
|
+
| 'succeeded';
|
|
117
|
+
|
|
118
|
+
export declare interface RedriveTarget {
|
|
119
|
+
/** The workflow definition — for its `name` (entity type) + `annotations` (shard group). */
|
|
120
|
+
readonly workflow: Workflow.Any;
|
|
121
|
+
/** The engine-assigned execution id (= cluster `entityId`) of the run to re-drive. */
|
|
122
|
+
readonly executionId: string;
|
|
123
|
+
/**
|
|
124
|
+
* Failed step attempts to reset so they re-execute fresh. Succeeded
|
|
125
|
+
* steps are intentionally NOT listed — their journaled replies stay,
|
|
126
|
+
* so they replay rather than re-run. Typically the `status='failed'`
|
|
127
|
+
* rows of `_voltro_workflow_run_steps` for this run.
|
|
128
|
+
*/
|
|
129
|
+
readonly failedActivities: ReadonlyArray<FailedActivityRef>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Read the failover-tuning knobs from the environment, for the boot paths to
|
|
134
|
+
* spread into {@link workflowEngineLayer}. Operators set:
|
|
135
|
+
*
|
|
136
|
+
* - `VOLTRO_WORKFLOW_FAILOVER_LEASE` (seconds → `failoverLeaseSeconds`) — how
|
|
137
|
+
* long after a crashed replica's last heartbeat its in-flight workflows
|
|
138
|
+
* become claimable by a survivor. Default 35. Lower = faster failover, at the
|
|
139
|
+
* cost of false-positive reclaims under a long GC / DB-latency stall.
|
|
140
|
+
* - `VOLTRO_WORKFLOW_FAILOVER_HEARTBEAT` (seconds → `failoverHeartbeatSeconds`)
|
|
141
|
+
* — the lease refresh cadence. Default 10. Keep it ≈ lease/3.
|
|
142
|
+
* - `VOLTRO_WORKFLOW_POLL_INTERVAL` (seconds → `messagePollSeconds`) — the
|
|
143
|
+
* NEW-message pickup latency for a workflow whose shard is owned by ANOTHER
|
|
144
|
+
* replica. Default 10. Lower for latency-sensitive multi-replica workloads,
|
|
145
|
+
* at the cost of more idle poll queries. No effect on a single replica.
|
|
146
|
+
*
|
|
147
|
+
* A malformed value is ignored (the default stands) rather than failing boot.
|
|
148
|
+
*/
|
|
149
|
+
export declare const resolveFailoverTuning: () => {
|
|
150
|
+
failoverLeaseSeconds?: number;
|
|
151
|
+
failoverHeartbeatSeconds?: number;
|
|
152
|
+
messagePollSeconds?: number;
|
|
153
|
+
};
|
|
154
|
+
|
|
50
155
|
/** Resolve the runner host/port the SAME way `workflowEngineLayer` does:
|
|
51
156
|
* explicit option → `POD_IP` → `VOLTRO_WORKFLOW_RUNNER_HOST` → loopback;
|
|
52
157
|
* port from option → `VOLTRO_WORKFLOW_RUNNER_PORT` → default. */
|
|
@@ -155,19 +260,7 @@ export declare type RunnerStorage = 'sql' | 'memory';
|
|
|
155
260
|
*/
|
|
156
261
|
export declare type ShardLockMode = 'auto' | 'row' | 'advisory';
|
|
157
262
|
|
|
158
|
-
|
|
159
|
-
* Compose the dialect's SqlClient → SingleRunner → ClusterWorkflowEngine
|
|
160
|
-
* into a single layer that user code provides to its program to get
|
|
161
|
-
* durable workflows.
|
|
162
|
-
*
|
|
163
|
-
* `extraShardingLayers` (e.g. `ClusterCron` layers from
|
|
164
|
-
* `cluster`-coordinated schedules) are merged into the same runtime so
|
|
165
|
-
* they share the runner identity + shard map.
|
|
166
|
-
*
|
|
167
|
-
* For dev / tests where no real engine is available, see
|
|
168
|
-
* `inMemoryWorkflowEngineLayer`.
|
|
169
|
-
*/
|
|
170
|
-
export declare const workflowEngineLayer: (options: WorkflowEngineLayerOptions) => Layer.Layer<WorkflowEngine.WorkflowEngine>;
|
|
263
|
+
export declare const workflowEngineLayer: (options: WorkflowEngineLayerOptions) => Layer.Layer<WorkflowEngine.WorkflowEngine | Sharding.Sharding | MessageStorage.MessageStorage>;
|
|
171
264
|
|
|
172
265
|
export declare interface WorkflowEngineLayerOptions {
|
|
173
266
|
/**
|
|
@@ -196,6 +289,40 @@ export declare interface WorkflowEngineLayerOptions {
|
|
|
196
289
|
readonly shardLockMode?: ShardLockMode;
|
|
197
290
|
readonly runnerListenHost?: string;
|
|
198
291
|
readonly runnerListenPort?: number;
|
|
292
|
+
/**
|
|
293
|
+
* FAILOVER TUNING — the two knobs that govern how fast a SURVIVING replica
|
|
294
|
+
* takes over a CRASHED replica's in-flight workflows. Reclaim is a
|
|
295
|
+
* lease-expiry problem, NOT a polling one: a dead runner keeps its shards
|
|
296
|
+
* until its heartbeat goes stale, so the floor is the lease TTL.
|
|
297
|
+
*
|
|
298
|
+
* - `failoverLeaseSeconds` (default 35, `@effect/cluster`
|
|
299
|
+
* `shardLockExpiration`) — how long after a runner's last heartbeat its
|
|
300
|
+
* shards become claimable. Lower = faster failover on a hard crash, at the
|
|
301
|
+
* cost of false-positive reclaims if a healthy runner is paused longer than
|
|
302
|
+
* this by a GC pause or a DB-latency spike (two runners could then briefly
|
|
303
|
+
* both think they own a shard).
|
|
304
|
+
* - `failoverHeartbeatSeconds` (default 10, `shardLockRefreshInterval`) — how
|
|
305
|
+
* often a runner refreshes its lease + heartbeat. Keep it well below the
|
|
306
|
+
* lease (≈ lease/3) so a single slow refresh doesn't trip a false reclaim.
|
|
307
|
+
*
|
|
308
|
+
* Omit both for production defaults. A push wake (LISTEN/NOTIFY) does NOT help
|
|
309
|
+
* here — reclaim is time-bound, not notification-bound; only these two (or a
|
|
310
|
+
* K8s liveness probe) move the floor.
|
|
311
|
+
*/
|
|
312
|
+
readonly failoverLeaseSeconds?: number;
|
|
313
|
+
readonly failoverHeartbeatSeconds?: number;
|
|
314
|
+
/**
|
|
315
|
+
* NEW-MESSAGE latency across replicas (default 10, `@effect/cluster`
|
|
316
|
+
* `entityMessagePollInterval`). DISTINCT from failover: this is how long a
|
|
317
|
+
* freshly-triggered workflow can wait before the replica that OWNS its shard
|
|
318
|
+
* picks it up. When the triggering replica owns the shard it's immediate
|
|
319
|
+
* (a same-process push); when ANOTHER replica owns it, Voltro's single-runner
|
|
320
|
+
* topology has no cross-runner push, so the owner sees it on its next poll —
|
|
321
|
+
* up to this interval. Lower it for latency-sensitive multi-replica workloads,
|
|
322
|
+
* at the cost of more idle poll queries. (The lower-load fix is a pg
|
|
323
|
+
* LISTEN/NOTIFY wake, not yet wired.) No effect on a single replica.
|
|
324
|
+
*/
|
|
325
|
+
readonly messagePollSeconds?: number;
|
|
199
326
|
/**
|
|
200
327
|
* Extra layers that need the cluster's `Sharding` service — most
|
|
201
328
|
* prominently `ClusterCron` layers for `cluster`-coordinated
|
package/dist/cluster.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as e, c as t,
|
|
2
|
-
export {
|
|
1
|
+
import { a as e, c as t, d as n, f as r, i, l as a, n as o, o as s, r as c, s as l, t as u, u as d } from "./cluster-BQ0H5M9n.js";
|
|
2
|
+
export { i as DEFAULT_RUNNER_LISTEN_PORT, u as activityPrimaryKey, e as detectWsrepCluster, c as makeClusterCronLayer, s as parseShardLockMode, l as pollWorkflowStorage, o as redriveFailedRun, t as resolveFailoverTuning, a as resolveRunnerIdentity, d as resolveRunnerStorage, n as resolveShardLockDisableAdvisory, r as workflowEngineLayer };
|
|
@@ -116,6 +116,40 @@ declare interface WorkflowEngineLayerOptions {
|
|
|
116
116
|
readonly shardLockMode?: ShardLockMode;
|
|
117
117
|
readonly runnerListenHost?: string;
|
|
118
118
|
readonly runnerListenPort?: number;
|
|
119
|
+
/**
|
|
120
|
+
* FAILOVER TUNING — the two knobs that govern how fast a SURVIVING replica
|
|
121
|
+
* takes over a CRASHED replica's in-flight workflows. Reclaim is a
|
|
122
|
+
* lease-expiry problem, NOT a polling one: a dead runner keeps its shards
|
|
123
|
+
* until its heartbeat goes stale, so the floor is the lease TTL.
|
|
124
|
+
*
|
|
125
|
+
* - `failoverLeaseSeconds` (default 35, `@effect/cluster`
|
|
126
|
+
* `shardLockExpiration`) — how long after a runner's last heartbeat its
|
|
127
|
+
* shards become claimable. Lower = faster failover on a hard crash, at the
|
|
128
|
+
* cost of false-positive reclaims if a healthy runner is paused longer than
|
|
129
|
+
* this by a GC pause or a DB-latency spike (two runners could then briefly
|
|
130
|
+
* both think they own a shard).
|
|
131
|
+
* - `failoverHeartbeatSeconds` (default 10, `shardLockRefreshInterval`) — how
|
|
132
|
+
* often a runner refreshes its lease + heartbeat. Keep it well below the
|
|
133
|
+
* lease (≈ lease/3) so a single slow refresh doesn't trip a false reclaim.
|
|
134
|
+
*
|
|
135
|
+
* Omit both for production defaults. A push wake (LISTEN/NOTIFY) does NOT help
|
|
136
|
+
* here — reclaim is time-bound, not notification-bound; only these two (or a
|
|
137
|
+
* K8s liveness probe) move the floor.
|
|
138
|
+
*/
|
|
139
|
+
readonly failoverLeaseSeconds?: number;
|
|
140
|
+
readonly failoverHeartbeatSeconds?: number;
|
|
141
|
+
/**
|
|
142
|
+
* NEW-MESSAGE latency across replicas (default 10, `@effect/cluster`
|
|
143
|
+
* `entityMessagePollInterval`). DISTINCT from failover: this is how long a
|
|
144
|
+
* freshly-triggered workflow can wait before the replica that OWNS its shard
|
|
145
|
+
* picks it up. When the triggering replica owns the shard it's immediate
|
|
146
|
+
* (a same-process push); when ANOTHER replica owns it, Voltro's single-runner
|
|
147
|
+
* topology has no cross-runner push, so the owner sees it on its next poll —
|
|
148
|
+
* up to this interval. Lower it for latency-sensitive multi-replica workloads,
|
|
149
|
+
* at the cost of more idle poll queries. (The lower-load fix is a pg
|
|
150
|
+
* LISTEN/NOTIFY wake, not yet wired.) No effect on a single replica.
|
|
151
|
+
*/
|
|
152
|
+
readonly messagePollSeconds?: number;
|
|
119
153
|
/**
|
|
120
154
|
* Extra layers that need the cluster's `Sharding` service — most
|
|
121
155
|
* prominently `ClusterCron` layers for `cluster`-coordinated
|
package/dist/clusterTestSuite.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { C as e, m as t, p as n, v as r, x as i } from "./primitives-
|
|
2
|
-
import { i as a } from "./src-
|
|
3
|
-
import {
|
|
1
|
+
import { C as e, m as t, p as n, v as r, x as i } from "./primitives-Dgu3O55Q.js";
|
|
2
|
+
import { i as a } from "./src-KDx7NTsa.js";
|
|
3
|
+
import { f as o, r as s } from "./cluster-BQ0H5M9n.js";
|
|
4
4
|
import { Cron as c, Deferred as l, Effect as u, Fiber as d, Layer as f, Schema as p } from "effect";
|
|
5
5
|
import { SqlClient as m } from "@effect/sql";
|
|
6
6
|
import { beforeAll as h, describe as g, expect as _, test as v } from "vitest";
|
|
@@ -33,14 +33,14 @@ var y = async (e, t, n = 750) => {
|
|
|
33
33
|
for (let t of b) yield* e.unsafe(`DELETE FROM ${t}`).pipe(u.ignore);
|
|
34
34
|
}).pipe(u.scoped, u.provide(e.sqlClientLayer)));
|
|
35
35
|
} catch {}
|
|
36
|
-
}, S = (e, t) => u.gen(function* () {
|
|
36
|
+
}, S = "10 seconds", C = "30 seconds", w = (e, t) => u.gen(function* () {
|
|
37
37
|
let n = yield* u.sync(() => Date.now() + t);
|
|
38
38
|
for (; !e();) {
|
|
39
39
|
if (Date.now() > n) return !1;
|
|
40
40
|
yield* u.sleep("250 millis");
|
|
41
41
|
}
|
|
42
42
|
return !0;
|
|
43
|
-
}),
|
|
43
|
+
}), T = (m) => {
|
|
44
44
|
let y = `${m.name}-${Date.now()}`, b = (e, t) => o({
|
|
45
45
|
sqlClientLayer: m.sqlClientLayer,
|
|
46
46
|
runnerStorage: m.runnerStorage,
|
|
@@ -104,23 +104,31 @@ var y = async (e, t, n = 750) => {
|
|
|
104
104
|
_(await u.runPromise(u.scoped(u.gen(function* () {
|
|
105
105
|
return yield* f.build(b(m.runnerPort + 1, [n])), yield* l.await(e).pipe(u.timeout("25 seconds"), u.orElseSucceed(() => !1));
|
|
106
106
|
})))).toBe(!0), _(t).toBeGreaterThanOrEqual(1);
|
|
107
|
-
}, 35e3)
|
|
107
|
+
}, 35e3);
|
|
108
|
+
let g = m.clusterResume ? v : (() => {});
|
|
109
|
+
g("a sleeping workflow resumes on a fresh runner after the first is torn down", async () => {
|
|
108
110
|
if (!o) return;
|
|
109
|
-
let e = `dormancyResume_${y}`, i = [], a = [], s = () => r({
|
|
111
|
+
let e = `dormancyResume_${y}`, i = [], a = [], s = [], c = () => r({
|
|
110
112
|
name: e,
|
|
111
113
|
payload: { id: p.String },
|
|
112
114
|
success: p.String,
|
|
113
115
|
idempotencyKey: ({ id: t }) => `${e}:${t}`
|
|
114
|
-
}),
|
|
116
|
+
}), l = (e, r) => e.toLayer(() => u.gen(function* () {
|
|
115
117
|
return yield* t({
|
|
116
118
|
name: "step1",
|
|
117
119
|
success: p.Void,
|
|
118
120
|
execute: u.sync(() => {
|
|
119
121
|
i.push(r);
|
|
120
122
|
})
|
|
123
|
+
}), yield* t({
|
|
124
|
+
name: "armed",
|
|
125
|
+
success: p.Void,
|
|
126
|
+
execute: u.sync(() => {
|
|
127
|
+
s.push(r);
|
|
128
|
+
})
|
|
121
129
|
}), yield* n({
|
|
122
130
|
name: "nap",
|
|
123
|
-
duration:
|
|
131
|
+
duration: S
|
|
124
132
|
}), yield* t({
|
|
125
133
|
name: "step2",
|
|
126
134
|
success: p.Void,
|
|
@@ -128,21 +136,21 @@ var y = async (e, t, n = 750) => {
|
|
|
128
136
|
a.push(r);
|
|
129
137
|
})
|
|
130
138
|
}), "done";
|
|
131
|
-
})),
|
|
132
|
-
let t =
|
|
139
|
+
})), h = m.runnerPort + 2, g = (e) => {
|
|
140
|
+
let t = c();
|
|
133
141
|
return {
|
|
134
142
|
wf: t,
|
|
135
|
-
layer:
|
|
143
|
+
layer: l(t, e).pipe(f.provideMerge(b(h)))
|
|
136
144
|
};
|
|
137
|
-
},
|
|
138
|
-
let { wf: e, layer: t } =
|
|
139
|
-
return (yield*
|
|
145
|
+
}, v = { id: `resume-${y}` }, x = await u.runPromise(u.scoped(u.gen(function* () {
|
|
146
|
+
let { wf: e, layer: t } = g("A"), n = yield* f.build(t), r = yield* u.forkDaemon(e.execute(v).pipe(u.provide(n), u.ignore));
|
|
147
|
+
return (yield* w(() => s.length > 0, 6e4)) ? r : yield* u.die(/* @__PURE__ */ Error("runner A never journaled step1"));
|
|
140
148
|
})));
|
|
141
|
-
await u.runPromise(d.interrupt(
|
|
142
|
-
let { wf: e, layer: t } =
|
|
143
|
-
return yield* e.execute(
|
|
149
|
+
await u.runPromise(d.interrupt(x).pipe(u.ignore)), _(i).toEqual(["A"]), _(a).toEqual([]), _(await u.runPromise(u.scoped(u.gen(function* () {
|
|
150
|
+
let { wf: e, layer: t } = g("B"), n = yield* f.build(t);
|
|
151
|
+
return yield* e.execute(v).pipe(u.provide(n), u.timeout("240 seconds"), u.orElseSucceed(() => "TIMEOUT"));
|
|
144
152
|
})))).toBe("done"), _(i).toEqual(["A"]), _(a).toEqual(["B"]);
|
|
145
|
-
},
|
|
153
|
+
}, 48e4), g("dormancy loop: a wake signal survives teardown and is cleared on resume", async () => {
|
|
146
154
|
if (!o) return;
|
|
147
155
|
let s = `dormancyLoop_${y}`, c = `wr_${s}`, l = [], h = /* @__PURE__ */ new Map(), g = a({
|
|
148
156
|
store: {
|
|
@@ -170,7 +178,7 @@ var y = async (e, t, n = 750) => {
|
|
|
170
178
|
execute: u.void
|
|
171
179
|
}), yield* n({
|
|
172
180
|
name: "nap",
|
|
173
|
-
duration:
|
|
181
|
+
duration: C
|
|
174
182
|
}), yield* t({
|
|
175
183
|
name: "step2",
|
|
176
184
|
success: p.Void,
|
|
@@ -178,22 +186,22 @@ var y = async (e, t, n = 750) => {
|
|
|
178
186
|
l.push(a);
|
|
179
187
|
})
|
|
180
188
|
}), "done";
|
|
181
|
-
}).pipe(u.locally(i, c), u.provideService(e, g))),
|
|
189
|
+
}).pipe(u.locally(i, c), u.provideService(e, g))), S = m.runnerPort + 2, T = (e) => {
|
|
182
190
|
let t = v();
|
|
183
191
|
return {
|
|
184
192
|
wf: t,
|
|
185
|
-
layer: x(t, e).pipe(f.provideMerge(b(
|
|
193
|
+
layer: x(t, e).pipe(f.provideMerge(b(S)))
|
|
186
194
|
};
|
|
187
|
-
},
|
|
188
|
-
let { wf: e, layer: t } =
|
|
189
|
-
return (yield*
|
|
195
|
+
}, E = { id: `loop-${y}` }, D = await u.runPromise(u.scoped(u.gen(function* () {
|
|
196
|
+
let { wf: e, layer: t } = T("A"), n = yield* f.build(t), r = yield* u.forkDaemon(e.execute(E).pipe(u.provide(n), u.ignore));
|
|
197
|
+
return (yield* w(() => h.has(c), 6e4)) ? (yield* u.sleep("1 second"), r) : yield* u.die(/* @__PURE__ */ Error("workflow never registered a wakeup"));
|
|
190
198
|
})));
|
|
191
|
-
await u.runPromise(d.interrupt(
|
|
192
|
-
let { wf: e, layer: t } =
|
|
193
|
-
return yield* e.execute(
|
|
199
|
+
await u.runPromise(d.interrupt(D).pipe(u.ignore)), _(h.has(c)).toBe(!0), _(l).toEqual([]), _(await u.runPromise(u.scoped(u.gen(function* () {
|
|
200
|
+
let { wf: e, layer: t } = T("B"), n = yield* f.build(t);
|
|
201
|
+
return yield* e.execute(E).pipe(u.provide(n), u.timeout("600 seconds"), u.orElseSucceed(() => "TIMEOUT"));
|
|
194
202
|
})))).toBe("done"), _(l).toEqual(["B"]), _(h.has(c)).toBe(!1);
|
|
195
|
-
},
|
|
203
|
+
}, 9e5);
|
|
196
204
|
});
|
|
197
205
|
};
|
|
198
206
|
//#endregion
|
|
199
|
-
export {
|
|
207
|
+
export { T as runClusterEngineSuite, y as tcpReachable };
|