@vzn/vx-dte 0.0.652 → 0.0.653
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 +135 -62
- package/package.json +3 -3
- package/src/dispatch.ts +39 -4
- package/src/github.ts +0 -3
- package/src/index.ts +1 -0
- package/src/init.ts +9 -10
- package/src/local.ts +112 -28
- package/src/messages.ts +14 -3
- package/src/orchestrator.ts +265 -33
- package/src/planner.ts +183 -55
- package/src/plugin.ts +209 -30
- package/src/seal.ts +159 -43
- package/src/server-fit.ts +80 -0
- package/src/tree.ts +215 -7
- package/src/usage.ts +65 -3
- package/src/warm-ups.ts +72 -6
- package/src/wire.ts +84 -42
- package/src/worker.ts +148 -15
package/README.md
CHANGED
|
@@ -5,7 +5,8 @@ Website: <https://vznjs.github.io/vx/> · source: <https://github.com/vznjs/vx>
|
|
|
5
5
|
Distributed task execution for vx: one `vx run` spread over workers your
|
|
6
6
|
own CI starts, replayed here as one run. The run you start is the
|
|
7
7
|
orchestrator. It runs no task itself, only restores cache hits: it starts
|
|
8
|
-
workers and hands the ready tasks to the workers with room, critical path first
|
|
8
|
+
workers and hands the ready tasks to the workers with room, critical path first,
|
|
9
|
+
ranked by history and ranked again when fetched timings land. A worker runs it in its own checkout and saves the task's
|
|
9
10
|
output and the files its declared outputs left to the workspace's cache,
|
|
10
11
|
under the task's key. They land here before the task ends, so the run
|
|
11
12
|
reports the task as its own. No file travels through the relay: it
|
|
@@ -64,12 +65,12 @@ the initiator.
|
|
|
64
65
|
anyone who reads the repository's Actions sees. A dispatch GitHub
|
|
65
66
|
answers without a run id (204) is refused, so its workers exit.
|
|
66
67
|
|
|
67
|
-
| Who | `info` / `body`
|
|
68
|
-
| ------------- |
|
|
69
|
-
| worker `join` | `info: { "noise": <message 1>, "token"? }`
|
|
70
|
-
| orchestrator | `{ "type": "hello", "noise": <message 2>, "token"? }`
|
|
71
|
-
| orchestrator | `{ "type": "refused", "reason" }`, plain, then `remove`
|
|
72
|
-
| either, then | `
|
|
68
|
+
| Who | `info` / `body` |
|
|
69
|
+
| ------------- | ---------------------------------------------------------- |
|
|
70
|
+
| worker `join` | `info: { "noise": <message 1>, "token"? }` |
|
|
71
|
+
| orchestrator | `{ "type": "hello", "noise": <message 2>, "token"? }` |
|
|
72
|
+
| orchestrator | `{ "type": "refused", "reason" }`, plain, then `remove` |
|
|
73
|
+
| either, then | `["<n>.<ciphertext>", …]`, or `["+", …]` with more to come |
|
|
73
74
|
|
|
74
75
|
The orchestrator checks each worker the moment the relay says it joined,
|
|
75
76
|
and `remove`s one whose handshake fails. Message 1's payload is the
|
|
@@ -78,17 +79,28 @@ body is Noise transport messages, base64, each at most 65535 bytes and
|
|
|
78
79
|
naming its nonce `n` (Noise §11.4, since a relay may reorder); each is
|
|
79
80
|
sealed with associated data `<first n>.<parts>` of its body, so no part
|
|
80
81
|
opens in another body, and a body's parts take consecutive nonces, so
|
|
81
|
-
none is repeated or reordered in it. A body
|
|
82
|
+
none is repeated or reordered in it. A body of more than 5 parts goes
|
|
83
|
+
over several sends of 5, each but the last led by `"+"`, so no frame
|
|
84
|
+
passes the relay's 1 MiB; the receiver holds them until the last and
|
|
85
|
+
opens the body whole. A body whose first nonce was seen before is a
|
|
82
86
|
replay and is dropped; one older than a body already opened (the relay
|
|
83
87
|
held it back: the wire's numbers are outside the seal) or that does not
|
|
84
|
-
open loses its sender. The
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
open loses its sender. The plaintext is a lead byte, the length of
|
|
89
|
+
every body its sender sealed before (4 bytes, big-endian, mod 2³²), and
|
|
90
|
+
then, `1`, its JSON, an array of the messages sent together (up to 64
|
|
91
|
+
KiB), or `0`, when smaller so, its zstd frame's length (4 bytes) and
|
|
92
|
+
the frame: compressed before sealing, since no
|
|
93
|
+
relay or socket compresses what is sealed. The orchestrator's are
|
|
94
|
+
compressed against the last 16 KiB of its bodies' JSON to that worker
|
|
95
|
+
as their dictionary, so a batch's env and paths said before cost a few
|
|
96
|
+
bytes; a worker's go without one, which would cost the orchestrator
|
|
97
|
+
some 100 µs to open each. A body whose length is not
|
|
98
|
+
the receiver's own count was sealed after one it never opened, which the
|
|
99
|
+
relay kept back, and loses its sender. It
|
|
88
100
|
is padded with trailing spaces to its size class, a power of two from
|
|
89
|
-
|
|
101
|
+
64 bytes, then whole transport messages, so the relay learns no size
|
|
90
102
|
within a class. Inside, each send is numbered and acknowledged
|
|
91
|
-
end to end, and a quiet worker reports (Switchboard § Delivery, § Liveness).
|
|
103
|
+
end to end (`{ "n", "b", "a"? }`: its number, body and ack), and a quiet worker reports (Switchboard § Delivery, § Liveness).
|
|
92
104
|
|
|
93
105
|
## Try it
|
|
94
106
|
|
|
@@ -104,7 +116,7 @@ import { dte, local } from '@vzn/vx-dte'
|
|
|
104
116
|
export default defineWorkspace({
|
|
105
117
|
plugins: [
|
|
106
118
|
dte({
|
|
107
|
-
workers: { small: { max: 4, cpu: 2, memory: '8GB' } },
|
|
119
|
+
workers: { linux: { machines: { small: { max: 4, cpu: 2, memory: '8GB' } } } },
|
|
108
120
|
adapter: local({ prepare: 'bun install' }),
|
|
109
121
|
}),
|
|
110
122
|
],
|
|
@@ -120,8 +132,9 @@ runs here as always (on GitHub Actions, `github()` with a relay names one
|
|
|
120
132
|
itself). It is a random UUID, so no one can guess a run to
|
|
121
133
|
open it first or join it; vx and the relay refuse any other. `local()` starts each worker as a process in its own
|
|
122
134
|
git worktree of the tracked files as they are, uncommitted changes
|
|
123
|
-
included
|
|
124
|
-
|
|
135
|
+
included, and a file mid-merge or flagged `assume-unchanged` or
|
|
136
|
+
`skip-worktree` as it is on disk (untracked files are not: a task that keys
|
|
137
|
+
one fails), at the
|
|
125
138
|
workspace's place in it (a workspace in a subdirectory of its repository too),
|
|
126
139
|
saving to the run's store (a relative `cache.dir` or `--cache-dir` too).
|
|
127
140
|
A run from a git hook works too: the worktree and its worker drop the
|
|
@@ -130,7 +143,10 @@ index being committed is left as it is. When a worker fails, its row names
|
|
|
130
143
|
the error line and the log that holds its whole stderr and all `prepare`
|
|
131
144
|
printed. At the run's end,
|
|
132
145
|
or on Ctrl-C, vx exits without waiting for the worktrees to be removed:
|
|
133
|
-
a detached shell
|
|
146
|
+
a detached shell waits up to 5 s for the workers to leave, kills any
|
|
147
|
+
still running, removes the worktrees and prunes git's records. A run whose vx
|
|
148
|
+
was killed (`kill -9`) removes none; the next `local()` run in the
|
|
149
|
+
repository removes them, once their run's process is gone.
|
|
134
150
|
|
|
135
151
|
`docker()` starts each worker as a container held to its size: `--cpus`
|
|
136
152
|
and `--memory` come from `cpu` and `memory`. The container clones this
|
|
@@ -148,7 +164,7 @@ names its error line and the log of its whole stderr and all `prepare` printed:
|
|
|
148
164
|
|
|
149
165
|
```ts
|
|
150
166
|
dte({
|
|
151
|
-
workers: { small: { max: 4, cpu: 2, memory: '4GB' } },
|
|
167
|
+
workers: { linux: { machines: { small: { max: 4, cpu: 2, memory: '4GB' } } } },
|
|
152
168
|
adapter: docker({ image: 'my/bun-git', prepare: 'bun install' }),
|
|
153
169
|
})
|
|
154
170
|
```
|
|
@@ -222,7 +238,7 @@ export default defineWorkspace({
|
|
|
222
238
|
plugins: [
|
|
223
239
|
dte({
|
|
224
240
|
relay: 'https://relay.example.com',
|
|
225
|
-
workers: { small: { max: 8, cpu: 4, memory: '16GB' } },
|
|
241
|
+
workers: { linux: { machines: { small: { max: 8, cpu: 4, memory: '16GB' } } } },
|
|
226
242
|
adapter: github(),
|
|
227
243
|
}),
|
|
228
244
|
],
|
|
@@ -285,7 +301,7 @@ workspace in a subdirectory of its repository gets them at the repository's
|
|
|
285
301
|
root, its jobs running in that directory. Each size runs on
|
|
286
302
|
its platform's GitHub-hosted runner (`ubuntu-latest` for none named,
|
|
287
303
|
`ubuntu-24.04-arm`, `macos-latest`, `macos-15-intel`), or on the label
|
|
288
|
-
`
|
|
304
|
+
a machine's `runsOn: 'ubuntu-latest-16-cores'` names, as
|
|
289
305
|
`gitlab({ tags })` does. It installs with the package manager your lockfile
|
|
290
306
|
names and runs vx as `bun vx`, which runs the repo's own and never
|
|
291
307
|
downloads one, as `pnpm exec` does (Yarn too: Bun reads no Plug'n'Play,
|
|
@@ -326,7 +342,7 @@ jobs:
|
|
|
326
342
|
id-token: write
|
|
327
343
|
# A worker of a run that is over cancels its own run.
|
|
328
344
|
actions: write
|
|
329
|
-
runs-on: ${{ fromJSON('{"small":"ubuntu-latest"}')[inputs.size] }}
|
|
345
|
+
runs-on: ${{ fromJSON('{"linux.small":"ubuntu-latest"}')[inputs.size] }}
|
|
330
346
|
timeout-minutes: 30
|
|
331
347
|
strategy:
|
|
332
348
|
fail-fast: false
|
|
@@ -381,7 +397,7 @@ export default defineWorkspace({
|
|
|
381
397
|
plugins: [
|
|
382
398
|
dte({
|
|
383
399
|
relay: 'https://relay.example.com',
|
|
384
|
-
workers: { small: { max: 4, cpu: 2, memory: '8GB' } },
|
|
400
|
+
workers: { linux: { machines: { small: { max: 4, cpu: 2, memory: '8GB' } } } },
|
|
385
401
|
adapter: gitlab(),
|
|
386
402
|
}),
|
|
387
403
|
],
|
|
@@ -437,12 +453,12 @@ vx:
|
|
|
437
453
|
- bun install --frozen-lockfile
|
|
438
454
|
script: [bun vx dte worker]
|
|
439
455
|
|
|
440
|
-
'vx-worker small':
|
|
456
|
+
'vx-worker linux.small':
|
|
441
457
|
extends: .vx-worker
|
|
442
458
|
parallel:
|
|
443
459
|
matrix:
|
|
444
460
|
- VX_DTE_SLOT: ['1', '2', '3', '4']
|
|
445
|
-
rules: [{ if: $VX_DTE_SIZE == "small" && $VX_DTE_SLOT =~ $VX_DTE_PICK }]
|
|
461
|
+
rules: [{ if: $VX_DTE_SIZE == "linux.small" && $VX_DTE_SLOT =~ $VX_DTE_PICK }]
|
|
446
462
|
```
|
|
447
463
|
|
|
448
464
|
`gitlab({ tags })` names each size's runner tags. Each job's ID token
|
|
@@ -485,6 +501,13 @@ use your own instead.
|
|
|
485
501
|
done. The run starts a worker of each
|
|
486
502
|
platform its work needs. A task none of whose platforms any size has
|
|
487
503
|
refuses the run before a worker starts.
|
|
504
|
+
- A task naming `exec.worker` runs only on machines of that worker type;
|
|
505
|
+
any other on the first type, in declared order, with a machine of one
|
|
506
|
+
of its platforms. So a type listed last (a costly Mac) gets only the
|
|
507
|
+
tasks no other type can take. Core refuses the run before a worker
|
|
508
|
+
starts when no type has the name, or none of its machines runs one of
|
|
509
|
+
the task's platforms. A worker that joins on its own is of the first
|
|
510
|
+
type with its platform.
|
|
488
511
|
- Tasks waiting for room go out longest tail first: a task's recorded
|
|
489
512
|
duration and its longest chain of dependants, so a long chain that got
|
|
490
513
|
ready late does not queue behind many short tasks ready at once.
|
|
@@ -501,9 +524,16 @@ use your own instead.
|
|
|
501
524
|
A sandbox the worker cannot host (no `bwrap` in its image) fails the
|
|
502
525
|
task there, as it fails here; the worker stays in the run.
|
|
503
526
|
- A persistent task a task depends on starts on the worker that runs the
|
|
504
|
-
task, once per worker, ready before it, and stops
|
|
527
|
+
task, once per worker, ready before it, and stops there once no task left
|
|
528
|
+
in the run uses it (the run's `unneeded`), or when the worker leaves;
|
|
505
529
|
the orchestrator never starts it, and a worker no dependant reaches
|
|
506
|
-
never does (2026-10-22).
|
|
530
|
+
never does (2026-10-22). Its CPU is learned on the worker each second
|
|
531
|
+
as `cores = base + each running dependant's cost`: the base from looks
|
|
532
|
+
with no dependant running, each cost from looks with it (a look on a
|
|
533
|
+
full machine only raises them). Each dependant's cost is added to its
|
|
534
|
+
CPU, so its history books it; the base is booked once on each worker
|
|
535
|
+
that starts the server, in placement and in the plan, and kept for the
|
|
536
|
+
next run under the commit, as warm-ups are.
|
|
507
537
|
- A task whose key hashes a runtime probe (`inputs.runtime`, such as
|
|
508
538
|
`bun --version`) runs on a worker, which answers the probe first and
|
|
509
539
|
fails the task when it answers otherwise than this machine did. A path
|
|
@@ -529,7 +559,9 @@ use your own instead.
|
|
|
529
559
|
deep graph's orders stay small.
|
|
530
560
|
- A task runs with the orchestrator's environment under the worker's
|
|
531
561
|
root. `HOME`, `TMPDIR`, `USER` and the like are the worker's own, unless
|
|
532
|
-
the task's `env.define` sets them
|
|
562
|
+
the task's `env.define` sets them or its `inputs.env` keys them: a keyed
|
|
563
|
+
one is the orchestrator's value, or unset where it is unset there, so
|
|
564
|
+
the worker runs on what the key saw; `npm_execpath` is the workspace's
|
|
533
565
|
package manager on the worker, as core sets it on a local task.
|
|
534
566
|
- A worker restores, by key, the declared outputs of every task the one
|
|
535
567
|
it runs depends on, each after its own dependencies (an additive task's
|
|
@@ -540,7 +572,9 @@ use your own instead.
|
|
|
540
572
|
leaves no file. One waits there up to 10 s; past that, with that worker
|
|
541
573
|
gone or of another platform, or with its uncached upstreams on two
|
|
542
574
|
workers, the upstream runs again where the dependant can go, and the
|
|
543
|
-
dependant follows it.
|
|
575
|
+
dependant follows it. One whose `exec.worker` or `exec.platforms` rules
|
|
576
|
+
out where its dependant runs refuses the run before a worker starts,
|
|
577
|
+
naming both: cache it, or run both alike. A task whose upload no remote took after three
|
|
544
578
|
tries (too big, a 429, a reset connection) is its worker's fault: that
|
|
545
579
|
worker takes no more tasks and the task runs on another; a second fault
|
|
546
580
|
fails the run. Before a task runs, its
|
|
@@ -562,20 +596,37 @@ use your own instead.
|
|
|
562
596
|
`--no-cache` or read-only run, a dependant of a failure under
|
|
563
597
|
`--continue=always`, one that left a process running that may still
|
|
564
598
|
write its outputs, one a stopped run cancelled), and one that left a tracked file under its project
|
|
565
|
-
changed on its worker (a formatter's input),
|
|
566
|
-
|
|
599
|
+
changed on its worker (a formatter's input), or that found an untracked,
|
|
600
|
+
unignored file under its project there (its outputs and its dependencies'
|
|
601
|
+
aside), which this machine never sees: core withholds its own save when
|
|
602
|
+
the key read that file or its inputs declare it, and says so.
|
|
567
603
|
- Under `--force`, a success is saved under its key (which keeps an entry
|
|
568
604
|
it already holds, as core does) and under the run's key, which this
|
|
569
605
|
machine and the dependants' workers restore: the bytes this run built,
|
|
570
606
|
never the entry it bypassed.
|
|
571
607
|
- A worker whose tracked files differ from the orchestrator's (another
|
|
572
608
|
tree, or the same commit with a tracked file changed, such as by a
|
|
573
|
-
prepare step) is refused
|
|
574
|
-
|
|
609
|
+
prepare step) is refused, and the orchestrator says so as it happens:
|
|
610
|
+
`[vx] dte: refused worker <id>: <reason>`. Both sides read their tracked files as they
|
|
611
|
+
are on disk, a file flagged `assume-unchanged` or `skip-worktree` too
|
|
612
|
+
(one a sparse checkout left off disk is its index's), so the same change
|
|
613
|
+
on both is the same tree.
|
|
614
|
+
- A worker runs the commit's bytes and modes, which are the ones the key
|
|
615
|
+
read: the run's snapshot stages each file's executable bit as on disk
|
|
616
|
+
(under `core.fileMode=false` too, as core keys it), and a worker writes
|
|
617
|
+
back as the commit holds it each file a git filter of its own checked
|
|
618
|
+
out otherwise (`text`, `eol`, `ident`, `filter` such as LFS,
|
|
619
|
+
`working-tree-encoding`, `core.autocrlf`). A task whose key hashes a
|
|
620
|
+
tracked file that is on disk other than the commit holds it (CRLF such
|
|
621
|
+
a filter wrote, an expanded ident, an LFS file's content) fails, naming
|
|
622
|
+
the file: no worker builds on those bytes.
|
|
575
623
|
- A task whose key hashes a file the run's commit lacks (untracked, or
|
|
576
624
|
gitignored and named outright, or inside a submodule, whose files the
|
|
577
625
|
commit holds only as a gitlink) fails, naming the file: no worker has it.
|
|
578
626
|
A dependency's declared output is not one: its worker restores it first.
|
|
627
|
+
A task whose project the commit lacks (its config untracked) fails,
|
|
628
|
+
naming the project, and a run whose `.vx/workspace.*` is untracked is
|
|
629
|
+
refused before a worker starts.
|
|
579
630
|
- A task's `exec.timeout` counts from its start on its worker: the wait
|
|
580
631
|
for a worker still booting is not its run (the executor declares
|
|
581
632
|
`timeouts`).
|
|
@@ -592,8 +643,14 @@ use your own instead.
|
|
|
592
643
|
- A relay that answers nothing for 10 s, every try counted, fails every task not done
|
|
593
644
|
(`the relay is unreachable`); none runs here, which may be far smaller
|
|
594
645
|
than the workers. What finished is saved, so a rerun resumes from it.
|
|
595
|
-
|
|
596
|
-
|
|
646
|
+
The footer counts each worker that had joined `lost`, with what it ran.
|
|
647
|
+
A fault (a `5xx`, a dropped or refused connection) is tried again 3
|
|
648
|
+
times, by the orchestrator and workers alike, and the third failed
|
|
649
|
+
retry ends them (`no socket in 3 dials`). The waits are random, from
|
|
650
|
+
up to 250 ms doubling to up to half a heartbeat, about 3 s at most in
|
|
651
|
+
all on the default, so a relay down or restarting is asked through
|
|
652
|
+
only when it is back within them: one down under a second usually is,
|
|
653
|
+
one down 2 s usually fails the run.
|
|
597
654
|
- The relay stores nothing, so a restart forgets the run. The orchestrator
|
|
598
655
|
joins again as its host and each worker joins again as itself, for two
|
|
599
656
|
heartbeats (4 s on the default); tasks keep running meanwhile, and what
|
|
@@ -601,9 +658,12 @@ use your own instead.
|
|
|
601
658
|
keeps what it runs, and one sent a task it runs already
|
|
602
659
|
does not run it again, so no work repeats. One not back by then
|
|
603
660
|
ends: a worker's tasks run elsewhere, an orchestrator's run fails.
|
|
604
|
-
Joining has the same 10 s in all
|
|
661
|
+
Joining has the same 10 s in all and the same 3 retries, its waits
|
|
662
|
+
spread over the 10 s (1.25 s doubling, jittered to half: 4.4 to 8.75 s
|
|
663
|
+
in all), so a relay or network down a few seconds is still joined. A
|
|
605
664
|
relay silent that long fails the run with `cannot connect to the relay
|
|
606
|
-
<origin>: no answer in 10 s
|
|
665
|
+
<origin>: no answer in 10 s`; four failed tries say why and how long,
|
|
666
|
+
as `cannot connect to the relay <origin>: ENOTFOUND, 4 dials in 9.1 s`.
|
|
607
667
|
- A worker that never joins says why on its row (`failed exited 1:
|
|
608
668
|
…`); its job ending, a success too, is what says so. Once the first
|
|
609
669
|
worker of a size joins, each other of that size not joined within twice
|
|
@@ -620,8 +680,8 @@ use your own instead.
|
|
|
620
680
|
- Only `leave` ends a participant at once (2026-10-25): the
|
|
621
681
|
orchestrator's at its run's end is the final call that closes the run,
|
|
622
682
|
and a worker's when it is done. Either whose socket drops without it
|
|
623
|
-
dials again, 3 times within 10 s, and the relay keeps it,
|
|
624
|
-
for
|
|
683
|
+
dials again, 3 times as above and within 10 s, and the relay keeps it,
|
|
684
|
+
and its run, for 10 s; past either the run is over. Either side hearing nothing
|
|
625
685
|
from the other for 3 heartbeats (6 s on the default) loses it, and the
|
|
626
686
|
rest follows from that: a worker that said nothing for half of it (3 s)
|
|
627
687
|
sends its report; the orchestrator never speaks on a timer, only orders
|
|
@@ -664,14 +724,16 @@ on its frame, in that worker's own color, by size and the order the run
|
|
|
664
724
|
started it:
|
|
665
725
|
|
|
666
726
|
```
|
|
667
|
-
⏺︎ 308ms success miss c#build · small#1
|
|
727
|
+
⏺︎ 308ms success miss c#build · linux.small#1
|
|
668
728
|
```
|
|
669
729
|
|
|
670
730
|
An idle worker the rest of the run cannot keep busy leaves (`left`): one
|
|
671
731
|
beyond the peak of the cores the remaining tasks keep busy, so a lull
|
|
672
732
|
between two wide parts keeps it. One a remaining task needs for its size
|
|
673
733
|
stays, and so does one a later task needs when its size's `max` is
|
|
674
|
-
spent, since none could start in its place.
|
|
734
|
+
spent, since none could start in its place. With that `max` spent, the
|
|
735
|
+
last worker of its platform also stays while any task of that platform
|
|
736
|
+
has not ended: a hit whose restore fails runs after all. A lost worker's tasks run again on
|
|
675
737
|
another, and the run starts one in its place. A worker is lost when its
|
|
676
738
|
socket closes, when it says nothing for 3 heartbeats (6 s), or at
|
|
677
739
|
once when its adapter sees its process or job end. Workers start at the first
|
|
@@ -684,7 +746,7 @@ workspace's cache under its commit, and the next commit's run plans by it
|
|
|
684
746
|
before any worker of that size joins.
|
|
685
747
|
|
|
686
748
|
The `workers` rows, live while the run goes and again in the footer,
|
|
687
|
-
give each worker its status (`starting`, `online`, `left`, `
|
|
749
|
+
give each worker its status (`starting`, `online`, `left`, `lost`, or `failed`
|
|
688
750
|
with why, such as `exited 1: Cannot find package '@vzn/vx-dte'`; once the
|
|
689
751
|
run is over, one still starting is `not joined`), the tasks it
|
|
690
752
|
has done, failed or had aborted by a stop, and the ones it runs now; while the run goes, those that left
|
|
@@ -702,28 +764,32 @@ declares and its machine reports, green when they agree and red when
|
|
|
702
764
|
not:
|
|
703
765
|
|
|
704
766
|
```
|
|
705
|
-
workers small#1 online 3 done · 2 running
|
|
706
|
-
small#2 starting
|
|
767
|
+
workers linux.small#1 online 3 done · 2 running
|
|
768
|
+
linux.small#2 starting
|
|
707
769
|
local online 1 done
|
|
708
770
|
```
|
|
709
771
|
|
|
710
772
|
```
|
|
711
|
-
workers small#1 stopped 5 done · warm-up 0.1s
|
|
773
|
+
workers linux.small#1 stopped 5 done · warm-up 0.1s
|
|
712
774
|
declared 2c/8G · actual 4c/16G
|
|
713
|
-
small#2 lost 1 done · warm-up 0.2s
|
|
775
|
+
linux.small#2 lost 1 done · warm-up 0.2s
|
|
714
776
|
declared 2c/8G · actual 4c/16G
|
|
715
777
|
local online 1 done
|
|
716
778
|
actual 10c/32G
|
|
717
779
|
```
|
|
718
780
|
|
|
719
781
|
A run over that started none (every task a hit) says
|
|
720
|
-
`workers none started: no task needed one
|
|
782
|
+
`workers none started: no task needed one`; one where a task waited
|
|
783
|
+
on a worker and none joined says
|
|
784
|
+
`workers none joined: a task waited on one`, or, when the run refused
|
|
785
|
+
workers, `workers none joined: 2 refused (<first reason>)`.
|
|
721
786
|
|
|
722
787
|
Under each worker, and `local`, the footer then spells out what it did:
|
|
723
788
|
its time online, busy and idle; its CPU peak and average in cores and
|
|
724
789
|
the cores the run booked for the tasks it was running, on average (a
|
|
725
|
-
booked from a task's start to its command's end); its cpu utilization
|
|
726
|
-
|
|
790
|
+
booked from a task's start to its command's end); its cpu utilization from its first task's send to its last
|
|
791
|
+
one's end, the warm-up and the wait for a first task left out: its machine's (any process's busy core-time over
|
|
792
|
+
the core-time it had, so what is left is idle cores), its tasks' and its worker process's; its memory peak and average, when its tasks reported any; and, for a worker, the messages and
|
|
727
793
|
bytes the run sent it and received from it. The `probe` line says when
|
|
728
794
|
core's cache probe answered, how many tasks it left to run (held in no
|
|
729
795
|
cache, or not answered for) and how many of them it found in none. The `asked` line says each
|
|
@@ -740,23 +806,24 @@ the worker started, any process's, as its last frame said. Where the
|
|
|
740
806
|
rest went, the worker says in its own log as it leaves: `cpu over 61.2s:
|
|
741
807
|
machine 2.80 cores · tasks 2.00 cores · this worker 0.15 cores · its other
|
|
742
808
|
children 0.05 cores · most besides: Runner.Worker 0.30 cores`, the last
|
|
743
|
-
from one `ps` as it starts and one as it leaves. The `total` line is the cpu utilization of every
|
|
809
|
+
from one `ps` as it starts and one as it leaves. The `total` line is the same cpu utilization of every
|
|
744
810
|
machine together, and the share of their history the run's tasks used:
|
|
745
811
|
|
|
746
812
|
```
|
|
747
|
-
small#1 stopped 5 done · warm-up 0.2s
|
|
813
|
+
linux.small#1 stopped 5 done · warm-up 0.2s
|
|
748
814
|
declared 4c/14G · actual 4c/14G
|
|
749
815
|
online 12.1s · busy 9.8s · idle 2.3s
|
|
750
|
-
cpu peak 3.6 · used avg 2.4 cores · machine avg 2.9 · booked avg 3.8
|
|
816
|
+
cpu peak 3.6 · used avg 2.4 cores · machine avg 2.9 · booked avg 3.8
|
|
817
|
+
cpu utilization machine 78% · tasks 64% · worker 2%
|
|
751
818
|
memory peak 1.2 GB · avg 640.0 MB
|
|
752
819
|
sent 6 messages · 3.1 MB, received 15 messages · 8.4 MB
|
|
753
820
|
probe at 0.4s: 5 tasks left to run, 5 known misses
|
|
754
821
|
asked 1 at 0.5s for 5 tasks (5 measured), plan 12.0s
|
|
755
822
|
ideal 10.8s: each task as long as it took, placed perfectly
|
|
756
|
-
best 2 × small (4c/14G): 11.4s, the fewest of its sizes
|
|
757
|
-
more 3 × small (4c/14G): 10.8s, past its sizes' max
|
|
823
|
+
best 2 × linux.small (4c/14G): 11.4s, the fewest of its sizes
|
|
824
|
+
more 3 × linux.small (4c/14G): 10.8s, past its sizes' max
|
|
758
825
|
any 1 × (8c/28G): 10.9s, of any cores, the least core time
|
|
759
|
-
total cpu utilization
|
|
826
|
+
total cpu utilization machine 74% · tasks 60% · worker 2%
|
|
760
827
|
```
|
|
761
828
|
|
|
762
829
|
`vx run --profile[=<path>]` adds the run in time to the profile it
|
|
@@ -830,7 +897,12 @@ What `@vzn/vx-dte` reads from the CI job it runs in:
|
|
|
830
897
|
process.env.CF_ACCESS_CLIENT_ID, … }`); an unset value is left out, and
|
|
831
898
|
a name the dial sets itself (`authorization`, `content-type`,
|
|
832
899
|
`relay-proof`) is refused. The rest:
|
|
833
|
-
- `workers` names each `
|
|
900
|
+
- `workers` names each worker type, a `WorkerType` `{ machines }`, in order of
|
|
901
|
+
preference (`exec.worker` picks one). Each machine is a size the
|
|
902
|
+
adapter may start, named `<type>.<machine>` (`linux.small`) in the
|
|
903
|
+
run's output and an adapter's options, and is a `WorkerSize`,
|
|
904
|
+
`{ cpu, memory, max?, platform?, runsOn? }` (`runsOn`: the runner
|
|
905
|
+
label `vx dte init` writes for it): its cores
|
|
834
906
|
and memory (bytes, or `'8GB'`, `'512MiB'`), which the run counts on
|
|
835
907
|
when it starts one, until the worker reports what its machine has,
|
|
836
908
|
how many of it one run may start (absent, no cap), and its machines'
|
|
@@ -852,9 +924,11 @@ process.env.CF_ACCESS_CLIENT_ID, … }`); an unset value is left out, and
|
|
|
852
924
|
That margin is spent once: later plans keep its deadline until new
|
|
853
925
|
work is known. A task waiting on one that runs waits until it ends.
|
|
854
926
|
Then the run follows that plan: a start it wants later is asked when
|
|
855
|
-
due, and a worker idle with its planned work done goes
|
|
927
|
+
due, and a worker idle with its planned work done goes, once every
|
|
928
|
+
task planned on it has started. It plays again
|
|
856
929
|
only when reality leaves the plan: new work, work that leaves without
|
|
857
|
-
running, a worker
|
|
930
|
+
running, a worker idle at its planned end with a task planned on it not
|
|
931
|
+
started, a worker that joins, leaves or warms up off plan, a task past
|
|
858
932
|
its time or ending sooner, one of no known time ending, a task starting
|
|
859
933
|
with its time, CPU or memory more than 5% off (1 s, 0.05 cores, 64 MiB
|
|
860
934
|
at least), or its window moving on.
|
|
@@ -938,7 +1012,7 @@ HEAD^`), and every task after one (`src/forecast.ts`): a lockfile or
|
|
|
938
1012
|
- `DteAdapter` is `{ spawn(worker: WorkerSpawn), relay?(), close?(), cancel?(ids), init?(setup: DteSetup), runId?(), adopt?(worker), report?(warn), keyless?, workerRuns?() }`; `init` gets a `DteSetup` (workspace root, its directory in the repository, relay, sizes, the `PackageManager` read from the lockfile, the tasks asked for) and returns a `DteInit`: the CI files `vx dte init` writes and the steps it prints; `report` hands it the run's warning channel as the run starts, where `github()` says a status poll or a cancel the CI API refused, by status and body; `adopt` hands each worker the CI started before the run to `worker(id, size)`, which counts it and returns its spawn; `cancel` stops workers the run no longer needs that have not joined, and may answer with those it left running, which the run takes (with `late`, workers given up on joining, stopped whatever their job holds); `runId` names the run, in the adapter's own CI, when `VX_DTE_RUN` is unset; `workerId` names a worker in a job its CI started for several, when `VX_DTE_WORKER` is unset; `keyless` workers prove themselves by GitHub OIDC, and `workerRuns` names the workflow runs whose jobs alone may join as them (`undefined`: any job of the repository).
|
|
939
1013
|
`WorkerSpawn` is `{ run, relay, size, max, cpu, memory, index, id, workspaceRoot, commit, cacheDir?, env }`,
|
|
940
1014
|
with `memory` in bytes and `max` its size's (`Infinity` uncapped),
|
|
941
|
-
where `id` is the worker's name (`small#2`, numbered within its size), `commit` the
|
|
1015
|
+
where `id` is the worker's name (`linux.small#2`, numbered within its size), `commit` the
|
|
942
1016
|
tracked files as the run saw them at its start (uncommitted changes included), whose
|
|
943
1017
|
tree a worker must check out, `cacheDir` the run's cache dir (absolute) when the
|
|
944
1018
|
workspace names one, for workers on this machine to save where the run reads, and
|
|
@@ -947,10 +1021,9 @@ HEAD^`), and every task after one (`src/forecast.ts`): a lockfile or
|
|
|
947
1021
|
of its own on loopback. `DockerOptions` is `{ image, prepare? }`.
|
|
948
1022
|
- `github(options?: GithubOptions)` dispatches workers as matrix jobs of
|
|
949
1023
|
one workflow, one dispatch per size and demand (at most 256 workers,
|
|
950
|
-
a matrix's limit). `GithubOptions` is `{ workflow?,
|
|
1024
|
+
a matrix's limit). `GithubOptions` is `{ workflow?, env? }`:
|
|
951
1025
|
`workflow` defaults to `vx-worker.yml`, a file name in
|
|
952
|
-
`.github/workflows` other than `ci.yml`, the run's own; `
|
|
953
|
-
of each size, which `vx dte init` writes; `env` is variables every
|
|
1026
|
+
`.github/workflows` other than `ci.yml`, the run's own; `env` is variables every
|
|
954
1027
|
worker gets (one-line strings, no secret-named one: the worker job's
|
|
955
1028
|
log shows them), sent as the
|
|
956
1029
|
dispatch's `env` input only when set, which `vx dte init`'s workflow
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-dte",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.653",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Distributed task execution for vx: one run spread over workers your own CI spawns, joined through a relay.",
|
|
6
6
|
"keywords": [
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
"bun": ">=1.4.4"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@vzn/vx-dte-server": "0.0.
|
|
32
|
+
"@vzn/vx-dte-server": "0.0.653"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"@vzn/vx": "^0.0.
|
|
35
|
+
"@vzn/vx": "^0.0.653"
|
|
36
36
|
},
|
|
37
37
|
"repository": {
|
|
38
38
|
"type": "git",
|
package/src/dispatch.ts
CHANGED
|
@@ -157,6 +157,12 @@ export interface Need {
|
|
|
157
157
|
* free core, but a run of only such tasks still asks enough workers.
|
|
158
158
|
*/
|
|
159
159
|
readonly ask?: { readonly cores: number; readonly memory: number } | undefined
|
|
160
|
+
/**
|
|
161
|
+
* Its persistent dependencies' bases, cores (`server-fit.ts`): a worker
|
|
162
|
+
* not running one yet books its base once beside the task, and keeps it
|
|
163
|
+
* booked while it serves.
|
|
164
|
+
*/
|
|
165
|
+
readonly servers?: readonly { readonly id: string; readonly cores: number }[] | undefined
|
|
160
166
|
}
|
|
161
167
|
|
|
162
168
|
/** A worker as dispatch sees it: its size and what is in use on it now. */
|
|
@@ -172,6 +178,8 @@ export interface Bin {
|
|
|
172
178
|
readonly has?: ReadonlySet<string>
|
|
173
179
|
/** Its machine's platform (`linux-x64`), as it reported or its size declares. */
|
|
174
180
|
readonly platform?: string | undefined
|
|
181
|
+
/** The servers it runs, each base booked: a task needing one books no base here. */
|
|
182
|
+
readonly serving?: Set<string>
|
|
175
183
|
}
|
|
176
184
|
|
|
177
185
|
export interface Ask {
|
|
@@ -193,7 +201,14 @@ export interface Ask {
|
|
|
193
201
|
|
|
194
202
|
/** What `Ask.kind` names: every field of a need `assign` reads. */
|
|
195
203
|
export function kindOf(need: Need): string {
|
|
196
|
-
return `${need.cores}\0${need.memory}\0${need.above?.cores}\0${need.above?.memory}\0${need.platform}`
|
|
204
|
+
return `${need.cores}\0${need.memory}\0${need.above?.cores}\0${need.above?.memory}\0${need.platform}\0${need.servers?.map((s) => s.id).join('\0')}`
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The cores `need` books on `bin`: its own, and the base of each server `bin` does not run yet. */
|
|
208
|
+
function coresOn(bin: Bin, need: Need): number {
|
|
209
|
+
let cores = need.cores
|
|
210
|
+
for (const s of need.servers ?? []) if (bin.serving?.has(s.id) !== true) cores += s.cores
|
|
211
|
+
return cores
|
|
197
212
|
}
|
|
198
213
|
|
|
199
214
|
/**
|
|
@@ -204,9 +219,10 @@ export function kindOf(need: Need): string {
|
|
|
204
219
|
export function fits(bin: Bin, need: Need): boolean {
|
|
205
220
|
if (need.platform !== undefined && bin.platform !== need.platform) return false
|
|
206
221
|
if (bin.running === 0) return true
|
|
222
|
+
const cores = coresOn(bin, need)
|
|
207
223
|
return (
|
|
208
|
-
|
|
209
|
-
bin.used.cores +
|
|
224
|
+
cores < bin.cores &&
|
|
225
|
+
bin.used.cores + cores <= bin.cores + 1e-9 &&
|
|
210
226
|
bin.used.memory + need.memory <= bin.memory
|
|
211
227
|
)
|
|
212
228
|
}
|
|
@@ -313,7 +329,8 @@ export function assign<B extends Bin>(
|
|
|
313
329
|
}
|
|
314
330
|
spent.clear()
|
|
315
331
|
out.set(ask.id, best)
|
|
316
|
-
best.used.cores += Math.min(need
|
|
332
|
+
best.used.cores += Math.min(coresOn(best, need), best.cores)
|
|
333
|
+
for (const sv of need.servers ?? []) best.serving?.add(sv.id)
|
|
317
334
|
best.used.memory += Math.min(need.memory, best.memory)
|
|
318
335
|
best.running++
|
|
319
336
|
rebound()
|
|
@@ -486,6 +503,24 @@ export class Queue {
|
|
|
486
503
|
if (!front) this.back = seq
|
|
487
504
|
}
|
|
488
505
|
|
|
506
|
+
/**
|
|
507
|
+
* History changed (`tail`, `need`): the waiting rank and group by it,
|
|
508
|
+
* ties in the order they stood. A rank is read once, as a task joins:
|
|
509
|
+
* on CI the roots join before the default branch's timings land, each
|
|
510
|
+
* at an unknown task's 30 s, and every test shard ready later ranked
|
|
511
|
+
* above them, so `test.wire` (73 s) waited 129 s for a worker (main
|
|
512
|
+
* 1c91c9d, 2026-11-07). `graphene` reads ranks at each pass.
|
|
513
|
+
*/
|
|
514
|
+
rerank(): void {
|
|
515
|
+
if (this.list !== undefined) return
|
|
516
|
+
const ids = this.ids()
|
|
517
|
+
this.groups.clear()
|
|
518
|
+
this.of.clear()
|
|
519
|
+
this.front = 0
|
|
520
|
+
this.back = 0
|
|
521
|
+
this.add(ids)
|
|
522
|
+
}
|
|
523
|
+
|
|
489
524
|
delete(id: string): boolean {
|
|
490
525
|
if (this.list !== undefined) {
|
|
491
526
|
const at = this.list.indexOf(id)
|
package/src/github.ts
CHANGED
|
@@ -14,8 +14,6 @@ import { printable } from './printable.js'
|
|
|
14
14
|
export interface GithubOptions {
|
|
15
15
|
/** The workers' workflow file in `.github/workflows` (default `vx-worker.yml`). */
|
|
16
16
|
readonly workflow?: string
|
|
17
|
-
/** The runner label of each size, by size name, which `vx dte init` writes; absent, its platform's GitHub-hosted runner. */
|
|
18
|
-
readonly runsOn?: Readonly<Record<string, string>>
|
|
19
17
|
/**
|
|
20
18
|
* Variables the run hands its workers, as the workflow's `env` input (a
|
|
21
19
|
* JSON object): a value the run's job decides, such as a server's URL
|
|
@@ -33,7 +31,6 @@ interface Job {
|
|
|
33
31
|
|
|
34
32
|
const KEYS: PluginOptionKinds<GithubOptions> = {
|
|
35
33
|
workflow: 'string',
|
|
36
|
-
runsOn: 'object',
|
|
37
34
|
env: 'object',
|
|
38
35
|
}
|
|
39
36
|
|
package/src/index.ts
CHANGED
package/src/init.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface DteSetup {
|
|
|
25
25
|
/** Absent: as many as the work needs. */
|
|
26
26
|
readonly max?: number
|
|
27
27
|
readonly platform?: Platform
|
|
28
|
+
/** The runner label its machine names (`WorkerSize.runsOn`). */
|
|
29
|
+
readonly runsOn?: string
|
|
28
30
|
}[]
|
|
29
31
|
/** The workspace's package manager, by its lockfile. */
|
|
30
32
|
readonly manager: PackageManager
|
|
@@ -45,12 +47,14 @@ export interface DteInit {
|
|
|
45
47
|
/** `owner/repo` of the `origin` remote and its host: what the relay's ALLOW names. */
|
|
46
48
|
export function originOf(root: string): { repository?: string; host?: string } {
|
|
47
49
|
const r = Bun.spawnSync(['git', 'config', '--get', 'remote.origin.url'], { cwd: root })
|
|
48
|
-
|
|
50
|
+
// No credential reaches the written files: a query (`?private_token=`)
|
|
51
|
+
// is dropped, and a URL's userinfo ends at its last `@` (a password may hold one).
|
|
52
|
+
const url = (r.exitCode === 0 ? r.stdout.toString().trim() : '').replace(/[?#].*$/s, '')
|
|
49
53
|
const m = /[:/]([^:]+?\/[^/]+?)(?:\.git)?\/?$/.exec(url.replace(/^[a-z+]+:\/\/[^/]+\//, '/'))
|
|
50
54
|
// An https URL's port is its issuer's; an ssh one's is not.
|
|
51
55
|
const host =
|
|
52
|
-
/^https?:\/\/(?:[
|
|
53
|
-
/^[a-z+]+:\/\/(?:[
|
|
56
|
+
/^https?:\/\/(?:[^/]*@)?([^/]+)/.exec(url)?.[1] ??
|
|
57
|
+
/^[a-z+]+:\/\/(?:[^/]*@)?([^/:]+)/.exec(url)?.[1] ??
|
|
54
58
|
/^(?:[^@/:]*@)?([^/:]+):/.exec(url)?.[1]
|
|
55
59
|
return {
|
|
56
60
|
...(m?.[1] === undefined ? {} : { repository: m[1] }),
|
|
@@ -249,12 +253,7 @@ const RUNNERS: Record<Platform, string> = {
|
|
|
249
253
|
export function githubInit(options: GithubOptions, setup: DteSetup): DteInit {
|
|
250
254
|
const workflow = options.workflow ?? 'vx-worker.yml'
|
|
251
255
|
const runners = Object.fromEntries(
|
|
252
|
-
setup.sizes.map((s) =>
|
|
253
|
-
const label: unknown = options.runsOn?.[s.name]
|
|
254
|
-
if (label !== undefined && (typeof label !== 'string' || label === ''))
|
|
255
|
-
throw new UserError(`vx dte init: github() runsOn.${s.name} is a runner label`)
|
|
256
|
-
return [s.name, label ?? RUNNERS[s.platform ?? 'linux-x64']]
|
|
257
|
-
}),
|
|
256
|
+
setup.sizes.map((s) => [s.name, s.runsOn ?? RUNNERS[s.platform ?? 'linux-x64']]),
|
|
258
257
|
)
|
|
259
258
|
// A workspace in a subdirectory: CI files live at the repository's root,
|
|
260
259
|
// and their commands run in the workspace.
|
|
@@ -448,7 +447,7 @@ export function gitlabInit(options: GitlabOptions, setup: DteSetup): DteInit {
|
|
|
448
447
|
// A matrix takes at most 200 jobs; a wave picks its slots.
|
|
449
448
|
if (s.max === undefined || s.max < 1 || s.max > 200)
|
|
450
449
|
throw new UserError(
|
|
451
|
-
`vx dte init: gitlab() needs workers.${s.name}.max, 1 to 200: its job is a matrix of that many slots`,
|
|
450
|
+
`vx dte init: gitlab() needs workers.${s.name.replace('.', '.machines.')}.max, 1 to 200: its job is a matrix of that many slots`,
|
|
452
451
|
)
|
|
453
452
|
const slots = Array.from({ length: s.max }, (_, i) => quoted(String(i + 1)))
|
|
454
453
|
return `
|