@oisincoveney/pipeline 4.0.3 → 4.1.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/dist/coordinator/composition.js +57 -15
- package/dist/coordinator/wave-advance.js +90 -17
- package/dist/coordinator/wave-tick-registry.js +56 -0
- package/dist/remote/jobs/job-outcome.js +18 -9
- package/dist/remote/jobs/reconcile.js +6 -3
- package/dist/run-control/execution-store.js +0 -0
- package/dist/run-control/postgres/execution-schema.js +43 -0
- package/dist/run-control/postgres/postgres-execution-store.js +103 -0
- package/package.json +1 -1
|
@@ -1,20 +1,25 @@
|
|
|
1
|
+
import { loadMokaDbUrl } from "../moka-global-config.js";
|
|
1
2
|
import { reconcileAllRuns } from "../remote/jobs/reconcile.js";
|
|
2
3
|
import { createRunSpecRegistry, startRunSpecServer } from "../remote/jobs/run-spec-server.js";
|
|
3
4
|
import { runJobFleetWatchLoop } from "../remote/jobs/watch.js";
|
|
4
5
|
import { submitParsedMoka } from "../remote/submit/service.js";
|
|
5
6
|
import { withRunControlStore } from "../run-control/command-context.js";
|
|
7
|
+
import { resolveExecutionStore } from "../run-control/postgres/postgres-execution-store.js";
|
|
6
8
|
import { kubernetesJobFleetReadApi } from "../runtime/services/kubernetes-jobs-read-service.js";
|
|
7
9
|
import { kubernetesJobFleetWatchApi } from "../runtime/services/kubernetes-jobs-watch-service.js";
|
|
8
10
|
import { loadCoordinatorJobsConfig } from "./config.js";
|
|
9
11
|
import { createRunScopedOwnerReference } from "./run-owner.js";
|
|
10
|
-
import {
|
|
12
|
+
import { makeSubmitWaveJobs } from "./wave-advance.js";
|
|
13
|
+
import { advanceReconciledWaves, makeWaveTickRegistry } from "./wave-tick-registry.js";
|
|
11
14
|
import * as DateTime from "effect/DateTime";
|
|
12
15
|
import * as Effect from "effect/Effect";
|
|
13
16
|
import * as ManagedRuntime from "effect/ManagedRuntime";
|
|
14
17
|
import * as Layer from "effect/Layer";
|
|
18
|
+
import * as Exit from "effect/Exit";
|
|
15
19
|
import * as Cause from "effect/Cause";
|
|
16
20
|
import { randomBytes } from "node:crypto";
|
|
17
21
|
import * as Fiber from "effect/Fiber";
|
|
22
|
+
import * as Scope from "effect/Scope";
|
|
18
23
|
//#region src/coordinator/composition.ts
|
|
19
24
|
/**
|
|
20
25
|
* ENG-57.1/57.2/57.3: the coordinator's composition root (doc-1 decision 3,
|
|
@@ -80,27 +85,58 @@ const defaultOnWatchLoopFailure = (error) => {
|
|
|
80
85
|
* drives the startup pass, every event-triggered pass, and every
|
|
81
86
|
* post-reconnect pass, so dynamic and static Jobs runs finalize through one
|
|
82
87
|
* run-id-keyed path (AC#4). */
|
|
83
|
-
const buildWatchLoopEffect = (dependencies, config, store) =>
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
const buildWatchLoopEffect = (dependencies, config, store, executionStore, registry) => {
|
|
89
|
+
const fleet = dependencies.jobFleetReadApi ?? kubernetesJobFleetReadApi({});
|
|
90
|
+
const now = () => DateTime.formatIso(DateTime.nowUnsafe());
|
|
91
|
+
return runJobFleetWatchLoop({
|
|
92
|
+
...dependencies.watchLoop,
|
|
88
93
|
namespace: config.namespace,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
reconcile: reconcileAllRuns({
|
|
95
|
+
fleet,
|
|
96
|
+
namespace: config.namespace,
|
|
97
|
+
now,
|
|
98
|
+
store,
|
|
99
|
+
submitted: (runId) => executionStore.listSubmitted({ runId })
|
|
100
|
+
}).pipe(Effect.tap((reports) => Effect.sync(() => dependencies.onReconcile?.(reports))), Effect.flatMap((reports) => advanceReconciledWaves({
|
|
101
|
+
executionStore,
|
|
102
|
+
fleet,
|
|
103
|
+
now,
|
|
104
|
+
registry
|
|
105
|
+
}, reports)), Effect.asVoid),
|
|
106
|
+
watch: dependencies.jobFleetWatchApi ?? kubernetesJobFleetWatchApi({})
|
|
107
|
+
});
|
|
108
|
+
};
|
|
94
109
|
/** ENG-57.3: starts the watch loop as its own fiber over the coordinator's
|
|
95
110
|
* lifetime. Resolves the store via the injected test override, or the
|
|
96
111
|
* production `withRunControlStore` seam (Postgres-backed, scoped so
|
|
97
112
|
* interrupting the returned fiber also releases the store's connection). A
|
|
98
113
|
* permanent failure is reported via `onWatchLoopFailure`, never left silent. */
|
|
99
|
-
const startWatchLoop = (dependencies, config) => {
|
|
114
|
+
const startWatchLoop = (dependencies, config, executionStore, registry) => {
|
|
100
115
|
const onWatchLoopFailure = dependencies.onWatchLoopFailure ?? defaultOnWatchLoopFailure;
|
|
101
|
-
const watchLoopEffect = dependencies.runControlStore === void 0 ? withRunControlStore((store) => buildWatchLoopEffect(dependencies, config, store)) : buildWatchLoopEffect(dependencies, config, dependencies.runControlStore);
|
|
116
|
+
const watchLoopEffect = dependencies.runControlStore === void 0 ? withRunControlStore((store) => buildWatchLoopEffect(dependencies, config, store, executionStore, registry)) : buildWatchLoopEffect(dependencies, config, dependencies.runControlStore, executionStore, registry);
|
|
102
117
|
return watchLoopRuntime.runFork(watchLoopEffect.pipe(Effect.tapCause((cause) => Effect.sync(() => onWatchLoopFailure(Cause.squash(cause))))));
|
|
103
118
|
};
|
|
119
|
+
/**
|
|
120
|
+
* ENG-57.10: acquire the ONE durable {@link ExecutionStore} the coordinator
|
|
121
|
+
* shares between `submit` and the watch loop, for the coordinator's whole
|
|
122
|
+
* lifetime. A test override is used as-is (the test owns its release). Otherwise
|
|
123
|
+
* the Postgres store is bound inside a coordinator-held {@link Scope} via
|
|
124
|
+
* `resolveExecutionStore`'s `acquireRelease`, so `close()` releases the connection
|
|
125
|
+
* exactly once (`Scope.close`) — never leaked, even under watch-fiber interrupt.
|
|
126
|
+
*/
|
|
127
|
+
const acquireSharedExecutionStore = async (dependencies) => {
|
|
128
|
+
if (dependencies.executionStore !== void 0) return {
|
|
129
|
+
release: async () => Promise.resolve(),
|
|
130
|
+
store: dependencies.executionStore
|
|
131
|
+
};
|
|
132
|
+
const scope = await watchLoopRuntime.runPromise(Scope.make());
|
|
133
|
+
return {
|
|
134
|
+
release: async () => {
|
|
135
|
+
await watchLoopRuntime.runPromise(Scope.close(scope, Exit.void));
|
|
136
|
+
},
|
|
137
|
+
store: await watchLoopRuntime.runPromise(Effect.provideService(resolveExecutionStore(loadMokaDbUrl()), Scope.Scope, scope))
|
|
138
|
+
};
|
|
139
|
+
};
|
|
104
140
|
const resolveRunSpecBind = (dependencies, env) => {
|
|
105
141
|
const host = dependencies.runSpecBind?.host ?? env[RUN_SPEC_HOST_ENV];
|
|
106
142
|
const port = dependencies.runSpecBind?.port ?? parseRunSpecPortEnv(env);
|
|
@@ -132,7 +168,9 @@ const createCoordinator = async (dependencies = {}) => {
|
|
|
132
168
|
registry
|
|
133
169
|
}, resolveRunSpecBind(dependencies, env));
|
|
134
170
|
const runSpecBaseUrl = dependencies.runSpecBaseUrl ?? env[RUN_SPEC_BASE_URL_ENV] ?? runSpecServer.baseUrl;
|
|
135
|
-
const
|
|
171
|
+
const { release: releaseExecutionStore, store: executionStore } = await acquireSharedExecutionStore(dependencies);
|
|
172
|
+
const waveRegistry = watchLoopRuntime.runSync(makeWaveTickRegistry());
|
|
173
|
+
const watchLoopFiber = startWatchLoop(dependencies, config, executionStore, waveRegistry);
|
|
136
174
|
const submit = async (options, callerDependencies = {}) => {
|
|
137
175
|
const runId = options.run?.id ?? (dependencies.generateRunId ?? generateFallbackRunId)();
|
|
138
176
|
const ownerReference = await createRunScopedOwnerReference({
|
|
@@ -145,7 +183,10 @@ const createCoordinator = async (dependencies = {}) => {
|
|
|
145
183
|
ownerReference,
|
|
146
184
|
registry,
|
|
147
185
|
runSpecBaseUrl,
|
|
148
|
-
submitJobs:
|
|
186
|
+
submitJobs: makeSubmitWaveJobs({
|
|
187
|
+
executionStore,
|
|
188
|
+
registerWaveContext: (context) => watchLoopRuntime.runSync(waveRegistry.register(context))
|
|
189
|
+
}),
|
|
149
190
|
...dependencies.jobsDependencies === void 0 ? {} : { jobsDependencies: dependencies.jobsDependencies }
|
|
150
191
|
};
|
|
151
192
|
return await submitParsedMoka(resolveSubmitOptions(options, config), {
|
|
@@ -157,6 +198,7 @@ const createCoordinator = async (dependencies = {}) => {
|
|
|
157
198
|
return {
|
|
158
199
|
close: async () => {
|
|
159
200
|
await watchLoopRuntime.runPromise(Fiber.interrupt(watchLoopFiber));
|
|
201
|
+
await releaseExecutionStore();
|
|
160
202
|
await runSpecServer.close();
|
|
161
203
|
},
|
|
162
204
|
config,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import "../schema-boundary.js";
|
|
1
|
+
import { parseStrictWithSchema } from "../schema-boundary.js";
|
|
2
2
|
import { compileScheduleArtifact, parseScheduleArtifact } from "../planning/generate.js";
|
|
3
|
-
import "../remote/jobs/job-outcome.js";
|
|
3
|
+
import { classifyNodeOutcome } from "../remote/jobs/job-outcome.js";
|
|
4
4
|
import { submitRunnerNodeJobs } from "../remote/jobs/submit.js";
|
|
5
5
|
import { parseJobsSubmitResult } from "../remote/submit/jobs-submit-result.js";
|
|
6
6
|
import { defaultBuildNodeRunSpec, requireCompiledSchedule, requireSubmitNamespace, sharedJobOptions } from "../remote/submit/jobs-submission.js";
|
|
7
|
-
import {
|
|
7
|
+
import { executionTerminalStatusSchema } from "../run-control/execution-store.js";
|
|
8
8
|
import { planNextWave } from "./wave-scheduler.js";
|
|
9
9
|
import * as DateTime from "effect/DateTime";
|
|
10
10
|
import * as Effect from "effect/Effect";
|
|
@@ -15,6 +15,7 @@ import * as Schema from "effect/Schema";
|
|
|
15
15
|
import * as Arr from "effect/Array";
|
|
16
16
|
import * as HashMap from "effect/HashMap";
|
|
17
17
|
import * as Layer from "effect/Layer";
|
|
18
|
+
import * as HashSet from "effect/HashSet";
|
|
18
19
|
import * as Str from "effect/String";
|
|
19
20
|
//#region src/coordinator/wave-advance.ts
|
|
20
21
|
/**
|
|
@@ -79,6 +80,53 @@ const advanceWave = (deps) => Effect.gen(function* body() {
|
|
|
79
80
|
})
|
|
80
81
|
};
|
|
81
82
|
});
|
|
83
|
+
/**
|
|
84
|
+
* The cluster-truth → ExecutionStore bridge. Observes the run's Jobs/pods once
|
|
85
|
+
* and, for every node NOT already terminal in the store, folds a terminal
|
|
86
|
+
* cluster outcome (via the ENG-19 {@link classifyNodeOutcome} classifier, reused
|
|
87
|
+
* not reimplemented) into the ExecutionStore under CAS. A node whose Job is still
|
|
88
|
+
* pending is left untouched. This is what makes a later {@link advanceWave} tick
|
|
89
|
+
* see a predecessor's terminal success and release the next wave.
|
|
90
|
+
*/
|
|
91
|
+
const recordClusterTerminals = (deps) => Effect.gen(function* body() {
|
|
92
|
+
const observation = yield* deps.fleet.observeRun({
|
|
93
|
+
namespace: deps.namespace,
|
|
94
|
+
runId: deps.runId
|
|
95
|
+
});
|
|
96
|
+
const submitted = yield* deps.executionStore.listSubmitted({ runId: deps.runId });
|
|
97
|
+
yield* Effect.forEach(deps.nodes, (node) => deps.executionStore.getExecution({
|
|
98
|
+
nodeId: node.id,
|
|
99
|
+
runId: deps.runId
|
|
100
|
+
}).pipe(Effect.flatMap((existing) => {
|
|
101
|
+
if (Option$1.isSome(existing)) return Effect.void;
|
|
102
|
+
const outcome = classifyNodeOutcome(observation.nodes[node.id] ?? { pods: [] }, { submitted: HashSet.has(submitted, node.id) });
|
|
103
|
+
if (outcome.kind === "pending") return Effect.void;
|
|
104
|
+
const status = parseStrictWithSchema(executionTerminalStatusSchema, outcome.status);
|
|
105
|
+
return deps.executionStore.recordTerminal({
|
|
106
|
+
at: deps.now(),
|
|
107
|
+
attempt: 1,
|
|
108
|
+
identity: {
|
|
109
|
+
generation: FIRST_EXECUTION_GENERATION,
|
|
110
|
+
nodeId: node.id,
|
|
111
|
+
runId: deps.runId
|
|
112
|
+
},
|
|
113
|
+
status
|
|
114
|
+
}).pipe(Effect.asVoid);
|
|
115
|
+
})), { concurrency: 1 });
|
|
116
|
+
});
|
|
117
|
+
/**
|
|
118
|
+
* A full reconcile-driven tick: fold cluster truth into the ExecutionStore, then
|
|
119
|
+
* advance the wave. This is the unit the coordinator's reconcile loop calls per
|
|
120
|
+
* cycle to release later waves as predecessors finish.
|
|
121
|
+
*/
|
|
122
|
+
const runWaveTick = (deps) => recordClusterTerminals({
|
|
123
|
+
executionStore: deps.executionStore,
|
|
124
|
+
fleet: deps.fleet,
|
|
125
|
+
namespace: deps.namespace,
|
|
126
|
+
nodes: deps.nodes,
|
|
127
|
+
now: deps.now,
|
|
128
|
+
runId: deps.runId
|
|
129
|
+
}).pipe(Effect.flatMap(() => advanceWave(deps)));
|
|
82
130
|
/** A wave-submit requested a node id absent from the compiled schedule — a
|
|
83
131
|
* scheduler invariant violation, never expected in normal operation. */
|
|
84
132
|
var WaveSubmitMissingNodeError = class extends Schema.TaggedErrorClass()("WaveSubmitMissingNodeError", { message: Schema.String }) {
|
|
@@ -87,15 +135,13 @@ var WaveSubmitMissingNodeError = class extends Schema.TaggedErrorClass()("WaveSu
|
|
|
87
135
|
}
|
|
88
136
|
};
|
|
89
137
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* driver's concern ({@link runWaveTick}). Replaces ENG-57.1's single-ready-node
|
|
96
|
-
* primitive so static and dynamic (post-schedule) runs share this one scheduler.
|
|
138
|
+
* Compile a submission into its {@link WaveTickContext}: the node graph and the
|
|
139
|
+
* per-wave create-or-observe submit closure. Extracted from the wave transport so
|
|
140
|
+
* the coordinator can register the SAME `{ nodes, submitReadyNodes }` the first
|
|
141
|
+
* tick used and re-tick it from the watch loop — one scheduler, one submit path,
|
|
142
|
+
* static and dynamic alike.
|
|
97
143
|
*/
|
|
98
|
-
const
|
|
144
|
+
const compileWaveContext = (input, executionStore) => {
|
|
99
145
|
const scheduleYaml = requireCompiledSchedule(input.plan);
|
|
100
146
|
const namespace = requireSubmitNamespace(input.options);
|
|
101
147
|
const nodes = compileScheduleArtifact(input.plan.config, parseScheduleArtifact(scheduleYaml, "schedule.yaml"), input.worktreePath).plan.topologicalOrder;
|
|
@@ -127,17 +173,44 @@ const submitWaveJobs = async (input) => {
|
|
|
127
173
|
nodeIds: [...nodeIds]
|
|
128
174
|
}, jobsDependencies);
|
|
129
175
|
}
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
|
|
176
|
+
}).pipe(Effect.tap((submitted) => Effect.forEach(submitted, (job) => executionStore.recordSubmitted({
|
|
177
|
+
nodeId: job.nodeId,
|
|
178
|
+
runId: input.plan.runId
|
|
179
|
+
}), {
|
|
180
|
+
concurrency: 1,
|
|
181
|
+
discard: true
|
|
182
|
+
})));
|
|
183
|
+
return {
|
|
184
|
+
namespace,
|
|
133
185
|
nodes,
|
|
134
|
-
now: () => DateTime.formatIso(DateTime.nowUnsafe()),
|
|
135
186
|
runId: input.plan.runId,
|
|
136
187
|
submitReadyNodes
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* The `SubmitMokaJobsTransport.submitJobs` seam (ENG-57.1/57.2/57.10): submit a
|
|
192
|
+
* run's FIRST wave as per-node Jobs over the coordinator's SHARED durable
|
|
193
|
+
* {@link ExecutionStore} (never a fresh in-memory one), registering the run's
|
|
194
|
+
* {@link WaveTickContext} so the watch loop re-ticks later waves as predecessors
|
|
195
|
+
* terminalize. Compiles the schedule, then runs a single {@link advanceWave} tick
|
|
196
|
+
* — resolving to the root wave (nodes with no predecessors), fanning parallel
|
|
197
|
+
* roots out and NEVER eagerly submitting a downstream node. Later waves are the
|
|
198
|
+
* reconcile driver's concern ({@link runWaveTick}). One scheduler for static and
|
|
199
|
+
* dynamic (post-schedule) runs alike.
|
|
200
|
+
*/
|
|
201
|
+
const makeSubmitWaveJobs = (deps) => async (input) => {
|
|
202
|
+
const context = compileWaveContext(input, deps.executionStore);
|
|
203
|
+
deps.registerWaveContext(context);
|
|
204
|
+
const result = await runWaveEffect(advanceWave({
|
|
205
|
+
executionStore: deps.executionStore,
|
|
206
|
+
nodes: context.nodes,
|
|
207
|
+
now: () => DateTime.formatIso(DateTime.nowUnsafe()),
|
|
208
|
+
runId: context.runId,
|
|
209
|
+
submitReadyNodes: context.submitReadyNodes
|
|
137
210
|
}));
|
|
138
211
|
return parseJobsSubmitResult({
|
|
139
212
|
jobs: [...result.submitted],
|
|
140
|
-
namespace,
|
|
213
|
+
namespace: context.namespace,
|
|
141
214
|
runId: input.plan.runId,
|
|
142
215
|
waves: [{ nodeIds: [...result.plan.ready] }],
|
|
143
216
|
workflowName: input.plan.workflowId,
|
|
@@ -145,4 +218,4 @@ const submitWaveJobs = async (input) => {
|
|
|
145
218
|
});
|
|
146
219
|
};
|
|
147
220
|
//#endregion
|
|
148
|
-
export { WaveSubmitMissingNodeError, advanceWave,
|
|
221
|
+
export { WaveSubmitMissingNodeError, advanceWave, compileWaveContext, makeSubmitWaveJobs, recordClusterTerminals, runWaveTick };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { isTerminalRunStatus } from "../remote/jobs/job-outcome.js";
|
|
2
|
+
import { runWaveTick } from "./wave-advance.js";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Option$1 from "effect/Option";
|
|
5
|
+
import * as HashMap from "effect/HashMap";
|
|
6
|
+
import * as Ref from "effect/Ref";
|
|
7
|
+
//#region src/coordinator/wave-tick-registry.ts
|
|
8
|
+
const makeWaveTickRegistry = () => Ref.make(HashMap.empty()).pipe(Effect.map((ref) => ({
|
|
9
|
+
get: (runId) => Ref.get(ref).pipe(Effect.map((map) => HashMap.get(map, runId))),
|
|
10
|
+
register: (context) => Ref.update(ref, (map) => HashMap.set(map, context.runId, context)),
|
|
11
|
+
remove: (runId) => Ref.update(ref, (map) => HashMap.remove(map, runId))
|
|
12
|
+
})));
|
|
13
|
+
/** A run whose reconcile report shows a terminal run status is finished — drop
|
|
14
|
+
* its context so it never re-ticks; nothing left to advance. */
|
|
15
|
+
const isReportTerminal = (report) => Option$1.match(report.runStatus, {
|
|
16
|
+
onNone: () => false,
|
|
17
|
+
onSome: (status) => isTerminalRunStatus(status)
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* Advance one reconciled run one wave-tick. A run with a terminal status is
|
|
21
|
+
* dropped from the registry. A run with a registered context is folded through
|
|
22
|
+
* {@link runWaveTick} over the SHARED durable ExecutionStore — releasing any wave
|
|
23
|
+
* whose predecessors just terminalized — and dropped once the whole run is
|
|
24
|
+
* complete. A run with no registered context (e.g. not re-driven after a restart)
|
|
25
|
+
* is a no-op: the reconcile loop still finalized it against cluster truth.
|
|
26
|
+
*/
|
|
27
|
+
const advanceReconciledRun = (deps, report) => {
|
|
28
|
+
if (isReportTerminal(report)) return deps.registry.remove(report.runId);
|
|
29
|
+
return deps.registry.get(report.runId).pipe(Effect.flatMap(Option$1.match({
|
|
30
|
+
onNone: () => Effect.void,
|
|
31
|
+
onSome: (context) => runWaveTick({
|
|
32
|
+
executionStore: deps.executionStore,
|
|
33
|
+
fleet: deps.fleet,
|
|
34
|
+
namespace: context.namespace,
|
|
35
|
+
nodes: context.nodes,
|
|
36
|
+
now: deps.now,
|
|
37
|
+
runId: context.runId,
|
|
38
|
+
submitReadyNodes: context.submitReadyNodes
|
|
39
|
+
}).pipe(Effect.flatMap((result) => result.plan.runComplete ? deps.registry.remove(context.runId) : Effect.void))
|
|
40
|
+
})));
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* ENG-57.10: the per-reconcile-cycle wave advancement the coordinator watch loop
|
|
44
|
+
* runs after {@link reconcileAllRuns}. For every run in the cycle's reports it
|
|
45
|
+
* runs one {@link runWaveTick} over the ONE shared durable ExecutionStore, so a
|
|
46
|
+
* later wave submits only once its predecessors terminalize — the missing driver
|
|
47
|
+
* that advanced a multi-wave graph past wave 1. Idempotent: re-running a tick with
|
|
48
|
+
* unchanged inputs re-observes existing Jobs (create-or-observe) and re-reads the
|
|
49
|
+
* ExecutionStore CAS, never a duplicate Job.
|
|
50
|
+
*/
|
|
51
|
+
const advanceReconciledWaves = (deps, reports) => Effect.forEach(reports, (report) => advanceReconciledRun(deps, report), {
|
|
52
|
+
concurrency: 1,
|
|
53
|
+
discard: true
|
|
54
|
+
});
|
|
55
|
+
//#endregion
|
|
56
|
+
export { advanceReconciledWaves, makeWaveTickRegistry };
|
|
@@ -42,7 +42,7 @@ const OUTCOME_RULES = [
|
|
|
42
42
|
status: "failed"
|
|
43
43
|
},
|
|
44
44
|
{
|
|
45
|
-
matches: ({ job }) => job === void 0,
|
|
45
|
+
matches: ({ job }, submitted) => job === void 0 && submitted,
|
|
46
46
|
reason: "job-lost",
|
|
47
47
|
status: "failed"
|
|
48
48
|
},
|
|
@@ -57,15 +57,24 @@ const OUTCOME_RULES = [
|
|
|
57
57
|
* `pending` (no terminal signal yet — leave it running). Only ever consulted
|
|
58
58
|
* for nodes the store still holds non-terminal, so a `finalize` result never
|
|
59
59
|
* overwrites a node that already reported its own outcome.
|
|
60
|
+
*
|
|
61
|
+
* ENG-57.10: `submitted` (default `true`) scopes the dead-Job (`job-lost`)
|
|
62
|
+
* sweeper. Whole-run callers submit every node up front and omit it (keeping the
|
|
63
|
+
* always-finalize net). Incremental wave callers pass the run's durable
|
|
64
|
+
* submitted-set membership so a not-yet-submitted future wave stays `pending`
|
|
65
|
+
* instead of being terminalized failed.
|
|
60
66
|
*/
|
|
61
|
-
const classifyNodeOutcome = (observation
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
kind: "
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
const classifyNodeOutcome = (observation, context = {}) => {
|
|
68
|
+
const submitted = context.submitted ?? true;
|
|
69
|
+
return Option$1.match(Arr.findFirst(OUTCOME_RULES, (candidate) => candidate.matches(observation, submitted)), {
|
|
70
|
+
onNone: () => ({ kind: "pending" }),
|
|
71
|
+
onSome: (rule) => ({
|
|
72
|
+
kind: "finalize",
|
|
73
|
+
reason: rule.reason,
|
|
74
|
+
status: rule.status
|
|
75
|
+
})
|
|
76
|
+
});
|
|
77
|
+
};
|
|
69
78
|
const TERMINAL_NODE_STATUSES = [
|
|
70
79
|
"passed",
|
|
71
80
|
"failed",
|
|
@@ -3,6 +3,7 @@ import * as Effect from "effect/Effect";
|
|
|
3
3
|
import * as Option$1 from "effect/Option";
|
|
4
4
|
import * as R from "effect/Record";
|
|
5
5
|
import * as Arr from "effect/Array";
|
|
6
|
+
import * as HashSet from "effect/HashSet";
|
|
6
7
|
//#region src/remote/jobs/reconcile.ts
|
|
7
8
|
/** A node absent from the cluster observation has no Job present — the dead-Job
|
|
8
9
|
* signal the classifier finalizes to failed. */
|
|
@@ -10,12 +11,12 @@ const observationForNode = (observation, nodeId) => Option$1.getOrElse(R.get(obs
|
|
|
10
11
|
const emit = (deps, event) => Effect.sync(() => {
|
|
11
12
|
deps.onEvent?.(event);
|
|
12
13
|
});
|
|
13
|
-
const reconcileNode = (deps, runId, observation, nodeId, currentStatus) => {
|
|
14
|
+
const reconcileNode = (deps, runId, observation, nodeId, currentStatus, submitted) => {
|
|
14
15
|
if (isTerminalNodeStatus(currentStatus)) return Effect.succeed({
|
|
15
16
|
finalization: Option$1.none(),
|
|
16
17
|
status: currentStatus
|
|
17
18
|
});
|
|
18
|
-
const outcome = classifyNodeOutcome(observationForNode(observation, nodeId));
|
|
19
|
+
const outcome = classifyNodeOutcome(observationForNode(observation, nodeId), { submitted });
|
|
19
20
|
if (outcome.kind === "pending") return Effect.succeed({
|
|
20
21
|
finalization: Option$1.none(),
|
|
21
22
|
status: currentStatus
|
|
@@ -77,7 +78,9 @@ const reconcileRun = (deps, runId) => Effect.gen(function* body() {
|
|
|
77
78
|
namespace: deps.namespace,
|
|
78
79
|
runId
|
|
79
80
|
});
|
|
80
|
-
const
|
|
81
|
+
const submittedSet = deps.submitted === void 0 ? void 0 : yield* deps.submitted(runId);
|
|
82
|
+
const isSubmitted = (nodeId) => submittedSet === void 0 || HashSet.has(submittedSet, nodeId);
|
|
83
|
+
const results = yield* Effect.forEach(R.toEntries(manifest.nodes), ([nodeId, status]) => reconcileNode(deps, runId, observation, nodeId, status, isSubmitted(nodeId)), { concurrency: 1 });
|
|
81
84
|
const finalizedNodes = results.flatMap((result) => Arr.fromOption(result.finalization));
|
|
82
85
|
const runStatus = aggregateRunStatus(results.map((result) => result.status));
|
|
83
86
|
yield* finalizeRun(deps, runId, runStatus);
|
|
Binary file
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { mokaPostgresSchema } from "../../runtime/durable-store/postgres/schema.js";
|
|
2
|
+
import { integer, primaryKey, text, timestamp } from "drizzle-orm/pg-core";
|
|
3
|
+
//#region src/run-control/postgres/execution-schema.ts
|
|
4
|
+
/**
|
|
5
|
+
* ENG-57.6: Drizzle schema backing the Postgres {@link ExecutionStore}. Exactly
|
|
6
|
+
* one row per `(run_id, node_id)` holds that node's AUTHORITATIVE terminal
|
|
7
|
+
* execution — the `generation`/`attempt` that won the compare-and-set, its
|
|
8
|
+
* terminal `status`, and the server wall time it terminalized.
|
|
9
|
+
*
|
|
10
|
+
* The CAS is enforced at the database layer: the write is a guarded upsert whose
|
|
11
|
+
* `ON CONFLICT ... DO UPDATE` only fires when the incoming `(generation, attempt)`
|
|
12
|
+
* is strictly newer than the stored row, so a late or replayed older execution
|
|
13
|
+
* can never overwrite a newer terminal state even under concurrent writers.
|
|
14
|
+
*
|
|
15
|
+
* Lives in the shared `moka` substrate schema alongside the run-control and
|
|
16
|
+
* durable-store tables. Deliberately carries no foreign key to a run table: an
|
|
17
|
+
* execution can be terminalized independently of the event-sourced run-control
|
|
18
|
+
* row, and the coordinator owns `(run_id, node_id)` as the sole key.
|
|
19
|
+
*/
|
|
20
|
+
const execution = mokaPostgresSchema.table("moka_execution", {
|
|
21
|
+
attempt: integer("attempt").notNull(),
|
|
22
|
+
generation: integer("generation").notNull(),
|
|
23
|
+
nodeId: text("node_id").notNull(),
|
|
24
|
+
runId: text("run_id").notNull(),
|
|
25
|
+
status: text("status").$type().notNull(),
|
|
26
|
+
terminalizedAt: timestamp("terminalized_at", { withTimezone: true }).defaultNow().notNull()
|
|
27
|
+
}, (table) => [primaryKey({ columns: [table.runId, table.nodeId] })]);
|
|
28
|
+
/**
|
|
29
|
+
* ENG-57.10: the durable SUBMITTED-SET the dead-Job sweeper scopes to. One row per
|
|
30
|
+
* `(run_id, node_id)` records that the wave scheduler actually submitted that
|
|
31
|
+
* node's Job. First-writer-wins (`ON CONFLICT DO NOTHING`): a node is submitted
|
|
32
|
+
* once per run and stays submitted. The sweeper only finalizes a node absent from
|
|
33
|
+
* the cluster as `job-lost` when it appears here — so a not-yet-submitted future
|
|
34
|
+
* wave stays pending instead of being terminalized failed, while a submitted node
|
|
35
|
+
* whose Job later vanished still fails.
|
|
36
|
+
*/
|
|
37
|
+
const executionSubmission = mokaPostgresSchema.table("moka_execution_submission", {
|
|
38
|
+
nodeId: text("node_id").notNull(),
|
|
39
|
+
runId: text("run_id").notNull(),
|
|
40
|
+
submittedAt: timestamp("submitted_at", { withTimezone: true }).defaultNow().notNull()
|
|
41
|
+
}, (table) => [primaryKey({ columns: [table.runId, table.nodeId] })]);
|
|
42
|
+
//#endregion
|
|
43
|
+
export { execution, executionSubmission };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { requireMokaDbUrl } from "../../moka-global-config.js";
|
|
2
|
+
import { migratePostgresSubstrate } from "../../runtime/durable-store/postgres/migrate-substrate.js";
|
|
3
|
+
import { openMokaSubstrateClient } from "../../runtime/durable-store/postgres/client.js";
|
|
4
|
+
import { parseStoredExecution, toStoredExecution } from "../execution-store.js";
|
|
5
|
+
import { execution, executionSubmission } from "./execution-schema.js";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
import * as Option$1 from "effect/Option";
|
|
8
|
+
import * as HashSet from "effect/HashSet";
|
|
9
|
+
import { drizzle } from "drizzle-orm/postgres-js";
|
|
10
|
+
import { and, eq, lt, or } from "drizzle-orm";
|
|
11
|
+
//#region src/run-control/postgres/postgres-execution-store.ts
|
|
12
|
+
/** Apply the shared Drizzle migrations (delegates to the lock-guarded substrate migrator). */
|
|
13
|
+
const migratePostgresExecutionStore = async (dbUrl) => {
|
|
14
|
+
await migratePostgresSubstrate(dbUrl);
|
|
15
|
+
};
|
|
16
|
+
const dbEffect = (run) => Effect.tryPromise({
|
|
17
|
+
catch: (error) => error,
|
|
18
|
+
try: run
|
|
19
|
+
});
|
|
20
|
+
const rowToStored = (row) => parseStoredExecution({
|
|
21
|
+
attempt: row.attempt,
|
|
22
|
+
generation: row.generation,
|
|
23
|
+
nodeId: row.nodeId,
|
|
24
|
+
runId: row.runId,
|
|
25
|
+
status: row.status,
|
|
26
|
+
terminalizedAt: row.terminalizedAt.toISOString()
|
|
27
|
+
});
|
|
28
|
+
const readExecution = (db, runId, nodeId) => Effect.gen(function* body() {
|
|
29
|
+
const row = (yield* dbEffect(() => db.select().from(execution).where(and(eq(execution.runId, runId), eq(execution.nodeId, nodeId))).limit(1))).at(0);
|
|
30
|
+
return row === void 0 ? Option$1.none() : Option$1.some(rowToStored(row));
|
|
31
|
+
});
|
|
32
|
+
const recordTerminal = (db, request) => Effect.gen(function* body() {
|
|
33
|
+
const incoming = toStoredExecution(request);
|
|
34
|
+
const values = {
|
|
35
|
+
attempt: incoming.attempt,
|
|
36
|
+
generation: incoming.generation,
|
|
37
|
+
nodeId: incoming.nodeId,
|
|
38
|
+
runId: incoming.runId,
|
|
39
|
+
status: incoming.status,
|
|
40
|
+
terminalizedAt: new Date(incoming.terminalizedAt)
|
|
41
|
+
};
|
|
42
|
+
const appliedRow = (yield* dbEffect(() => db.insert(execution).values(values).onConflictDoUpdate({
|
|
43
|
+
set: {
|
|
44
|
+
attempt: values.attempt,
|
|
45
|
+
generation: values.generation,
|
|
46
|
+
status: values.status,
|
|
47
|
+
terminalizedAt: values.terminalizedAt
|
|
48
|
+
},
|
|
49
|
+
setWhere: or(lt(execution.generation, values.generation), and(eq(execution.generation, values.generation), lt(execution.attempt, values.attempt))),
|
|
50
|
+
target: [execution.runId, execution.nodeId]
|
|
51
|
+
}).returning())).at(0);
|
|
52
|
+
if (appliedRow !== void 0) return {
|
|
53
|
+
decision: "applied",
|
|
54
|
+
record: rowToStored(appliedRow)
|
|
55
|
+
};
|
|
56
|
+
const current = yield* readExecution(db, incoming.runId, incoming.nodeId);
|
|
57
|
+
const surviving = Option$1.getOrElse(current, () => incoming);
|
|
58
|
+
return {
|
|
59
|
+
decision: surviving.generation === incoming.generation && surviving.attempt === incoming.attempt ? "idempotent" : "rejected-stale",
|
|
60
|
+
record: surviving
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
const recordSubmitted = (db, runId, nodeId) => dbEffect(() => db.insert(executionSubmission).values({
|
|
64
|
+
nodeId,
|
|
65
|
+
runId
|
|
66
|
+
}).onConflictDoNothing()).pipe(Effect.asVoid);
|
|
67
|
+
const listSubmitted = (db, runId) => dbEffect(() => db.select({ nodeId: executionSubmission.nodeId }).from(executionSubmission).where(eq(executionSubmission.runId, runId))).pipe(Effect.map((rows) => HashSet.fromIterable(rows.map((row) => row.nodeId))));
|
|
68
|
+
const postgresExecutionStore = (dbUrl) => {
|
|
69
|
+
const client = openMokaSubstrateClient(dbUrl);
|
|
70
|
+
const db = drizzle(client);
|
|
71
|
+
return {
|
|
72
|
+
close: async () => {
|
|
73
|
+
await client.end();
|
|
74
|
+
},
|
|
75
|
+
getExecution: (input) => readExecution(db, input.runId, input.nodeId),
|
|
76
|
+
listSubmitted: (input) => listSubmitted(db, input.runId),
|
|
77
|
+
recordSubmitted: (input) => recordSubmitted(db, input.runId, input.nodeId),
|
|
78
|
+
recordTerminal: (request) => recordTerminal(db, request)
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* ENG-57.10: the scoped durable {@link ExecutionStore} the coordinator shares
|
|
83
|
+
* between `submit()` and the watch loop. `db.url` presence is the durable-substrate
|
|
84
|
+
* switch (mirroring {@link resolveRunControlStore}): set → the Postgres store,
|
|
85
|
+
* migrated then bound as a scoped resource whose connection is released on scope
|
|
86
|
+
* exit; absent → `db.url-required` before any store is bound. Because the store's
|
|
87
|
+
* connection is acquired via {@link Effect.acquireRelease}, closing the coordinator
|
|
88
|
+
* scope releases it exactly once — no leaked connection even if the watch fiber is
|
|
89
|
+
* interrupted.
|
|
90
|
+
*/
|
|
91
|
+
const resolveExecutionStore = (dbUrl) => requireMokaDbUrl(dbUrl).pipe(Effect.flatMap((requiredDbUrl) => Effect.tryPromise({
|
|
92
|
+
catch: (error) => error,
|
|
93
|
+
try: async () => {
|
|
94
|
+
await migratePostgresExecutionStore(requiredDbUrl);
|
|
95
|
+
}
|
|
96
|
+
}).pipe(Effect.flatMap(() => Effect.acquireRelease(Effect.sync(() => postgresExecutionStore(requiredDbUrl)), (store) => Effect.tryPromise({
|
|
97
|
+
catch: (error) => error,
|
|
98
|
+
try: async () => {
|
|
99
|
+
await store.close();
|
|
100
|
+
}
|
|
101
|
+
}).pipe(Effect.orDie))))));
|
|
102
|
+
//#endregion
|
|
103
|
+
export { migratePostgresExecutionStore, postgresExecutionStore, resolveExecutionStore };
|