@norskvideo/norsk-auto-manager 0.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/README.md +344 -0
- package/lib/src/automanager.d.ts +1070 -0
- package/lib/src/automanager.js +2746 -0
- package/lib/src/clock.d.ts +14 -0
- package/lib/src/clock.js +21 -0
- package/lib/src/conversions.d.ts +59 -0
- package/lib/src/conversions.js +416 -0
- package/lib/src/index.d.ts +9 -0
- package/lib/src/index.js +37 -0
- package/lib/src/inventory.d.ts +34 -0
- package/lib/src/inventory.js +64 -0
- package/lib/src/knownCapabilities.d.ts +18 -0
- package/lib/src/knownCapabilities.js +22 -0
- package/lib/src/placement.d.ts +268 -0
- package/lib/src/placement.js +468 -0
- package/lib/src/settingsValidation.d.ts +34 -0
- package/lib/src/settingsValidation.js +232 -0
- package/lib/src/shared/utils.d.ts +17 -0
- package/lib/src/shared/utils.js +220 -0
- package/lib/src/types.d.ts +295 -0
- package/lib/src/types.js +20 -0
- package/lib/src/validation.d.ts +68 -0
- package/lib/src/validation.js +198 -0
- package/package.json +66 -0
- package/src/automanager.ts +3570 -0
- package/src/clock.ts +29 -0
- package/src/conversions.ts +458 -0
- package/src/index.ts +27 -0
- package/src/inventory.ts +64 -0
- package/src/knownCapabilities.ts +23 -0
- package/src/placement.ts +804 -0
- package/src/settingsValidation.ts +386 -0
- package/src/types.ts +346 -0
- package/src/validation.ts +297 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,2746 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.AutoManager = exports.NODE_SERVICE_ROLE = exports.MIGRATION_TARGET_ROLE = void 0;
|
|
37
|
+
exports.getJobIdForNode = getJobIdForNode;
|
|
38
|
+
exports.currentNodeIdForJob = currentNodeIdForJob;
|
|
39
|
+
const util = __importStar(require("util"));
|
|
40
|
+
const uuid = __importStar(require("uuid"));
|
|
41
|
+
const clock_1 = require("./clock");
|
|
42
|
+
const types_1 = require("./types");
|
|
43
|
+
const validation_1 = require("./validation");
|
|
44
|
+
const settingsValidation_1 = require("./settingsValidation");
|
|
45
|
+
const conversions_1 = require("./conversions");
|
|
46
|
+
const placement_1 = require("./placement");
|
|
47
|
+
const norsk_manager_sdk_1 = require("@norskvideo/norsk-manager-sdk");
|
|
48
|
+
/** Fixed role for the migration-target instance. No concurrent migrations per Job. @public */
|
|
49
|
+
exports.MIGRATION_TARGET_ROLE = "migrationTarget";
|
|
50
|
+
/**
|
|
51
|
+
* Fixed role assigned to runtime jobs that exist to back a
|
|
52
|
+
* NodeService. Worker reads `WorkerJob.role === NODE_SERVICE_ROLE`
|
|
53
|
+
* to recognise the instance as a service rather than a regular
|
|
54
|
+
* workload, and adds the service's `provides[]` capabilities to its
|
|
55
|
+
* inventory once the job reaches `ready`.
|
|
56
|
+
*
|
|
57
|
+
* @public
|
|
58
|
+
*/
|
|
59
|
+
exports.NODE_SERVICE_ROLE = "nodeService";
|
|
60
|
+
const DEFAULT_REJECTION_BACKOFF_MS = 5000;
|
|
61
|
+
const DEFAULT_REJECTION_ESCALATION_COUNT = 3;
|
|
62
|
+
const DEFAULT_FAILURE_BACKOFF_MS = 30000;
|
|
63
|
+
const DEFAULT_MAX_RESTARTS_PER_HOUR = 6;
|
|
64
|
+
const RESTART_WINDOW_MS = 60 * 60 * 1000;
|
|
65
|
+
// Minimum spacing between consecutive recovery placement attempts for
|
|
66
|
+
// the same JobId. Stops the "provisionJob → JobFailed → provisionJob"
|
|
67
|
+
// tight loop that JobStore state-transition events naturally drive
|
|
68
|
+
// when worker-side launches keep failing fast. 5s is short enough that
|
|
69
|
+
// transient failures still recover quickly but long enough that even a
|
|
70
|
+
// pathological failure pattern doesn't burn through `maxRestartsPerHour`
|
|
71
|
+
// in seconds. Operator-tunable later via AutoSettings.
|
|
72
|
+
const MIN_RECOVERY_INTERVAL_MS = 5000;
|
|
73
|
+
// Minimum spacing between any `placeAndProvision` entries for the same
|
|
74
|
+
// JobId. Tighter than MIN_RECOVERY_INTERVAL_MS — the window we need is
|
|
75
|
+
// just "time for daemon's provisionJob to land + JobStarted to round-
|
|
76
|
+
// trip back into AutoManager's nodeMap". A second is comfortably more
|
|
77
|
+
// than typical local-host dispatch latency.
|
|
78
|
+
const MIN_PLACEMENT_INTERVAL_MS = 1000;
|
|
79
|
+
const DEFAULT_JOB_STARTUP_GRACE_MS = 120000;
|
|
80
|
+
const DEFAULT_HOT_SPARE_RECONCILE_INTERVAL_MS = 60000;
|
|
81
|
+
const DEFAULT_NODE_SERVICE_RECONCILE_INTERVAL_MS = 60000;
|
|
82
|
+
// Phase E phase-deadline defaults. Tuned to the design's expectation
|
|
83
|
+
// that target launch + workflow init typically completes well under
|
|
84
|
+
// two minutes, source-stop is workflow-driven and quick, and apply
|
|
85
|
+
// is a handful of wire hops.
|
|
86
|
+
const DEFAULT_MIGRATION_LAUNCHING_DEADLINE_MS = 120000;
|
|
87
|
+
const DEFAULT_MIGRATION_TARGET_READY_DEADLINE_MS = 120000;
|
|
88
|
+
const DEFAULT_MIGRATION_SOURCE_STOP_DEADLINE_MS = 30000;
|
|
89
|
+
const DEFAULT_MIGRATION_APPLYING_DEADLINE_MS = 10000;
|
|
90
|
+
class AutoManager {
|
|
91
|
+
/** @public */
|
|
92
|
+
static async run(settings) {
|
|
93
|
+
// Phase D1 — fail fast on configuration mistakes.
|
|
94
|
+
const validationErrors = (0, settingsValidation_1.validateAutoSettings)(settings);
|
|
95
|
+
if (validationErrors.length > 0) {
|
|
96
|
+
throw new settingsValidation_1.AutoSettingsValidationError(validationErrors);
|
|
97
|
+
}
|
|
98
|
+
debuglog("Trying to connect", { url: settings.url });
|
|
99
|
+
const fire = (state) => settings.onConnectionStateChange?.(state);
|
|
100
|
+
try {
|
|
101
|
+
const norsk = await norsk_manager_sdk_1.NorskManager.connect({
|
|
102
|
+
url: settings.url,
|
|
103
|
+
signal: settings.signal,
|
|
104
|
+
onAttemptingToConnect: () => fire("idle"),
|
|
105
|
+
onConnecting: () => fire("connecting"),
|
|
106
|
+
onReady: () => fire("ready"),
|
|
107
|
+
onFailedToConnect: () => fire("transientFailure"),
|
|
108
|
+
onShutdown: () => fire("shutdown"),
|
|
109
|
+
});
|
|
110
|
+
const autoManager = new AutoManager(norsk, settings);
|
|
111
|
+
const promise = autoManager.runInternal();
|
|
112
|
+
return [autoManager, promise];
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
debuglog("Exception in connection");
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** @public */
|
|
120
|
+
async terminateNode(nodeId) {
|
|
121
|
+
await this.norsk.terminateNode(nodeId);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Phase C8 — cordon a node. AutoManager projects this onto every
|
|
125
|
+
* placement decision: cordoned nodes are filtered out, so no new
|
|
126
|
+
* placements land on them. Existing jobs continue running.
|
|
127
|
+
* Idempotent.
|
|
128
|
+
* @public
|
|
129
|
+
*/
|
|
130
|
+
cordonNode(nodeId) {
|
|
131
|
+
if (this.cordonedNodes.has(nodeId))
|
|
132
|
+
return;
|
|
133
|
+
this.cordonedNodes.add(nodeId);
|
|
134
|
+
debuglog("Node cordoned", { nodeId });
|
|
135
|
+
// Reconcile in case a cordoned node was holding a spare slot —
|
|
136
|
+
// its un-spare-ness now opens the underflow path. Same for any
|
|
137
|
+
// NodeServices placed on the now-cordoned node.
|
|
138
|
+
this.reconcileHotSpares();
|
|
139
|
+
this.reconcileNodeServices();
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Phase C8 — un-cordon a node, restoring it to the placement pool.
|
|
143
|
+
* Idempotent.
|
|
144
|
+
* @public
|
|
145
|
+
*/
|
|
146
|
+
uncordonNode(nodeId) {
|
|
147
|
+
if (!this.cordonedNodes.delete(nodeId))
|
|
148
|
+
return;
|
|
149
|
+
debuglog("Node uncordoned", { nodeId });
|
|
150
|
+
this.reconcileHotSpares();
|
|
151
|
+
this.reconcileNodeServices();
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Phase C8 — whether a node is currently cordoned by AutoManager.
|
|
155
|
+
* @public
|
|
156
|
+
*/
|
|
157
|
+
isCordoned(nodeId) {
|
|
158
|
+
return this.cordonedNodes.has(nodeId);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Phase C8 — drain a node. Cordons it (so no new placements land
|
|
162
|
+
* here) and terminates the node — Manager emits `nodeStopping`
|
|
163
|
+
* for every job hosted there, and the existing band-aware recovery
|
|
164
|
+
* path re-places them onto other nodes (excluding this cordoned
|
|
165
|
+
* one). Bronze jobs are dropped; gold prefers hot spares.
|
|
166
|
+
*
|
|
167
|
+
* v1 takes the immediate-terminate path; the design's "graceful
|
|
168
|
+
* shuffle without recovery gap" needs Phase E job migration to
|
|
169
|
+
* land first.
|
|
170
|
+
* @public
|
|
171
|
+
*/
|
|
172
|
+
async drainNode(nodeId) {
|
|
173
|
+
this.cordonNode(nodeId);
|
|
174
|
+
await this.norsk.terminateNode(nodeId);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Shut down this AutoManager. Cancels periodic intervals and closes
|
|
178
|
+
* the underlying NorskManager — which causes the event-loop iterator
|
|
179
|
+
* to terminate, resolving the `Promise<void>` returned from `run()`.
|
|
180
|
+
* Idempotent.
|
|
181
|
+
* @public
|
|
182
|
+
*/
|
|
183
|
+
close() {
|
|
184
|
+
if (this.closed)
|
|
185
|
+
return;
|
|
186
|
+
this.closed = true;
|
|
187
|
+
this.cleanupInterval?.cancel();
|
|
188
|
+
this.cleanupInterval = undefined;
|
|
189
|
+
this.hotSpareReconcileInterval?.cancel();
|
|
190
|
+
this.hotSpareReconcileInterval = undefined;
|
|
191
|
+
this.nodeServiceReconcileInterval?.cancel();
|
|
192
|
+
this.nodeServiceReconcileInterval = undefined;
|
|
193
|
+
for (const t of this.startupGraceTimers.values())
|
|
194
|
+
t.cancel();
|
|
195
|
+
this.startupGraceTimers.clear();
|
|
196
|
+
for (const t of this.migrationPhaseTimers.values())
|
|
197
|
+
t.cancel();
|
|
198
|
+
this.migrationPhaseTimers.clear();
|
|
199
|
+
for (const t of this.pendingPlacementRetries.values())
|
|
200
|
+
clearTimeout(t);
|
|
201
|
+
this.pendingPlacementRetries.clear();
|
|
202
|
+
this.lastPlacementAttemptAt.clear();
|
|
203
|
+
this.norsk.close();
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Alias for `close()`. Provided for symmetry with the
|
|
207
|
+
* `using`-style disposal conventions emerging in TypeScript.
|
|
208
|
+
* @public
|
|
209
|
+
*/
|
|
210
|
+
dispose() {
|
|
211
|
+
this.close();
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* AutoManager v1 — submit a new bundle. The bundle is validated by
|
|
215
|
+
* Manager (per §4.3) and expanded into N replica jobs server-side;
|
|
216
|
+
* AutoManager mirrors the resulting state via the bundleUpdated +
|
|
217
|
+
* jobUpdated events. The returned bundleId matches the one Manager
|
|
218
|
+
* assigns (typically the same as bundle.bundleId, but Manager has
|
|
219
|
+
* the final word).
|
|
220
|
+
*
|
|
221
|
+
* @public
|
|
222
|
+
*/
|
|
223
|
+
async createBundle(bundle) {
|
|
224
|
+
return this.norsk.createBundle((0, conversions_1.toPbBundle)(bundle));
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* AutoManager v1 — update an existing bundle.
|
|
228
|
+
* @public
|
|
229
|
+
*/
|
|
230
|
+
async updateBundle(bundle) {
|
|
231
|
+
await this.norsk.updateBundle((0, conversions_1.toPbBundle)(bundle));
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* AutoManager v1 — delete a bundle and all its replica jobs.
|
|
235
|
+
* @public
|
|
236
|
+
*/
|
|
237
|
+
async deleteBundle(bundleId) {
|
|
238
|
+
await this.norsk.deleteBundle(bundleId);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* AutoManager v1 — activate a dormant (start_mode = "manual") bundle
|
|
242
|
+
* job. Manager removes the dormant tag and re-emits jobUpdated;
|
|
243
|
+
* AutoManager's placement path picks the job up via the standard
|
|
244
|
+
* flow. See §10.2 of the design.
|
|
245
|
+
* @public
|
|
246
|
+
*/
|
|
247
|
+
async startBundleJob(bundleId, replicaIndex, jobName) {
|
|
248
|
+
await this.norsk.startBundleJob(bundleId, replicaIndex, jobName);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* AutoManager v1 — deactivate a bundle job (tag-flip).
|
|
252
|
+
*
|
|
253
|
+
* Runtime termination of an in-flight workload and empty-node
|
|
254
|
+
* release with linger are pre-go-live work tracked as Phase E.5
|
|
255
|
+
* (§10.2.1). Until they land, calling stopBundleJob on an active
|
|
256
|
+
* job marks it dormant for future placement but does not stop the
|
|
257
|
+
* currently-running worker.
|
|
258
|
+
* @public
|
|
259
|
+
*/
|
|
260
|
+
async stopBundleJob(bundleId, replicaIndex, jobName) {
|
|
261
|
+
await this.norsk.stopBundleJob(bundleId, replicaIndex, jobName);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* AutoManager v1 — typed view of the jobs Manager has told us about.
|
|
265
|
+
* `JobWithHistory` carries the state-transition log for each job so
|
|
266
|
+
* consumers can render lifecycle timelines without a separate
|
|
267
|
+
* round-trip.
|
|
268
|
+
* @public
|
|
269
|
+
*/
|
|
270
|
+
jobs() {
|
|
271
|
+
return Array.from(this.jobMap.values());
|
|
272
|
+
}
|
|
273
|
+
nodes() {
|
|
274
|
+
debuglog("Getting nodes", this.nodeMap);
|
|
275
|
+
return Array.from(this.nodeMap.values());
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* AutoManager v1 — typed view of the bundle definitions Manager has
|
|
279
|
+
* told us about. Mirror only; AutoManager owns no durable state.
|
|
280
|
+
* @public
|
|
281
|
+
*/
|
|
282
|
+
bundles() {
|
|
283
|
+
return Array.from(this.bundleMap.values());
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Phase D4 — snapshot of AutoManager's operational metrics.
|
|
287
|
+
* Cumulative counters since `startedAt`; per-pool gauges computed
|
|
288
|
+
* at call time. Cheap to call repeatedly — consumers poll on their
|
|
289
|
+
* own cadence and compute deltas if they want rates.
|
|
290
|
+
* @public
|
|
291
|
+
*/
|
|
292
|
+
metrics() {
|
|
293
|
+
const now = this.clock.now();
|
|
294
|
+
const uptimeMs = this.startedAt
|
|
295
|
+
? now.getTime() - this.startedAt.getTime()
|
|
296
|
+
: 0;
|
|
297
|
+
const poolNames = new Set();
|
|
298
|
+
for (const p of this.settings.placementPools ?? [])
|
|
299
|
+
poolNames.add(p.name);
|
|
300
|
+
// Include any pool name we've seen on a node, in case nodes are
|
|
301
|
+
// tagged with a pool that isn't in placementPools (rare but worth
|
|
302
|
+
// surfacing — operator's likely misconfigured).
|
|
303
|
+
for (const node of this.nodeMap.values()) {
|
|
304
|
+
const pn = node.nodeMetadata?.tags?.["pool"];
|
|
305
|
+
if (pn)
|
|
306
|
+
poolNames.add(pn);
|
|
307
|
+
}
|
|
308
|
+
const pools = [];
|
|
309
|
+
for (const poolName of poolNames) {
|
|
310
|
+
pools.push(this.computePoolMetrics(poolName));
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
startedAt: this.startedAt,
|
|
314
|
+
uptimeMs,
|
|
315
|
+
...this.metricsCounters,
|
|
316
|
+
pools,
|
|
317
|
+
brokenBundleIds: [...this.brokenBundles],
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
computePoolMetrics(poolName) {
|
|
321
|
+
let nodeCount = 0;
|
|
322
|
+
let liveSpares = 0;
|
|
323
|
+
let utilSum = 0;
|
|
324
|
+
let utilN = 0;
|
|
325
|
+
for (const node of this.nodeMap.values()) {
|
|
326
|
+
if (this.poolNameForNode(node.nodeId) !== poolName)
|
|
327
|
+
continue;
|
|
328
|
+
nodeCount++;
|
|
329
|
+
if (this.isHotSpareNode(node.nodeId) &&
|
|
330
|
+
node.nodeState === "running" &&
|
|
331
|
+
(this.inventoryMap.get(node.nodeId)?.assignedJobs.length ?? 0) === 0) {
|
|
332
|
+
liveSpares++;
|
|
333
|
+
}
|
|
334
|
+
const inv = this.inventoryMap.get(node.nodeId);
|
|
335
|
+
if (inv && inv.totalCapacity > 0) {
|
|
336
|
+
utilSum += inv.reservedCapacity / inv.totalCapacity;
|
|
337
|
+
utilN++;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
let spareTarget = 0;
|
|
341
|
+
for (const cfg of this.settings.hotSpares ?? []) {
|
|
342
|
+
if (cfg.pool === poolName)
|
|
343
|
+
spareTarget += cfg.targetCount;
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
poolName,
|
|
347
|
+
nodeCount,
|
|
348
|
+
liveSpares,
|
|
349
|
+
spareTarget,
|
|
350
|
+
usedCapacityFraction: utilN > 0 ? utilSum / utilN : 0,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Look up a single bundle by id. Convenience over `bundles().find(...)`.
|
|
355
|
+
* @public
|
|
356
|
+
*/
|
|
357
|
+
bundleById(bundleId) {
|
|
358
|
+
return this.bundleMap.get(bundleId);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Every job (with history) that belongs to a given bundle. Derived
|
|
362
|
+
* from `tags.bundleId` on the job entries in jobMap. Returns an empty
|
|
363
|
+
* array if the bundle is unknown or has no jobs.
|
|
364
|
+
* @public
|
|
365
|
+
*/
|
|
366
|
+
jobsForBundle(bundleId) {
|
|
367
|
+
const out = [];
|
|
368
|
+
for (const entry of this.jobMap.values()) {
|
|
369
|
+
if ((entry.job.tags ?? {})["bundleId"] === bundleId) {
|
|
370
|
+
out.push(entry);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return out;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* The node the given job is currently placed on, or undefined if
|
|
377
|
+
* the job hasn't been placed (or has been deleted). Useful for
|
|
378
|
+
* operator UIs that render "job X is on node Y" and for proxy
|
|
379
|
+
* routers that need to know which worker hosts each job.
|
|
380
|
+
*
|
|
381
|
+
* O(1) lookup against the `jobToNode` index — set at placement
|
|
382
|
+
* time, deleted on jobDeleted, rebuilt from job history on R7
|
|
383
|
+
* reconnect. Works regardless of whether the node hosts one job
|
|
384
|
+
* (cloud, today) or many (cluster, or any future cloud N:1)
|
|
385
|
+
* because the assignment is recorded against the JOB rather than
|
|
386
|
+
* scanned out of NODE tags.
|
|
387
|
+
* @public
|
|
388
|
+
*/
|
|
389
|
+
nodeForJob(jobId) {
|
|
390
|
+
const nodeId = this.jobToNode.get(jobId);
|
|
391
|
+
return nodeId !== undefined ? this.nodeMap.get(nodeId) : undefined;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Current placement of every job AutoManager has bound to a node,
|
|
395
|
+
* newest-first by decision timestamp. Mirrors `nodeForJob` but
|
|
396
|
+
* returns the full `PlacementDecisionInfo` (so consumers get the
|
|
397
|
+
* pool, trace, replicaIndex etc., not just the node) — and exposes
|
|
398
|
+
* it as queryable live state rather than only via the
|
|
399
|
+
* `onPlacementDecision` event stream.
|
|
400
|
+
*
|
|
401
|
+
* Hydrated jobs (placed in a previous AutoManager generation, picked
|
|
402
|
+
* up on R7 reconnect) get an entry with an empty `trace.attempts`
|
|
403
|
+
* and a decision inferred from job history. Consumers that *need*
|
|
404
|
+
* a real trace should subscribe to `onPlacementDecision` for live
|
|
405
|
+
* decisions; this getter is for "where is everything right now?"
|
|
406
|
+
* operator views.
|
|
407
|
+
*
|
|
408
|
+
* @public
|
|
409
|
+
*/
|
|
410
|
+
placements() {
|
|
411
|
+
return [...this.placementsByJob.values()].sort((a, b) => b.at.getTime() - a.at.getTime());
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* O(1) lookup for the most recent placement decision binding `jobId`
|
|
415
|
+
* to a node. Returns undefined for jobs that have never been placed
|
|
416
|
+
* (or whose placement attempts have only failed). @public
|
|
417
|
+
*/
|
|
418
|
+
placementForJob(jobId) {
|
|
419
|
+
return this.placementsByJob.get(jobId);
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Run the placement engine against AutoManager's current inventory
|
|
423
|
+
* snapshot and configured pools, without actually placing or
|
|
424
|
+
* provisioning anything. Powers preview affordances in operator
|
|
425
|
+
* UIs ("if I submitted this bundle now, where would each replica
|
|
426
|
+
* land?").
|
|
427
|
+
*
|
|
428
|
+
* `jobName` is optional; if omitted the *first* job in the bundle
|
|
429
|
+
* is used. For multi-job bundles, supply the name to get a decision
|
|
430
|
+
* specific to that job.
|
|
431
|
+
*
|
|
432
|
+
* Returns undefined if the bundle's named job is missing or the
|
|
433
|
+
* replica index is out of range.
|
|
434
|
+
* @public
|
|
435
|
+
*/
|
|
436
|
+
placeDryRun(bundle, replicaIndex, jobName) {
|
|
437
|
+
if (replicaIndex < 0 || replicaIndex >= (0, types_1.replicaCount)(bundle))
|
|
438
|
+
return undefined;
|
|
439
|
+
const job = jobName
|
|
440
|
+
? bundle.jobs.find((j) => j.jobName === jobName)
|
|
441
|
+
: bundle.jobs[0];
|
|
442
|
+
if (!job)
|
|
443
|
+
return undefined;
|
|
444
|
+
const pools = new Map((this.settings.placementPools ?? []).map((p) => [p.name, p]));
|
|
445
|
+
return (0, placement_1.placeWithTrace)({
|
|
446
|
+
bundle,
|
|
447
|
+
job,
|
|
448
|
+
replicaIndex,
|
|
449
|
+
inventory: this.inventoriesAsNodeViews(),
|
|
450
|
+
pools,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Static validation of a bundle against AutoManager's currently
|
|
455
|
+
* configured pools. Equivalent to calling the module-level
|
|
456
|
+
* `validateBundle` with `PoolDescriptor[]` derived from
|
|
457
|
+
* `AutoSettings.placementPools` — elastic pools project their
|
|
458
|
+
* `candidateInstanceTypes` into node profiles; cluster pools project
|
|
459
|
+
* their currently-known live inventory.
|
|
460
|
+
* @public
|
|
461
|
+
*/
|
|
462
|
+
validateBundle(bundle) {
|
|
463
|
+
return (0, validation_1.validateBundle)(bundle, this.poolDescriptorsForValidation());
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Phase C4 — packaged admission check. Combines `validateBundle`
|
|
467
|
+
* (static spec sanity) with `placeDryRun` for every (replica,
|
|
468
|
+
* jobName) pair. Returns `ok` only when both pass for every
|
|
469
|
+
* member; otherwise reports the structured failure. Use as a
|
|
470
|
+
* pre-submit guard in operator-facing tooling.
|
|
471
|
+
*
|
|
472
|
+
* Per the design's §3.4 band table, all bands reject at admission
|
|
473
|
+
* on "no capacity right now" — there's no band differentiation
|
|
474
|
+
* here. (Gold/silver get *queued* internally on capacity-after-
|
|
475
|
+
* submit failures via the rejection re-place path, but that's a
|
|
476
|
+
* runtime concern, not admission.)
|
|
477
|
+
*
|
|
478
|
+
* @public
|
|
479
|
+
*/
|
|
480
|
+
validateBundleAdmission(bundle) {
|
|
481
|
+
const validation = this.validateBundle(bundle);
|
|
482
|
+
if (validation.errors.length > 0) {
|
|
483
|
+
return { kind: "validationErrors", errors: validation.errors };
|
|
484
|
+
}
|
|
485
|
+
const unplaceable = [];
|
|
486
|
+
for (let replicaIndex = 0; replicaIndex < (0, types_1.replicaCount)(bundle); replicaIndex++) {
|
|
487
|
+
for (const job of bundle.jobs) {
|
|
488
|
+
const dry = this.placeDryRun(bundle, replicaIndex, job.jobName);
|
|
489
|
+
if (!dry) {
|
|
490
|
+
// placeDryRun returns undefined only for malformed input
|
|
491
|
+
// (out-of-range replica, unknown jobName) — validateBundle
|
|
492
|
+
// should have caught those already. Treat as unplaceable.
|
|
493
|
+
unplaceable.push({
|
|
494
|
+
replicaIndex,
|
|
495
|
+
jobName: job.jobName,
|
|
496
|
+
reason: { code: "noCapacityInAnyPool", triedPools: [] },
|
|
497
|
+
});
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
if (dry.result.kind === "failure") {
|
|
501
|
+
unplaceable.push({
|
|
502
|
+
replicaIndex,
|
|
503
|
+
jobName: job.jobName,
|
|
504
|
+
reason: dry.result.reason,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (unplaceable.length > 0) {
|
|
510
|
+
return { kind: "noCapacity", unplaceable };
|
|
511
|
+
}
|
|
512
|
+
return { kind: "ok" };
|
|
513
|
+
}
|
|
514
|
+
poolDescriptorsForValidation() {
|
|
515
|
+
const out = [];
|
|
516
|
+
for (const pool of this.settings.placementPools ?? []) {
|
|
517
|
+
// Union the feasibility profiles across the pool's tiers: elastic
|
|
518
|
+
// tiers contribute the shapes they can provision; fixed (cluster)
|
|
519
|
+
// tiers contribute the live inventory projected for them.
|
|
520
|
+
const profiles = [];
|
|
521
|
+
for (const tier of pool.tiers) {
|
|
522
|
+
if (tier.scaleOut === "elastic") {
|
|
523
|
+
for (const opt of tier.candidateInstanceTypes) {
|
|
524
|
+
profiles.push({
|
|
525
|
+
totalCapacity: opt.totalCapacity,
|
|
526
|
+
totalCores: opt.totalCores,
|
|
527
|
+
capabilities: opt.capabilities,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
for (const view of this.inventoriesAsNodeViews()) {
|
|
533
|
+
if (!(0, placement_1.nodeMatchesTier)(view, pool, tier))
|
|
534
|
+
continue;
|
|
535
|
+
profiles.push({
|
|
536
|
+
totalCapacity: view.inventory.totalCapacity,
|
|
537
|
+
totalCores: view.inventory.totalCores,
|
|
538
|
+
capabilities: view.inventory.capabilities,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// A pool that can provision is judged on what it can provision (a
|
|
544
|
+
// miss is a hard error); a purely-fixed pool's misses are warnings —
|
|
545
|
+
// a worker may join after submission (see validation.ts).
|
|
546
|
+
const kind = pool.tiers.some((t) => t.scaleOut === "elastic")
|
|
547
|
+
? "elastic"
|
|
548
|
+
: "cluster";
|
|
549
|
+
const clouds = [...new Set(pool.tiers.map((t) => t.kind))];
|
|
550
|
+
out.push({ poolName: pool.name, kind, nodeProfiles: profiles, clouds });
|
|
551
|
+
}
|
|
552
|
+
return out;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* AutoManager v1 — typed inventory snapshot for a single node, or
|
|
556
|
+
* undefined if the worker hasn't (yet) advertised one.
|
|
557
|
+
* @public
|
|
558
|
+
*/
|
|
559
|
+
inventory(nodeId) {
|
|
560
|
+
return this.inventoryMap.get(nodeId);
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* AutoManager v1 — typed inventory snapshots for every node we know
|
|
564
|
+
* about. Iteration order is insertion order.
|
|
565
|
+
* @public
|
|
566
|
+
*/
|
|
567
|
+
inventories() {
|
|
568
|
+
return Array.from(this.inventoryMap.entries()).map(([nodeId, inventory]) => ({
|
|
569
|
+
nodeId,
|
|
570
|
+
inventory,
|
|
571
|
+
}));
|
|
572
|
+
}
|
|
573
|
+
// ---- AutoManager v1 event handlers ----
|
|
574
|
+
handleNodeInventoryUpdated(raw) {
|
|
575
|
+
if (!raw.nodeId || !raw.inventory)
|
|
576
|
+
return;
|
|
577
|
+
const nodeId = raw.nodeId.nodeId;
|
|
578
|
+
this.inventoryMap.set(nodeId, (0, conversions_1.fromPbNodeInventory)(raw.inventory));
|
|
579
|
+
// A clean inventory update clears any "unhealthy" / recently-failed
|
|
580
|
+
// mark — the worker has reported back successfully, treat as recovered.
|
|
581
|
+
this.unhealthyNodes.delete(nodeId);
|
|
582
|
+
this.rejectionsPerNode.delete(nodeId);
|
|
583
|
+
this.recentFailures.delete(nodeId);
|
|
584
|
+
// Phase C6: inventory change can flip a spare to "consumed" (jobs
|
|
585
|
+
// assigned > 0). Reconcile so a replacement is provisioned
|
|
586
|
+
// promptly rather than waiting for the periodic tick.
|
|
587
|
+
this.reconcileHotSpares();
|
|
588
|
+
// Phase F: inventory change might newly satisfy / no-longer satisfy
|
|
589
|
+
// a NodeService's `consumes` — reconcile placements.
|
|
590
|
+
this.reconcileNodeServices();
|
|
591
|
+
// Fresh inventory means capacity may have appeared (a node's first
|
|
592
|
+
// report after Hello — the startup race — a freed reservation, or an
|
|
593
|
+
// un-cordon). Re-attempt any job that couldn't be placed before.
|
|
594
|
+
this.reconcilePendingPlacements();
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* A running job has had its config updated at runtime (operator action,
|
|
598
|
+
* job-driven, or other side channel). Refresh our in-memory copy so a
|
|
599
|
+
* subsequent `redeploy` (placeAndProvision triggered by node failure
|
|
600
|
+
* etc.) hands the new config rather than the launch one.
|
|
601
|
+
*
|
|
602
|
+
* Manager is responsible for persisting the new value; AutoManager
|
|
603
|
+
* just keeps its mirror up to date.
|
|
604
|
+
*/
|
|
605
|
+
handleJobConfigUpdated(raw) {
|
|
606
|
+
if (!raw.jobId)
|
|
607
|
+
return;
|
|
608
|
+
const jobId = raw.jobId.jobId;
|
|
609
|
+
const existing = this.jobMap.get(jobId);
|
|
610
|
+
if (!existing)
|
|
611
|
+
return;
|
|
612
|
+
this.jobMap.set(jobId, {
|
|
613
|
+
...existing,
|
|
614
|
+
job: { ...existing.job, managerConfiguration: raw.config },
|
|
615
|
+
});
|
|
616
|
+
this.settings.onJobConfigUpdated?.(jobId, raw.config);
|
|
617
|
+
}
|
|
618
|
+
handleJobRejected(raw) {
|
|
619
|
+
if (!raw.nodeId || !raw.jobId)
|
|
620
|
+
return;
|
|
621
|
+
const nodeId = raw.nodeId.nodeId;
|
|
622
|
+
const jobId = raw.jobId.jobId;
|
|
623
|
+
const typedInventory = raw.inventory
|
|
624
|
+
? (0, conversions_1.fromPbNodeInventory)(raw.inventory)
|
|
625
|
+
: undefined;
|
|
626
|
+
if (typedInventory) {
|
|
627
|
+
// Carries the worker's latest view, but does NOT clear unhealthy —
|
|
628
|
+
// a rejection is the opposite of "I'm fine". Update the cache only.
|
|
629
|
+
this.inventoryMap.set(nodeId, typedInventory);
|
|
630
|
+
}
|
|
631
|
+
this.metricsCounters.jobsRejected++;
|
|
632
|
+
this.settings.onJobRejected?.({
|
|
633
|
+
jobId,
|
|
634
|
+
nodeId,
|
|
635
|
+
reason: raw.reason,
|
|
636
|
+
inventory: typedInventory,
|
|
637
|
+
at: this.clock.now(),
|
|
638
|
+
});
|
|
639
|
+
const escalated = this.recordRejection(nodeId);
|
|
640
|
+
if (escalated) {
|
|
641
|
+
debuglog("Node escalated to unhealthy", { nodeId });
|
|
642
|
+
this.settings.onNodeUnhealthy?.(nodeId);
|
|
643
|
+
}
|
|
644
|
+
const existing = this.jobMap.get(jobId);
|
|
645
|
+
const existingReq = existing?.job.autoPlacement?.requirements;
|
|
646
|
+
if (existing && existingReq) {
|
|
647
|
+
// Re-place the job, excluding the rejecting node (and any others
|
|
648
|
+
// currently considered unfit).
|
|
649
|
+
const exclude = this.currentExcludeSet();
|
|
650
|
+
exclude.add(nodeId);
|
|
651
|
+
void this.placeAndProvision(jobId, existingReq, existing.job.tags ?? {}, { excludeNodes: exclude }).catch((err) => this.settings.onError?.(err));
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
recordRejection(nodeId) {
|
|
655
|
+
const now = this.clock.now();
|
|
656
|
+
const window = this.cfgRejectionBackoffMs();
|
|
657
|
+
const previous = this.rejectionsPerNode.get(nodeId) ?? [];
|
|
658
|
+
const recent = previous.filter((d) => now.getTime() - d.getTime() < window);
|
|
659
|
+
recent.push(now);
|
|
660
|
+
this.rejectionsPerNode.set(nodeId, recent);
|
|
661
|
+
const threshold = this.cfgRejectionEscalationCount();
|
|
662
|
+
if (recent.length >= threshold && !this.unhealthyNodes.has(nodeId)) {
|
|
663
|
+
this.unhealthyNodes.add(nodeId);
|
|
664
|
+
return true;
|
|
665
|
+
}
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
currentExcludeSet() {
|
|
669
|
+
const exclude = new Set(this.unhealthyNodes);
|
|
670
|
+
const now = this.clock.now().getTime();
|
|
671
|
+
for (const [nodeId, deadline] of this.recentFailures) {
|
|
672
|
+
if (deadline.getTime() < now) {
|
|
673
|
+
this.recentFailures.delete(nodeId);
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
exclude.add(nodeId);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return exclude;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Phase C2 — restart-rate check. Records the current attempt; if the
|
|
683
|
+
* trailing-hour count crosses the band's `maxRestartsPerHour`, marks
|
|
684
|
+
* the bundle broken, fires `onBundleBroken`, and returns true
|
|
685
|
+
* (caller must short-circuit). Returns false when within budget.
|
|
686
|
+
*/
|
|
687
|
+
shouldBreakOnRestart(jobId, bundleId, band) {
|
|
688
|
+
const now = this.clock.now();
|
|
689
|
+
const previous = this.restartHistory.get(jobId) ?? [];
|
|
690
|
+
const recent = previous.filter((d) => now.getTime() - d.getTime() < RESTART_WINDOW_MS);
|
|
691
|
+
recent.push(now);
|
|
692
|
+
this.restartHistory.set(jobId, recent);
|
|
693
|
+
const limit = this.cfgMaxRestartsPerHour(band);
|
|
694
|
+
if (recent.length > limit) {
|
|
695
|
+
if (!this.brokenBundles.has(bundleId)) {
|
|
696
|
+
this.brokenBundles.add(bundleId);
|
|
697
|
+
debuglog("Bundle marked broken — restart rate exceeded", {
|
|
698
|
+
bundleId,
|
|
699
|
+
jobId,
|
|
700
|
+
attempts: recent.length,
|
|
701
|
+
limit,
|
|
702
|
+
});
|
|
703
|
+
this.metricsCounters.bundlesBroken++;
|
|
704
|
+
const reason = `Restart rate ${recent.length}/h exceeds ceiling ${limit}/h for replica ${jobId}`;
|
|
705
|
+
this.settings.onBundleBroken?.(bundleId, reason);
|
|
706
|
+
// Persist the verdict back to the daemon so the poison job is not
|
|
707
|
+
// re-placed after a restart (the in-process `brokenBundles` flag is
|
|
708
|
+
// rebuilt-from-scratch on reconnect). Fire-and-forget; the live flag
|
|
709
|
+
// above already suppresses further placement this session.
|
|
710
|
+
void this.norsk
|
|
711
|
+
.markJobFailed(jobId, reason)
|
|
712
|
+
.catch((err) => this.settings.onError?.(err));
|
|
713
|
+
}
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Whether the named bundle has tripped the restart ceiling and is
|
|
720
|
+
* being held by AutoManager. Cleared by `bundleUpdated` (operator
|
|
721
|
+
* pushed a fresh spec) or `bundleDeleted`.
|
|
722
|
+
* @public
|
|
723
|
+
*/
|
|
724
|
+
isBundleBroken(bundleId) {
|
|
725
|
+
return this.brokenBundles.has(bundleId);
|
|
726
|
+
}
|
|
727
|
+
// ---- Phase E — migration state machine ---------------------------
|
|
728
|
+
/**
|
|
729
|
+
* Initiate a migration of a running job to a target node. AutoManager
|
|
730
|
+
* provisions a second instance of the same Job on `targetNodeId` with
|
|
731
|
+
* role="migrationTarget" and drives the cutover handshake. Returns
|
|
732
|
+
* a stable `migrationId` for tracking.
|
|
733
|
+
*
|
|
734
|
+
* Throws if a migration is already in flight for `sourceJobId` (no
|
|
735
|
+
* concurrent migrations per Job).
|
|
736
|
+
*
|
|
737
|
+
* `sourceRole` defaults to whichever role the first node hosting
|
|
738
|
+
* `sourceJobId` has — adequate for single-instance Jobs. Specify
|
|
739
|
+
* explicitly for multi-replica bundles.
|
|
740
|
+
*
|
|
741
|
+
* @public
|
|
742
|
+
*/
|
|
743
|
+
async migrateJob(sourceJobId, targetNodeId, sourceRole) {
|
|
744
|
+
if (this.migrationByJobId.has(sourceJobId)) {
|
|
745
|
+
throw new Error(`migrateJob: a migration is already in flight for jobId ${sourceJobId}`);
|
|
746
|
+
}
|
|
747
|
+
const resolvedSourceRole = sourceRole ?? this.inferSourceRole(sourceJobId);
|
|
748
|
+
if (!resolvedSourceRole) {
|
|
749
|
+
throw new Error(`migrateJob: cannot locate a running instance for jobId ${sourceJobId} — pass sourceRole explicitly`);
|
|
750
|
+
}
|
|
751
|
+
const migrationId = uuid.v4();
|
|
752
|
+
const now = this.clock.now();
|
|
753
|
+
const state = {
|
|
754
|
+
migrationId,
|
|
755
|
+
sourceJobId,
|
|
756
|
+
sourceRole: resolvedSourceRole,
|
|
757
|
+
targetNodeId,
|
|
758
|
+
startedAt: now,
|
|
759
|
+
phase: "launching",
|
|
760
|
+
phaseHistory: [{ phase: "launching", at: now }],
|
|
761
|
+
};
|
|
762
|
+
this.migrationStates.set(migrationId, state);
|
|
763
|
+
this.migrationByJobId.set(sourceJobId, migrationId);
|
|
764
|
+
this.settings.onMigrationPhase?.(state);
|
|
765
|
+
this.armMigrationPhaseDeadline(state);
|
|
766
|
+
debuglog("Migration started", { migrationId, sourceJobId, targetNodeId });
|
|
767
|
+
try {
|
|
768
|
+
await this.norsk.migrateJob(sourceJobId, targetNodeId, migrationId);
|
|
769
|
+
}
|
|
770
|
+
catch (err) {
|
|
771
|
+
this.failMigration(migrationId, `Manager.migrateJob failed: ${String(err)}`);
|
|
772
|
+
throw err;
|
|
773
|
+
}
|
|
774
|
+
return migrationId;
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Abort an in-flight migration. AutoManager sends MigrationAbort to
|
|
778
|
+
* any live source/target instances and marks the migration aborted.
|
|
779
|
+
* No-op (with warning log) for an unknown migration id.
|
|
780
|
+
*
|
|
781
|
+
* @public
|
|
782
|
+
*/
|
|
783
|
+
async abortMigration(migrationId, reason) {
|
|
784
|
+
const state = this.migrationStates.get(migrationId);
|
|
785
|
+
if (!state) {
|
|
786
|
+
debuglog("abortMigration: unknown migrationId", { migrationId });
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
if (state.phase === "done" || state.phase === "aborted")
|
|
790
|
+
return;
|
|
791
|
+
await this.dispatchMigrationAborts(state, reason).catch((err) => this.settings.onError?.(err));
|
|
792
|
+
this.markMigrationAborted(state, reason);
|
|
793
|
+
}
|
|
794
|
+
/** All known migrations (active + done + aborted). @public */
|
|
795
|
+
migrations() {
|
|
796
|
+
return Array.from(this.migrationStates.values());
|
|
797
|
+
}
|
|
798
|
+
/** Look up a single migration by id. @public */
|
|
799
|
+
migrationById(migrationId) {
|
|
800
|
+
return this.migrationStates.get(migrationId);
|
|
801
|
+
}
|
|
802
|
+
/** Migration active for the given source jobId, if any. @public */
|
|
803
|
+
migrationForJob(jobId) {
|
|
804
|
+
const id = this.migrationByJobId.get(jobId);
|
|
805
|
+
return id ? this.migrationStates.get(id) : undefined;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Find the role of the (first) node currently running this Job.
|
|
809
|
+
* Returns undefined if no node hosts the job.
|
|
810
|
+
*/
|
|
811
|
+
inferSourceRole(jobId) {
|
|
812
|
+
for (const node of this.nodeMap.values()) {
|
|
813
|
+
if (getJobIdForNode(node.nodeMetadata) === jobId) {
|
|
814
|
+
const role = node.nodeMetadata?.tags?.["role"] ?? node.provisional?.role;
|
|
815
|
+
if (role)
|
|
816
|
+
return role;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return undefined;
|
|
820
|
+
}
|
|
821
|
+
advanceMigration(state, phase) {
|
|
822
|
+
if (state.phase === phase)
|
|
823
|
+
return;
|
|
824
|
+
const now = this.clock.now();
|
|
825
|
+
state.phase = phase;
|
|
826
|
+
state.phaseHistory.push({ phase, at: now });
|
|
827
|
+
this.settings.onMigrationPhase?.(state);
|
|
828
|
+
debuglog("Migration phase", { migrationId: state.migrationId, phase });
|
|
829
|
+
this.armMigrationPhaseDeadline(state);
|
|
830
|
+
}
|
|
831
|
+
/** Phase E deadlines — re-arm the per-phase timer for the migration. */
|
|
832
|
+
armMigrationPhaseDeadline(state) {
|
|
833
|
+
// Always cancel any prior timer; terminal phases don't re-arm.
|
|
834
|
+
this.migrationPhaseTimers.get(state.migrationId)?.cancel();
|
|
835
|
+
this.migrationPhaseTimers.delete(state.migrationId);
|
|
836
|
+
if (state.phase === "done" || state.phase === "aborted")
|
|
837
|
+
return;
|
|
838
|
+
const deadlineMs = this.cfgMigrationPhaseDeadline(state.phase);
|
|
839
|
+
if (deadlineMs === undefined)
|
|
840
|
+
return;
|
|
841
|
+
const id = state.migrationId;
|
|
842
|
+
const phase = state.phase;
|
|
843
|
+
const timer = this.clock.setTimeout(() => {
|
|
844
|
+
this.migrationPhaseTimers.delete(id);
|
|
845
|
+
const current = this.migrationStates.get(id);
|
|
846
|
+
// Only fire if the migration is still in the phase we armed for.
|
|
847
|
+
if (!current || current.phase !== phase)
|
|
848
|
+
return;
|
|
849
|
+
debuglog("Migration phase deadline fired — aborting", {
|
|
850
|
+
migrationId: id,
|
|
851
|
+
phase,
|
|
852
|
+
deadlineMs,
|
|
853
|
+
});
|
|
854
|
+
const reason = `phase timeout: ${phase}`;
|
|
855
|
+
void this.dispatchMigrationAborts(current, reason).catch((err) => this.settings.onError?.(err));
|
|
856
|
+
this.markMigrationAborted(current, reason);
|
|
857
|
+
}, deadlineMs);
|
|
858
|
+
this.migrationPhaseTimers.set(state.migrationId, timer);
|
|
859
|
+
}
|
|
860
|
+
cfgMigrationPhaseDeadline(phase) {
|
|
861
|
+
const m = this.settings.migration;
|
|
862
|
+
switch (phase) {
|
|
863
|
+
case "launching":
|
|
864
|
+
return m?.launchingDeadlineMs ?? DEFAULT_MIGRATION_LAUNCHING_DEADLINE_MS;
|
|
865
|
+
case "awaitingTargetReady":
|
|
866
|
+
return m?.awaitingTargetReadyDeadlineMs ?? DEFAULT_MIGRATION_TARGET_READY_DEADLINE_MS;
|
|
867
|
+
case "awaitingSourceStop":
|
|
868
|
+
return m?.awaitingSourceStopDeadlineMs ?? DEFAULT_MIGRATION_SOURCE_STOP_DEADLINE_MS;
|
|
869
|
+
case "applying":
|
|
870
|
+
return m?.applyingDeadlineMs ?? DEFAULT_MIGRATION_APPLYING_DEADLINE_MS;
|
|
871
|
+
case "done":
|
|
872
|
+
case "aborted":
|
|
873
|
+
return undefined;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
markMigrationDone(state) {
|
|
877
|
+
this.advanceMigration(state, "done");
|
|
878
|
+
this.migrationByJobId.delete(state.sourceJobId);
|
|
879
|
+
this.settings.onMigrationCompleted?.(state.migrationId);
|
|
880
|
+
}
|
|
881
|
+
markMigrationAborted(state, reason) {
|
|
882
|
+
state.abortReason = reason;
|
|
883
|
+
this.advanceMigration(state, "aborted");
|
|
884
|
+
this.migrationByJobId.delete(state.sourceJobId);
|
|
885
|
+
this.settings.onMigrationAborted?.(state.migrationId, reason);
|
|
886
|
+
}
|
|
887
|
+
failMigration(migrationId, reason) {
|
|
888
|
+
const state = this.migrationStates.get(migrationId);
|
|
889
|
+
if (!state)
|
|
890
|
+
return;
|
|
891
|
+
if (state.phase === "done" || state.phase === "aborted")
|
|
892
|
+
return;
|
|
893
|
+
this.markMigrationAborted(state, reason);
|
|
894
|
+
}
|
|
895
|
+
async dispatchMigrationAborts(state, reason) {
|
|
896
|
+
// Manager's abortMigration takes both keys and fans out to source
|
|
897
|
+
// + target workers; AutoManager doesn't need to send anything
|
|
898
|
+
// else.
|
|
899
|
+
await this.norsk
|
|
900
|
+
.abortMigration(state.sourceJobId, state.sourceRole, exports.MIGRATION_TARGET_ROLE, reason)
|
|
901
|
+
.catch((err) => this.settings.onError?.(err));
|
|
902
|
+
}
|
|
903
|
+
// ---- Phase E — event handlers ------------------------------------
|
|
904
|
+
async handleMigrationTargetReady(raw) {
|
|
905
|
+
const jobId = raw.jobKey?.jobId?.jobId;
|
|
906
|
+
if (!jobId)
|
|
907
|
+
return;
|
|
908
|
+
const state = this.migrationForJob(jobId);
|
|
909
|
+
if (!state) {
|
|
910
|
+
debuglog("MigrationTargetReady for unknown migration", { jobId });
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
if (state.phase !== "launching" && state.phase !== "awaitingTargetReady") {
|
|
914
|
+
debuglog("MigrationTargetReady in unexpected phase", {
|
|
915
|
+
migrationId: state.migrationId,
|
|
916
|
+
phase: state.phase,
|
|
917
|
+
});
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
this.advanceMigration(state, "awaitingSourceStop");
|
|
921
|
+
try {
|
|
922
|
+
await this.norsk.notifyMigrationCutoverReady({
|
|
923
|
+
jobId: state.sourceJobId,
|
|
924
|
+
role: state.sourceRole,
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
catch (err) {
|
|
928
|
+
this.settings.onError?.(err);
|
|
929
|
+
this.failMigration(state.migrationId, `notifyMigrationCutoverReady failed: ${String(err)}`);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
async handleMigrationSourceStoppedOutput(raw) {
|
|
933
|
+
const jobId = raw.jobKey?.jobId?.jobId;
|
|
934
|
+
if (!jobId)
|
|
935
|
+
return;
|
|
936
|
+
const state = this.migrationForJob(jobId);
|
|
937
|
+
if (!state)
|
|
938
|
+
return;
|
|
939
|
+
if (state.phase !== "awaitingSourceStop") {
|
|
940
|
+
debuglog("MigrationSourceStoppedOutput in unexpected phase", {
|
|
941
|
+
migrationId: state.migrationId,
|
|
942
|
+
phase: state.phase,
|
|
943
|
+
});
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
const ts = Number(raw.lastOutputTimestampNs);
|
|
947
|
+
state.cutoverTimestampNs = ts;
|
|
948
|
+
this.advanceMigration(state, "applying");
|
|
949
|
+
try {
|
|
950
|
+
await this.norsk.notifyMigrationCutoverApply({ jobId: state.sourceJobId, role: exports.MIGRATION_TARGET_ROLE }, raw.lastOutputTimestampNs);
|
|
951
|
+
}
|
|
952
|
+
catch (err) {
|
|
953
|
+
this.settings.onError?.(err);
|
|
954
|
+
this.failMigration(state.migrationId, `notifyMigrationCutoverApply failed: ${String(err)}`);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
// Source workflow has already stopped output; safe to terminate the
|
|
958
|
+
// source instance. norsk-mgr-equivalents would do this via the
|
|
959
|
+
// existing stopJob path, but since AutoManager owns the protocol
|
|
960
|
+
// we drive the cleanup here.
|
|
961
|
+
// R10: don't dispatch stopJob with nodeId="" if we can't locate
|
|
962
|
+
// the source — that just sends a malformed RPC. If the source is
|
|
963
|
+
// already gone, treat as success; surface other mismatches.
|
|
964
|
+
const sourceNodeId = this.findNodeForJobKey(state.sourceJobId, state.sourceRole);
|
|
965
|
+
if (sourceNodeId === undefined) {
|
|
966
|
+
debuglog("Migration: source instance not located in nodeMap — skipping stopJob", {
|
|
967
|
+
migrationId: state.migrationId,
|
|
968
|
+
jobId: state.sourceJobId,
|
|
969
|
+
role: state.sourceRole,
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
else {
|
|
973
|
+
try {
|
|
974
|
+
await this.norsk.stopJob(state.sourceJobId, state.sourceRole, sourceNodeId);
|
|
975
|
+
}
|
|
976
|
+
catch (err) {
|
|
977
|
+
debuglog("Migration: source stopJob failed (continuing)", {
|
|
978
|
+
migrationId: state.migrationId,
|
|
979
|
+
err: String(err),
|
|
980
|
+
});
|
|
981
|
+
this.settings.onError?.(err);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
this.markMigrationDone(state);
|
|
985
|
+
}
|
|
986
|
+
handleMigrationAborted(raw) {
|
|
987
|
+
const jobId = raw.jobKey?.jobId?.jobId;
|
|
988
|
+
if (!jobId)
|
|
989
|
+
return;
|
|
990
|
+
const state = this.migrationForJob(jobId);
|
|
991
|
+
if (!state)
|
|
992
|
+
return;
|
|
993
|
+
debuglog("Migration aborted by worker", {
|
|
994
|
+
migrationId: state.migrationId,
|
|
995
|
+
reason: raw.reason,
|
|
996
|
+
});
|
|
997
|
+
// Dispatch an abort to the other side so it tears down its
|
|
998
|
+
// migration state too. Don't await — fire-and-forget.
|
|
999
|
+
void this.dispatchMigrationAborts(state, raw.reason);
|
|
1000
|
+
this.markMigrationAborted(state, raw.reason);
|
|
1001
|
+
}
|
|
1002
|
+
findNodeForJobKey(jobId, role) {
|
|
1003
|
+
for (const node of this.nodeMap.values()) {
|
|
1004
|
+
const tags = node.nodeMetadata?.tags ?? {};
|
|
1005
|
+
if (tags["jobId"] === jobId && tags["role"] === role) {
|
|
1006
|
+
return node.nodeId;
|
|
1007
|
+
}
|
|
1008
|
+
const prov = node.provisional;
|
|
1009
|
+
if (prov && prov.jobId === jobId && prov.role === role) {
|
|
1010
|
+
return node.nodeId;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
return undefined;
|
|
1014
|
+
}
|
|
1015
|
+
// ---- Phase C4 — config resolution ---------------------------------
|
|
1016
|
+
// Precedence for every tunable: settings.placement.X > settings.X
|
|
1017
|
+
// > compiled-in default. Per-band tunables use settings.bands[band]
|
|
1018
|
+
// first, with the same flat/default fallback.
|
|
1019
|
+
cfgRejectionBackoffMs() {
|
|
1020
|
+
return (this.settings.placement?.rejectionBackoffMs ??
|
|
1021
|
+
this.settings.rejectionBackoffMs ??
|
|
1022
|
+
DEFAULT_REJECTION_BACKOFF_MS);
|
|
1023
|
+
}
|
|
1024
|
+
cfgRejectionEscalationCount() {
|
|
1025
|
+
return (this.settings.placement?.rejectionEscalationCount ??
|
|
1026
|
+
this.settings.rejectionEscalationCount ??
|
|
1027
|
+
DEFAULT_REJECTION_ESCALATION_COUNT);
|
|
1028
|
+
}
|
|
1029
|
+
cfgFailureBackoffMs() {
|
|
1030
|
+
return (this.settings.placement?.failureBackoffMs ??
|
|
1031
|
+
this.settings.failureBackoffMs ??
|
|
1032
|
+
DEFAULT_FAILURE_BACKOFF_MS);
|
|
1033
|
+
}
|
|
1034
|
+
cfgJobStartupGraceMs() {
|
|
1035
|
+
return (this.settings.placement?.jobStartupGraceMs ??
|
|
1036
|
+
this.settings.jobStartupGraceMs ??
|
|
1037
|
+
DEFAULT_JOB_STARTUP_GRACE_MS);
|
|
1038
|
+
}
|
|
1039
|
+
cfgMaxRestartsPerHour(band) {
|
|
1040
|
+
// Bronze drops on failure — restart ceiling is moot, but return a
|
|
1041
|
+
// large number so the rate-check path can be called uniformly
|
|
1042
|
+
// without crashing if anyone reaches it.
|
|
1043
|
+
if (band === "bronze")
|
|
1044
|
+
return Number.POSITIVE_INFINITY;
|
|
1045
|
+
const perBand = this.settings.bands?.[band]?.maxRestartsPerHour;
|
|
1046
|
+
return (perBand ??
|
|
1047
|
+
this.settings.maxRestartsPerHour ??
|
|
1048
|
+
DEFAULT_MAX_RESTARTS_PER_HOUR);
|
|
1049
|
+
}
|
|
1050
|
+
// ---- AutoManager v1 placement-driven path ----
|
|
1051
|
+
//
|
|
1052
|
+
// A "modern" job — one with `requirements` set — is a member of a
|
|
1053
|
+
// bundle that Manager has expanded into N replica jobs. The job
|
|
1054
|
+
// carries bundleId / replicaIndex / jobName tags so AutoManager can
|
|
1055
|
+
// recover its bundle context and run the placement engine. The
|
|
1056
|
+
// legacy createJob path (no requirements) is unaffected.
|
|
1057
|
+
async placeAndProvision(jobId, pbRequirements, tags, recovery) {
|
|
1058
|
+
const bundleId = tags["bundleId"];
|
|
1059
|
+
const replicaIndexStr = tags["replicaIndex"];
|
|
1060
|
+
const jobName = tags["jobName"];
|
|
1061
|
+
if (!bundleId || replicaIndexStr === undefined || !jobName) {
|
|
1062
|
+
debuglog("Job has requirements but missing bundle context tags", tags);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
const replicaIndex = parseInt(replicaIndexStr, 10);
|
|
1066
|
+
// In-flight-placement gate (initial placements only). handleJobChanged
|
|
1067
|
+
// can re-fire `placeAndProvision` for the same jobId while the
|
|
1068
|
+
// daemon's provisionJob is mid-dispatch — `nodesByJob().size` is
|
|
1069
|
+
// still 0 because JobStarted hasn't landed yet. Each re-fire ends
|
|
1070
|
+
// in `INVALID_ARGUMENT: Job already running on node` from the
|
|
1071
|
+
// daemon and a `placementsDispatchFailed++`. Suppress. Recovery
|
|
1072
|
+
// placements bypass this gate; they're driven by failure events
|
|
1073
|
+
// (JobRejected, NodeStopped) and have their own MIN_RECOVERY_INTERVAL_MS
|
|
1074
|
+
// backoff further down — and the bronze-drop / band-aware-recovery
|
|
1075
|
+
// branch needs to run even when an initial placement just dispatched.
|
|
1076
|
+
const now = this.clock.now();
|
|
1077
|
+
if (!recovery) {
|
|
1078
|
+
const lastAttempt = this.lastPlacementAttemptAt.get(jobId);
|
|
1079
|
+
if (lastAttempt && now.getTime() - lastAttempt.getTime() < MIN_PLACEMENT_INTERVAL_MS) {
|
|
1080
|
+
debuglog("Placement attempt suppressed (in-flight)", { jobId, bundleId });
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
this.lastPlacementAttemptAt.set(jobId, now);
|
|
1084
|
+
}
|
|
1085
|
+
const bundle = this.bundleMap.get(bundleId);
|
|
1086
|
+
if (!bundle) {
|
|
1087
|
+
debuglog("Job's bundle not in map yet", { jobId, bundleId });
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
const bundleJobSpec = bundle.jobs.find((j) => j.jobName === jobName);
|
|
1091
|
+
if (!bundleJobSpec) {
|
|
1092
|
+
debuglog("Bundle has no job named", { bundleId, jobName });
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
// Use the bundle's typed requirements (richer / canonical) rather
|
|
1096
|
+
// than re-converting from PB. Keeps placement decisions consistent
|
|
1097
|
+
// with the bundle the user submitted.
|
|
1098
|
+
void pbRequirements;
|
|
1099
|
+
// Band-aware recovery (Phase C1). On a recovery path:
|
|
1100
|
+
// - bronze: drop without re-placing; fire onJobDropped.
|
|
1101
|
+
// - gold: re-place, preferring hot spares.
|
|
1102
|
+
// - silver: re-place, no spare preference.
|
|
1103
|
+
// Initial placements bypass this entirely.
|
|
1104
|
+
const band = bundleJobSpec.requirements.priorityBand;
|
|
1105
|
+
if (recovery && band === "bronze") {
|
|
1106
|
+
debuglog("Bronze job dropped (no restart on failure)", { jobId, bundleId });
|
|
1107
|
+
this.metricsCounters.jobsDropped++;
|
|
1108
|
+
this.settings.onJobDropped?.(jobId);
|
|
1109
|
+
// Bronze never restarts — this is terminal. Persist the verdict so the
|
|
1110
|
+
// job isn't re-placed after a restart.
|
|
1111
|
+
void this.norsk
|
|
1112
|
+
.markJobFailed(jobId, "bronze job dropped after failure")
|
|
1113
|
+
.catch((err) => this.settings.onError?.(err));
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
// Phase C2 — restart-rate ceiling + broken-bundle short-circuit.
|
|
1117
|
+
// Always run the rate check on every recovery trigger — the
|
|
1118
|
+
// ceiling tracks failure rate, which is real-time, even if we
|
|
1119
|
+
// defer the actual placement below. Without this, the
|
|
1120
|
+
// minimum-interval backoff would coalesce N rapid failures into
|
|
1121
|
+
// a single recorded attempt and the rate-broken trip would never
|
|
1122
|
+
// fire.
|
|
1123
|
+
if (recovery) {
|
|
1124
|
+
if (this.brokenBundles.has(bundleId)) {
|
|
1125
|
+
debuglog("Skipping recovery — bundle is marked broken", { jobId, bundleId });
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
if (this.shouldBreakOnRestart(jobId, bundleId, band)) {
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
// Minimum-interval backoff between consecutive recovery placement
|
|
1133
|
+
// dispatches for the same job. The failure rate has already been
|
|
1134
|
+
// counted above; this gate only delays the next actual placement
|
|
1135
|
+
// attempt so we don't hammer the daemon/worker at 12 attempts/sec.
|
|
1136
|
+
// Coalescing: if a deferred retry is already pending, further
|
|
1137
|
+
// triggers in the window become no-ops (the timer will see the
|
|
1138
|
+
// latest state when it fires).
|
|
1139
|
+
if (recovery) {
|
|
1140
|
+
const history = this.restartHistory.get(jobId) ?? [];
|
|
1141
|
+
// The just-pushed entry is the last one; we want the entry
|
|
1142
|
+
// before it to determine spacing.
|
|
1143
|
+
const previousAttempt = history[history.length - 2];
|
|
1144
|
+
const now = this.clock.now();
|
|
1145
|
+
if (previousAttempt && now.getTime() - previousAttempt.getTime() < MIN_RECOVERY_INTERVAL_MS) {
|
|
1146
|
+
if (this.pendingPlacementRetries.has(jobId)) {
|
|
1147
|
+
debuglog("Recovery placement already pending (coalescing)", { jobId, bundleId });
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
const delay = MIN_RECOVERY_INTERVAL_MS - (now.getTime() - previousAttempt.getTime());
|
|
1151
|
+
debuglog("Recovery placement deferred (backoff)", { jobId, bundleId, delayMs: delay });
|
|
1152
|
+
const timer = setTimeout(() => {
|
|
1153
|
+
this.pendingPlacementRetries.delete(jobId);
|
|
1154
|
+
void this.placeAndProvision(jobId, pbRequirements, tags, recovery).catch((err) => this.settings.onError?.(err));
|
|
1155
|
+
}, delay);
|
|
1156
|
+
this.pendingPlacementRetries.set(jobId, timer);
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
const inventory = this.inventoriesAsNodeViews();
|
|
1161
|
+
const pools = new Map((this.settings.placementPools ?? []).map((p) => [p.name, p]));
|
|
1162
|
+
if (pools.size === 0) {
|
|
1163
|
+
debuglog("placementPools not configured — cannot place", { jobId });
|
|
1164
|
+
this.settings.onError?.(new Error(`placementPools not configured; can't place job ${jobId}. ` +
|
|
1165
|
+
`Add to AutoSettings or this job will never start.`));
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
const { result, trace } = (0, placement_1.placeWithTrace)({
|
|
1169
|
+
bundle,
|
|
1170
|
+
job: bundleJobSpec,
|
|
1171
|
+
replicaIndex,
|
|
1172
|
+
inventory,
|
|
1173
|
+
pools,
|
|
1174
|
+
recoveryContext: recovery
|
|
1175
|
+
? { excludeNodes: recovery.excludeNodes, preferHotSpares: band === "gold" }
|
|
1176
|
+
: undefined,
|
|
1177
|
+
});
|
|
1178
|
+
debuglog("Placement decision", {
|
|
1179
|
+
jobId,
|
|
1180
|
+
bundleId,
|
|
1181
|
+
replicaIndex,
|
|
1182
|
+
decision: result,
|
|
1183
|
+
trace: summariseTrace(trace),
|
|
1184
|
+
});
|
|
1185
|
+
const at = this.clock.now();
|
|
1186
|
+
this.metricsCounters.placementsAttempted++;
|
|
1187
|
+
if (result.kind === "failure") {
|
|
1188
|
+
this.metricsCounters.placementsFailed++;
|
|
1189
|
+
}
|
|
1190
|
+
// R12: placementsSucceeded counted post-dispatch; see below.
|
|
1191
|
+
const info = { jobId, bundleId, replicaIndex, jobName, result, trace, at };
|
|
1192
|
+
// Mirror into live-state map so consumers can query the current
|
|
1193
|
+
// placement without subscribing from process start. Failures don't
|
|
1194
|
+
// bind a placement, so they don't go in the map — `placementForJob`
|
|
1195
|
+
// returning undefined for "we tried and failed" is the correct
|
|
1196
|
+
// signal; failures still flow through `onPlacementFailed` for
|
|
1197
|
+
// consumers that care.
|
|
1198
|
+
if (result.kind !== "failure") {
|
|
1199
|
+
this.placementsByJob.set(jobId, info);
|
|
1200
|
+
}
|
|
1201
|
+
this.settings.onPlacementDecision?.(info);
|
|
1202
|
+
if (result.kind === "failure") {
|
|
1203
|
+
// A failed placement dispatched nothing, so the in-flight gate
|
|
1204
|
+
// (lastPlacementAttemptAt + MIN_PLACEMENT_INTERVAL_MS) must not
|
|
1205
|
+
// hold this job back: a capacity-increasing event can arrive
|
|
1206
|
+
// moments later and `reconcilePendingPlacements` needs to be free
|
|
1207
|
+
// to retry. The gate exists to suppress duplicate dispatch while a
|
|
1208
|
+
// real placement is in flight — a failure is not in flight.
|
|
1209
|
+
this.lastPlacementAttemptAt.delete(jobId);
|
|
1210
|
+
this.settings.onPlacementFailed?.({
|
|
1211
|
+
jobId,
|
|
1212
|
+
bundleId,
|
|
1213
|
+
replicaIndex,
|
|
1214
|
+
jobName,
|
|
1215
|
+
reason: result.reason,
|
|
1216
|
+
triedPools: result.reason.triedPools,
|
|
1217
|
+
at,
|
|
1218
|
+
});
|
|
1219
|
+
this.settings.onError?.(new Error(`Placement failed for ${jobId}: ${JSON.stringify(result.reason)}`));
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
const role = roleFromReplicaIndex(replicaIndex);
|
|
1223
|
+
// A degraded backup still gets placed/provisioned (best-effort) — surface
|
|
1224
|
+
// the reduced DR posture so an operator can act / re-place later.
|
|
1225
|
+
if (result.degraded) {
|
|
1226
|
+
this.settings.onResilienceDegraded?.({
|
|
1227
|
+
jobId,
|
|
1228
|
+
bundleId,
|
|
1229
|
+
replicaIndex,
|
|
1230
|
+
jobName,
|
|
1231
|
+
violated: result.degraded.violated,
|
|
1232
|
+
...(result.degraded.interruptiblePrimary ? { interruptiblePrimary: true } : {}),
|
|
1233
|
+
at,
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
if (result.kind === "place") {
|
|
1237
|
+
// Existing node already running on the chosen target. Manager's
|
|
1238
|
+
// StartJob RPC binds the job to that node.
|
|
1239
|
+
try {
|
|
1240
|
+
await this.norsk.startJob(jobId, role, result.nodeId);
|
|
1241
|
+
this.jobToNode.set(jobId, result.nodeId);
|
|
1242
|
+
this.metricsCounters.placementsSucceeded++;
|
|
1243
|
+
}
|
|
1244
|
+
catch (err) {
|
|
1245
|
+
this.metricsCounters.placementsDispatchFailed++;
|
|
1246
|
+
this.settings.onError?.(err);
|
|
1247
|
+
}
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
// Provision in the chosen pool's chosen tier.
|
|
1251
|
+
const pool = pools.get(result.pool);
|
|
1252
|
+
if (!pool)
|
|
1253
|
+
return;
|
|
1254
|
+
const tier = pool.tiers.find((t) => t.name === result.tier);
|
|
1255
|
+
if (!tier)
|
|
1256
|
+
return;
|
|
1257
|
+
// `pool` + `tier` tags are load-bearing: poolNameForNode / tierNameForNode
|
|
1258
|
+
// read them back via nodeMetadata.tags to identify which pool+tier an
|
|
1259
|
+
// existing node belongs to. We must set them here, or every placement
|
|
1260
|
+
// after the node starts will filter it out as wrong-pool/wrong-tier.
|
|
1261
|
+
const provisionTags = {
|
|
1262
|
+
"auto-manager": "true",
|
|
1263
|
+
jobId,
|
|
1264
|
+
role,
|
|
1265
|
+
bundleId,
|
|
1266
|
+
replicaIndex: replicaIndexStr,
|
|
1267
|
+
jobName,
|
|
1268
|
+
pool: pool.name,
|
|
1269
|
+
tier: tier.name,
|
|
1270
|
+
// Failure-domain metadata for the resilience engine + observability.
|
|
1271
|
+
reliability: (0, placement_1.tierReliability)(tier),
|
|
1272
|
+
};
|
|
1273
|
+
const nodeId = uuid.v4() + "." + jobId + "." + role;
|
|
1274
|
+
this.updateNodeMap(nodeId, "starting", undefined, undefined, { jobId, role });
|
|
1275
|
+
this.jobToNode.set(jobId, nodeId);
|
|
1276
|
+
this.settings.onNodeStarting?.(nodeId);
|
|
1277
|
+
if (tier.kind === "aws") {
|
|
1278
|
+
try {
|
|
1279
|
+
await this.norsk.createAwsNode({
|
|
1280
|
+
nodeId,
|
|
1281
|
+
region: tier.region ?? "",
|
|
1282
|
+
instanceType: result.instanceType,
|
|
1283
|
+
tags: provisionTags,
|
|
1284
|
+
writeFiles: [],
|
|
1285
|
+
launchMode: result.launchMode,
|
|
1286
|
+
});
|
|
1287
|
+
this.metricsCounters.placementsSucceeded++;
|
|
1288
|
+
}
|
|
1289
|
+
catch (err) {
|
|
1290
|
+
this.metricsCounters.placementsDispatchFailed++;
|
|
1291
|
+
this.settings.onError?.(err);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
else if (tier.kind === "oci") {
|
|
1295
|
+
try {
|
|
1296
|
+
await this.norsk.createOciNode({
|
|
1297
|
+
nodeId,
|
|
1298
|
+
availabilityDomain: tier.region ?? "",
|
|
1299
|
+
architecture: "x86_64",
|
|
1300
|
+
shape: result.instanceType,
|
|
1301
|
+
subnet: "",
|
|
1302
|
+
tags: provisionTags,
|
|
1303
|
+
writeFiles: [],
|
|
1304
|
+
});
|
|
1305
|
+
this.metricsCounters.placementsSucceeded++;
|
|
1306
|
+
}
|
|
1307
|
+
catch (err) {
|
|
1308
|
+
this.metricsCounters.placementsDispatchFailed++;
|
|
1309
|
+
this.settings.onError?.(err);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
else {
|
|
1313
|
+
// Cluster tiers never auto-scale; place-on-existing only.
|
|
1314
|
+
this.settings.onError?.(new Error(`Cluster tier '${pool.name}/${tier.name}' returned a 'provision' ` +
|
|
1315
|
+
`placement; cluster tiers should only place onto existing nodes.`));
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
inventoriesAsNodeViews() {
|
|
1319
|
+
// Reservation (reservedCapacity / cores / capabilities, assignedJobs) is
|
|
1320
|
+
// not populated upstream: the worker advertises raw hardware and the
|
|
1321
|
+
// daemon forwards it verbatim, so the wire values arrive as 0 / []. Left
|
|
1322
|
+
// as-is, every node would look fully free regardless of what's running on
|
|
1323
|
+
// it — placement would over-subscribe, and the capacity-driven reconcile
|
|
1324
|
+
// could never observe a node fill up and later free up. AutoManager is the
|
|
1325
|
+
// one component that knows both which jobs it placed where (`jobToNode`)
|
|
1326
|
+
// and each job's requirement (the bundle's typed spec), so it derives the
|
|
1327
|
+
// reservation here. A non-zero wire reservation, if a producer ever sends
|
|
1328
|
+
// one, is treated as authoritative and the derivation defers to it.
|
|
1329
|
+
const placedJobsByNode = new Map();
|
|
1330
|
+
for (const [jobId, nodeId] of this.jobToNode) {
|
|
1331
|
+
const list = placedJobsByNode.get(nodeId);
|
|
1332
|
+
if (list)
|
|
1333
|
+
list.push(jobId);
|
|
1334
|
+
else
|
|
1335
|
+
placedJobsByNode.set(nodeId, [jobId]);
|
|
1336
|
+
}
|
|
1337
|
+
const out = [];
|
|
1338
|
+
for (const [nodeId, inv] of this.inventoryMap.entries()) {
|
|
1339
|
+
const placedHere = placedJobsByNode.get(nodeId) ?? [];
|
|
1340
|
+
// Prefer the wire's assigned jobs when present; otherwise list the
|
|
1341
|
+
// jobs AutoManager itself placed on this node. Either feeds the
|
|
1342
|
+
// affinity / spread constraints in the placement engine.
|
|
1343
|
+
const runningJobs = inv.assignedJobs.length > 0
|
|
1344
|
+
? inv.assignedJobs.flatMap((a) => this.runningJobFor(a.jobId))
|
|
1345
|
+
: placedHere.flatMap((jobId) => this.runningJobFor(jobId));
|
|
1346
|
+
// Phase C8: merge the AutoManager-local cordon into the wire-reported
|
|
1347
|
+
// cordoned flag. Either side can mark a node off-limits.
|
|
1348
|
+
const cordoned = this.cordonedNodes.has(nodeId)
|
|
1349
|
+
? { ...inv, cordoned: true }
|
|
1350
|
+
: inv;
|
|
1351
|
+
// Overlay derived reservation, deferring to a non-zero wire value.
|
|
1352
|
+
const reservation = this.derivedReservation(placedHere);
|
|
1353
|
+
const effectiveInv = {
|
|
1354
|
+
...cordoned,
|
|
1355
|
+
reservedCapacity: cordoned.reservedCapacity > 0
|
|
1356
|
+
? cordoned.reservedCapacity
|
|
1357
|
+
: reservation.capacity,
|
|
1358
|
+
reservedCores: cordoned.reservedCores > 0 ? cordoned.reservedCores : reservation.cores,
|
|
1359
|
+
reservedCapabilities: cordoned.reservedCapabilities.length > 0
|
|
1360
|
+
? cordoned.reservedCapabilities
|
|
1361
|
+
: reservation.capabilities,
|
|
1362
|
+
};
|
|
1363
|
+
// poolName / tierName are read from the node's NodeMetadata (nodeMap);
|
|
1364
|
+
// nodes we haven't seen there yet resolve to "" / undefined and never
|
|
1365
|
+
// match a pool filter.
|
|
1366
|
+
out.push({
|
|
1367
|
+
nodeId,
|
|
1368
|
+
poolName: this.poolNameForNode(nodeId),
|
|
1369
|
+
tierName: this.tierNameForNode(nodeId),
|
|
1370
|
+
cloud: this.cloudForNode(nodeId),
|
|
1371
|
+
inventory: effectiveInv,
|
|
1372
|
+
runningJobs,
|
|
1373
|
+
az: this.azForNode(nodeId),
|
|
1374
|
+
// Phase C5: spare-ness is advertised via NodeMetadata.tags["spare"].
|
|
1375
|
+
isHotSpare: this.isHotSpareNode(nodeId),
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
return out;
|
|
1379
|
+
}
|
|
1380
|
+
/** Map a placed jobId to the RunningJob view the placement engine uses
|
|
1381
|
+
* for affinity / spread, via the job's bundle-context tags. Empty when
|
|
1382
|
+
* the job lacks bundle tags. */
|
|
1383
|
+
runningJobFor(jobId) {
|
|
1384
|
+
const tags = this.tagsForJob(jobId);
|
|
1385
|
+
if (!tags)
|
|
1386
|
+
return [];
|
|
1387
|
+
const bundleId = tags["bundleId"];
|
|
1388
|
+
const replicaIndexStr = tags["replicaIndex"];
|
|
1389
|
+
const jobName = tags["jobName"];
|
|
1390
|
+
if (!bundleId || replicaIndexStr === undefined || !jobName)
|
|
1391
|
+
return [];
|
|
1392
|
+
return [{ bundleId, replicaIndex: parseInt(replicaIndexStr, 10), jobName }];
|
|
1393
|
+
}
|
|
1394
|
+
/** Sum the resource requirements of the given placed jobs into a node
|
|
1395
|
+
* reservation. Capacity / cores / capabilities come from each job's
|
|
1396
|
+
* bundle spec — the canonical typed requirements the placement engine
|
|
1397
|
+
* scores against, looked up via the job's bundleId / jobName tags. Jobs
|
|
1398
|
+
* whose bundle or spec we don't have yet contribute nothing. */
|
|
1399
|
+
derivedReservation(jobIds) {
|
|
1400
|
+
let capacity = 0;
|
|
1401
|
+
let cores = 0;
|
|
1402
|
+
const caps = new Map();
|
|
1403
|
+
for (const jobId of jobIds) {
|
|
1404
|
+
const req = this.requirementsForPlacedJob(jobId);
|
|
1405
|
+
if (!req)
|
|
1406
|
+
continue;
|
|
1407
|
+
capacity += req.requiredCapacity;
|
|
1408
|
+
cores += req.requiredCores ?? 0;
|
|
1409
|
+
for (const c of req.requiredCapabilities) {
|
|
1410
|
+
caps.set(c.name, (caps.get(c.name) ?? 0) + c.count);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
const capabilities = [...caps.entries()].map(([name, count]) => ({ name, count }));
|
|
1414
|
+
return { capacity, cores, capabilities };
|
|
1415
|
+
}
|
|
1416
|
+
/** The canonical typed requirements for a placed job, from its bundle
|
|
1417
|
+
* spec (the same source `placeAndProvision` scores against). */
|
|
1418
|
+
requirementsForPlacedJob(jobId) {
|
|
1419
|
+
const tags = this.tagsForJob(jobId);
|
|
1420
|
+
if (!tags)
|
|
1421
|
+
return undefined;
|
|
1422
|
+
const bundleId = tags["bundleId"];
|
|
1423
|
+
const jobName = tags["jobName"];
|
|
1424
|
+
if (!bundleId || !jobName)
|
|
1425
|
+
return undefined;
|
|
1426
|
+
return this.bundleMap
|
|
1427
|
+
.get(bundleId)
|
|
1428
|
+
?.jobs.find((j) => j.jobName === jobName)?.requirements;
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Phase C5 — is the named node currently tagged as a hot spare?
|
|
1432
|
+
* Spares are warm-running nodes provisioned (by the C6 reconciler)
|
|
1433
|
+
* with `NodeMetadata.tags["spare"] = "true"`. The optional
|
|
1434
|
+
* `tags["spareBand"]` ("gold" | "silver") dedicates a spare to a
|
|
1435
|
+
* band; if absent, the spare is band-agnostic.
|
|
1436
|
+
* @public
|
|
1437
|
+
*/
|
|
1438
|
+
isHotSpareNode(nodeId) {
|
|
1439
|
+
return this.nodeMap.get(nodeId)?.nodeMetadata?.tags?.["spare"] === "true";
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* Phase C5 — band a spare is dedicated to, if any. `undefined` for a
|
|
1443
|
+
* non-spare or for a band-agnostic spare.
|
|
1444
|
+
* @public
|
|
1445
|
+
*/
|
|
1446
|
+
spareBandForNode(nodeId) {
|
|
1447
|
+
if (!this.isHotSpareNode(nodeId))
|
|
1448
|
+
return undefined;
|
|
1449
|
+
const v = this.nodeMap.get(nodeId)?.nodeMetadata?.tags?.["spareBand"];
|
|
1450
|
+
return v === "gold" || v === "silver" || v === "bronze" ? v : undefined;
|
|
1451
|
+
}
|
|
1452
|
+
// ---- Phase C6 — hot-spare reconciliation -------------------------
|
|
1453
|
+
/**
|
|
1454
|
+
* Drive every configured `hotSpares` entry toward its `targetCount`.
|
|
1455
|
+
* Counts live + pending spares matching (pool, band); provisions
|
|
1456
|
+
* short, terminates excess (oldest live first). Cluster pools are
|
|
1457
|
+
* non-elastic — under-target just fires `onSpareUnderflow` and
|
|
1458
|
+
* leaves provisioning to the operator. Idempotent and cheap; safe
|
|
1459
|
+
* to call on every lifecycle event plus the periodic tick.
|
|
1460
|
+
*/
|
|
1461
|
+
reconcileHotSpares() {
|
|
1462
|
+
const entries = this.settings.hotSpares ?? [];
|
|
1463
|
+
if (entries.length === 0)
|
|
1464
|
+
return;
|
|
1465
|
+
for (const cfg of entries) {
|
|
1466
|
+
const pool = (this.settings.placementPools ?? []).find((p) => p.name === cfg.pool);
|
|
1467
|
+
if (!pool) {
|
|
1468
|
+
debuglog("hotSpares entry references unknown pool", { entry: cfg });
|
|
1469
|
+
continue;
|
|
1470
|
+
}
|
|
1471
|
+
const matching = this.spareNodesMatching(cfg);
|
|
1472
|
+
const pending = this.pendingSparesMatching(cfg);
|
|
1473
|
+
const effective = matching.length + pending;
|
|
1474
|
+
const target = cfg.targetCount;
|
|
1475
|
+
if (effective < target) {
|
|
1476
|
+
const short = target - effective;
|
|
1477
|
+
this.settings.onSpareUnderflow?.(cfg.pool, effective, target);
|
|
1478
|
+
// Warm the cheapest provisionable tier (first elastic tier in
|
|
1479
|
+
// cost order). A pool with no elastic tier can't grow spares.
|
|
1480
|
+
const provisionTier = pool.tiers.find((t) => t.scaleOut === "elastic");
|
|
1481
|
+
if (!provisionTier) {
|
|
1482
|
+
debuglog("Pool has no elastic tier — under spare target, no provision", {
|
|
1483
|
+
cfg,
|
|
1484
|
+
effective,
|
|
1485
|
+
target,
|
|
1486
|
+
});
|
|
1487
|
+
continue;
|
|
1488
|
+
}
|
|
1489
|
+
for (let i = 0; i < short; i++) {
|
|
1490
|
+
void this.provisionHotSpare(pool, provisionTier, cfg);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
else if (matching.length > target) {
|
|
1494
|
+
// Only terminate from the truly-live (non-pending) set. Sort
|
|
1495
|
+
// by lastActivity ascending so the oldest goes first — matches
|
|
1496
|
+
// the design's "support rotation for security patching."
|
|
1497
|
+
const excess = matching.length - target;
|
|
1498
|
+
const sorted = [...matching].sort((a, b) => a.lastActivity.getTime() - b.lastActivity.getTime());
|
|
1499
|
+
for (let i = 0; i < excess; i++) {
|
|
1500
|
+
const victim = sorted[i];
|
|
1501
|
+
debuglog("Hot-spare excess — terminating oldest", {
|
|
1502
|
+
nodeId: victim.nodeId,
|
|
1503
|
+
cfg,
|
|
1504
|
+
});
|
|
1505
|
+
void this.norsk
|
|
1506
|
+
.terminateNode(victim.nodeId)
|
|
1507
|
+
.catch((err) => this.settings.onError?.(err));
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
spareNodesMatching(cfg) {
|
|
1513
|
+
const out = [];
|
|
1514
|
+
for (const node of this.nodeMap.values()) {
|
|
1515
|
+
if (!this.isHotSpareNode(node.nodeId))
|
|
1516
|
+
continue;
|
|
1517
|
+
if (node.nodeState !== "running")
|
|
1518
|
+
continue;
|
|
1519
|
+
if (this.poolNameForNode(node.nodeId) !== cfg.pool)
|
|
1520
|
+
continue;
|
|
1521
|
+
if (cfg.forBand && this.spareBandForNode(node.nodeId) !== cfg.forBand)
|
|
1522
|
+
continue;
|
|
1523
|
+
// A spare with jobs assigned is consumed — exclude from the count
|
|
1524
|
+
// so the underflow fires and a replacement provisions.
|
|
1525
|
+
const inv = this.inventoryMap.get(node.nodeId);
|
|
1526
|
+
if (inv && inv.assignedJobs.length > 0)
|
|
1527
|
+
continue;
|
|
1528
|
+
out.push(node);
|
|
1529
|
+
}
|
|
1530
|
+
return out;
|
|
1531
|
+
}
|
|
1532
|
+
pendingSparesMatching(cfg) {
|
|
1533
|
+
let n = 0;
|
|
1534
|
+
for (const p of this.pendingSpareProvisions.values()) {
|
|
1535
|
+
if (p.pool !== cfg.pool)
|
|
1536
|
+
continue;
|
|
1537
|
+
if (cfg.forBand && p.band !== cfg.forBand)
|
|
1538
|
+
continue;
|
|
1539
|
+
n++;
|
|
1540
|
+
}
|
|
1541
|
+
return n;
|
|
1542
|
+
}
|
|
1543
|
+
async provisionHotSpare(pool, tier, cfg) {
|
|
1544
|
+
const instanceType = cfg.spec.instanceType;
|
|
1545
|
+
if (!instanceType) {
|
|
1546
|
+
this.settings.onError?.(new Error(`hotSpares entry for elastic pool '${cfg.pool}' needs spec.instanceType`));
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
const nodeId = uuid.v4() + ".spare." + cfg.pool;
|
|
1550
|
+
const tags = {
|
|
1551
|
+
"auto-manager": "true",
|
|
1552
|
+
spare: "true",
|
|
1553
|
+
pool: cfg.pool,
|
|
1554
|
+
tier: tier.name,
|
|
1555
|
+
};
|
|
1556
|
+
if (cfg.forBand)
|
|
1557
|
+
tags["spareBand"] = cfg.forBand;
|
|
1558
|
+
this.pendingSpareProvisions.set(nodeId, { pool: cfg.pool, band: cfg.forBand });
|
|
1559
|
+
this.updateNodeMap(nodeId, "starting", undefined, undefined);
|
|
1560
|
+
this.settings.onNodeStarting?.(nodeId);
|
|
1561
|
+
try {
|
|
1562
|
+
if (tier.kind === "aws") {
|
|
1563
|
+
await this.norsk.createAwsNode({
|
|
1564
|
+
nodeId,
|
|
1565
|
+
region: cfg.spec.region ?? tier.region ?? "",
|
|
1566
|
+
instanceType,
|
|
1567
|
+
tags,
|
|
1568
|
+
writeFiles: [],
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
else if (tier.kind === "oci") {
|
|
1572
|
+
await this.norsk.createOciNode({
|
|
1573
|
+
nodeId,
|
|
1574
|
+
availabilityDomain: cfg.spec.az ?? cfg.spec.region ?? tier.region ?? "",
|
|
1575
|
+
architecture: "x86_64",
|
|
1576
|
+
shape: instanceType,
|
|
1577
|
+
subnet: "",
|
|
1578
|
+
tags,
|
|
1579
|
+
writeFiles: [],
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
// Cluster path falls through — non-elastic, no auto-provision.
|
|
1583
|
+
}
|
|
1584
|
+
catch (err) {
|
|
1585
|
+
// Failed to even submit. Drop the pending marker so the next
|
|
1586
|
+
// reconcile can try again instead of waiting forever.
|
|
1587
|
+
this.pendingSpareProvisions.delete(nodeId);
|
|
1588
|
+
this.settings.onError?.(err);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
// ---- Phase F — NodeService reconciliation ------------------------
|
|
1592
|
+
/**
|
|
1593
|
+
* @public
|
|
1594
|
+
* Current placements for each configured NodeService. Returns a
|
|
1595
|
+
* snapshot map of `serviceId → (nodeId → runtimeJobId)`.
|
|
1596
|
+
*/
|
|
1597
|
+
nodeServices() {
|
|
1598
|
+
return this.settings.nodeServices ?? [];
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* @public
|
|
1602
|
+
* Nodes on which the named NodeService is currently placed,
|
|
1603
|
+
* keyed by the runtime job's jobId.
|
|
1604
|
+
*/
|
|
1605
|
+
nodeServicePlacementsFor(serviceId) {
|
|
1606
|
+
return new Map(this.nodeServicePlacements.get(serviceId) ?? []);
|
|
1607
|
+
}
|
|
1608
|
+
/**
|
|
1609
|
+
* Drive every configured NodeService toward its lifecycle target.
|
|
1610
|
+
* v1 only handles `eager` — places on every eligible node.
|
|
1611
|
+
* `lazy` placements + capability-loss-driven consumer recovery (F3)
|
|
1612
|
+
* are deferred.
|
|
1613
|
+
*/
|
|
1614
|
+
reconcileNodeServices() {
|
|
1615
|
+
const services = this.settings.nodeServices ?? [];
|
|
1616
|
+
if (services.length === 0)
|
|
1617
|
+
return;
|
|
1618
|
+
for (const svc of services) {
|
|
1619
|
+
if (svc.lifecycle !== "eager") {
|
|
1620
|
+
// Lazy lifecycle not implemented in v1; skip.
|
|
1621
|
+
continue;
|
|
1622
|
+
}
|
|
1623
|
+
this.reconcileEagerNodeService(svc);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
reconcileEagerNodeService(svc) {
|
|
1627
|
+
const eligible = this.eligibleNodesForService(svc);
|
|
1628
|
+
const placed = this.nodeServicePlacements.get(svc.serviceId) ?? new Map();
|
|
1629
|
+
// Place on every eligible node that doesn't already host the service.
|
|
1630
|
+
for (const nodeId of eligible) {
|
|
1631
|
+
if (placed.has(nodeId))
|
|
1632
|
+
continue;
|
|
1633
|
+
void this.placeNodeService(svc, nodeId).catch((err) => this.settings.onError?.(err));
|
|
1634
|
+
}
|
|
1635
|
+
// Tear down any placement that's no longer eligible.
|
|
1636
|
+
for (const [nodeId, jobId] of placed) {
|
|
1637
|
+
if (eligible.has(nodeId))
|
|
1638
|
+
continue;
|
|
1639
|
+
void this.teardownNodeService(svc.serviceId, nodeId, jobId).catch((err) => this.settings.onError?.(err));
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
/**
|
|
1643
|
+
* Identify nodes in the service's named pools whose current inventory
|
|
1644
|
+
* satisfies its `consumes` requirements. Used both for placement and
|
|
1645
|
+
* for tear-down decisions.
|
|
1646
|
+
*/
|
|
1647
|
+
eligibleNodesForService(svc) {
|
|
1648
|
+
const eligible = new Set();
|
|
1649
|
+
const poolSet = new Set(svc.pools);
|
|
1650
|
+
for (const node of this.nodeMap.values()) {
|
|
1651
|
+
if (node.nodeState !== "running")
|
|
1652
|
+
continue;
|
|
1653
|
+
if (!poolSet.has(this.poolNameForNode(node.nodeId)))
|
|
1654
|
+
continue;
|
|
1655
|
+
const inv = this.inventoryMap.get(node.nodeId);
|
|
1656
|
+
if (!inv || !inv.reachable || inv.cordoned)
|
|
1657
|
+
continue;
|
|
1658
|
+
if (this.cordonedNodes.has(node.nodeId))
|
|
1659
|
+
continue;
|
|
1660
|
+
if (this.nodeSatisfiesConsumes(inv, svc.consumes)) {
|
|
1661
|
+
eligible.add(node.nodeId);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
return eligible;
|
|
1665
|
+
}
|
|
1666
|
+
nodeSatisfiesConsumes(inv, consumes) {
|
|
1667
|
+
for (const req of consumes) {
|
|
1668
|
+
const cap = inv.capabilities.find((c) => c.name === req.name);
|
|
1669
|
+
if (!cap)
|
|
1670
|
+
return false;
|
|
1671
|
+
const reserved = inv.reservedCapabilities.find((c) => c.name === req.name)?.count ?? 0;
|
|
1672
|
+
if (cap.count - reserved < req.count)
|
|
1673
|
+
return false;
|
|
1674
|
+
if (req.attributeMatches) {
|
|
1675
|
+
for (const [k, v] of Object.entries(req.attributeMatches)) {
|
|
1676
|
+
if (cap.attributes?.[k] !== v)
|
|
1677
|
+
return false;
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
return true;
|
|
1682
|
+
}
|
|
1683
|
+
async placeNodeService(svc, nodeId) {
|
|
1684
|
+
const jobId = `${svc.serviceId}.${uuid.v4().slice(0, 8)}`;
|
|
1685
|
+
debuglog("Placing NodeService", { serviceId: svc.serviceId, nodeId, jobId });
|
|
1686
|
+
// Optimistically record the placement so a re-entrant reconcile
|
|
1687
|
+
// doesn't double-place.
|
|
1688
|
+
let placements = this.nodeServicePlacements.get(svc.serviceId);
|
|
1689
|
+
if (!placements) {
|
|
1690
|
+
placements = new Map();
|
|
1691
|
+
this.nodeServicePlacements.set(svc.serviceId, placements);
|
|
1692
|
+
}
|
|
1693
|
+
placements.set(nodeId, jobId);
|
|
1694
|
+
// Build the runtime Job: take the configured template, override
|
|
1695
|
+
// the jobId, and tag it as a NodeService instance.
|
|
1696
|
+
const job = {
|
|
1697
|
+
...svc.jobSpec,
|
|
1698
|
+
jobId,
|
|
1699
|
+
tags: {
|
|
1700
|
+
...(svc.jobSpec.tags ?? {}),
|
|
1701
|
+
"auto-manager": "true",
|
|
1702
|
+
nodeService: svc.serviceId,
|
|
1703
|
+
},
|
|
1704
|
+
};
|
|
1705
|
+
let createdJob = false;
|
|
1706
|
+
try {
|
|
1707
|
+
await this.norsk.createJob(job);
|
|
1708
|
+
createdJob = true;
|
|
1709
|
+
await this.norsk.startJob(jobId, exports.NODE_SERVICE_ROLE, nodeId);
|
|
1710
|
+
this.jobToNode.set(jobId, nodeId);
|
|
1711
|
+
}
|
|
1712
|
+
catch (err) {
|
|
1713
|
+
// Roll back the optimistic placement on failure.
|
|
1714
|
+
placements.delete(nodeId);
|
|
1715
|
+
// R9: if createJob succeeded but startJob failed, the Manager
|
|
1716
|
+
// has a dangling Job row that the next reconcile would orphan
|
|
1717
|
+
// by allocating a fresh jobId. Best-effort delete.
|
|
1718
|
+
if (createdJob) {
|
|
1719
|
+
try {
|
|
1720
|
+
await this.norsk.deleteJob(jobId);
|
|
1721
|
+
}
|
|
1722
|
+
catch (cleanupErr) {
|
|
1723
|
+
debuglog("placeNodeService: cleanup deleteJob failed", {
|
|
1724
|
+
jobId,
|
|
1725
|
+
err: String(cleanupErr),
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
this.settings.onError?.(err);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
async teardownNodeService(serviceId, nodeId, jobId) {
|
|
1733
|
+
debuglog("Tearing down NodeService instance", { serviceId, nodeId, jobId });
|
|
1734
|
+
const placements = this.nodeServicePlacements.get(serviceId);
|
|
1735
|
+
placements?.delete(nodeId);
|
|
1736
|
+
try {
|
|
1737
|
+
await this.norsk.stopJob(jobId, exports.NODE_SERVICE_ROLE, nodeId);
|
|
1738
|
+
}
|
|
1739
|
+
catch (err) {
|
|
1740
|
+
// Job might be gone already; tolerate.
|
|
1741
|
+
debuglog("NodeService stopJob failed (continuing)", { serviceId, err: String(err) });
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
tagsForJob(jobId) {
|
|
1745
|
+
return this.jobMap.get(jobId)?.job.tags;
|
|
1746
|
+
}
|
|
1747
|
+
poolNameForNode(nodeId) {
|
|
1748
|
+
const node = this.nodeMap.get(nodeId);
|
|
1749
|
+
return node?.nodeMetadata?.tags?.["pool"] ?? "";
|
|
1750
|
+
}
|
|
1751
|
+
// Tier within the pool, from the node's `tier` tag. Undefined for
|
|
1752
|
+
// pre-registered cluster nodes (no tier tag) — nodeMatchesTier then
|
|
1753
|
+
// treats them as the pool's fixed tier.
|
|
1754
|
+
tierNameForNode(nodeId) {
|
|
1755
|
+
const node = this.nodeMap.get(nodeId);
|
|
1756
|
+
return node?.nodeMetadata?.tags?.["tier"];
|
|
1757
|
+
}
|
|
1758
|
+
azForNode(nodeId) {
|
|
1759
|
+
const node = this.nodeMap.get(nodeId);
|
|
1760
|
+
return node?.nodeMetadata?.tags?.["az"];
|
|
1761
|
+
}
|
|
1762
|
+
// Cloud / failure-domain key for the resilience sameCloud rule: the node's
|
|
1763
|
+
// tier kind (aws / oci / local-servers), resolved via its pool+tier tags. Matches
|
|
1764
|
+
// how provisionDegradation and validation derive a pool's clouds.
|
|
1765
|
+
cloudForNode(nodeId) {
|
|
1766
|
+
const poolName = this.poolNameForNode(nodeId);
|
|
1767
|
+
const tierName = this.tierNameForNode(nodeId);
|
|
1768
|
+
const pool = (this.settings.placementPools ?? []).find((p) => p.name === poolName);
|
|
1769
|
+
if (!pool)
|
|
1770
|
+
return undefined;
|
|
1771
|
+
const tier = tierName !== undefined
|
|
1772
|
+
? pool.tiers.find((t) => t.name === tierName)
|
|
1773
|
+
: pool.tiers.find((t) => t.scaleOut === "fixed");
|
|
1774
|
+
return tier?.kind;
|
|
1775
|
+
}
|
|
1776
|
+
handleBundleUpdated(raw) {
|
|
1777
|
+
if (!raw.bundle)
|
|
1778
|
+
return;
|
|
1779
|
+
const b = (0, conversions_1.fromPbBundle)(raw.bundle);
|
|
1780
|
+
this.bundleMap.set(b.bundleId, b);
|
|
1781
|
+
// Phase C2: operator pushed a fresh spec — clear any prior broken
|
|
1782
|
+
// state for this bundle and reset its replicas' restart counters
|
|
1783
|
+
// so the new spec gets a clean start.
|
|
1784
|
+
if (this.brokenBundles.delete(b.bundleId)) {
|
|
1785
|
+
debuglog("Cleared broken flag on bundleUpdated", { bundleId: b.bundleId });
|
|
1786
|
+
}
|
|
1787
|
+
this.clearRestartHistoryForBundle(b.bundleId);
|
|
1788
|
+
this.settings.onBundleUpdated?.(b);
|
|
1789
|
+
}
|
|
1790
|
+
handleBundleDeleted(raw) {
|
|
1791
|
+
if (!raw.bundleId)
|
|
1792
|
+
return;
|
|
1793
|
+
const id = raw.bundleId.bundleId;
|
|
1794
|
+
this.bundleMap.delete(id);
|
|
1795
|
+
this.brokenBundles.delete(id);
|
|
1796
|
+
this.clearRestartHistoryForBundle(id);
|
|
1797
|
+
const stop = this.pendingStopTimers.get(id);
|
|
1798
|
+
if (stop) {
|
|
1799
|
+
stop.timer.cancel();
|
|
1800
|
+
this.pendingStopTimers.delete(id);
|
|
1801
|
+
}
|
|
1802
|
+
this.settings.onBundleDeleted?.(id);
|
|
1803
|
+
}
|
|
1804
|
+
clearRestartHistoryForBundle(bundleId) {
|
|
1805
|
+
for (const jobId of this.jobMap.keys()) {
|
|
1806
|
+
const tags = this.jobMap.get(jobId)?.job.tags ?? {};
|
|
1807
|
+
if (tags["bundleId"] === bundleId)
|
|
1808
|
+
this.restartHistory.delete(jobId);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
nodesByJob(jobId) {
|
|
1812
|
+
// Uses the jobToNode index so the "is this job placed yet?" check
|
|
1813
|
+
// works for multi-job nodes too — the older `tags["jobId"]` scan
|
|
1814
|
+
// always returned empty for cluster workers (the tag is per-node
|
|
1815
|
+
// and can only carry one value), which caused AutoManager to
|
|
1816
|
+
// repeatedly re-attempt placement and the daemon to reject with
|
|
1817
|
+
// `INVALID_ARGUMENT: Job already running on node`.
|
|
1818
|
+
const nodes = new Set();
|
|
1819
|
+
const nodeId = this.jobToNode.get(jobId);
|
|
1820
|
+
if (nodeId !== undefined) {
|
|
1821
|
+
const node = this.nodeMap.get(nodeId);
|
|
1822
|
+
if (node)
|
|
1823
|
+
nodes.add(node);
|
|
1824
|
+
}
|
|
1825
|
+
return nodes;
|
|
1826
|
+
}
|
|
1827
|
+
/** @internal */
|
|
1828
|
+
constructor(norsk, settings) {
|
|
1829
|
+
// All AutoManager state is a mirror of what the event stream most
|
|
1830
|
+
// recently said — AutoManager owns no durable state. Maps are cleared
|
|
1831
|
+
// and re-hydrated on (re)connect from the event-stream initial state.
|
|
1832
|
+
this.jobMap = new Map();
|
|
1833
|
+
this.nodeMap = new Map();
|
|
1834
|
+
this.bundleMap = new Map();
|
|
1835
|
+
this.inventoryMap = new Map();
|
|
1836
|
+
// Per-node rejection history (timestamps). Pruned to the configured
|
|
1837
|
+
// backoff window on read; entries trigger escalation to `unhealthyNodes`
|
|
1838
|
+
// once the threshold is crossed.
|
|
1839
|
+
this.rejectionsPerNode = new Map();
|
|
1840
|
+
// Nodes excluded from placement entirely until the next clean
|
|
1841
|
+
// `nodeInventoryUpdated` clears the entry.
|
|
1842
|
+
this.unhealthyNodes = new Set();
|
|
1843
|
+
// Recently-failed nodes — excluded from placement for `failureBackoffMs`
|
|
1844
|
+
// after we observe their `nodeStopping`. Map value is the deadline.
|
|
1845
|
+
this.recentFailures = new Map();
|
|
1846
|
+
// Phase C2: per-replica restart history. Each entry is the time a
|
|
1847
|
+
// recovery placement was attempted; older-than-1h entries are
|
|
1848
|
+
// pruned on read. Used to trip the maxRestartsPerHour ceiling.
|
|
1849
|
+
this.restartHistory = new Map();
|
|
1850
|
+
// Canonical jobId → nodeId index. Set at placement time (either
|
|
1851
|
+
// when AutoManager binds the job to an existing node via startJob,
|
|
1852
|
+
// or when it provisions a new node and records the provisional
|
|
1853
|
+
// marker); deleted on jobDeleted. Underpins `nodeForJob` as an
|
|
1854
|
+
// O(1) lookup so consumers (proxy routers, operator UIs) don't pay
|
|
1855
|
+
// a fleet-wide scan on every read — important when a single host
|
|
1856
|
+
// can carry many jobs (cluster, or any future cloud N:1) and the
|
|
1857
|
+
// `tags["jobId"]` shortcut on the node stops being expressive.
|
|
1858
|
+
// Rebuilt on R7 reconnect from each job's history via
|
|
1859
|
+
// `currentNodeIdForJob`. Entries for jobs whose node has since
|
|
1860
|
+
// terminated remain in the map but resolve to undefined through
|
|
1861
|
+
// nodeMap — harmless, and cleaned up the next time the job is
|
|
1862
|
+
// re-placed or deleted.
|
|
1863
|
+
this.jobToNode = new Map();
|
|
1864
|
+
// Per-job placement record. Set at the same callsite that fires
|
|
1865
|
+
// `onPlacementDecision` (live decisions during this AutoManager
|
|
1866
|
+
// generation); rebuilt from job history on R7 reconnect — for
|
|
1867
|
+
// hydrated jobs we have no live trace, so we synthesise one with
|
|
1868
|
+
// empty `trace.attempts` and the decision inferred from the
|
|
1869
|
+
// history event ("place" = re-binding to an existing node, "provision"
|
|
1870
|
+
// = node started for the job). Consumers query this via
|
|
1871
|
+
// `placements()` / `placementForJob()` so an operator UI shows the
|
|
1872
|
+
// current placement immediately on (re)connect rather than waiting
|
|
1873
|
+
// for the next live decision — same mirror-of-state pattern jobMap
|
|
1874
|
+
// and nodeMap use. Cleared on jobDeleted; entries are kept across
|
|
1875
|
+
// node restarts since the placement-of-record doesn't change just
|
|
1876
|
+
// because a node bounced.
|
|
1877
|
+
this.placementsByJob = new Map();
|
|
1878
|
+
// Pending placement timers — one per scheduled job. Set when
|
|
1879
|
+
// `handleJobChanged` sees a pre/active job whose `startDateTime`
|
|
1880
|
+
// (minus the pool's `placementLeadMs`) is still in the future;
|
|
1881
|
+
// fires placeAndProvision when the lead-time window opens.
|
|
1882
|
+
// Cancelled on jobDeleted, on R7 reconnect (re-evaluated from
|
|
1883
|
+
// hydrated state), and replaced on subsequent job-updates that
|
|
1884
|
+
// change the schedule. Tracks `scheduledFor` so `pendingPlacements()`
|
|
1885
|
+
// can render upcoming work in operator UIs.
|
|
1886
|
+
this.pendingPlacementTimers = new Map();
|
|
1887
|
+
// Pending stop timers — one per bundleId. Set when any job in the
|
|
1888
|
+
// bundle carries the `__stopDateTime` tag and the instant is still
|
|
1889
|
+
// in the future; on fire, calls `deleteBundle(bundleId)` and
|
|
1890
|
+
// removes the entry. Daemon has no first-class concept of stop
|
|
1891
|
+
// time so this state is rebuilt from job tags on R7 reconnect.
|
|
1892
|
+
// Public surface: `setBundleStopTime(bundleId, newStopTime?)` for
|
|
1893
|
+
// the future Edit-bundle UI; `bundleStopTimes()` for read.
|
|
1894
|
+
this.pendingStopTimers = new Map();
|
|
1895
|
+
// Outstanding deferred recovery placements. Each entry caps to one
|
|
1896
|
+
// setTimeout-driven retry per JobId; subsequent triggers while a
|
|
1897
|
+
// retry is in flight are no-ops (the scheduled fire will see the
|
|
1898
|
+
// latest state). Prevents the tight-loop "provisionJob → JobFailed →
|
|
1899
|
+
// provisionJob" pattern we observed when worker-side launch errors
|
|
1900
|
+
// were instantly retryable. See 04-e2e-todo's B-daemon-retry-storm.
|
|
1901
|
+
this.pendingPlacementRetries = new Map();
|
|
1902
|
+
// Timestamp of the most recent `placeAndProvision` entry per JobId.
|
|
1903
|
+
// Used by both initial and recovery branches to skip duplicate
|
|
1904
|
+
// attempts while a placement is still in flight (the daemon has
|
|
1905
|
+
// dispatched provisionJob but JobStarted hasn't landed yet, so
|
|
1906
|
+
// `nodesByJob().size` is still 0 and handleJobChanged would
|
|
1907
|
+
// otherwise re-fire). See 04-e2e-todo's
|
|
1908
|
+
// B-placement-overretry-dispatch-fail.
|
|
1909
|
+
this.lastPlacementAttemptAt = new Map();
|
|
1910
|
+
// Phase C2: bundles that have tripped the restart ceiling. While in
|
|
1911
|
+
// this set, AutoManager refuses recovery placements for any of the
|
|
1912
|
+
// bundle's replicas. Cleared on bundleUpdated (operator pushed a
|
|
1913
|
+
// fresh spec) or bundleDeleted.
|
|
1914
|
+
this.brokenBundles = new Set();
|
|
1915
|
+
// Phase C3: pending nodeStarting → nodeStarted grace timers. If a
|
|
1916
|
+
// timer fires before the node reports running, AutoManager
|
|
1917
|
+
// terminates the node and the existing nodeStopping path runs
|
|
1918
|
+
// recovery per band rules.
|
|
1919
|
+
this.startupGraceTimers = new Map();
|
|
1920
|
+
// Phase C6: spares we have asked the provider to create but which
|
|
1921
|
+
// haven't yet appeared in nodeMap with the spare tag. Counted
|
|
1922
|
+
// alongside live spares so the reconciler doesn't fire duplicate
|
|
1923
|
+
// provisions while a previous one is in flight.
|
|
1924
|
+
this.pendingSpareProvisions = new Map();
|
|
1925
|
+
// Phase C8: nodes the operator has cordoned client-side. Merged
|
|
1926
|
+
// into NodeView.inventory.cordoned at placement time so existing
|
|
1927
|
+
// placement filter excludes them. (When Manager grows a cordon
|
|
1928
|
+
// API and emits `cordoned: true` on inventory updates, this local
|
|
1929
|
+
// set becomes redundant — until then it's the API.)
|
|
1930
|
+
this.cordonedNodes = new Set();
|
|
1931
|
+
// Phase D4 — cumulative metrics counters since startedAt. Read via
|
|
1932
|
+
// `metrics()`. Counters never reset; consumers compute deltas.
|
|
1933
|
+
this.metricsCounters = {
|
|
1934
|
+
placementsAttempted: 0,
|
|
1935
|
+
placementsSucceeded: 0,
|
|
1936
|
+
placementsFailed: 0,
|
|
1937
|
+
placementsDispatchFailed: 0,
|
|
1938
|
+
jobsRejected: 0,
|
|
1939
|
+
bundlesBroken: 0,
|
|
1940
|
+
jobsDropped: 0,
|
|
1941
|
+
};
|
|
1942
|
+
// Phase E — active migrations. "No concurrent migrations per Job"
|
|
1943
|
+
// means migrationByJobId is a 1-to-1 index of migrations.
|
|
1944
|
+
this.migrationStates = new Map();
|
|
1945
|
+
this.migrationByJobId = new Map();
|
|
1946
|
+
// Phase E deadlines — a per-migration timer fires if the current
|
|
1947
|
+
// phase hasn't transitioned by the configured window. Re-armed
|
|
1948
|
+
// on every phase transition; cleared on done/aborted/close().
|
|
1949
|
+
this.migrationPhaseTimers = new Map();
|
|
1950
|
+
// Phase F — NodeService runtime tracking. Per service, the set of
|
|
1951
|
+
// (nodeId → runtime jobId) AutoManager has placed it on. The
|
|
1952
|
+
// reconciler keeps this in sync with the configured eligible set.
|
|
1953
|
+
this.nodeServicePlacements = new Map();
|
|
1954
|
+
this.closed = false;
|
|
1955
|
+
this.norsk = norsk;
|
|
1956
|
+
this.settings = settings;
|
|
1957
|
+
this.clock = settings.clock ?? new clock_1.RealClock();
|
|
1958
|
+
if (!this.settings.onError) {
|
|
1959
|
+
this.settings.onError = (err) => {
|
|
1960
|
+
debuglog("Unhandled error: %o", err);
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
/** @internal */
|
|
1965
|
+
async runInternal() {
|
|
1966
|
+
this.startedAt = this.clock.now();
|
|
1967
|
+
const [initialState, iter] = await (0, norsk_manager_sdk_1.eventStream)(this.norsk, {
|
|
1968
|
+
pendingWindow: this.settings.pendingWindow,
|
|
1969
|
+
});
|
|
1970
|
+
// R7: Hydrate state from initial-state. AutoManager owns nothing
|
|
1971
|
+
// durable; clear-then-populate so a reconnect drops anything the
|
|
1972
|
+
// server has since forgotten about. Includes every transient map
|
|
1973
|
+
// we maintain in-process — without this, a reconnect would carry
|
|
1974
|
+
// forward unhealthy/restart/migration/spare state against a fleet
|
|
1975
|
+
// view that may no longer match.
|
|
1976
|
+
this.jobMap.clear();
|
|
1977
|
+
this.nodeMap.clear();
|
|
1978
|
+
this.bundleMap.clear();
|
|
1979
|
+
this.inventoryMap.clear();
|
|
1980
|
+
this.unhealthyNodes.clear();
|
|
1981
|
+
this.recentFailures.clear();
|
|
1982
|
+
this.rejectionsPerNode.clear();
|
|
1983
|
+
this.restartHistory.clear();
|
|
1984
|
+
this.brokenBundles.clear();
|
|
1985
|
+
this.pendingSpareProvisions.clear();
|
|
1986
|
+
this.jobToNode.clear();
|
|
1987
|
+
this.placementsByJob.clear();
|
|
1988
|
+
// Pending placement timers are session-local; the recovery loop
|
|
1989
|
+
// below re-evaluates each hydrated pre/active job and schedules
|
|
1990
|
+
// fresh timers (or places immediately) as appropriate.
|
|
1991
|
+
for (const { timer } of this.pendingPlacementTimers.values())
|
|
1992
|
+
timer.cancel();
|
|
1993
|
+
this.pendingPlacementTimers.clear();
|
|
1994
|
+
// Same for stop timers — the recovery loop calls reconcileStopTimer
|
|
1995
|
+
// for each hydrated job so the daemon-persisted __stopDateTime tag
|
|
1996
|
+
// re-arms the timer.
|
|
1997
|
+
for (const { timer } of this.pendingStopTimers.values())
|
|
1998
|
+
timer.cancel();
|
|
1999
|
+
this.pendingStopTimers.clear();
|
|
2000
|
+
for (const t of this.startupGraceTimers.values())
|
|
2001
|
+
t.cancel();
|
|
2002
|
+
this.startupGraceTimers.clear();
|
|
2003
|
+
// Migrations are in-flight client-side state; on reconnect the
|
|
2004
|
+
// worker-side actors are gone with the old connection, so any
|
|
2005
|
+
// pending migration is effectively dead. Cancel deadlines first
|
|
2006
|
+
// (they refer to timers we're about to cancel anyway), then drop
|
|
2007
|
+
// the state maps.
|
|
2008
|
+
for (const d of this.migrationPhaseTimers.values())
|
|
2009
|
+
d.cancel();
|
|
2010
|
+
this.migrationPhaseTimers.clear();
|
|
2011
|
+
this.migrationStates.clear();
|
|
2012
|
+
this.migrationByJobId.clear();
|
|
2013
|
+
this.nodeServicePlacements.clear();
|
|
2014
|
+
for (const jobWithHistory of initialState.activeJobs) {
|
|
2015
|
+
this.jobMap.set(jobWithHistory.job.jobId, jobWithHistory);
|
|
2016
|
+
// Rebuild jobToNode from history — on reconnect we don't get a
|
|
2017
|
+
// fresh placement-decision stream for jobs that were already
|
|
2018
|
+
// placed; the history is the source of truth.
|
|
2019
|
+
const nodeId = currentNodeIdForJob(jobWithHistory);
|
|
2020
|
+
if (nodeId !== undefined) {
|
|
2021
|
+
this.jobToNode.set(jobWithHistory.job.jobId, nodeId);
|
|
2022
|
+
// Same reasoning for the placement map: a job that was placed
|
|
2023
|
+
// in a prior generation has no live `onPlacementDecision` event
|
|
2024
|
+
// we can replay, so we synthesise a record from history. The
|
|
2025
|
+
// trace is empty (we never had it) but the result + at +
|
|
2026
|
+
// job-identity fields are all derivable.
|
|
2027
|
+
const synth = synthesisePlacementFromHistory(jobWithHistory, nodeId);
|
|
2028
|
+
if (synth)
|
|
2029
|
+
this.placementsByJob.set(jobWithHistory.job.jobId, synth);
|
|
2030
|
+
}
|
|
2031
|
+
// Fire onJobUpdated for each hydrated job so consumers can
|
|
2032
|
+
// treat hydration identically to a stream of fresh
|
|
2033
|
+
// job-updated events. Without this, a consumer subscribed
|
|
2034
|
+
// through onJobUpdated wouldn't see existing jobs until the
|
|
2035
|
+
// next genuine update — proxies that build routing tables
|
|
2036
|
+
// from the event stream would stay empty until the operator
|
|
2037
|
+
// touched something.
|
|
2038
|
+
this.settings.onJobUpdated?.(jobWithHistory);
|
|
2039
|
+
}
|
|
2040
|
+
for (const runningNode of initialState.nodesRunning) {
|
|
2041
|
+
const nodeId = runningNode.nodeMetadata.nodeId;
|
|
2042
|
+
this.updateNodeMap(nodeId, "running", runningNode.nodeMetadata, runningNode.runningNodeMetadata);
|
|
2043
|
+
// Surface hydrated nodes to consumers as started — symmetric with
|
|
2044
|
+
// the job-hydration loop above replaying each job via onJobUpdated.
|
|
2045
|
+
// A worker already up before AutoManager (re)connected is delivered
|
|
2046
|
+
// as a `NodeRunning` snapshot, never a live `NodeStarted`; without
|
|
2047
|
+
// this, downstream listeners (e.g. the norsk-mgr Cluster page, which
|
|
2048
|
+
// only refetches on a node-started signal) never learn it joined.
|
|
2049
|
+
const node = this.nodeMap.get(nodeId);
|
|
2050
|
+
if (node)
|
|
2051
|
+
this.settings.onNodeStarted?.(node);
|
|
2052
|
+
}
|
|
2053
|
+
for (const ba of initialState.bundles) {
|
|
2054
|
+
if (ba.bundle) {
|
|
2055
|
+
const b = (0, conversions_1.fromPbBundle)(ba.bundle);
|
|
2056
|
+
this.bundleMap.set(b.bundleId, b);
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
for (const snap of initialState.inventories) {
|
|
2060
|
+
if (snap.inventory && snap.nodeId) {
|
|
2061
|
+
this.inventoryMap.set(snap.nodeId.nodeId, (0, conversions_1.fromPbNodeInventory)(snap.inventory));
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
// hotSpares + nodeServices reserved (Phase F).
|
|
2065
|
+
// Recover any active jobs that we don't have a node for — these
|
|
2066
|
+
// exist when AutoManager has just (re)connected and the server
|
|
2067
|
+
// tells us about them via initial-state. Route through
|
|
2068
|
+
// `placeOrDefer` so scheduled jobs whose `startDateTime - lead`
|
|
2069
|
+
// is still in the future get a fresh timer; ones whose window
|
|
2070
|
+
// has already arrived (or was missed while mgr was down) place
|
|
2071
|
+
// immediately.
|
|
2072
|
+
for (const entry of this.jobMap.values()) {
|
|
2073
|
+
// Rebuild stop timers from the daemon-persisted __stopDateTime
|
|
2074
|
+
// tag on every hydrated job. Dedupes per bundleId — first job
|
|
2075
|
+
// wins, subsequent jobs in the same bundle are no-ops.
|
|
2076
|
+
this.reconcileStopTimer(entry);
|
|
2077
|
+
if (this.wantsPlacementNow(entry)) {
|
|
2078
|
+
await this.placeOrDefer(entry);
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
// Periodic cleanup: terminate stopped nodes older than the threshold.
|
|
2082
|
+
// R19: cadence derived from removeStoppedNodesAfter (1/4 of the
|
|
2083
|
+
// threshold, capped to [10s, 5min]) so the sweep is proportionate
|
|
2084
|
+
// to the retention window — tests that lower the threshold to
|
|
2085
|
+
// seconds see the sweep fire quickly without rewiring the cadence.
|
|
2086
|
+
const cleanupCadenceMs = Math.max(10000, Math.min(300000, Math.floor((this.settings.removeStoppedNodesAfter * 1000) / 4)));
|
|
2087
|
+
this.cleanupInterval = this.clock.setInterval(async () => {
|
|
2088
|
+
if (this.closed)
|
|
2089
|
+
return; // R14: don't run cleanup after close()
|
|
2090
|
+
// R13: snapshot entries up front so concurrent mutations during
|
|
2091
|
+
// the await terminateNode don't reshuffle traversal.
|
|
2092
|
+
const snapshot = Array.from(this.nodeMap.entries());
|
|
2093
|
+
for (const [nodeId, node] of snapshot) {
|
|
2094
|
+
if (this.closed)
|
|
2095
|
+
return;
|
|
2096
|
+
if (node.nodeState === "stopped" &&
|
|
2097
|
+
this.clock.now().getTime() - node.lastActivity.getTime() >
|
|
2098
|
+
this.settings.removeStoppedNodesAfter * 1000) {
|
|
2099
|
+
await this.norsk.terminateNode(nodeId).catch((err) => {
|
|
2100
|
+
this.settings.onError?.(err);
|
|
2101
|
+
});
|
|
2102
|
+
this.nodeMap.delete(nodeId);
|
|
2103
|
+
this.settings.onNodeTerminated?.(nodeId);
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
}, cleanupCadenceMs);
|
|
2107
|
+
// Phase C6: periodic hot-spare reconciliation tick. Event-driven
|
|
2108
|
+
// reconciles handle most cases; this is the safety net for
|
|
2109
|
+
// missed signals (e.g. a provider that quietly terminated a
|
|
2110
|
+
// spare without Manager emitting nodeStopping).
|
|
2111
|
+
const reconcileInterval = this.settings.hotSpareReconcileIntervalMs ??
|
|
2112
|
+
DEFAULT_HOT_SPARE_RECONCILE_INTERVAL_MS;
|
|
2113
|
+
this.hotSpareReconcileInterval = this.clock.setInterval(() => this.reconcileHotSpares(), reconcileInterval);
|
|
2114
|
+
// Kick once at startup so a fresh AutoManager with hotSpares
|
|
2115
|
+
// configured begins provisioning immediately rather than waiting
|
|
2116
|
+
// for the first event or interval.
|
|
2117
|
+
this.reconcileHotSpares();
|
|
2118
|
+
// Phase F: same shape as hot-spare reconcile — periodic tick
|
|
2119
|
+
// for NodeServices, with a startup kick.
|
|
2120
|
+
const nsReconcileInterval = this.settings.nodeServiceReconcileIntervalMs ??
|
|
2121
|
+
DEFAULT_NODE_SERVICE_RECONCILE_INTERVAL_MS;
|
|
2122
|
+
this.nodeServiceReconcileInterval = this.clock.setInterval(() => this.reconcileNodeServices(), nsReconcileInterval);
|
|
2123
|
+
this.reconcileNodeServices();
|
|
2124
|
+
for await (const event of iter) {
|
|
2125
|
+
try {
|
|
2126
|
+
const eventType = event.event;
|
|
2127
|
+
switch (eventType) {
|
|
2128
|
+
case "jobUpdated":
|
|
2129
|
+
case "jobPending":
|
|
2130
|
+
await this.handleJobChanged(event.jobWithHistory);
|
|
2131
|
+
break;
|
|
2132
|
+
case "jobDeleted":
|
|
2133
|
+
this.handleJobDeleted(event.jobId);
|
|
2134
|
+
break;
|
|
2135
|
+
case "jobOutOfWindow":
|
|
2136
|
+
this.handleJobDeleted(event.jobWithHistory.job.jobId);
|
|
2137
|
+
break;
|
|
2138
|
+
case "jobInfo":
|
|
2139
|
+
this.settings.onJobInfo?.(event);
|
|
2140
|
+
break;
|
|
2141
|
+
case "nodeStarting":
|
|
2142
|
+
this.handleNodeStarting(event.nodeMetadata);
|
|
2143
|
+
break;
|
|
2144
|
+
case "nodeStarted":
|
|
2145
|
+
this.handleNodeStarted(event.nodeMetadata, event.runningNodeMetadata);
|
|
2146
|
+
break;
|
|
2147
|
+
case "nodeStopping":
|
|
2148
|
+
await this.handleNodeStopping(event.nodeId, event.reason);
|
|
2149
|
+
break;
|
|
2150
|
+
case "nodeStopped":
|
|
2151
|
+
this.handleNodeStopped(event.nodeId);
|
|
2152
|
+
break;
|
|
2153
|
+
case "physicalNodeConnected":
|
|
2154
|
+
// Reserved.
|
|
2155
|
+
break;
|
|
2156
|
+
case "providerHealthChange":
|
|
2157
|
+
debuglog("Provider health change %o", event.health);
|
|
2158
|
+
break;
|
|
2159
|
+
case "eventStreamClosed":
|
|
2160
|
+
break;
|
|
2161
|
+
case "nodeInventoryUpdated":
|
|
2162
|
+
this.handleNodeInventoryUpdated(event.raw);
|
|
2163
|
+
break;
|
|
2164
|
+
case "jobRejected":
|
|
2165
|
+
this.handleJobRejected(event.raw);
|
|
2166
|
+
break;
|
|
2167
|
+
case "jobConfigUpdated":
|
|
2168
|
+
this.handleJobConfigUpdated(event.raw);
|
|
2169
|
+
break;
|
|
2170
|
+
case "bundleUpdated":
|
|
2171
|
+
this.handleBundleUpdated(event.raw);
|
|
2172
|
+
break;
|
|
2173
|
+
case "bundleDeleted":
|
|
2174
|
+
this.handleBundleDeleted(event.raw);
|
|
2175
|
+
break;
|
|
2176
|
+
case "nodeServiceUpdated":
|
|
2177
|
+
case "nodeServiceDeleted":
|
|
2178
|
+
// Phase F (reserved).
|
|
2179
|
+
break;
|
|
2180
|
+
case "migrationTargetReady":
|
|
2181
|
+
await this.handleMigrationTargetReady(event.raw);
|
|
2182
|
+
break;
|
|
2183
|
+
case "migrationSourceStoppedOutput":
|
|
2184
|
+
await this.handleMigrationSourceStoppedOutput(event.raw);
|
|
2185
|
+
break;
|
|
2186
|
+
case "migrationAborted":
|
|
2187
|
+
this.handleMigrationAborted(event.raw);
|
|
2188
|
+
break;
|
|
2189
|
+
case "auditAppended":
|
|
2190
|
+
this.settings.onAuditAppended?.(event.entry);
|
|
2191
|
+
break;
|
|
2192
|
+
default: {
|
|
2193
|
+
const exhaustiveCheck = eventType;
|
|
2194
|
+
throw new Error(`Unhandled case: ${exhaustiveCheck}`);
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
catch (err) {
|
|
2199
|
+
this.settings.onError?.(err);
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
// ---- job lifecycle ----
|
|
2204
|
+
async handleJobChanged(jobWithHistory) {
|
|
2205
|
+
const job = jobWithHistory.job;
|
|
2206
|
+
this.jobMap.set(job.jobId, jobWithHistory);
|
|
2207
|
+
this.settings.onJobUpdated?.(jobWithHistory);
|
|
2208
|
+
// Reconcile the per-bundle stop timer from the well-known tag on
|
|
2209
|
+
// this job. Done before the placement decisions so an already-past
|
|
2210
|
+
// stop time triggers deleteBundle without waiting for placement.
|
|
2211
|
+
this.reconcileStopTimer(jobWithHistory);
|
|
2212
|
+
if (this.wantsPlacementNow(jobWithHistory)) {
|
|
2213
|
+
await this.placeOrDefer(jobWithHistory);
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
/** Read the `__stopDateTime` tag off the job, and (re)schedule the
|
|
2217
|
+
* bundle's stop timer accordingly. Tag is set per-job but applies
|
|
2218
|
+
* to the whole bundle — first-job-wins for read; all jobs in a
|
|
2219
|
+
* bundle share the same value at submit. */
|
|
2220
|
+
reconcileStopTimer(jh) {
|
|
2221
|
+
const tags = jh.job.tags ?? {};
|
|
2222
|
+
const bundleId = tags["bundleId"];
|
|
2223
|
+
if (!bundleId)
|
|
2224
|
+
return;
|
|
2225
|
+
const stopStr = tags[conversions_1.STOP_DATE_TIME_TAG];
|
|
2226
|
+
const stopAt = stopStr ? new Date(stopStr) : undefined;
|
|
2227
|
+
const prev = this.pendingStopTimers.get(bundleId);
|
|
2228
|
+
// If the tag is gone OR unchanged, just keep / drop the existing
|
|
2229
|
+
// timer. Equality on millis is fine — daemon round-trips ISO
|
|
2230
|
+
// strings, no float fuzz.
|
|
2231
|
+
if (!stopAt || Number.isNaN(stopAt.getTime())) {
|
|
2232
|
+
if (prev) {
|
|
2233
|
+
prev.timer.cancel();
|
|
2234
|
+
this.pendingStopTimers.delete(bundleId);
|
|
2235
|
+
}
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
if (prev && prev.stopAt.getTime() === stopAt.getTime())
|
|
2239
|
+
return;
|
|
2240
|
+
if (prev)
|
|
2241
|
+
prev.timer.cancel();
|
|
2242
|
+
this.scheduleStopTimer(bundleId, stopAt);
|
|
2243
|
+
}
|
|
2244
|
+
scheduleStopTimer(bundleId, stopAt) {
|
|
2245
|
+
const delayMs = stopAt.getTime() - this.clock.now().getTime();
|
|
2246
|
+
if (delayMs <= 0) {
|
|
2247
|
+
// Already past — fire immediately and don't keep a timer entry.
|
|
2248
|
+
debuglog("Stop time already past; deleting bundle now", { bundleId, stopAt: stopAt.toISOString() });
|
|
2249
|
+
void this.deleteBundle(bundleId).catch((e) => this.settings.onError?.(e instanceof Error ? e : new Error(String(e))));
|
|
2250
|
+
this.pendingStopTimers.delete(bundleId);
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
debuglog("Scheduling bundle stop", { bundleId, stopAt: stopAt.toISOString(), inMs: delayMs });
|
|
2254
|
+
const timer = this.clock.setTimeout(() => {
|
|
2255
|
+
this.pendingStopTimers.delete(bundleId);
|
|
2256
|
+
void this.deleteBundle(bundleId).catch((e) => this.settings.onError?.(e instanceof Error ? e : new Error(String(e))));
|
|
2257
|
+
}, delayMs);
|
|
2258
|
+
this.pendingStopTimers.set(bundleId, { timer, stopAt });
|
|
2259
|
+
}
|
|
2260
|
+
/**
|
|
2261
|
+
* Update (or clear) a bundle's auto-stop time. Calls `updateBundle`
|
|
2262
|
+
* so the new value persists via the daemon's tag store and any other
|
|
2263
|
+
* clients see it through their event streams. Pass `undefined` to
|
|
2264
|
+
* cancel a previously-set stop time.
|
|
2265
|
+
*
|
|
2266
|
+
* Throws if the bundle isn't known to this AutoManager (no live
|
|
2267
|
+
* bundleMap entry); operators wanting to set stop on a not-yet-
|
|
2268
|
+
* streamed bundle should retry after JobPending fires.
|
|
2269
|
+
* @public
|
|
2270
|
+
*/
|
|
2271
|
+
async setBundleStopTime(bundleId, stopDateTime) {
|
|
2272
|
+
const bundle = this.bundleById(bundleId);
|
|
2273
|
+
if (!bundle) {
|
|
2274
|
+
throw new Error(`No bundle ${bundleId} in AutoManager state`);
|
|
2275
|
+
}
|
|
2276
|
+
const updated = {
|
|
2277
|
+
...bundle,
|
|
2278
|
+
jobs: bundle.jobs.map((j) => j.launch?.kind === "productTemplate"
|
|
2279
|
+
? { ...j, launch: { ...j.launch, stopDateTime } }
|
|
2280
|
+
: j),
|
|
2281
|
+
};
|
|
2282
|
+
await this.updateBundle(updated);
|
|
2283
|
+
}
|
|
2284
|
+
/**
|
|
2285
|
+
* Snapshot of all currently-scheduled bundle stop timers. UIs render
|
|
2286
|
+
* this alongside `pendingPlacements()` for an "upcoming events"
|
|
2287
|
+
* view.
|
|
2288
|
+
* @public
|
|
2289
|
+
*/
|
|
2290
|
+
bundleStopTimes() {
|
|
2291
|
+
return [...this.pendingStopTimers.entries()]
|
|
2292
|
+
.map(([bundleId, { stopAt }]) => ({ bundleId, stopAt }))
|
|
2293
|
+
.sort((a, b) => a.stopAt.getTime() - b.stopAt.getTime());
|
|
2294
|
+
}
|
|
2295
|
+
/** A job that should be running but currently has no node bound to
|
|
2296
|
+
* it: it carries placement `requirements`, isn't manual-start, is in
|
|
2297
|
+
* a placeable state (`pre` / `active`), and `nodesByJob` is empty.
|
|
2298
|
+
* This is the single source of truth for "this job wants a placement
|
|
2299
|
+
* and doesn't have one" — shared by initial hydration, the per-job
|
|
2300
|
+
* `handleJobChanged` path, and the capacity-driven reconcile sweep,
|
|
2301
|
+
* so the decision lives in exactly one place. */
|
|
2302
|
+
wantsPlacementNow(jh) {
|
|
2303
|
+
const job = jh.job;
|
|
2304
|
+
if (!job.autoPlacement?.requirements)
|
|
2305
|
+
return false;
|
|
2306
|
+
if ((job.tags ?? {})["startMode"] === "manual")
|
|
2307
|
+
return false;
|
|
2308
|
+
// Durable terminal verdict (poison / bronze drop) persisted by a prior
|
|
2309
|
+
// session: never re-place it. This is what makes "fully failed" survive a
|
|
2310
|
+
// daemon restart — on reconnect the job hydrates with terminalState set.
|
|
2311
|
+
if (job.terminalState)
|
|
2312
|
+
return false;
|
|
2313
|
+
if (job.state !== "pre" && job.state !== "active")
|
|
2314
|
+
return false;
|
|
2315
|
+
return this.nodesByJob(job.jobId).size === 0;
|
|
2316
|
+
}
|
|
2317
|
+
/**
|
|
2318
|
+
* Re-run placement for every job that wants a node but hasn't got
|
|
2319
|
+
* one. Called whenever a *capacity-increasing* event arrives — a node
|
|
2320
|
+
* comes online (`nodeStarted`), a worker reports fresh inventory
|
|
2321
|
+
* (`nodeInventoryUpdated`, which also covers an un-cordon or a node
|
|
2322
|
+
* that has just freed reserved capacity), or a job finishes and
|
|
2323
|
+
* releases its node (`jobDeleted`). Those are the only transitions
|
|
2324
|
+
* that can turn a previously-unplaceable job placeable, so they are
|
|
2325
|
+
* exactly when re-running the engine is worthwhile — there is no
|
|
2326
|
+
* polling retry, the trigger is always a concrete event.
|
|
2327
|
+
*
|
|
2328
|
+
* Placement stays implemented once: each candidate routes through
|
|
2329
|
+
* `placeOrDefer` (the same entry point hydration and `handleJobChanged`
|
|
2330
|
+
* use), inheriting its scheduling, the in-flight gate, and the
|
|
2331
|
+
* failure path. Jobs already waiting on a schedule timer or a recovery
|
|
2332
|
+
* backoff are skipped — their own timers own them — so a capacity
|
|
2333
|
+
* event never disturbs a future-scheduled or backing-off job.
|
|
2334
|
+
*
|
|
2335
|
+
* Cheap and idempotent (mirrors `reconcileHotSpares` /
|
|
2336
|
+
* `reconcileNodeServices`); safe to call on every relevant event.
|
|
2337
|
+
*/
|
|
2338
|
+
reconcilePendingPlacements() {
|
|
2339
|
+
for (const jh of this.jobMap.values()) {
|
|
2340
|
+
if (!this.wantsPlacementNow(jh))
|
|
2341
|
+
continue;
|
|
2342
|
+
const jobId = jh.job.jobId;
|
|
2343
|
+
if (this.pendingPlacementTimers.has(jobId))
|
|
2344
|
+
continue;
|
|
2345
|
+
if (this.pendingPlacementRetries.has(jobId))
|
|
2346
|
+
continue;
|
|
2347
|
+
void this.placeOrDefer(jh).catch((err) => this.settings.onError?.(err));
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
/** Decide whether to place immediately or defer until the job's
|
|
2351
|
+
* scheduled `startDateTime` (minus pool lead time) arrives.
|
|
2352
|
+
* Re-entry from job-update events: cancels and re-evaluates so a
|
|
2353
|
+
* schedule change moves the timer. Hydrated jobs with `startTime -
|
|
2354
|
+
* lead` in the past place immediately (operator intent was to run;
|
|
2355
|
+
* schedule was a "when", not a "drop if missed"). */
|
|
2356
|
+
async placeOrDefer(jh) {
|
|
2357
|
+
const job = jh.job;
|
|
2358
|
+
const requirements = job.autoPlacement?.requirements;
|
|
2359
|
+
if (!requirements)
|
|
2360
|
+
return;
|
|
2361
|
+
const tags = job.tags ?? {};
|
|
2362
|
+
// Any previous pending timer for this jobId is now stale — either
|
|
2363
|
+
// we'll re-schedule below or we'll place immediately. Either way,
|
|
2364
|
+
// drop the prior timer.
|
|
2365
|
+
const prev = this.pendingPlacementTimers.get(job.jobId);
|
|
2366
|
+
if (prev) {
|
|
2367
|
+
prev.timer.cancel();
|
|
2368
|
+
this.pendingPlacementTimers.delete(job.jobId);
|
|
2369
|
+
}
|
|
2370
|
+
const startDateTime = job.startDateTime;
|
|
2371
|
+
if (!startDateTime) {
|
|
2372
|
+
// No schedule — fire now, current behaviour.
|
|
2373
|
+
await this.placeAndProvision(job.jobId, requirements, tags);
|
|
2374
|
+
return;
|
|
2375
|
+
}
|
|
2376
|
+
const leadMs = this.placementLeadMsForJob(jh);
|
|
2377
|
+
const fireAt = startDateTime.getTime() - leadMs;
|
|
2378
|
+
const delayMs = fireAt - this.clock.now().getTime();
|
|
2379
|
+
if (delayMs <= 0) {
|
|
2380
|
+
// Schedule has arrived (or passed — hydration after mgr was
|
|
2381
|
+
// down through the launch window). Operator's intent was for
|
|
2382
|
+
// the job to be running; honour it.
|
|
2383
|
+
await this.placeAndProvision(job.jobId, requirements, tags);
|
|
2384
|
+
return;
|
|
2385
|
+
}
|
|
2386
|
+
debuglog("Deferring placement until scheduled startDateTime", {
|
|
2387
|
+
jobId: job.jobId,
|
|
2388
|
+
startDateTime: startDateTime.toISOString(),
|
|
2389
|
+
leadMs,
|
|
2390
|
+
fireInMs: delayMs,
|
|
2391
|
+
});
|
|
2392
|
+
const timer = this.clock.setTimeout(() => {
|
|
2393
|
+
this.pendingPlacementTimers.delete(job.jobId);
|
|
2394
|
+
// Re-check that we still want to place — the job may have been
|
|
2395
|
+
// deleted or another instance placed it during the wait. Cheap
|
|
2396
|
+
// re-test using nodesByJob (now jobToNode-backed and O(1)).
|
|
2397
|
+
const live = this.jobMap.get(job.jobId);
|
|
2398
|
+
if (!live)
|
|
2399
|
+
return;
|
|
2400
|
+
if (this.nodesByJob(job.jobId).size > 0)
|
|
2401
|
+
return;
|
|
2402
|
+
void this.placeAndProvision(job.jobId, requirements, tags);
|
|
2403
|
+
}, delayMs);
|
|
2404
|
+
this.pendingPlacementTimers.set(job.jobId, { timer, scheduledFor: new Date(fireAt) });
|
|
2405
|
+
}
|
|
2406
|
+
/** Maximum `placementLeadMs` across the bundle pool's tiers (worst case
|
|
2407
|
+
* — guarantees the job is ready by `startDateTime` regardless of which
|
|
2408
|
+
* tier the placement engine ends up picking). Falls back to the per-kind
|
|
2409
|
+
* default when a tier doesn't specify one. Returns 0 if the bundle or
|
|
2410
|
+
* pool isn't found — we'd rather race the schedule than indefinitely
|
|
2411
|
+
* delay placement. */
|
|
2412
|
+
placementLeadMsForJob(jh) {
|
|
2413
|
+
const bundleId = (jh.job.tags ?? {})["bundleId"];
|
|
2414
|
+
if (!bundleId)
|
|
2415
|
+
return 0;
|
|
2416
|
+
const bundle = this.bundleMap.get(bundleId);
|
|
2417
|
+
if (!bundle)
|
|
2418
|
+
return 0;
|
|
2419
|
+
const pools = this.settings.placementPools ?? [];
|
|
2420
|
+
const pool = pools.find((p) => p.name === bundle.pool);
|
|
2421
|
+
if (!pool)
|
|
2422
|
+
return 0;
|
|
2423
|
+
let max = 0;
|
|
2424
|
+
for (const tier of pool.tiers) {
|
|
2425
|
+
const lead = tier.placementLeadMs ?? (0, placement_1.defaultPlacementLeadMs)(tier.kind);
|
|
2426
|
+
if (lead > max)
|
|
2427
|
+
max = lead;
|
|
2428
|
+
}
|
|
2429
|
+
return max;
|
|
2430
|
+
}
|
|
2431
|
+
/**
|
|
2432
|
+
* Currently-scheduled placements (jobs AutoManager has deferred
|
|
2433
|
+
* because their `startDateTime - leadTime` is still in the future).
|
|
2434
|
+
* Returns one entry per pending timer; the UI can render an
|
|
2435
|
+
* "upcoming jobs" list and show when each will go live.
|
|
2436
|
+
* @public
|
|
2437
|
+
*/
|
|
2438
|
+
pendingPlacements() {
|
|
2439
|
+
return [...this.pendingPlacementTimers.entries()]
|
|
2440
|
+
.map(([jobId, { scheduledFor }]) => ({ jobId, scheduledFor }))
|
|
2441
|
+
.sort((a, b) => a.scheduledFor.getTime() - b.scheduledFor.getTime());
|
|
2442
|
+
}
|
|
2443
|
+
handleJobDeleted(jobId) {
|
|
2444
|
+
// Pure local-state cleanup. The daemon's deleteBundle saga is now
|
|
2445
|
+
// responsible for stopping the worker-side compose stacks before
|
|
2446
|
+
// emitting JobDeleted; node-level lifecycle (terminate / drain) is
|
|
2447
|
+
// AutoManager policy (drainNode, hot-spare reconciliation) and
|
|
2448
|
+
// intentionally not coupled to job deletion. Prior versions called
|
|
2449
|
+
// `terminateNode` here, which was provider-incorrect for cluster
|
|
2450
|
+
// workers (terminate is a no-op on long-lived operator-attached
|
|
2451
|
+
// nodes; the worker's compose stack would leak as a result).
|
|
2452
|
+
const existed = this.jobMap.delete(jobId);
|
|
2453
|
+
this.restartHistory.delete(jobId);
|
|
2454
|
+
this.lastPlacementAttemptAt.delete(jobId);
|
|
2455
|
+
this.jobToNode.delete(jobId);
|
|
2456
|
+
this.placementsByJob.delete(jobId);
|
|
2457
|
+
const pending = this.pendingPlacementTimers.get(jobId);
|
|
2458
|
+
if (pending) {
|
|
2459
|
+
pending.timer.cancel();
|
|
2460
|
+
this.pendingPlacementTimers.delete(jobId);
|
|
2461
|
+
}
|
|
2462
|
+
const pendingRetry = this.pendingPlacementRetries.get(jobId);
|
|
2463
|
+
if (pendingRetry) {
|
|
2464
|
+
clearTimeout(pendingRetry);
|
|
2465
|
+
this.pendingPlacementRetries.delete(jobId);
|
|
2466
|
+
}
|
|
2467
|
+
if (existed)
|
|
2468
|
+
this.settings.onJobDeleted?.(jobId);
|
|
2469
|
+
// A job leaving frees the capacity it reserved, which may let a
|
|
2470
|
+
// previously-unplaceable job land. The freed capacity becomes
|
|
2471
|
+
// visible to the placement engine once the worker re-reports
|
|
2472
|
+
// inventory (`nodeInventoryUpdated` will reconcile again then); this
|
|
2473
|
+
// trigger covers the case where AutoManager's cached inventory
|
|
2474
|
+
// already reflects the release.
|
|
2475
|
+
this.reconcilePendingPlacements();
|
|
2476
|
+
}
|
|
2477
|
+
// ---- node lifecycle ----
|
|
2478
|
+
handleNodeStarting(nodeMetadata) {
|
|
2479
|
+
this.updateNodeMap(nodeMetadata.nodeId, "starting", nodeMetadata, undefined);
|
|
2480
|
+
this.settings.onNodeStarting?.(nodeMetadata.nodeId);
|
|
2481
|
+
this.armStartupGrace(nodeMetadata.nodeId);
|
|
2482
|
+
}
|
|
2483
|
+
/** Phase C3. Schedule the grace timer; replace any prior timer. */
|
|
2484
|
+
armStartupGrace(nodeId) {
|
|
2485
|
+
this.startupGraceTimers.get(nodeId)?.cancel();
|
|
2486
|
+
const grace = this.cfgJobStartupGraceMs();
|
|
2487
|
+
const timer = this.clock.setTimeout(() => {
|
|
2488
|
+
this.startupGraceTimers.delete(nodeId);
|
|
2489
|
+
debuglog("Node startup grace exceeded — terminating", { nodeId, grace });
|
|
2490
|
+
void this.norsk
|
|
2491
|
+
.terminateNode(nodeId)
|
|
2492
|
+
.catch((err) => this.settings.onError?.(err));
|
|
2493
|
+
}, grace);
|
|
2494
|
+
this.startupGraceTimers.set(nodeId, timer);
|
|
2495
|
+
}
|
|
2496
|
+
cancelStartupGrace(nodeId) {
|
|
2497
|
+
this.startupGraceTimers.get(nodeId)?.cancel();
|
|
2498
|
+
this.startupGraceTimers.delete(nodeId);
|
|
2499
|
+
}
|
|
2500
|
+
handleNodeStarted(nodeMetadata, runningNodeMetadata) {
|
|
2501
|
+
this.updateNodeMap(nodeMetadata.nodeId, "running", nodeMetadata, runningNodeMetadata);
|
|
2502
|
+
this.cancelStartupGrace(nodeMetadata.nodeId);
|
|
2503
|
+
// Phase C2: a successful start clears restart history for this
|
|
2504
|
+
// job — the "fundamentally broken" diagnosis only fires when no
|
|
2505
|
+
// amount of replacement is helping, so a job that has ever started
|
|
2506
|
+
// is by definition not fundamentally broken.
|
|
2507
|
+
const jobId = getJobIdForNode(nodeMetadata);
|
|
2508
|
+
if (jobId)
|
|
2509
|
+
this.restartHistory.delete(jobId);
|
|
2510
|
+
// Phase C6: a pending spare just landed.
|
|
2511
|
+
this.pendingSpareProvisions.delete(nodeMetadata.nodeId);
|
|
2512
|
+
const node = this.nodeMap.get(nodeMetadata.nodeId);
|
|
2513
|
+
if (node)
|
|
2514
|
+
this.settings.onNodeStarted?.(node);
|
|
2515
|
+
this.reconcileHotSpares();
|
|
2516
|
+
this.reconcileNodeServices();
|
|
2517
|
+
// A node coming online is new capacity — a cluster worker joining or
|
|
2518
|
+
// a hot spare landing can host a job that previously had nowhere to
|
|
2519
|
+
// go. Re-attempt pending placements.
|
|
2520
|
+
this.reconcilePendingPlacements();
|
|
2521
|
+
// R6: a migration target node has come up — advance from
|
|
2522
|
+
// `launching` to `awaitingTargetReady`. The workflow may still
|
|
2523
|
+
// need time to initialise; the awaitingTargetReady deadline now
|
|
2524
|
+
// bounds that step independently from launching.
|
|
2525
|
+
this.advanceMigrationsOnNodeStarted(nodeMetadata.nodeId);
|
|
2526
|
+
}
|
|
2527
|
+
advanceMigrationsOnNodeStarted(nodeId) {
|
|
2528
|
+
for (const state of this.migrationStates.values()) {
|
|
2529
|
+
if (state.targetNodeId !== nodeId)
|
|
2530
|
+
continue;
|
|
2531
|
+
if (state.phase !== "launching")
|
|
2532
|
+
continue;
|
|
2533
|
+
this.advanceMigration(state, "awaitingTargetReady");
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
async handleNodeStopping(nodeId, _reason) {
|
|
2537
|
+
this.updateNodeMap(nodeId, "stopping", undefined, undefined);
|
|
2538
|
+
this.cancelStartupGrace(nodeId);
|
|
2539
|
+
// Surface to consumers so cluster-membership views (mgr's Cluster
|
|
2540
|
+
// page) refresh promptly. Without this callback, a Ctrl-C'd
|
|
2541
|
+
// cluster worker only surfaces in mgr UI when the eventual
|
|
2542
|
+
// NodeStopped/NodeTerminated arrives — which can be many seconds
|
|
2543
|
+
// later via the daemon's keepalive timeout + cleanup loop.
|
|
2544
|
+
this.settings.onNodeStopping?.(nodeId);
|
|
2545
|
+
// Mark the node as recently-failed so subsequent placements skip it
|
|
2546
|
+
// for `failureBackoffMs`. Cleared when the node next reports a
|
|
2547
|
+
// healthy `nodeInventoryUpdated`.
|
|
2548
|
+
const window = this.cfgFailureBackoffMs();
|
|
2549
|
+
this.recentFailures.set(nodeId, new Date(this.clock.now().getTime() + window));
|
|
2550
|
+
// TODO future slice: thread the nodeStopping reason into
|
|
2551
|
+
// recoveryContext so band-specific behaviour applies (gold→prefer
|
|
2552
|
+
// hot spare, bronze drop, etc.).
|
|
2553
|
+
const node = this.nodeMap.get(nodeId);
|
|
2554
|
+
const jobId = node?.provisional?.jobId ?? getJobIdForNode(node?.nodeMetadata);
|
|
2555
|
+
if (jobId) {
|
|
2556
|
+
// R8: if the job is mid-migration, don't run recovery placement —
|
|
2557
|
+
// the migration target is already a second live instance for the
|
|
2558
|
+
// same jobId, and a fresh placeAndProvision would create a third.
|
|
2559
|
+
// Abort the migration instead; operator can re-attempt once the
|
|
2560
|
+
// source is back. The target on the new node is allowed to keep
|
|
2561
|
+
// running (it's stoppable through the normal jobMap path).
|
|
2562
|
+
const migration = this.migrationForJob(jobId);
|
|
2563
|
+
if (migration && (migration.phase !== "done" && migration.phase !== "aborted")) {
|
|
2564
|
+
this.failMigration(migration.migrationId, `source nodeStopping during phase ${migration.phase}`);
|
|
2565
|
+
}
|
|
2566
|
+
else {
|
|
2567
|
+
const entry = this.jobMap.get(jobId);
|
|
2568
|
+
const job = entry?.job;
|
|
2569
|
+
const req = job?.autoPlacement?.requirements;
|
|
2570
|
+
if (job && req && (job.state === "pre" || job.state === "active")) {
|
|
2571
|
+
await this.placeAndProvision(job.jobId, req, job.tags ?? {}, { excludeNodes: this.currentExcludeSet() });
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
// Phase C6: if the stopping node was a spare, reconcile to
|
|
2576
|
+
// provision a replacement promptly (don't wait for the tick).
|
|
2577
|
+
this.reconcileHotSpares();
|
|
2578
|
+
// Phase F: lost capacity → reconcile NodeService placements.
|
|
2579
|
+
// Also drop any tracked placement on this node (the runtime job
|
|
2580
|
+
// is gone with the node).
|
|
2581
|
+
this.dropNodeServicePlacementsOnNode(nodeId);
|
|
2582
|
+
this.reconcileNodeServices();
|
|
2583
|
+
}
|
|
2584
|
+
handleNodeStopped(nodeId) {
|
|
2585
|
+
this.updateNodeMap(nodeId, "stopped", undefined, undefined);
|
|
2586
|
+
// Phase C6: a spare going away should trigger a replacement.
|
|
2587
|
+
this.pendingSpareProvisions.delete(nodeId);
|
|
2588
|
+
this.settings.onNodeStopped?.(nodeId);
|
|
2589
|
+
this.reconcileHotSpares();
|
|
2590
|
+
// Phase F: clear NodeService placements on stopped nodes.
|
|
2591
|
+
this.dropNodeServicePlacementsOnNode(nodeId);
|
|
2592
|
+
this.reconcileNodeServices();
|
|
2593
|
+
}
|
|
2594
|
+
dropNodeServicePlacementsOnNode(nodeId) {
|
|
2595
|
+
for (const placements of this.nodeServicePlacements.values()) {
|
|
2596
|
+
placements.delete(nodeId);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
// ---- maps ----
|
|
2600
|
+
updateNodeMap(nodeId, nodeState, nodeMetadata, runningNodeMetadata, provisional) {
|
|
2601
|
+
const existing = this.nodeMap.get(nodeId);
|
|
2602
|
+
this.nodeMap.set(nodeId, {
|
|
2603
|
+
nodeId,
|
|
2604
|
+
nodeState,
|
|
2605
|
+
lastActivity: this.clock.now(),
|
|
2606
|
+
nodeMetadata: nodeMetadata ?? existing?.nodeMetadata,
|
|
2607
|
+
runningNodeMetadata: runningNodeMetadata ?? existing?.runningNodeMetadata,
|
|
2608
|
+
provisional: provisional ?? existing?.provisional,
|
|
2609
|
+
});
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
exports.AutoManager = AutoManager;
|
|
2613
|
+
function summariseTrace(trace) {
|
|
2614
|
+
// One-line per attempted pool, listing how nodes were filtered
|
|
2615
|
+
// (grouped by reason) and what happened. Compact enough that it
|
|
2616
|
+
// doesn't drown a debuglog stream when many placements happen in a
|
|
2617
|
+
// burst, but informative enough that "why did my job land there?"
|
|
2618
|
+
// is immediately answerable.
|
|
2619
|
+
const lines = trace.attempts.map((a) => {
|
|
2620
|
+
const groups = {};
|
|
2621
|
+
for (const f of a.filtered)
|
|
2622
|
+
groups[f.reason] = (groups[f.reason] ?? 0) + 1;
|
|
2623
|
+
const filterSummary = Object.entries(groups)
|
|
2624
|
+
.map(([reason, count]) => `${reason}=${count}`)
|
|
2625
|
+
.join(", ");
|
|
2626
|
+
const winner = a.outcome.kind === "placed"
|
|
2627
|
+
? ` → placed on ${a.outcome.nodeId}${a.outcome.gpuIndex !== undefined ? ` (gpu ${a.outcome.gpuIndex})` : ""}`
|
|
2628
|
+
: a.outcome.kind === "provisioned"
|
|
2629
|
+
? ` → provisioned ${a.outcome.instanceType}`
|
|
2630
|
+
: a.outcome.kind === "exhausted"
|
|
2631
|
+
? " → fixed-pool exhausted, fall through"
|
|
2632
|
+
: a.outcome.kind === "no-instance-type"
|
|
2633
|
+
? " → no allowed instance type fits, fall through"
|
|
2634
|
+
: " → pool not configured";
|
|
2635
|
+
const matchSummary = `${a.scored.length}/${a.initialCandidates} matched`;
|
|
2636
|
+
const filterPart = filterSummary ? `, filtered: ${filterSummary}` : "";
|
|
2637
|
+
return `[${a.pool}] ${matchSummary}${filterPart}${winner}`;
|
|
2638
|
+
});
|
|
2639
|
+
return `${trace.decision}: ${lines.join(" || ")}`;
|
|
2640
|
+
}
|
|
2641
|
+
function roleFromReplicaIndex(replicaIndex) {
|
|
2642
|
+
// Preserve the existing primary/backup convention for replicas 0 and 1
|
|
2643
|
+
// so consumers that grep on role keep working. Higher replica counts
|
|
2644
|
+
// get a numeric form.
|
|
2645
|
+
if (replicaIndex === 0)
|
|
2646
|
+
return "primary";
|
|
2647
|
+
if (replicaIndex === 1)
|
|
2648
|
+
return "backup";
|
|
2649
|
+
return `replica-${replicaIndex}`;
|
|
2650
|
+
}
|
|
2651
|
+
function debuglog(msg, ...param) {
|
|
2652
|
+
util.debuglog("norsk-automanager")(util.formatWithOptions({ maxArrayLength: null, depth: null, colors: true }, "[" + new Date().toISOString() + "] " + msg, ...param));
|
|
2653
|
+
}
|
|
2654
|
+
function getJobIdForNode(nodeMetadata) {
|
|
2655
|
+
return nodeMetadata?.tags["jobId"];
|
|
2656
|
+
}
|
|
2657
|
+
/**
|
|
2658
|
+
* Walk a job's history backward and return the nodeId where it is
|
|
2659
|
+
* currently placed (or was most recently placed). Prefers `running`
|
|
2660
|
+
* entries; falls back to `provisioned` for jobs still booting their
|
|
2661
|
+
* node. Returns undefined for jobs that have never been placed or
|
|
2662
|
+
* whose history is empty (defensive — runtime data always has at
|
|
2663
|
+
* least a `created` entry, which carries no nodeMetadata).
|
|
2664
|
+
*
|
|
2665
|
+
* This is the canonical "where does this job live" lookup and
|
|
2666
|
+
* underpins `AutoManager.nodeForJob`. It works regardless of whether
|
|
2667
|
+
* a node hosts one job (cloud, today) or many (cluster, or future
|
|
2668
|
+
* cloud N:1) because the nodeId is recorded against the JOB rather
|
|
2669
|
+
* than scanned out of NODE tags.
|
|
2670
|
+
*/
|
|
2671
|
+
function currentNodeIdForJob(jh) {
|
|
2672
|
+
if (!jh)
|
|
2673
|
+
return undefined;
|
|
2674
|
+
// Two-pass: prefer the most recent `running` over the most recent
|
|
2675
|
+
// `provisioned`. A job that has been provisioned, run, stopped,
|
|
2676
|
+
// re-provisioned, but not yet run again should report the newest
|
|
2677
|
+
// provisioned node — but if it's running we always want that.
|
|
2678
|
+
let mostRecentProvisioned;
|
|
2679
|
+
for (let i = jh.history.length - 1; i >= 0; i--) {
|
|
2680
|
+
const e = jh.history[i];
|
|
2681
|
+
if (e.event === "running")
|
|
2682
|
+
return e.nodeMetadata.nodeId;
|
|
2683
|
+
if (e.event === "provisioned" && mostRecentProvisioned === undefined) {
|
|
2684
|
+
mostRecentProvisioned = e.nodeMetadata.nodeId;
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
return mostRecentProvisioned;
|
|
2688
|
+
}
|
|
2689
|
+
/**
|
|
2690
|
+
* Build a `PlacementDecisionInfo` from a hydrated job's history. Used
|
|
2691
|
+
* on R7 reconnect to seed the placementsByJob map for jobs that were
|
|
2692
|
+
* placed in a previous generation — we have no live trace, so this
|
|
2693
|
+
* synthesises a minimal record (empty `trace.attempts`) that's still
|
|
2694
|
+
* shaped like a real decision so consumers can render it uniformly.
|
|
2695
|
+
*
|
|
2696
|
+
* `nodeId` is passed in by the caller (already resolved via
|
|
2697
|
+
* `currentNodeIdForJob`), to avoid walking the history twice.
|
|
2698
|
+
*
|
|
2699
|
+
* Returns undefined if the job's tags are too sparse to derive
|
|
2700
|
+
* `bundleId` / `replicaIndex` / `jobName` (un-managed jobs the
|
|
2701
|
+
* AutoManager-driven UI couldn't usefully display anyway).
|
|
2702
|
+
*/
|
|
2703
|
+
function synthesisePlacementFromHistory(jh, nodeId) {
|
|
2704
|
+
const tags = jh.job.tags ?? {};
|
|
2705
|
+
const bundleId = tags["bundleId"];
|
|
2706
|
+
const replicaIndexStr = tags["replicaIndex"];
|
|
2707
|
+
const jobName = tags["jobName"];
|
|
2708
|
+
if (!bundleId || replicaIndexStr === undefined || !jobName)
|
|
2709
|
+
return undefined;
|
|
2710
|
+
const replicaIndex = Number(replicaIndexStr);
|
|
2711
|
+
if (!Number.isFinite(replicaIndex))
|
|
2712
|
+
return undefined;
|
|
2713
|
+
// Find the most recent placement event for `at` + decision kind.
|
|
2714
|
+
// running > provisioned (same precedence as currentNodeIdForJob).
|
|
2715
|
+
// If the only event we found is `provisioned`, decision="provision"
|
|
2716
|
+
// (the node was started for this job); `running` could be either —
|
|
2717
|
+
// we treat it as "place" because by the time we hydrate the node is
|
|
2718
|
+
// already running, so functionally the job was bound to an existing
|
|
2719
|
+
// running node. The trace stays empty either way.
|
|
2720
|
+
let at;
|
|
2721
|
+
let decision = "place";
|
|
2722
|
+
for (let i = jh.history.length - 1; i >= 0; i--) {
|
|
2723
|
+
const e = jh.history[i];
|
|
2724
|
+
if (e.event === "running") {
|
|
2725
|
+
at = e.timestamp instanceof Date ? e.timestamp : new Date(e.timestamp);
|
|
2726
|
+
decision = "place";
|
|
2727
|
+
break;
|
|
2728
|
+
}
|
|
2729
|
+
if (e.event === "provisioned" && at === undefined) {
|
|
2730
|
+
at = e.timestamp instanceof Date ? e.timestamp : new Date(e.timestamp);
|
|
2731
|
+
decision = "provision";
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
if (!at)
|
|
2735
|
+
return undefined;
|
|
2736
|
+
// `pool`, `tier` and `instanceType` aren't on the history entries —
|
|
2737
|
+
// leave them as best-guess placeholders. Consumers that care about
|
|
2738
|
+
// accurate pool/tier attribution should walk the node's tags via
|
|
2739
|
+
// nodeMap.get(nodeId).nodeMetadata.tags.{pool,tier}.
|
|
2740
|
+
const result = decision === "place"
|
|
2741
|
+
? { kind: "place", nodeId, pool: "", tier: "" }
|
|
2742
|
+
: { kind: "provision", pool: "", tier: "", instanceType: "", launchMode: "on-demand" };
|
|
2743
|
+
const trace = { attempts: [], decision };
|
|
2744
|
+
return { jobId: jh.job.jobId, bundleId, replicaIndex, jobName, result, trace, at };
|
|
2745
|
+
}
|
|
2746
|
+
//# sourceMappingURL=automanager.js.map
|