@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.
@@ -0,0 +1,468 @@
1
+ "use strict";
2
+ // Pure-function placement engine. Given a job-to-place, the bundle it
3
+ // belongs to, the current inventory snapshot, and the pool configuration,
4
+ // returns a placement decision: place on a specific node, provision a new
5
+ // node in an elastic pool, or fail.
6
+ //
7
+ // Per §6 of the design doc: filter by hard constraints, score by packing
8
+ // strategy, tiebreak by nodeId for determinism. Per-replica pool
9
+ // preferences (replicaOverrides) take priority over the bundle default.
10
+ //
11
+ // Soft constraints (same-AZ preference, hot-spare scoring bonus) are
12
+ // not yet implemented in this slice — they'll layer on top of the score
13
+ // function once the hard-constraint path is settled and tested.
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.tierLaunchMode = tierLaunchMode;
16
+ exports.tierReliability = tierReliability;
17
+ exports.defaultPlacementLeadMs = defaultPlacementLeadMs;
18
+ exports.nodeMatchesTier = nodeMatchesTier;
19
+ exports.place = place;
20
+ exports.placeWithTrace = placeWithTrace;
21
+ const inventory_1 = require("./inventory");
22
+ /** The tier's launch mode, defaulting to on-demand. @public */
23
+ function tierLaunchMode(tier) {
24
+ return tier.launchMode ?? "on-demand";
25
+ }
26
+ /** The tier's reliability — explicit, else interruptible for spot. @public */
27
+ function tierReliability(tier) {
28
+ return tier.reliability ?? (tierLaunchMode(tier) === "spot" ? "interruptible" : "durable");
29
+ }
30
+ /** Default placement lead time by tier kind. Cluster tiers place
31
+ * against existing running nodes so we don't need lead. Cloud tiers
32
+ * default to 5 minutes — typical EC2/OCI instance boot + image pull
33
+ * budget; operators can override per-tier via PlacementTier. */
34
+ function defaultPlacementLeadMs(kind) {
35
+ return kind === "local-servers" ? 0 : 5 * 60 * 1000;
36
+ }
37
+ /**
38
+ * Whether a node belongs to (pool, tier). Elastic nodes carry an explicit
39
+ * `tierName` (set from `tags["tier"]` at provision). Pre-registered cluster
40
+ * nodes carry no tier tag and are taken to belong to the pool's `fixed`
41
+ * tier — so a pool must have at most one fixed tier (enforced in
42
+ * settingsValidation) for this to be unambiguous.
43
+ *
44
+ * @public
45
+ */
46
+ function nodeMatchesTier(n, pool, tier) {
47
+ if (n.poolName !== pool.name)
48
+ return false;
49
+ if (n.tierName !== undefined)
50
+ return n.tierName === tier.name;
51
+ return tier.scaleOut === "fixed";
52
+ }
53
+ /**
54
+ * Core placement function. Pure — no side effects, no I/O.
55
+ *
56
+ * @public
57
+ */
58
+ function place(input) {
59
+ return placeWithTrace(input).result;
60
+ }
61
+ /**
62
+ * Placement with a structured trace of every pool attempted and every
63
+ * node's filter outcome. AutoManager logs the trace via debuglog when
64
+ * placing; tests use it to assert on the precise reason a candidate
65
+ * was rejected.
66
+ *
67
+ * @public
68
+ */
69
+ function placeWithTrace(input) {
70
+ const { bundle, job } = input;
71
+ const tried = [];
72
+ const attempts = [];
73
+ const pool = input.pools.get(bundle.pool);
74
+ if (!pool || pool.tiers.length === 0) {
75
+ return {
76
+ result: { kind: "failure", reason: { code: "noConfiguredPools", triedPools: [] } },
77
+ trace: { attempts: [], decision: "failure" },
78
+ };
79
+ }
80
+ // Walk the tiers cheapest-first; first tier that can place or provision
81
+ // wins. This positional order is the cost cascade (no cost weight in v1).
82
+ for (const tier of pool.tiers) {
83
+ tried.push(tier.name);
84
+ const { matched, filtered } = filterCandidatesTraced(input, pool, tier);
85
+ const initialCandidates = input.inventory.length;
86
+ if (matched.length > 0) {
87
+ const scored = matched.map((n) => ({
88
+ node: n,
89
+ score: scoreNode(n, tier.packingStrategy, input),
90
+ }));
91
+ // Sort descending by score, lexicographic nodeId tiebreak.
92
+ scored.sort((a, b) => {
93
+ if (a.score !== b.score)
94
+ return b.score - a.score;
95
+ return a.node.nodeId.localeCompare(b.node.nodeId);
96
+ });
97
+ const winner = scored[0].node;
98
+ const gpuIndex = pickGpuIndex(winner, job.requirements, tier.packingStrategy);
99
+ const degraded = placeDegradation(winner, input) ?? primaryInterruptibleDegradation(tier, input);
100
+ attempts.push({
101
+ pool: pool.name,
102
+ tier: tier.name,
103
+ initialCandidates,
104
+ filtered,
105
+ scored: scored.map((s) => ({ nodeId: s.node.nodeId, score: s.score })),
106
+ outcome: { kind: "placed", nodeId: winner.nodeId, gpuIndex },
107
+ });
108
+ return {
109
+ result: {
110
+ kind: "place",
111
+ nodeId: winner.nodeId,
112
+ gpuIndex,
113
+ pool: pool.name,
114
+ tier: tier.name,
115
+ ...(degraded ? { degraded } : {}),
116
+ },
117
+ trace: { attempts, decision: "place" },
118
+ };
119
+ }
120
+ // A tier at its node cap can't grow — the cost cascade spills to the
121
+ // next tier. (Placing onto an existing node above doesn't add a node,
122
+ // so the cap only gates provisioning.)
123
+ const atCap = tier.maxNodes !== undefined && tierNodeCount(input.inventory, pool, tier) >= tier.maxNodes;
124
+ if (tier.scaleOut === "elastic" && !atCap) {
125
+ const instanceType = findProvisionableInstanceType(tier, job.requirements);
126
+ if (instanceType) {
127
+ const degraded = provisionDegradation(tier, input) ?? primaryInterruptibleDegradation(tier, input);
128
+ attempts.push({
129
+ pool: pool.name,
130
+ tier: tier.name,
131
+ initialCandidates,
132
+ filtered,
133
+ scored: [],
134
+ outcome: { kind: "provisioned", instanceType },
135
+ });
136
+ return {
137
+ result: {
138
+ kind: "provision",
139
+ pool: pool.name,
140
+ tier: tier.name,
141
+ instanceType,
142
+ launchMode: tierLaunchMode(tier),
143
+ ...(degraded ? { degraded } : {}),
144
+ },
145
+ trace: { attempts, decision: "provision" },
146
+ };
147
+ }
148
+ attempts.push({
149
+ pool: pool.name,
150
+ tier: tier.name,
151
+ initialCandidates,
152
+ filtered,
153
+ scored: [],
154
+ outcome: { kind: "no-instance-type" },
155
+ });
156
+ continue;
157
+ }
158
+ attempts.push({
159
+ pool: pool.name,
160
+ tier: tier.name,
161
+ initialCandidates,
162
+ filtered,
163
+ scored: [],
164
+ outcome: { kind: "exhausted" },
165
+ });
166
+ }
167
+ return {
168
+ result: { kind: "failure", reason: { code: "noCapacityInAnyPool", triedPools: tried } },
169
+ trace: { attempts, decision: "failure" },
170
+ };
171
+ }
172
+ // ---------- filter ----------
173
+ function filterCandidatesTraced(input, pool, tier) {
174
+ const { bundle, job, replicaIndex, inventory, recoveryContext } = input;
175
+ const matched = [];
176
+ const filtered = [];
177
+ for (const n of inventory) {
178
+ const reason = firstFilterMiss(n, input, pool, tier, recoveryContext);
179
+ if (reason === undefined) {
180
+ matched.push(n);
181
+ }
182
+ else {
183
+ // Skip "wrong-pool"/"wrong-tier" entries from the trace by default —
184
+ // for a large cross-pool inventory the "this isn't in my pool/tier"
185
+ // entries dominate the trace and aren't useful. Keep all other reasons.
186
+ if (reason !== "wrong-pool" && reason !== "wrong-tier")
187
+ filtered.push({ nodeId: n.nodeId, reason });
188
+ }
189
+ }
190
+ // Suppressed unused-import lint when no consumer references these
191
+ // utilities directly any more.
192
+ void bundle;
193
+ void job;
194
+ void replicaIndex;
195
+ return { matched, filtered };
196
+ }
197
+ function firstFilterMiss(n, input, pool, tier, recoveryContext) {
198
+ const { bundle, job, replicaIndex, inventory } = input;
199
+ if (n.poolName !== pool.name)
200
+ return "wrong-pool";
201
+ if (!nodeMatchesTier(n, pool, tier))
202
+ return "wrong-tier";
203
+ if (recoveryContext?.excludeNodes.has(n.nodeId))
204
+ return "excluded-recovery";
205
+ if (!n.inventory.reachable)
206
+ return "unreachable";
207
+ if (n.inventory.cordoned)
208
+ return "cordoned";
209
+ if (!matchesAllCapabilities(n.inventory, job.requirements.requiredCapabilities))
210
+ return "missing-capability";
211
+ if ((0, inventory_1.freeCapacity)(n.inventory) < job.requirements.requiredCapacity)
212
+ return "insufficient-capacity";
213
+ if ((0, inventory_1.freeCores)(n.inventory) < (job.requirements.requiredCores ?? 0))
214
+ return "insufficient-cores";
215
+ if (!matchesGpu(n.inventory.gpus, job.requirements))
216
+ return "no-fitting-gpu";
217
+ if (!satisfiesIntraReplica(n, bundle, job, replicaIndex, inventory))
218
+ return "fails-intra-replica";
219
+ if (!satisfiesIntraReplicaAz(n, bundle, replicaIndex, inventory))
220
+ return "fails-intra-az";
221
+ // Resilience anti-affinity (backup vs primary) is NOT a hard filter — it
222
+ // is best-effort, applied as a scoring penalty so the backup still
223
+ // places when no compliant node has capacity (then flagged degraded).
224
+ return undefined;
225
+ }
226
+ function matchesAllCapabilities(inv, reqs) {
227
+ for (const req of reqs) {
228
+ if ((0, inventory_1.freeCapability)(inv, req.name) < req.count)
229
+ return false;
230
+ if (req.attributeMatches) {
231
+ const cap = inv.capabilities.find((c) => c.name === req.name);
232
+ if (!cap)
233
+ return false;
234
+ for (const [k, v] of Object.entries(req.attributeMatches)) {
235
+ if (cap.attributes?.[k] !== v)
236
+ return false;
237
+ }
238
+ }
239
+ }
240
+ return true;
241
+ }
242
+ function matchesGpu(gpus, req) {
243
+ if (req.requiredGpuCapacity === undefined || req.requiredGpuCapacity <= 0) {
244
+ return true;
245
+ }
246
+ return gpus.some((g) => gpuFits(g, req));
247
+ }
248
+ function gpuFits(g, req) {
249
+ if (g.exclusivelyReserved)
250
+ return false;
251
+ if (req.requiredGpuModel !== undefined && g.model !== req.requiredGpuModel)
252
+ return false;
253
+ const free = g.totalCapacity - g.reservedCapacity;
254
+ return free >= (req.requiredGpuCapacity ?? 0);
255
+ }
256
+ function satisfiesIntraReplica(n, bundle, job, replicaIndex, inventory) {
257
+ // coLocateWith: every named job in this same replica must be running on this node.
258
+ if (job.coLocateWith && job.coLocateWith.length > 0) {
259
+ for (const peer of job.coLocateWith) {
260
+ const peerNode = findRunningJob(inventory, bundle.bundleId, replicaIndex, peer);
261
+ // If peer not yet placed, no constraint to check yet.
262
+ if (peerNode && peerNode.nodeId !== n.nodeId)
263
+ return false;
264
+ }
265
+ }
266
+ // separateFrom: every named peer must be on a different node.
267
+ if (job.separateFrom && job.separateFrom.length > 0) {
268
+ for (const peer of job.separateFrom) {
269
+ const peerNode = findRunningJob(inventory, bundle.bundleId, replicaIndex, peer);
270
+ if (peerNode && peerNode.nodeId === n.nodeId)
271
+ return false;
272
+ }
273
+ }
274
+ return true;
275
+ }
276
+ function satisfiesIntraReplicaAz(n, bundle, replicaIndex, inventory) {
277
+ const sameAz = bundle.intraReplicaPlacement?.sameAz ?? "soft";
278
+ if (sameAz !== "hard")
279
+ return true;
280
+ const peerAzs = peerAzsInSameReplica(inventory, bundle.bundleId, replicaIndex);
281
+ if (peerAzs.length === 0)
282
+ return true;
283
+ // Hard sameAz with peers in known AZs: this node's AZ must match one of theirs.
284
+ return n.az !== undefined && peerAzs.includes(n.az);
285
+ }
286
+ function peerAzsInSameReplica(inventory, bundleId, replicaIndex) {
287
+ const azs = new Set();
288
+ for (const n of inventory) {
289
+ if (n.az !== undefined &&
290
+ n.runningJobs.some((rj) => rj.bundleId === bundleId && rj.replicaIndex === replicaIndex)) {
291
+ azs.add(n.az);
292
+ }
293
+ }
294
+ return [...azs];
295
+ }
296
+ // ---------- resilience anti-affinity (backup vs primary) ----------
297
+ /** True when this placement is a backup whose policy carries anti-affinity. */
298
+ function backupPolicy(input) {
299
+ return input.replicaIndex > 0 ? input.bundle.resiliencePolicy : undefined;
300
+ }
301
+ /** Failure domains the primary (replica 0) currently occupies. */
302
+ function primaryDomains(inventory, bundleId) {
303
+ const nodeIds = new Set();
304
+ const azs = new Set();
305
+ const clouds = new Set();
306
+ for (const n of inventory) {
307
+ if (n.runningJobs.some((rj) => rj.bundleId === bundleId && rj.replicaIndex === 0)) {
308
+ nodeIds.add(n.nodeId);
309
+ if (n.az !== undefined)
310
+ azs.add(n.az);
311
+ if (n.cloud !== undefined)
312
+ clouds.add(n.cloud);
313
+ }
314
+ }
315
+ return { nodeIds, azs, clouds };
316
+ }
317
+ /** Which `forbid` flags placing the backup on `n` would violate. */
318
+ function resilienceViolations(n, policy, primary) {
319
+ const violated = [];
320
+ if (policy.sameNode === "forbid" && primary.nodeIds.has(n.nodeId))
321
+ violated.push("sameNode");
322
+ if (policy.sameAz === "forbid" && n.az !== undefined && primary.azs.has(n.az))
323
+ violated.push("sameAz");
324
+ if (policy.sameCloud === "forbid" && n.cloud !== undefined && primary.clouds.has(n.cloud))
325
+ violated.push("sameCloud");
326
+ return violated;
327
+ }
328
+ /** Degradation for placing a backup on an existing node `n`, if any. */
329
+ function placeDegradation(n, input) {
330
+ const policy = backupPolicy(input);
331
+ if (!policy)
332
+ return undefined;
333
+ const violated = resilienceViolations(n, policy, primaryDomains(input.inventory, input.bundle.bundleId));
334
+ return violated.length > 0 ? { violated } : undefined;
335
+ }
336
+ /**
337
+ * Degradation for provisioning a backup into `tier`. Only the cloud domain
338
+ * is knowable pre-boot (it is the tier's provider); node/AZ are decided
339
+ * once the instance starts, so they aren't evaluated here.
340
+ */
341
+ function provisionDegradation(tier, input) {
342
+ const policy = backupPolicy(input);
343
+ if (!policy || policy.sameCloud !== "forbid")
344
+ return undefined;
345
+ const primary = primaryDomains(input.inventory, input.bundle.bundleId);
346
+ return primary.clouds.has(tier.kind) ? { violated: ["sameCloud"] } : undefined;
347
+ }
348
+ /** Degradation for placing the *primary* (replica 0) onto interruptible
349
+ * capacity with no durable backup configured — one interruption and the
350
+ * workload is gone. (Backups have their own anti-affinity degradation.) */
351
+ function primaryInterruptibleDegradation(tier, input) {
352
+ if (input.replicaIndex !== 0 || input.bundle.resiliencePolicy)
353
+ return undefined;
354
+ if (tierReliability(tier) !== "interruptible")
355
+ return undefined;
356
+ return { violated: [], interruptiblePrimary: true };
357
+ }
358
+ /** Count of nodes currently in (pool, tier) — used for the tier node cap. */
359
+ function tierNodeCount(inventory, pool, tier) {
360
+ let n = 0;
361
+ for (const node of inventory)
362
+ if (nodeMatchesTier(node, pool, tier))
363
+ n++;
364
+ return n;
365
+ }
366
+ function findRunningJob(inventory, bundleId, replicaIndex, jobName) {
367
+ return inventory.find((n) => n.runningJobs.some((rj) => rj.bundleId === bundleId &&
368
+ rj.replicaIndex === replicaIndex &&
369
+ rj.jobName === jobName));
370
+ }
371
+ // ---------- score ----------
372
+ //
373
+ // Score = base (pack/spread on capacity) + soft-constraint bonuses.
374
+ // Bonuses are deliberately small relative to a fully-laden vs empty
375
+ // difference (which is 1.0 for binpack), but large enough to break
376
+ // ties between similar candidates. Hot-spare bonus dominates because
377
+ // recovery placements should aggressively prefer prewarmed slots.
378
+ const HOT_SPARE_BONUS = 1.0;
379
+ const INTRA_SAME_AZ_BONUS = 0.1;
380
+ // A resilience `forbid` violation is best-effort, not a hard reject: a
381
+ // large per-violation penalty so the backup lands outside the primary's
382
+ // failure domains whenever a node there has capacity, but still places
383
+ // (and is flagged degraded) when none does. Dwarfs the [-1,1] capacity
384
+ // score and the small AZ/hot-spare bonuses so a compliant node always wins.
385
+ const RESILIENCE_VIOLATION_PENALTY = 100;
386
+ function scoreNode(n, strategy, input) {
387
+ const used = (0, inventory_1.usedCapacityFraction)(n.inventory);
388
+ let score = strategy === "binpack" ? used : -used;
389
+ score += softBonus(n, input);
390
+ return score;
391
+ }
392
+ function softBonus(n, input) {
393
+ const { bundle, replicaIndex, inventory, recoveryContext } = input;
394
+ let bonus = 0;
395
+ if (recoveryContext?.preferHotSpares && n.isHotSpare) {
396
+ bonus += HOT_SPARE_BONUS;
397
+ }
398
+ // Soft sameAz (intra-replica): bonus if node's AZ matches a peer in the same replica.
399
+ const intraAz = bundle.intraReplicaPlacement?.sameAz ?? "soft";
400
+ if (intraAz === "soft" && n.az !== undefined) {
401
+ const peerAzs = peerAzsInSameReplica(inventory, bundle.bundleId, replicaIndex);
402
+ if (peerAzs.includes(n.az))
403
+ bonus += INTRA_SAME_AZ_BONUS;
404
+ }
405
+ // Resilience anti-affinity (backup vs primary): strong best-effort penalty
406
+ // for each `forbid` flag this node would violate, so the backup prefers a
407
+ // node outside the primary's failure domains.
408
+ const policy = backupPolicy(input);
409
+ if (policy) {
410
+ const primary = primaryDomains(inventory, bundle.bundleId);
411
+ bonus -= resilienceViolations(n, policy, primary).length * RESILIENCE_VIOLATION_PENALTY;
412
+ }
413
+ return bonus;
414
+ }
415
+ // ---------- GPU pick ----------
416
+ function pickGpuIndex(n, req, strategy) {
417
+ if (req.requiredGpuCapacity === undefined || req.requiredGpuCapacity <= 0) {
418
+ return undefined;
419
+ }
420
+ const eligible = n.inventory.gpus.filter((g) => gpuFits(g, req));
421
+ if (eligible.length === 0)
422
+ return undefined;
423
+ // Pack: pick the GPU with least free capacity that still fits. Spread:
424
+ // pick the most free. Same packing intent as node-level scoring.
425
+ const sorted = eligible.slice().sort((a, b) => {
426
+ const freeA = a.totalCapacity - a.reservedCapacity;
427
+ const freeB = b.totalCapacity - b.reservedCapacity;
428
+ if (freeA !== freeB) {
429
+ return strategy === "binpack" ? freeA - freeB : freeB - freeA;
430
+ }
431
+ return a.index - b.index;
432
+ });
433
+ return sorted[0].index;
434
+ }
435
+ // ---------- elastic provision ----------
436
+ function findProvisionableInstanceType(tier, req) {
437
+ for (const it of tier.candidateInstanceTypes) {
438
+ if (instanceTypeSatisfies(it, req))
439
+ return it.instanceType;
440
+ }
441
+ return undefined;
442
+ }
443
+ function instanceTypeSatisfies(it, req) {
444
+ if (it.totalCapacity < req.requiredCapacity)
445
+ return false;
446
+ if (it.totalCores < (req.requiredCores ?? 0))
447
+ return false;
448
+ for (const cap of req.requiredCapabilities) {
449
+ const found = it.capabilities.find((c) => c.name === cap.name);
450
+ if (!found || found.count < cap.count)
451
+ return false;
452
+ if (cap.attributeMatches) {
453
+ for (const [k, v] of Object.entries(cap.attributeMatches)) {
454
+ if (found.attributes?.[k] !== v)
455
+ return false;
456
+ }
457
+ }
458
+ }
459
+ if (req.requiredGpuCapacity !== undefined && req.requiredGpuCapacity > 0) {
460
+ const gpus = it.gpus ?? [];
461
+ const fits = gpus.some((g) => (req.requiredGpuModel === undefined || g.model === req.requiredGpuModel) &&
462
+ g.totalCapacity >= (req.requiredGpuCapacity ?? 0));
463
+ if (!fits)
464
+ return false;
465
+ }
466
+ return true;
467
+ }
468
+ //# sourceMappingURL=placement.js.map
@@ -0,0 +1,34 @@
1
+ import type { AutoSettings } from "./automanager";
2
+ /** @public */
3
+ export interface SettingsValidationError {
4
+ code: SettingsValidationErrorCode;
5
+ /** Dotted path to the offending field. */
6
+ path: string;
7
+ message: string;
8
+ }
9
+ /** @public */
10
+ export type SettingsValidationErrorCode = "invalidType" | "missingRequired" | "outOfRange" | "invalidEnumValue" | "unknownPool" | "duplicatePool" | "incompatibleConfig";
11
+ /**
12
+ * Validate an `AutoSettings` value at boot. Returns all errors found
13
+ * (does not short-circuit). Callbacks are not validated — TypeScript
14
+ * structural typing already covers them as well as a runtime check
15
+ * usefully can. The point of this validator is to catch
16
+ * configuration mistakes operators make (typos in pool names, bad
17
+ * enum values, negative timeouts), not to second-guess the type
18
+ * system.
19
+ *
20
+ * @public
21
+ */
22
+ export declare function validateAutoSettings(settings: AutoSettings): SettingsValidationError[];
23
+ /**
24
+ * Thrown by `AutoManager.run` when `validateAutoSettings` finds
25
+ * configuration errors. The `.errors` property carries the structured
26
+ * details; `.message` contains a human-readable summary.
27
+ *
28
+ * @public
29
+ */
30
+ export declare class AutoSettingsValidationError extends Error {
31
+ readonly errors: SettingsValidationError[];
32
+ constructor(errors: SettingsValidationError[]);
33
+ }
34
+ //# sourceMappingURL=settingsValidation.d.ts.map