@vzn/vx-dte 0.0.0 → 0.0.650
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/LICENSE +21 -0
- package/README.md +923 -1
- package/index.ts +4 -0
- package/package.json +40 -4
- package/src/cpu-account.ts +134 -0
- package/src/dispatch.ts +268 -0
- package/src/docker.ts +156 -0
- package/src/forecast.ts +68 -0
- package/src/github.ts +523 -0
- package/src/gitlab.ts +130 -0
- package/src/index.ts +12 -0
- package/src/init.ts +522 -0
- package/src/local.ts +256 -0
- package/src/messages.ts +219 -0
- package/src/noise.ts +248 -0
- package/src/orchestrator.ts +2359 -0
- package/src/outputs.ts +117 -0
- package/src/planner.ts +988 -0
- package/src/plugin.ts +1018 -0
- package/src/printable.ts +11 -0
- package/src/seal.ts +509 -0
- package/src/strategy.ts +50 -0
- package/src/trace.ts +288 -0
- package/src/tree.ts +78 -0
- package/src/usage.ts +135 -0
- package/src/warm-ups.ts +85 -0
- package/src/wire.ts +1060 -0
- package/src/worker.ts +1038 -0
package/index.ts
ADDED
package/package.json
CHANGED
|
@@ -1,8 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-dte",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Placeholder; real releases are published by CI.",
|
|
3
|
+
"version": "0.0.650",
|
|
5
4
|
"license": "MIT",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
5
|
+
"description": "Distributed task execution for vx: one run spread over workers your own CI spawns, joined through a relay.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"vx",
|
|
8
|
+
"monorepo",
|
|
9
|
+
"vx-plugin",
|
|
10
|
+
"distributed",
|
|
11
|
+
"ci"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./src/index.ts",
|
|
15
|
+
"types": "./src/index.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./src/index.ts",
|
|
19
|
+
"import": "./src/index.ts"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"index.ts",
|
|
24
|
+
"src",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"bun": ">=1.4.4"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@vzn/vx-dte-server": "0.0.650"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@vzn/vx": "^0.0.650"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/vznjs/vx.git",
|
|
40
|
+
"directory": "packages/vx-dte"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/vznjs/vx/tree/main/packages/vx-dte#readme",
|
|
43
|
+
"bugs": "https://github.com/vznjs/vx/issues"
|
|
8
44
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Where a worker's machine spent its CPU while the worker served a run,
|
|
2
|
+
// said once as it leaves, in its own log: its tasks, the worker itself, its
|
|
3
|
+
// other children, and the processes that used the most besides. The run's
|
|
4
|
+
// summary showed machines busier than their tasks (2.8 cores against 2.0,
|
|
5
|
+
// 2026-10-24) and nothing said what the rest was.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
|
|
9
|
+
/** Each process's CPU so far by pid, with its name, from one `ps`. */
|
|
10
|
+
export type ProcTable = Map<number, { name: string; ms: number }>
|
|
11
|
+
|
|
12
|
+
export interface CpuAccount {
|
|
13
|
+
readonly onlineMs: number
|
|
14
|
+
/** Busy core-time of the whole machine over `onlineMs`, any process's. */
|
|
15
|
+
readonly machineMs: number
|
|
16
|
+
readonly tasksMs: number
|
|
17
|
+
readonly selfMs: number
|
|
18
|
+
/** Its reaped children's, less its tasks': Linux only. */
|
|
19
|
+
readonly childrenMs?: number | undefined
|
|
20
|
+
/** The processes that used the most while it served, its own tree aside. */
|
|
21
|
+
readonly top: readonly { name: string; ms: number }[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `ps`'s `time`, `[[dd-]hh:]mm:ss[.cc]`, in ms; macOS may print a comma for the point. */
|
|
25
|
+
export function psTime(s: string): number | undefined {
|
|
26
|
+
const dash = s.indexOf('-')
|
|
27
|
+
const days = dash === -1 ? 0 : Number(s.slice(0, dash))
|
|
28
|
+
const parts = s
|
|
29
|
+
.slice(dash + 1)
|
|
30
|
+
.replace(',', '.')
|
|
31
|
+
.split(':')
|
|
32
|
+
.map(Number)
|
|
33
|
+
if (parts.length < 2 || !Number.isFinite(days) || parts.some((n) => !Number.isFinite(n))) {
|
|
34
|
+
return undefined
|
|
35
|
+
}
|
|
36
|
+
return Math.round((days * 86_400 + parts.reduce((a, n) => a * 60 + n, 0)) * 1000)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Ps {
|
|
40
|
+
readonly table: ProcTable
|
|
41
|
+
readonly parents: Map<number, number>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `ps -A -o pid=,ppid=,time=,comm=`, read. */
|
|
45
|
+
export function parsePs(text: string): Ps {
|
|
46
|
+
const table: ProcTable = new Map()
|
|
47
|
+
const parents = new Map<number, number>()
|
|
48
|
+
for (const line of text.split('\n')) {
|
|
49
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+?)\s*$/.exec(line)
|
|
50
|
+
if (m === null) continue
|
|
51
|
+
const ms = psTime(m[3]!)
|
|
52
|
+
if (ms === undefined) continue
|
|
53
|
+
// macOS prints the path it ran; Linux a name, a kernel thread's with a
|
|
54
|
+
// slash in it (`kworker/0:1`).
|
|
55
|
+
const name = /^[./]/.test(m[4]!) ? m[4]!.split('/').pop()! : m[4]!
|
|
56
|
+
table.set(Number(m[1]), { name, ms })
|
|
57
|
+
parents.set(Number(m[1]), Number(m[2]))
|
|
58
|
+
}
|
|
59
|
+
return { table, parents }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One `ps` of every process. Undefined where it fails. */
|
|
63
|
+
export function readPs(): Ps | undefined {
|
|
64
|
+
try {
|
|
65
|
+
const r = Bun.spawnSync(['ps', '-A', '-o', 'pid=,ppid=,time=,comm='], { stderr: 'ignore' })
|
|
66
|
+
return r.exitCode === 0 ? parsePs(r.stdout.toString()) : undefined
|
|
67
|
+
} catch {
|
|
68
|
+
// quiet: no `ps`, no list of the rest
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The CPU each process used between two tables, by name, the most first:
|
|
75
|
+
* `self`'s tree left out (its tasks and itself are counted apart). A
|
|
76
|
+
* process gone by the second table is not seen.
|
|
77
|
+
*/
|
|
78
|
+
export function topBetween(
|
|
79
|
+
before: ProcTable,
|
|
80
|
+
after: Ps,
|
|
81
|
+
self: number,
|
|
82
|
+
n = 5,
|
|
83
|
+
): { name: string; ms: number }[] {
|
|
84
|
+
const mine = (pid: number): boolean => {
|
|
85
|
+
// `self` asked before the stop at pid 1: docker() execs the worker as
|
|
86
|
+
// its container's init.
|
|
87
|
+
for (let p: number | undefined = pid, hops = 0; p !== undefined && hops < 64; hops++) {
|
|
88
|
+
if (p === self) return true
|
|
89
|
+
if (p <= 1) break
|
|
90
|
+
p = after.parents.get(p)
|
|
91
|
+
}
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
const byName = new Map<string, number>()
|
|
95
|
+
for (const [pid, p] of after.table) {
|
|
96
|
+
if (mine(pid)) continue
|
|
97
|
+
const was = before.get(pid)
|
|
98
|
+
const ms = was !== undefined && was.name === p.name ? p.ms - was.ms : p.ms
|
|
99
|
+
if (ms > 0) byName.set(p.name, (byName.get(p.name) ?? 0) + ms)
|
|
100
|
+
}
|
|
101
|
+
return [...byName]
|
|
102
|
+
.map(([name, ms]) => ({ name, ms }))
|
|
103
|
+
.sort((a, b) => b.ms - a.ms)
|
|
104
|
+
.slice(0, n)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The CPU its reaped children used, from `/proc/self/stat` (`cutime`, `cstime`); undefined off Linux. */
|
|
108
|
+
export function childrenCpuMs(
|
|
109
|
+
read: () => string = () => readFileSync('/proc/self/stat', 'utf8'),
|
|
110
|
+
): number | undefined {
|
|
111
|
+
try {
|
|
112
|
+
const stat = read()
|
|
113
|
+
const f = stat.slice(stat.lastIndexOf(')') + 2).split(' ')
|
|
114
|
+
// USER_HZ is 100 on every Linux ABI.
|
|
115
|
+
return (Number(f[13]) + Number(f[14])) * 10
|
|
116
|
+
} catch {
|
|
117
|
+
// quiet: not Linux
|
|
118
|
+
return undefined
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const cores = (ms: number, over: number): string => `${(ms / over).toFixed(2)} cores`
|
|
123
|
+
|
|
124
|
+
export function describeCpu(a: CpuAccount): string {
|
|
125
|
+
const over = Math.max(1, a.onlineMs)
|
|
126
|
+
const parts = [
|
|
127
|
+
`machine ${cores(a.machineMs, over)}`,
|
|
128
|
+
`tasks ${cores(a.tasksMs, over)}`,
|
|
129
|
+
`this worker ${cores(a.selfMs, over)}`,
|
|
130
|
+
...(a.childrenMs === undefined ? [] : [`its other children ${cores(a.childrenMs, over)}`]),
|
|
131
|
+
]
|
|
132
|
+
const rest = a.top.map((p) => `${p.name} ${cores(p.ms, over)}`).join(', ')
|
|
133
|
+
return `cpu over ${(over / 1000).toFixed(1)}s: ${parts.join(' · ')}${rest === '' ? '' : ` · most besides: ${rest}`}`
|
|
134
|
+
}
|
package/src/dispatch.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Which worker each waiting task goes to now: one rule for the live run
|
|
2
|
+
// (`Orchestrator.pump`) and for the plan's play-out (`planner.ts`), so the
|
|
3
|
+
// plan sizes the run the way the run packs it.
|
|
4
|
+
|
|
5
|
+
import type { Strategy } from './strategy.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `ids` (waiting, in the order they got ready) in the order `how` takes
|
|
9
|
+
* them. `tail`: each one's longest chain after it, its own ms included.
|
|
10
|
+
*/
|
|
11
|
+
export function ordered(
|
|
12
|
+
ids: readonly string[],
|
|
13
|
+
how: Strategy['order'],
|
|
14
|
+
tail: (id: string) => number,
|
|
15
|
+
need: (id: string) => Need,
|
|
16
|
+
cores: number,
|
|
17
|
+
): string[] {
|
|
18
|
+
if (how === 'fifo') return [...ids]
|
|
19
|
+
const byTail = (x: string, y: string): number => tail(y) - tail(x)
|
|
20
|
+
if (how === 'critical-path') return [...ids].sort(byTail)
|
|
21
|
+
// Troublesome: within a quarter of the longest chain, or half a worker wide.
|
|
22
|
+
let longest = 0
|
|
23
|
+
for (const id of ids) longest = Math.max(longest, tail(id))
|
|
24
|
+
const hard = (id: string): boolean => tail(id) >= 0.75 * longest || need(id).cores >= cores / 2
|
|
25
|
+
return [...ids].sort((x, y) => Number(hard(y)) - Number(hard(x)) || byTail(x, y))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What a task asks of a worker: its history's cores and peak memory. */
|
|
29
|
+
export interface Need {
|
|
30
|
+
readonly cores: number
|
|
31
|
+
readonly memory: number
|
|
32
|
+
/** It filled a machine this big last time: only a bigger one takes it, if any worker is. */
|
|
33
|
+
readonly above?: { readonly cores: number; readonly memory: number } | undefined
|
|
34
|
+
/** The platform it runs on (`ExecuteRequest.platform`): only a worker of it takes it. */
|
|
35
|
+
readonly platform?: string | undefined
|
|
36
|
+
/**
|
|
37
|
+
* What it counts for when the plan sizes the run, when that is not what
|
|
38
|
+
* it books: an unmeasured task books next to nothing, so it goes to any
|
|
39
|
+
* free core, but a run of only such tasks still asks enough workers.
|
|
40
|
+
*/
|
|
41
|
+
readonly ask?: { readonly cores: number; readonly memory: number } | undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A worker as dispatch sees it: its size and what is in use on it now. */
|
|
45
|
+
export interface Bin {
|
|
46
|
+
readonly id: string
|
|
47
|
+
readonly cores: number
|
|
48
|
+
readonly memory: number
|
|
49
|
+
/** Cores and memory in use: booked, or more when its load says more. */
|
|
50
|
+
readonly used: { cores: number; memory: number }
|
|
51
|
+
/** Tasks on it holding cores. */
|
|
52
|
+
running: number
|
|
53
|
+
/** Tasks whose outputs it holds. */
|
|
54
|
+
readonly has?: ReadonlySet<string>
|
|
55
|
+
/** Its machine's platform (`linux-x64`), as it reported or its size declares. */
|
|
56
|
+
readonly platform?: string | undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Ask {
|
|
60
|
+
readonly id: string
|
|
61
|
+
readonly need: Need
|
|
62
|
+
/** Its dependencies, all the way up: a worker holding them is preferred. */
|
|
63
|
+
readonly up?: Iterable<string>
|
|
64
|
+
/** Names its need: asks of one kind fit and fail alike (`kindOf`). */
|
|
65
|
+
readonly kind?: string
|
|
66
|
+
/** The one bin that may take it: it holds files no cache does. */
|
|
67
|
+
readonly only?: string
|
|
68
|
+
/** A bin that may not take it. */
|
|
69
|
+
readonly avoid?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What `Ask.kind` names: every field of a need `assign` reads. */
|
|
73
|
+
export function kindOf(need: Need): string {
|
|
74
|
+
return `${need.cores}\0${need.memory}\0${need.above?.cores}\0${need.above?.memory}\0${need.platform}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether `need` fits on `bin` now: alone on an empty one, whatever it
|
|
79
|
+
* asks; beside others only in full, cores and memory. Beside others on a
|
|
80
|
+
* free core alone, main's workers booked 4.3 of 4 (2026-10-23).
|
|
81
|
+
*/
|
|
82
|
+
export function fits(bin: Bin, need: Need): boolean {
|
|
83
|
+
if (need.platform !== undefined && bin.platform !== need.platform) return false
|
|
84
|
+
if (bin.running === 0) return true
|
|
85
|
+
return (
|
|
86
|
+
need.cores < bin.cores &&
|
|
87
|
+
bin.used.cores + need.cores <= bin.cores + 1e-9 &&
|
|
88
|
+
bin.used.memory + need.memory <= bin.memory
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Each of `asks`, in order, to the bin it fits now (`fits`): of those, the
|
|
94
|
+
* one holding most of its dependencies' outputs, then the one with fewest
|
|
95
|
+
* cores free, so workers fill before the next takes work and the rest
|
|
96
|
+
* fall idle and leave. A task bigger than every bin's last size goes to a
|
|
97
|
+
* bigger one only, unless none is or `waits(above, platform)` says one of its platform is coming.
|
|
98
|
+
* The first that fits nowhere but an empty bin holds the one using least: no task after it goes there, or later tasks fill it again and
|
|
99
|
+
* again (check.bun, the gate of 56 tasks, waited 50 s on main 871ffba).
|
|
100
|
+
* Bins' `used` and `running` count what is given. `all`, what the asks
|
|
101
|
+
* need all told (the least of each, and how many kinds), ends the pass
|
|
102
|
+
* once no bin takes the least or every kind failed since the last given:
|
|
103
|
+
* the rest change nothing.
|
|
104
|
+
*/
|
|
105
|
+
export function assign<B extends Bin>(
|
|
106
|
+
asks: Iterable<Ask>,
|
|
107
|
+
bins: readonly B[],
|
|
108
|
+
waits: (above: NonNullable<Need['above']>, platform: string | undefined) => boolean = () => false,
|
|
109
|
+
place: Strategy['place'] = 'best-fit',
|
|
110
|
+
all?: {
|
|
111
|
+
readonly floor: { readonly cores: number; readonly memory: number }
|
|
112
|
+
readonly kinds: number
|
|
113
|
+
/** Where it keeps the kinds spent, for the asks to skip them as it reads. */
|
|
114
|
+
readonly spent?: Set<string>
|
|
115
|
+
/** Where it keeps the bins' bounds as it gives, for the asks to skip each `dead` one as it reads. */
|
|
116
|
+
readonly bounds?: Bounds
|
|
117
|
+
},
|
|
118
|
+
): Map<string, B> {
|
|
119
|
+
const out = new Map<string, B>()
|
|
120
|
+
const held = new Set<B>()
|
|
121
|
+
const bounds = all?.bounds
|
|
122
|
+
const rebound = (): void => {
|
|
123
|
+
if (bounds !== undefined) boundsOf(bins, held, bounds, all?.floor)
|
|
124
|
+
}
|
|
125
|
+
rebound()
|
|
126
|
+
// Kinds that failed and held nothing since a bin was last given a task:
|
|
127
|
+
// until one is, each ask of them fails alike.
|
|
128
|
+
const spent = all?.spent ?? new Set<string>()
|
|
129
|
+
spent.clear()
|
|
130
|
+
for (const ask of asks) {
|
|
131
|
+
if (
|
|
132
|
+
all !== undefined &&
|
|
133
|
+
(spent.size >= all.kinds || !bins.some((b) => !held.has(b) && fits(b, all.floor)))
|
|
134
|
+
)
|
|
135
|
+
break
|
|
136
|
+
if (ask.kind !== undefined && spent.has(ask.kind)) continue
|
|
137
|
+
const { need } = ask
|
|
138
|
+
const bigger = (b: B): boolean =>
|
|
139
|
+
need.above === undefined || b.cores > need.above.cores || b.memory > need.above.memory
|
|
140
|
+
const allowed =
|
|
141
|
+
ask.only === undefined && ask.avoid === undefined
|
|
142
|
+
? bins
|
|
143
|
+
: bins.filter((b) => (ask.only === undefined || b.id === ask.only) && b.id !== ask.avoid)
|
|
144
|
+
const of =
|
|
145
|
+
need.platform === undefined ? allowed : allowed.filter((b) => b.platform === need.platform)
|
|
146
|
+
const anyBigger = of.some(bigger)
|
|
147
|
+
if (!anyBigger && need.above !== undefined && waits(need.above, need.platform)) {
|
|
148
|
+
if (ask.kind !== undefined) spent.add(ask.kind)
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
const pool = anyBigger ? of.filter(bigger) : of
|
|
152
|
+
const up = ask.up === undefined ? [] : [...ask.up]
|
|
153
|
+
let best: B | undefined
|
|
154
|
+
let bestScore: [number, number] = [0, 0]
|
|
155
|
+
for (const b of pool) {
|
|
156
|
+
if (held.has(b) || !fits(b, need)) continue
|
|
157
|
+
let has = 0
|
|
158
|
+
for (const id of up) if (b.has?.has(id) === true) has++
|
|
159
|
+
const score: [number, number] = [has, fit(b, need, place, pool.indexOf(b))]
|
|
160
|
+
if (
|
|
161
|
+
best === undefined ||
|
|
162
|
+
score[0] > bestScore[0] ||
|
|
163
|
+
(score[0] === bestScore[0] && score[1] > bestScore[1])
|
|
164
|
+
) {
|
|
165
|
+
best = b
|
|
166
|
+
bestScore = score
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (best === undefined) {
|
|
170
|
+
// One it fits only empty: the one using least is held for it.
|
|
171
|
+
let soonest: B | undefined
|
|
172
|
+
for (const b of pool) {
|
|
173
|
+
if (held.has(b) || b.running === 0) continue
|
|
174
|
+
if (need.cores < b.cores && need.memory <= b.memory - b.used.memory) continue
|
|
175
|
+
if (soonest === undefined || b.used.cores < soonest.used.cores) soonest = b
|
|
176
|
+
}
|
|
177
|
+
if (soonest !== undefined) {
|
|
178
|
+
held.add(soonest)
|
|
179
|
+
rebound()
|
|
180
|
+
} else if (ask.kind !== undefined) spent.add(ask.kind)
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
spent.clear()
|
|
184
|
+
out.set(ask.id, best)
|
|
185
|
+
best.used.cores += Math.min(need.cores, best.cores)
|
|
186
|
+
best.used.memory += Math.min(need.memory, best.memory)
|
|
187
|
+
best.running++
|
|
188
|
+
rebound()
|
|
189
|
+
}
|
|
190
|
+
return out
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The bins not held, as an ask `dead` reads them: one asked of nowhere
|
|
195
|
+
* fits it and holds nothing, so `assign` only spends its kind. With every
|
|
196
|
+
* task its own kind, a pass read each ready one to learn that: 2.4 million
|
|
197
|
+
* asks for a plan of 9000 (2026-10-25).
|
|
198
|
+
*/
|
|
199
|
+
export interface Bounds {
|
|
200
|
+
/** None takes the least any ask needs: the pass is over. */
|
|
201
|
+
done: boolean
|
|
202
|
+
/** One is empty: every ask fits it. */
|
|
203
|
+
open: boolean
|
|
204
|
+
/** The most cores free on one. */
|
|
205
|
+
free: number
|
|
206
|
+
/** The fewest cores one has. */
|
|
207
|
+
cores: number
|
|
208
|
+
/** The least memory free on one. */
|
|
209
|
+
memory: number
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function boundsOf(
|
|
213
|
+
bins: readonly Bin[],
|
|
214
|
+
held: ReadonlySet<Bin>,
|
|
215
|
+
out: Bounds,
|
|
216
|
+
floor: Need | undefined,
|
|
217
|
+
): void {
|
|
218
|
+
out.done = floor !== undefined
|
|
219
|
+
out.open = false
|
|
220
|
+
out.free = -Infinity
|
|
221
|
+
out.cores = Infinity
|
|
222
|
+
out.memory = Infinity
|
|
223
|
+
for (const b of bins) {
|
|
224
|
+
if (held.has(b)) continue
|
|
225
|
+
if (floor !== undefined && fits(b, floor)) out.done = false
|
|
226
|
+
if (b.running === 0) out.open = true
|
|
227
|
+
out.free = Math.max(out.free, b.cores - b.used.cores)
|
|
228
|
+
out.cores = Math.min(out.cores, b.cores)
|
|
229
|
+
out.memory = Math.min(out.memory, b.memory - b.used.memory)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* True when `assign` would fit `need` on no bin and hold none for it, by
|
|
235
|
+
* `fits` and its hold rule: no bin is empty, and on each it is less than
|
|
236
|
+
* the bin, fits its memory, and wants more cores than are free.
|
|
237
|
+
*/
|
|
238
|
+
export function dead(need: Need, b: Bounds): boolean {
|
|
239
|
+
return (
|
|
240
|
+
need.platform === undefined &&
|
|
241
|
+
need.above === undefined &&
|
|
242
|
+
!b.open &&
|
|
243
|
+
need.cores > b.free + 1e-9 &&
|
|
244
|
+
need.cores < b.cores &&
|
|
245
|
+
need.memory <= b.memory
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** How well `need` suits `b` by `place`: the higher, the better. */
|
|
250
|
+
function fit(b: Bin, need: Need, place: Strategy['place'], index: number): number {
|
|
251
|
+
switch (place) {
|
|
252
|
+
case 'best-fit':
|
|
253
|
+
return b.used.cores - b.cores
|
|
254
|
+
case 'worst-fit':
|
|
255
|
+
return b.cores - b.used.cores
|
|
256
|
+
case 'first-fit':
|
|
257
|
+
return -index
|
|
258
|
+
case 'tetris': {
|
|
259
|
+
// The dot product of the need and what is free, each as a share of
|
|
260
|
+
// the worker: a task goes where its shape matches the hole.
|
|
261
|
+
const cores = Math.min(need.cores, b.cores) / b.cores
|
|
262
|
+
const memory = b.memory > 0 ? Math.min(need.memory, b.memory) / b.memory : 0
|
|
263
|
+
const freeCores = (b.cores - b.used.cores) / b.cores
|
|
264
|
+
const freeMemory = b.memory > 0 ? (b.memory - b.used.memory) / b.memory : 0
|
|
265
|
+
return cores * freeCores + memory * freeMemory
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
package/src/docker.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// `docker()`: each worker a container on this machine, held to its size's
|
|
2
|
+
// cores and memory, in a fresh clone made inside it of the tracked files
|
|
3
|
+
// as the run sees them, sharing this machine's store. The relay
|
|
4
|
+
// is the run's own on loopback; a container reaches it through the host's
|
|
5
|
+
// network on Linux and through `host.docker.internal` elsewhere.
|
|
6
|
+
|
|
7
|
+
import { mkdirSync, realpathSync } from 'node:fs'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { refuseUnknownOptions, UserError, type PluginOptionKinds } from '@vzn/vx'
|
|
11
|
+
import { loopbackRelay, reasons } from './local.js'
|
|
12
|
+
import type { DteAdapter } from './plugin.js'
|
|
13
|
+
|
|
14
|
+
export interface DockerOptions {
|
|
15
|
+
/** An image with Bun and git, such as one built `FROM oven/bun` with git added. */
|
|
16
|
+
readonly image: string
|
|
17
|
+
/** A shell line the clone runs before its worker starts, such as `bun install`. */
|
|
18
|
+
readonly prepare?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const KEYS: PluginOptionKinds<DockerOptions> = { image: 'string', prepare: 'string' }
|
|
22
|
+
|
|
23
|
+
export function docker(options: DockerOptions): DteAdapter {
|
|
24
|
+
refuseUnknownOptions('docker()', options, KEYS)
|
|
25
|
+
// A bare `docker()` read `options.image` off undefined: a TypeError, a crash.
|
|
26
|
+
if (typeof options?.image !== 'string' || options.image === '') {
|
|
27
|
+
throw new UserError(
|
|
28
|
+
"docker() needs image: one with Bun and git, such as 'oven/bun:1.4' plus git",
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
let relay: { url: string; stop(): void } | undefined
|
|
32
|
+
const workers = new Map<string, ReturnType<typeof Bun.spawn>>()
|
|
33
|
+
const linux = process.platform === 'linux'
|
|
34
|
+
let closed = false
|
|
35
|
+
const why = reasons()
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
// Each run asks for one (`vx watch` runs again): a new run, on the tree as it is now.
|
|
39
|
+
async relay() {
|
|
40
|
+
closed = false
|
|
41
|
+
relay = await loopbackRelay()
|
|
42
|
+
return relay.url
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async spawn(a) {
|
|
46
|
+
const git = (...args: string[]): string => {
|
|
47
|
+
const r = Bun.spawnSync(['git', ...args], { cwd: a.workspaceRoot, stderr: 'pipe' })
|
|
48
|
+
if (r.exitCode !== 0) throw new Error(`git ${args[0]}: ${r.stderr.toString().trim()}`)
|
|
49
|
+
return r.stdout.toString().trim()
|
|
50
|
+
}
|
|
51
|
+
const top = git('rev-parse', '--show-toplevel')
|
|
52
|
+
// The tracked files as the run saw them at its start, uncommitted
|
|
53
|
+
// changes too: a clone holds only refs, so the commit is fetched by its id.
|
|
54
|
+
const sha = a.commit
|
|
55
|
+
// git names the real path: through a link (macOS's /tmp) the relative one climbed out.
|
|
56
|
+
const rel = path.relative(top, realpathSync(a.workspaceRoot)) || '.'
|
|
57
|
+
// The store is keyed by the repository's identity, its origin: the
|
|
58
|
+
// clone's would be /src, a store of its own the run never reads.
|
|
59
|
+
const origin = Bun.spawnSync(['git', 'remote', 'get-url', 'origin'], {
|
|
60
|
+
cwd: a.workspaceRoot,
|
|
61
|
+
stderr: 'ignore',
|
|
62
|
+
})
|
|
63
|
+
.stdout.toString()
|
|
64
|
+
.trim()
|
|
65
|
+
// On Linux a container's root writes the store as root; as this user
|
|
66
|
+
// it has no home but /tmp, where the store is mounted instead.
|
|
67
|
+
const uid = process.getuid?.() ?? 0
|
|
68
|
+
const asUser = linux && uid !== 0
|
|
69
|
+
const work = asUser ? '/tmp/work' : '/work'
|
|
70
|
+
const script = [
|
|
71
|
+
'set -e',
|
|
72
|
+
`git clone -q /src ${work}`,
|
|
73
|
+
`cd ${work}`,
|
|
74
|
+
origin === '' ? 'git remote remove origin' : `git remote set-url origin ${quote(origin)}`,
|
|
75
|
+
`git fetch -q --upload-pack='git -c uploadpack.allowAnySHA1InWant=true upload-pack' /src ${sha}`,
|
|
76
|
+
`git checkout -q --detach ${sha}`,
|
|
77
|
+
`cd ${quote(rel)}`,
|
|
78
|
+
// On stderr, which names why a container failed: pnpm prints an install's error on stdout.
|
|
79
|
+
...(options.prepare === undefined ? [] : ['{', options.prepare, '} >&2']),
|
|
80
|
+
'exec ./node_modules/.bin/vx dte worker',
|
|
81
|
+
].join('\n')
|
|
82
|
+
// This machine's store and cache dir at their own paths, as local()'s
|
|
83
|
+
// workers share them: what a worker saves, the run reads.
|
|
84
|
+
const home = process.env['HOME'] || homedir()
|
|
85
|
+
const store = path.join(home, '.vx')
|
|
86
|
+
mkdirSync(store, { recursive: true })
|
|
87
|
+
const mounts = [store]
|
|
88
|
+
const cacheEnv: Record<string, string> = {}
|
|
89
|
+
if (a.cacheDir !== undefined) {
|
|
90
|
+
mkdirSync(a.cacheDir, { recursive: true })
|
|
91
|
+
mounts.push(a.cacheDir)
|
|
92
|
+
cacheEnv['VX_CACHE_DIR'] = a.cacheDir
|
|
93
|
+
}
|
|
94
|
+
const inHome = asUser ? '/tmp' : home
|
|
95
|
+
const env = {
|
|
96
|
+
...a.env,
|
|
97
|
+
HOME: inHome,
|
|
98
|
+
...cacheEnv,
|
|
99
|
+
...(linux ? {} : { VX_DTE_RELAY: a.relay.replace('127.0.0.1', 'host.docker.internal') }),
|
|
100
|
+
// The checkout is the host's, owned by another user than the container's.
|
|
101
|
+
GIT_CONFIG_COUNT: '1',
|
|
102
|
+
GIT_CONFIG_KEY_0: 'safe.directory',
|
|
103
|
+
GIT_CONFIG_VALUE_0: '*',
|
|
104
|
+
}
|
|
105
|
+
const name = `vx-dte-${process.pid}-${a.index}`
|
|
106
|
+
const proc = Bun.spawn(
|
|
107
|
+
[
|
|
108
|
+
'docker',
|
|
109
|
+
'run',
|
|
110
|
+
'--rm',
|
|
111
|
+
'--name',
|
|
112
|
+
name,
|
|
113
|
+
'--cpus',
|
|
114
|
+
String(a.cpu),
|
|
115
|
+
'--memory',
|
|
116
|
+
`${a.memory}b`,
|
|
117
|
+
...(linux ? ['--network', 'host'] : []),
|
|
118
|
+
'--volume',
|
|
119
|
+
`${top}:/src:ro`,
|
|
120
|
+
...(asUser ? ['--user', `${uid}:${process.getgid?.() ?? uid}`] : []),
|
|
121
|
+
...mounts.flatMap((m) => [
|
|
122
|
+
'--volume',
|
|
123
|
+
`${m}:${m === store ? path.join(inHome, '.vx') : m}`,
|
|
124
|
+
]),
|
|
125
|
+
...Object.keys(env).flatMap((k) => ['--env', k]),
|
|
126
|
+
options.image,
|
|
127
|
+
'sh',
|
|
128
|
+
'-c',
|
|
129
|
+
script,
|
|
130
|
+
],
|
|
131
|
+
{ env: { ...process.env, ...env }, stdin: 'ignore', stdout: 'ignore', stderr: 'pipe' },
|
|
132
|
+
)
|
|
133
|
+
workers.set(name, proc)
|
|
134
|
+
void (async () => {
|
|
135
|
+
const [err, code] = await Promise.all([new Response(proc.stderr).text(), proc.exited])
|
|
136
|
+
workers.delete(name)
|
|
137
|
+
if (!closed) a.exited(await why(`exited ${code}`, a.id, err))
|
|
138
|
+
})()
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
async close() {
|
|
142
|
+
closed = true
|
|
143
|
+
// The relay closing the run tells each worker to leave; a container
|
|
144
|
+
// still up after the grace is removed.
|
|
145
|
+
relay?.stop()
|
|
146
|
+
relay = undefined
|
|
147
|
+
const left = [...workers]
|
|
148
|
+
await Promise.race([Promise.all(left.map(([, p]) => p.exited)), Bun.sleep(2_000)])
|
|
149
|
+
const up = left.filter(([name]) => workers.has(name)).map(([name]) => name)
|
|
150
|
+
if (up.length > 0)
|
|
151
|
+
Bun.spawnSync(['docker', 'rm', '-f', ...up], { stdout: 'ignore', stderr: 'ignore' })
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const quote = (s: string): string => `'${s.replaceAll("'", `'\\''`)}'`
|
package/src/forecast.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Which cached tasks will miss, guessed before any worker can say: those
|
|
2
|
+
// whose inputs the last commit changed, and every task after one, its key
|
|
3
|
+
// folding theirs. The run learns a task missed only once a worker keys it,
|
|
4
|
+
// 20 s in: main a09b987's plan saw its 56 shards only then, and ended
|
|
5
|
+
// 14 s after the best (tests/sim.test.ts). The guess only sizes the run: a
|
|
6
|
+
// hit it names costs a worker the plan cancels, a miss it does not names
|
|
7
|
+
// the wait the run always had.
|
|
8
|
+
|
|
9
|
+
import type { TaskNode } from '@vzn/vx'
|
|
10
|
+
|
|
11
|
+
/** Root files every key reads: a lockfile, the workspace config. */
|
|
12
|
+
const WHOLE = /^(?:\.vx\/|(?:bun\.lockb?|pnpm-lock\.yaml|package-lock\.json|yarn\.lock)$)/
|
|
13
|
+
const GLOB = /[*?[{]/
|
|
14
|
+
|
|
15
|
+
/** Whether `rel` is one of `patterns`' files: a literal names a file or its tree; `!` entries are left out, so it errs to a miss. */
|
|
16
|
+
function matches(patterns: readonly string[] | undefined, rel: string): boolean {
|
|
17
|
+
for (const raw of patterns ?? []) {
|
|
18
|
+
if (raw.startsWith('!')) continue
|
|
19
|
+
const p = raw.replace(/^\.\//, '').replace(/\/$/, '')
|
|
20
|
+
if (GLOB.test(p) ? new Bun.Glob(p).match(rel) : rel === p || rel.startsWith(`${p}/`))
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The cached tasks of `nodes` that `changed` (workspace-relative paths)
|
|
28
|
+
* makes miss: a task whose `inputs` match one, whose project's
|
|
29
|
+
* `package.json` or config is one, or after such a task.
|
|
30
|
+
*/
|
|
31
|
+
export function likelyMisses(
|
|
32
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
33
|
+
changed: readonly string[],
|
|
34
|
+
workspaceRoot: string,
|
|
35
|
+
): Set<string> {
|
|
36
|
+
const out = new Set<string>()
|
|
37
|
+
if (changed.length === 0) return out
|
|
38
|
+
const whole = changed.some((p) => WHOLE.test(p))
|
|
39
|
+
const root = workspaceRoot.endsWith('/') ? workspaceRoot : `${workspaceRoot}/`
|
|
40
|
+
const own = (n: TaskNode): boolean => {
|
|
41
|
+
const inputs = n.config.inputs
|
|
42
|
+
if (inputs === undefined) return false
|
|
43
|
+
if (whole) return true
|
|
44
|
+
const dir = n.projectDir.startsWith(root) ? n.projectDir.slice(root.length) : ''
|
|
45
|
+
for (const p of changed) {
|
|
46
|
+
if (matches(inputs.workspaceFiles, p)) return true
|
|
47
|
+
if (dir !== '' && !p.startsWith(`${dir}/`)) continue
|
|
48
|
+
const rel = dir === '' ? p : p.slice(dir.length + 1)
|
|
49
|
+
if (rel === 'package.json' || /^vx\.config\.[cm]?[jt]s$/.test(rel)) return true
|
|
50
|
+
if (matches(inputs.files, rel)) return true
|
|
51
|
+
}
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
const keyed = new Map<string, boolean>()
|
|
55
|
+
const changes = (id: string): boolean => {
|
|
56
|
+
const known = keyed.get(id)
|
|
57
|
+
if (known !== undefined) return known
|
|
58
|
+
keyed.set(id, false)
|
|
59
|
+
const n = nodes.get(id)
|
|
60
|
+
const c = n !== undefined && (own(n) || n.deps.some(changes))
|
|
61
|
+
keyed.set(id, c)
|
|
62
|
+
return c
|
|
63
|
+
}
|
|
64
|
+
for (const [id, n] of nodes)
|
|
65
|
+
if (n.config.exec?.command !== undefined && n.config.cache !== undefined && changes(id))
|
|
66
|
+
out.add(id)
|
|
67
|
+
return out
|
|
68
|
+
}
|