@vzn/vx-dte 0.0.0 → 0.0.649
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 +1020 -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 +72 -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/README.md
CHANGED
|
@@ -1,3 +1,925 @@
|
|
|
1
1
|
# @vzn/vx-dte
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Website: <https://vznjs.github.io/vx/> · source: <https://github.com/vznjs/vx>
|
|
4
|
+
|
|
5
|
+
Distributed task execution for vx: one `vx run` spread over workers your
|
|
6
|
+
own CI starts, replayed here as one run. The run you start is the
|
|
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. A worker runs it in its own checkout and saves the task's
|
|
9
|
+
output and the files its declared outputs left to the workspace's cache,
|
|
10
|
+
under the task's key. They land here before the task ends, so the run
|
|
11
|
+
reports the task as its own. No file travels through the relay: it
|
|
12
|
+
carries only orders and status, so the workers and this machine need one
|
|
13
|
+
cache they all read (a remote the workers write, or one shared store). A worker reads that cache as the run does:
|
|
14
|
+
under the run's refs, with the short-lived access the run's remote lends
|
|
15
|
+
it (an OIDC exchange's token, never a stored secret) when it has none of
|
|
16
|
+
its own.
|
|
17
|
+
|
|
18
|
+
Workers and the orchestrator meet at a relay
|
|
19
|
+
([`@vzn/vx-dte-server`](../vx-dte-server/README.md)), since CI runners
|
|
20
|
+
take no inbound connection. It speaks the
|
|
21
|
+
[Switchboard protocol](../vx-dte-server/PROTOCOL.md): the run is a
|
|
22
|
+
session, its id the run id, the orchestrator its host and each worker a
|
|
23
|
+
peer. Host your own: its
|
|
24
|
+
[README](../vx-dte-server/README.md) has a Deploy to Cloudflare button,
|
|
25
|
+
which asks only for the repositories whose runs it carries.
|
|
26
|
+
|
|
27
|
+
A run id is a channel. Workers join it before or after the orchestrator
|
|
28
|
+
does, and the orchestrator claims it with a key made for that run; the
|
|
29
|
+
relay keeps the public half and passes nothing to or from a side that
|
|
30
|
+
cannot sign by it. The orchestrator checks each worker the moment it
|
|
31
|
+
appears and removes any without the run's key from the channel.
|
|
32
|
+
|
|
33
|
+
The relay reads nothing they say. Each worker and the orchestrator run a
|
|
34
|
+
[Noise](https://noiseprotocol.org/noise.html) handshake through it, one
|
|
35
|
+
session per worker, with keys made for that session only. With the run's
|
|
36
|
+
key (`VX_DTE_SECRET`, or one an adapter made for its workers) it is `Noise_NNpsk0_25519_AESGCM_SHA256`: the key is the
|
|
37
|
+
pre-shared key, so a side without it cannot finish the handshake, and the
|
|
38
|
+
worker's `join` info (cores, memory, tree) is already encrypted. On GitHub
|
|
39
|
+
Actions with no key it is `Noise_NN_25519_AESGCM_SHA256`, and each side
|
|
40
|
+
binds the handshake by an OIDC token of this repository (`id-token:
|
|
41
|
+
write`): no secret is stored anywhere. With no adapter and no GitHub, set
|
|
42
|
+
the same `VX_DTE_SECRET` on the run and each worker. A message the relay
|
|
43
|
+
changes loses its worker; one it replays is dropped. The key never
|
|
44
|
+
encrypts a message, so a leaked one opens no past run.
|
|
45
|
+
|
|
46
|
+
It runs workers with `local()`, `docker()`, `github()` and `gitlab()`.
|
|
47
|
+
|
|
48
|
+
### Bodies
|
|
49
|
+
|
|
50
|
+
What vx-dte puts in Switchboard's `info` and `body`, for anyone writing a
|
|
51
|
+
worker or orchestrator of their own. Each worker and the orchestrator run
|
|
52
|
+
a Noise handshake through the relay, prologue `vx-dte\0<run>`, the worker
|
|
53
|
+
the initiator.
|
|
54
|
+
|
|
55
|
+
- With the run's key: `Noise_NNpsk0_25519_AESGCM_SHA256`, the psk the
|
|
56
|
+
key's SHA-256.
|
|
57
|
+
- Keyless on GitHub Actions: `Noise_NN_25519_AESGCM_SHA256`. The worker's
|
|
58
|
+
`join` carries an OIDC token whose audience is
|
|
59
|
+
`vx-dte:<base64url SHA-256 of "join\0<run>\0<message 1>">`; the
|
|
60
|
+
orchestrator's `hello` one for `"hello\0<run>\0<handshake hash>"`. Each
|
|
61
|
+
side checks the other's as a token of the same `repository_id`; with
|
|
62
|
+
`github()`, the orchestrator also checks a worker's `run_id` is a
|
|
63
|
+
workflow run it dispatched, since the run's id is a dispatch input
|
|
64
|
+
anyone who reads the repository's Actions sees. A dispatch GitHub
|
|
65
|
+
answers without a run id (204) is refused, so its workers exit.
|
|
66
|
+
|
|
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 | `{ "seal": ["<n>.<ciphertext>", …] }` |
|
|
73
|
+
|
|
74
|
+
The orchestrator checks each worker the moment the relay says it joined,
|
|
75
|
+
and `remove`s one whose handshake fails. Message 1's payload is the
|
|
76
|
+
worker's info (JSON), encrypted under the psk. After the handshake every
|
|
77
|
+
body is Noise transport messages, base64, each at most 65535 bytes and
|
|
78
|
+
naming its nonce `n` (Noise §11.4, since a relay may reorder); each is
|
|
79
|
+
sealed with associated data `<first n>.<parts>` of its body, so no part
|
|
80
|
+
opens in another body, and a body's parts take consecutive nonces, so
|
|
81
|
+
none is repeated or reordered in it. A body whose first nonce was seen before is a
|
|
82
|
+
replay and is dropped; one older than a body already opened (the relay
|
|
83
|
+
held it back: the wire's numbers are outside the seal) or that does not
|
|
84
|
+
open loses its sender. The
|
|
85
|
+
plaintext is JSON, or over 256 bytes and smaller so, a `0` byte, the
|
|
86
|
+
zstd frame's length (4 bytes, big-endian) and the frame: compressed
|
|
87
|
+
before sealing, since no relay or socket compresses what is sealed. It
|
|
88
|
+
is padded with trailing spaces to its size class, a power of two from
|
|
89
|
+
256 bytes, then whole transport messages, so the relay learns no size
|
|
90
|
+
within a class. Inside, each send is numbered and acknowledged
|
|
91
|
+
end to end, and a quiet peer probed (Switchboard § Delivery, § Liveness).
|
|
92
|
+
|
|
93
|
+
## Try it
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
bun add -d @vzn/vx-dte
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
// .vx/workspace.ts
|
|
101
|
+
import { defineWorkspace } from '@vzn/vx/config'
|
|
102
|
+
import { dte, local } from '@vzn/vx-dte'
|
|
103
|
+
|
|
104
|
+
export default defineWorkspace({
|
|
105
|
+
plugins: [
|
|
106
|
+
dte({
|
|
107
|
+
workers: { small: { max: 4, cpu: 2, memory: '8GB' } },
|
|
108
|
+
adapter: local({ prepare: 'bun install' }),
|
|
109
|
+
}),
|
|
110
|
+
],
|
|
111
|
+
})
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
VX_DTE_RUN=$(uuidgen) vx run build --all
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`VX_DTE_RUN` names the run and turns distribution on; without it, `vx run`
|
|
119
|
+
runs here as always (on GitHub Actions, `github()` with a relay names one
|
|
120
|
+
itself). It is a random UUID, so no one can guess a run to
|
|
121
|
+
open it first or join it; vx and the relay refuse any other. `local()` starts each worker as a process in its own
|
|
122
|
+
git worktree of the tracked files as they are, uncommitted changes
|
|
123
|
+
included (untracked files are not: a task that keys one fails; a run with
|
|
124
|
+
an unmerged index, mid-merge, is refused), at the
|
|
125
|
+
workspace's place in it (a workspace in a subdirectory of its repository too),
|
|
126
|
+
saving to the run's store (a relative `cache.dir` or `--cache-dir` too).
|
|
127
|
+
A run from a git hook works too: the worktree and its worker drop the
|
|
128
|
+
hook's repository variables (`git rev-parse --local-env-vars`), so the
|
|
129
|
+
index being committed is left as it is. When a worker fails, its row names
|
|
130
|
+
the error line and the log that holds its whole stderr and all `prepare`
|
|
131
|
+
printed. At the run's end,
|
|
132
|
+
or on Ctrl-C, vx exits without waiting for the worktrees to be removed:
|
|
133
|
+
a detached shell removes them and prunes git's records.
|
|
134
|
+
|
|
135
|
+
`docker()` starts each worker as a container held to its size: `--cpus`
|
|
136
|
+
and `--memory` come from `cpu` and `memory`. The container clones this
|
|
137
|
+
checkout (mounted read-only) into its own filesystem at its tracked
|
|
138
|
+
files as they are, uncommitted changes too, runs
|
|
139
|
+
`prepare`, then the workspace's own `node_modules/.bin/vx dte worker`, so
|
|
140
|
+
the image needs Bun and git, and `prepare` installs vx. This machine's store
|
|
141
|
+
(`~/.vx`) and a cache dir the workspace names (`cache.dir`, `VX_CACHE_DIR`, `--cache-dir`)
|
|
142
|
+
are mounted at their own paths, and the
|
|
143
|
+
clone keeps this checkout's `origin`, so the workers save where the run
|
|
144
|
+
reads, as `local()`'s do; no remote is needed. On Linux a container runs
|
|
145
|
+
as this user (its home is `/tmp`, where the store is mounted), so what it
|
|
146
|
+
writes is this user's and git trusts the mounted checkout. A container that fails
|
|
147
|
+
names its error line and the log of its whole stderr and all `prepare` printed:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
dte({
|
|
151
|
+
workers: { small: { max: 4, cpu: 2, memory: '4GB' } },
|
|
152
|
+
adapter: docker({ image: 'my/bun-git', prepare: 'bun install' }),
|
|
153
|
+
})
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The relay stays on loopback: on Linux a container joins the host's
|
|
157
|
+
network; elsewhere it reaches the relay through `host.docker.internal`.
|
|
158
|
+
A container ends with its worker; one still up when the run ends is
|
|
159
|
+
removed.
|
|
160
|
+
|
|
161
|
+
With no adapter, vx starts no workers: the run uses whichever join its
|
|
162
|
+
relay. In three terminals on one checkout's commit:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
// .vx/workspace.ts
|
|
166
|
+
export default defineWorkspace({ plugins: [dte({ relay: 'http://127.0.0.1:8080' })] })
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
bun /path/to/vx/packages/vx-dte-server/src/bun.ts --no-auth --port 8080 # the relay
|
|
171
|
+
export VX_DTE_SECRET=$(openssl rand -base64 32) # in each terminal: the same one
|
|
172
|
+
export VX_DTE_RUN=$(uuidgen) # in each terminal: the same one
|
|
173
|
+
vx run build --all # the run, first
|
|
174
|
+
vx dte worker # a worker, as many as you like
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
A worker started before its run finds no run on the relay: it says so
|
|
178
|
+
and exits.
|
|
179
|
+
|
|
180
|
+
A worker prints its name, the run, the relay and its machine, then a row
|
|
181
|
+
as each task starts and ends:
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
─ vx dte worker ───────────────────────────────────────────────
|
|
185
|
+
worker worker#1
|
|
186
|
+
run 5f0c7a52-…
|
|
187
|
+
relay http://127.0.0.1:8080
|
|
188
|
+
machine 10 cores · 17.2 GB
|
|
189
|
+
› b#build running
|
|
190
|
+
✓ b#build 308ms
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## On GitHub
|
|
194
|
+
|
|
195
|
+
The run is your own job's `vx run`. When it needs workers it dispatches
|
|
196
|
+
`.github/workflows/vx-worker.yml` once per size, with the workers of that
|
|
197
|
+
size as a matrix, each job named for its worker (`name: ${{ matrix.worker }}`):
|
|
198
|
+
a job that ends badly before its worker joins marks that worker `failed`
|
|
199
|
+
with the step it failed at, and the run starts another in its place. When the run ends it cancels each worker run it
|
|
200
|
+
dispatched, so one still queued does not hold a runner the next run
|
|
201
|
+
waits for. Every job of a repository shares its `GITHUB_TOKEN`'s API
|
|
202
|
+
limit, 1000 requests an hour, so the run reads its workers' jobs at most
|
|
203
|
+
every 30 s, asks with each answer's ETag (an unchanged answer is free),
|
|
204
|
+
and asks nothing until a spent limit resets. A dispatch the limit refuses
|
|
205
|
+
is sent again at the reset, if that comes within 5 minutes; past that it
|
|
206
|
+
fails, naming when. A cancel the limit refuses is sent again at the
|
|
207
|
+
reset, if that comes within a minute. A run that dies before it cancels
|
|
208
|
+
(a cancelled or timed-out job) leaves it to the job's last step, which
|
|
209
|
+
cancels each worker run the run listed in `RUNNER_TEMP`. Past both, a
|
|
210
|
+
worker that starts reads its run's attempt (`parent`) first, and once
|
|
211
|
+
that is over cancels its own workflow run, its queued fellows with it.
|
|
212
|
+
On GitHub Actions with a relay, the run names itself: no
|
|
213
|
+
`VX_DTE_RUN` to set. No secret: each side proves its key with the job's OIDC
|
|
214
|
+
token, and the relay's token is an OIDC token for the relay's URL. Such a
|
|
215
|
+
token lasts 5 minutes, so each side asks for a new one a minute before
|
|
216
|
+
its token expires.
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
// .vx/workspace.ts
|
|
220
|
+
export default defineWorkspace({
|
|
221
|
+
plugins: [
|
|
222
|
+
dte({
|
|
223
|
+
relay: 'https://relay.example.com',
|
|
224
|
+
workers: { small: { max: 8, cpu: 4, memory: '16GB' } },
|
|
225
|
+
adapter: github(),
|
|
226
|
+
}),
|
|
227
|
+
],
|
|
228
|
+
})
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
```yaml
|
|
232
|
+
# .github/workflows/ci.yml
|
|
233
|
+
# Written by vx dte init: the run. main and a merge queue run every task;
|
|
234
|
+
# a pull request runs what it changed and its dependents. This job only
|
|
235
|
+
# orchestrates: every task runs on a worker (vx-worker.yml).
|
|
236
|
+
name: ci
|
|
237
|
+
|
|
238
|
+
on:
|
|
239
|
+
pull_request:
|
|
240
|
+
merge_group:
|
|
241
|
+
push:
|
|
242
|
+
branches: [main]
|
|
243
|
+
|
|
244
|
+
# A newer push to a pull request cancels its older run, and its workers.
|
|
245
|
+
concurrency:
|
|
246
|
+
group: ci-${{ github.event.pull_request.number || github.ref }}
|
|
247
|
+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
248
|
+
|
|
249
|
+
jobs:
|
|
250
|
+
vx:
|
|
251
|
+
runs-on: ubuntu-latest
|
|
252
|
+
timeout-minutes: 30
|
|
253
|
+
permissions:
|
|
254
|
+
contents: read
|
|
255
|
+
# The relay and the workers: each side proves itself by its OIDC token.
|
|
256
|
+
id-token: write
|
|
257
|
+
# Dispatching and cancelling the workers.
|
|
258
|
+
actions: write
|
|
259
|
+
env:
|
|
260
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
261
|
+
steps:
|
|
262
|
+
# One commit: --affected fetches its base itself.
|
|
263
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
264
|
+
with: { persist-credentials: false }
|
|
265
|
+
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
|
266
|
+
- run: bun install --frozen-lockfile --filter './'
|
|
267
|
+
- run: bun vx lock --check
|
|
268
|
+
# Name the tasks to run, as 'bun vx run build test --affected': with
|
|
269
|
+
# none named, vx fails, listing the workspace's tasks.
|
|
270
|
+
- run: bun vx run ${{ github.event_name == 'pull_request' && '--affected' || '--all' }}
|
|
271
|
+
- name: Cancel the workers
|
|
272
|
+
if: cancelled()
|
|
273
|
+
run: |
|
|
274
|
+
[ -f "$RUNNER_TEMP/vx-dte-runs" ] || exit 0
|
|
275
|
+
while read -r run; do curl -sS -o /dev/null -X POST -H "authorization: Bearer $GITHUB_TOKEN" "$GITHUB_API_URL$run/cancel" || true; done < "$RUNNER_TEMP/vx-dte-runs"
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
`vx dte init` writes both from `dte()`, the run's above and the workers' below,
|
|
279
|
+
and prints what is left: a relay that admits them (its `ALLOW`, and how
|
|
280
|
+
to deploy one), a shared remote cache, the commit. In a terminal it asks
|
|
281
|
+
what main runs and what a pull request runs (main's by default); without
|
|
282
|
+
one it names none, and the run fails, listing the tasks, until you do. A
|
|
283
|
+
workspace in a subdirectory of its repository gets them at the repository's
|
|
284
|
+
root, its jobs running in that directory. Each size runs on
|
|
285
|
+
its platform's GitHub-hosted runner (`ubuntu-latest` for none named,
|
|
286
|
+
`ubuntu-24.04-arm`, `macos-latest`, `macos-15-intel`), or on the label
|
|
287
|
+
`github({ runsOn: { small: 'ubuntu-latest-16-cores' } })` names, as
|
|
288
|
+
`gitlab({ tags })` does. It installs with the package manager your lockfile
|
|
289
|
+
names and runs vx as `bun vx`, which runs the repo's own and never
|
|
290
|
+
downloads one, as `pnpm exec` does (Yarn too: Bun reads no Plug'n'Play,
|
|
291
|
+
so a Yarn workspace sets `nodeLinker: node-modules`). `--dry` prints it; a file you changed
|
|
292
|
+
is kept unless `--force`. For the sizes above:
|
|
293
|
+
|
|
294
|
+
```yaml
|
|
295
|
+
# .github/workflows/vx-worker.yml
|
|
296
|
+
# Written by vx dte init from dte() in .vx/workspace.ts: the workers of a
|
|
297
|
+
# DTE run, which github() dispatches once per size. Run vx dte init
|
|
298
|
+
# again after changing the sizes.
|
|
299
|
+
name: vx-worker
|
|
300
|
+
|
|
301
|
+
on:
|
|
302
|
+
workflow_dispatch:
|
|
303
|
+
inputs:
|
|
304
|
+
run: { required: true }
|
|
305
|
+
relay: { required: true }
|
|
306
|
+
sha: { required: true }
|
|
307
|
+
size: { required: true }
|
|
308
|
+
workers: { required: true }
|
|
309
|
+
# The variables github({ env }) hands the workers, as a JSON object.
|
|
310
|
+
env: { required: false, default: '{}' }
|
|
311
|
+
# The run's attempt, which a worker reads first.
|
|
312
|
+
parent: { required: false, default: '' }
|
|
313
|
+
|
|
314
|
+
permissions:
|
|
315
|
+
contents: read
|
|
316
|
+
id-token: write
|
|
317
|
+
|
|
318
|
+
jobs:
|
|
319
|
+
worker:
|
|
320
|
+
# The run reads a job that failed before its worker joined by this name.
|
|
321
|
+
name: ${{ matrix.worker }}
|
|
322
|
+
# A job's permissions replace the workflow's, so it names all three.
|
|
323
|
+
permissions:
|
|
324
|
+
contents: read
|
|
325
|
+
id-token: write
|
|
326
|
+
# A worker of a run that is over cancels its own run.
|
|
327
|
+
actions: write
|
|
328
|
+
runs-on: ${{ fromJSON('{"small":"ubuntu-latest"}')[inputs.size] }}
|
|
329
|
+
timeout-minutes: 30
|
|
330
|
+
strategy:
|
|
331
|
+
fail-fast: false
|
|
332
|
+
matrix:
|
|
333
|
+
worker: ${{ fromJSON(inputs.workers) }}
|
|
334
|
+
env:
|
|
335
|
+
VX_DTE_RUN: ${{ inputs.run }}
|
|
336
|
+
VX_DTE_RELAY: ${{ inputs.relay }}
|
|
337
|
+
VX_DTE_WORKER: ${{ matrix.worker }}
|
|
338
|
+
steps:
|
|
339
|
+
- name: Leave if the run is over
|
|
340
|
+
if: inputs.parent != ''
|
|
341
|
+
env: { GH_TOKEN: '${{ github.token }}', PARENT: '${{ inputs.parent }}' }
|
|
342
|
+
run: |
|
|
343
|
+
runs="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs"
|
|
344
|
+
status=$(curl -fsS -H "authorization: Bearer $GH_TOKEN" "$runs/$PARENT" | jq -r .status) || exit 0
|
|
345
|
+
[ "$status" = completed ] || exit 0
|
|
346
|
+
echo "::notice::run $PARENT is over: cancelling this one"
|
|
347
|
+
curl -fsS -o /dev/null -X POST -H "authorization: Bearer $GH_TOKEN" "$runs/$GITHUB_RUN_ID/cancel"
|
|
348
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
349
|
+
with: { ref: '${{ inputs.sha }}', persist-credentials: false }
|
|
350
|
+
- env: { HANDED: '${{ inputs.env }}' }
|
|
351
|
+
run: |
|
|
352
|
+
jq -r 'to_entries[] | if (.key | test("^[A-Za-z_][A-Za-z0-9_]*$")) and (.value | type == "string") and (.value | test("[\r\n]") | not) then "\(.key)=\(.value)" else error("env \(.key): a name, and a string of one line") end' <<<"$HANDED" >> "$GITHUB_ENV"
|
|
353
|
+
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
|
354
|
+
# Every project: only pnpm selects by a git diff, as
|
|
355
|
+
# `pnpm install --frozen-lockfile --filter "...[origin/main]"`
|
|
356
|
+
# (fetch origin/main first: the checkout is one commit).
|
|
357
|
+
- run: bun install --frozen-lockfile
|
|
358
|
+
- run: bun vx dte worker
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
## On GitLab
|
|
362
|
+
|
|
363
|
+
One pipeline file. Your `vx` job is the orchestrator; once it is on the
|
|
364
|
+
relay, it triggers a pipeline of the same file for each wave of workers
|
|
365
|
+
it asks of a size, with the job's own `CI_JOB_TOKEN`, so no worker starts
|
|
366
|
+
before the run and no token is stored. Each size's job is a matrix of
|
|
367
|
+
`max` slots (`VX_DTE_SLOT`); that pipeline runs only the slots the wave
|
|
368
|
+
names (`VX_DTE_PICK`), at the run's commit, and each takes its name from
|
|
369
|
+
`VX_DTE_IDS`. The run starts workers as its work needs them, up to each
|
|
370
|
+
size's `max`, which `gitlab()` needs (at most 200, a matrix's limit).
|
|
371
|
+
GitLab lets a job token trigger but not cancel, so a worker the run no
|
|
372
|
+
longer needs leaves when it joins or when the run ends. Each trigger
|
|
373
|
+
passes the workers' variables, so set Settings > CI/CD > Variables >
|
|
374
|
+
"Minimum role to use pipeline variables" to Developer: a project made
|
|
375
|
+
since GitLab 17.7 lets no one, and refuses every trigger.
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
// .vx/workspace.ts
|
|
379
|
+
export default defineWorkspace({
|
|
380
|
+
plugins: [
|
|
381
|
+
dte({
|
|
382
|
+
relay: 'https://relay.example.com',
|
|
383
|
+
workers: { small: { max: 4, cpu: 2, memory: '8GB' } },
|
|
384
|
+
adapter: gitlab(),
|
|
385
|
+
}),
|
|
386
|
+
],
|
|
387
|
+
})
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
`vx dte init` writes it, from `dte()`, in the package manager your
|
|
391
|
+
lockfile names, asking what main and a merge request run:
|
|
392
|
+
|
|
393
|
+
```yaml
|
|
394
|
+
# .gitlab-ci.yml
|
|
395
|
+
# Written by vx dte init from dte() in .vx/workspace.ts. The vx job
|
|
396
|
+
# runs no task: once it is on the relay it triggers a pipeline of this
|
|
397
|
+
# file for each wave of workers of a size, which runs only those slots
|
|
398
|
+
# of that size's job.
|
|
399
|
+
# Name the tasks in the vx job, as 'build test --all': with none named,
|
|
400
|
+
# vx fails, listing them.
|
|
401
|
+
workflow:
|
|
402
|
+
rules:
|
|
403
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
404
|
+
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
405
|
+
- if: $CI_PIPELINE_SOURCE == "pipeline" && $VX_DTE_SIZE
|
|
406
|
+
|
|
407
|
+
# The image needs Bun; a job adds git if it lacks it. By digest: a tag
|
|
408
|
+
# can move under the same name.
|
|
409
|
+
default:
|
|
410
|
+
image: oven/bun:1.4.4@sha256:1d22548f177f4ff38e0c15baa3c2c46c6c2060ba2d1bc573bb4720b407548f1d
|
|
411
|
+
|
|
412
|
+
vx:
|
|
413
|
+
rules:
|
|
414
|
+
- if: $VX_DTE_SIZE
|
|
415
|
+
when: never
|
|
416
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
417
|
+
variables: { VX_TASKS: '--affected' }
|
|
418
|
+
- when: on_success
|
|
419
|
+
variables: { VX_TASKS: '--all' }
|
|
420
|
+
id_tokens: { VX_DTE_TOKEN: { aud: https://relay.example.com } }
|
|
421
|
+
before_script:
|
|
422
|
+
- command -v git || (apt-get update -qq && apt-get install -qq -y --no-install-recommends git)
|
|
423
|
+
- bun install --frozen-lockfile --filter './'
|
|
424
|
+
script:
|
|
425
|
+
- bun vx lock --check
|
|
426
|
+
- bun vx run $VX_TASKS
|
|
427
|
+
|
|
428
|
+
# A worker runs at the run's commit, which its branch may have moved past.
|
|
429
|
+
.vx-worker:
|
|
430
|
+
variables: { GIT_DEPTH: 1 }
|
|
431
|
+
id_tokens: { VX_DTE_TOKEN: { aud: https://relay.example.com } }
|
|
432
|
+
before_script:
|
|
433
|
+
- command -v git || (apt-get update -qq && apt-get install -qq -y --no-install-recommends git)
|
|
434
|
+
- git fetch -q --depth 1 origin $VX_DTE_SHA
|
|
435
|
+
- git checkout -q FETCH_HEAD
|
|
436
|
+
- bun install --frozen-lockfile
|
|
437
|
+
script: [bun vx dte worker]
|
|
438
|
+
|
|
439
|
+
'vx-worker small':
|
|
440
|
+
extends: .vx-worker
|
|
441
|
+
parallel:
|
|
442
|
+
matrix:
|
|
443
|
+
- VX_DTE_SLOT: ['1', '2', '3', '4']
|
|
444
|
+
rules: [{ if: $VX_DTE_SIZE == "small" && $VX_DTE_SLOT =~ $VX_DTE_PICK }]
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
`gitlab({ tags })` names each size's runner tags. Each job's ID token
|
|
448
|
+
(`VX_DTE_TOKEN`) admits it to the relay; the worker proves itself to the
|
|
449
|
+
run by the run's secret, made for that run only and passed as a trigger
|
|
450
|
+
variable, since a GitLab ID token's audience is fixed when its job starts
|
|
451
|
+
and cannot bind the handshake as GitHub's does. Set `VX_DTE_SECRET` to
|
|
452
|
+
use your own instead.
|
|
453
|
+
|
|
454
|
+
## What runs where
|
|
455
|
+
|
|
456
|
+
- A worker reports the cores and memory its machine has when it joins.
|
|
457
|
+
One the run started is held to the size it was started as, so
|
|
458
|
+
`local()` workers sharing a host split it rather than each take all of
|
|
459
|
+
it. Each frame it sends (a task begun, a command ended, a task saved,
|
|
460
|
+
its heartbeat) carries its machine's load: cores busy over the last
|
|
461
|
+
second and memory in use. A task goes to it only when it fits now: while
|
|
462
|
+
one of its cores is free and the task's memory fits. What a worker has
|
|
463
|
+
in use is what its running tasks used on their last run (history's
|
|
464
|
+
measured CPU and peak RAM), or its live load plus what was sent since
|
|
465
|
+
when that is more. Never less: load lags a task's start, and a test
|
|
466
|
+
shard idle for a second then uses all its cores. A command that ended
|
|
467
|
+
frees its cores while its outputs save, and its worker's load until the
|
|
468
|
+
next frame: that load counted the task. Every
|
|
469
|
+
task sent begins at once; none waits in a worker's queue. A task asking
|
|
470
|
+
a whole worker, or bigger than any, runs alone on an idle one (one that
|
|
471
|
+
filled its worker last time). The first such task waiting holds
|
|
472
|
+
the worker that empties soonest: no task after it is sent there, so it
|
|
473
|
+
is not passed over again and again.
|
|
474
|
+
- A task naming `exec.platforms` runs only on a worker of the platform
|
|
475
|
+
core placed it on: this machine's when a size has it, else the first of
|
|
476
|
+
the task's a size has. A worker reports its platform when it joins: a
|
|
477
|
+
size naming one refuses a worker of another; a size naming none is
|
|
478
|
+
planned as this machine's and takes any worker. When the sizes run
|
|
479
|
+
this machine's platform and another, a task naming none runs only on
|
|
480
|
+
this machine's: a worker of another platform runs only the tasks that
|
|
481
|
+
name it, so they never wait behind the rest, and leaves when they are
|
|
482
|
+
done. The run starts a worker of each
|
|
483
|
+
platform its work needs. A task none of whose platforms any size has
|
|
484
|
+
refuses the run before a worker starts.
|
|
485
|
+
- Tasks waiting for room go out longest tail first: a task's recorded
|
|
486
|
+
duration and its longest chain of dependants, so a long chain that got
|
|
487
|
+
ready late does not queue behind many short tasks ready at once.
|
|
488
|
+
- A task goes to the worker that holds most of its dependencies' outputs,
|
|
489
|
+
so a chain stays on one machine; then to the one with fewest cores
|
|
490
|
+
free that it fits, so workers fill before the next takes work and the
|
|
491
|
+
ones the rest no longer needs fall idle and leave.
|
|
492
|
+
- A sandboxed task runs on a worker inside the worker's own sandbox, with
|
|
493
|
+
the same `exec.sandbox`, built there (`sandboxRequestFor`): its paths,
|
|
494
|
+
walls and credential stores are the worker's, so an orchestrator on
|
|
495
|
+
Linux can run macOS workers. A violation there fails it here, named in
|
|
496
|
+
this checkout's paths. An empty file it wrote over a write grant's
|
|
497
|
+
placeholder is its output; only a placeholder it never touched goes.
|
|
498
|
+
A sandbox the worker cannot host (no `bwrap` in its image) fails the
|
|
499
|
+
task there, as it fails here; the worker stays in the run.
|
|
500
|
+
- A persistent task a task depends on starts on the worker that runs the
|
|
501
|
+
task, once per worker, ready before it, and stops when the worker leaves;
|
|
502
|
+
the orchestrator never starts it, and a worker no dependant reaches
|
|
503
|
+
never does (2026-10-22).
|
|
504
|
+
- A task whose key hashes a runtime probe (`inputs.runtime`, such as
|
|
505
|
+
`bun --version`) runs on a worker, which answers the probe first and
|
|
506
|
+
fails the task when it answers otherwise than this machine did. A path
|
|
507
|
+
under the worker's checkout root reads as the same path under this
|
|
508
|
+
machine's, as the task's environment does.
|
|
509
|
+
- No task runs on the orchestrator, only cache hits restore there
|
|
510
|
+
(2026-10-24): a task with `exec.remote: false`, an interactive
|
|
511
|
+
one and their dependants run on workers too (the executor is
|
|
512
|
+
`exclusive`). A task no worker can take refuses the run before any
|
|
513
|
+
starts, naming it: one whose `exec.platforms` no size offers, or a
|
|
514
|
+
persistent task asked for on its own.
|
|
515
|
+
- An `exec.remote: 'only'` task's files stay on the workers: this machine
|
|
516
|
+
restores its output, never its files. Under `--cache-download=toplevel`
|
|
517
|
+
or `none`, a task's files it defers land here only if a task here needs
|
|
518
|
+
them.
|
|
519
|
+
- A task's output replays here as its worker read it: stdout and stderr
|
|
520
|
+
in the order the task wrote them, bounded as core bounds a run's.
|
|
521
|
+
- A result too long for one relay frame (thousands of sandbox violations,
|
|
522
|
+
or of files a formatter changed) is sent in parts, none of it dropped.
|
|
523
|
+
An order too long for one frame (a task with thousands of deps) sends
|
|
524
|
+
its deps ahead of it the same way.
|
|
525
|
+
- A task runs with the orchestrator's environment under the worker's
|
|
526
|
+
root. `HOME`, `TMPDIR`, `USER` and the like are the worker's own, unless
|
|
527
|
+
the task's `env.define` sets them; `npm_execpath` is the workspace's
|
|
528
|
+
package manager on the worker, as core sets it on a local task.
|
|
529
|
+
- A worker restores, by key, the declared outputs of every task the one
|
|
530
|
+
it runs depends on, each after its own dependencies (an additive task's
|
|
531
|
+
rewrite of an upstream file stands), unless it ran or restored them before. Files a task
|
|
532
|
+
writes without declaring them stay on the worker. An uncached task
|
|
533
|
+
declares none, so no cache carries its files: its dependants go to the
|
|
534
|
+
worker it ran on, unless it is sandboxed with no write grant and so
|
|
535
|
+
leaves no file. One waits there up to 10 s; past that, with that worker
|
|
536
|
+
gone or of another platform, or with its uncached upstreams on two
|
|
537
|
+
workers, the upstream runs again where the dependant can go, and the
|
|
538
|
+
dependant follows it. A task whose upload no remote took (too big, a 429) is kept the same way: its output comes to the orchestrator with
|
|
539
|
+
its result, its files stay on the worker, and a task on the
|
|
540
|
+
orchestrator's machine that needs them fails saying so. Before a task
|
|
541
|
+
runs, its
|
|
542
|
+
worker removes the files under its outputs that an earlier task left,
|
|
543
|
+
except committed files, nested projects' files, and its dependencies'
|
|
544
|
+
outputs (an additive task builds on its upstream's tree). An upstream's
|
|
545
|
+
file the task leaves as it found it is not saved in its entry; one it
|
|
546
|
+
removes is removed on this machine too, unless a link on its path leads
|
|
547
|
+
elsewhere (that file is kept, with a warning), and the task saves no
|
|
548
|
+
entry, as core saves none.
|
|
549
|
+
- A hit only this machine's cache holds (the remote evicted it, or this
|
|
550
|
+
run does not write the remote) is uploaded to the remotes the workers
|
|
551
|
+
read before a task that depends on it is sent; with none written, its
|
|
552
|
+
dependant fails on the worker, naming the entry. One whose remote copy
|
|
553
|
+
does not read back on a worker (a truncated upload) is sent again from
|
|
554
|
+
this machine over that copy, and the task runs again; once per entry.
|
|
555
|
+
- A failed or uncached task is saved under a key of the run, the task and
|
|
556
|
+
the send (a retry gets its own), so no later lookup ever hits it; so is one core would not save (a
|
|
557
|
+
`--no-cache` or read-only run, a dependant of a failure under
|
|
558
|
+
`--continue=always`, one that left a process running that may still
|
|
559
|
+
write its outputs, one a stopped run cancelled), and one that left a tracked file under its project
|
|
560
|
+
changed on its worker (a formatter's input), which this machine never
|
|
561
|
+
sees: core withholds its own save when the key read that file, and says so.
|
|
562
|
+
- Under `--force`, a success is saved under its key (which keeps an entry
|
|
563
|
+
it already holds, as core does) and under the run's key, which this
|
|
564
|
+
machine and the dependants' workers restore: the bytes this run built,
|
|
565
|
+
never the entry it bypassed.
|
|
566
|
+
- A worker whose tracked files differ from the orchestrator's (another
|
|
567
|
+
tree, or the same commit with a tracked file changed, such as by a
|
|
568
|
+
prepare step) is refused. Both sides read their tracked files as they
|
|
569
|
+
are, so the same change on both is the same tree.
|
|
570
|
+
- A task whose key hashes a file the run's commit lacks (untracked, or
|
|
571
|
+
gitignored and named outright, or inside a submodule, whose files the
|
|
572
|
+
commit holds only as a gitlink) fails, naming the file: no worker has it.
|
|
573
|
+
A dependency's declared output is not one: its worker restores it first.
|
|
574
|
+
- A task's `exec.timeout` counts from its start on its worker: the wait
|
|
575
|
+
for a worker still booting is not its run (the executor declares
|
|
576
|
+
`timeouts`).
|
|
577
|
+
- A worker that stops answering, stops saying it holds a task, or cannot
|
|
578
|
+
run one (a full disk) gets no more work, and its tasks run again on
|
|
579
|
+
another. A task two such workers had begun fails, naming both; one an
|
|
580
|
+
worker had not begun yet does not count. With no worker of the run left
|
|
581
|
+
to take it and none starting, it fails at once.
|
|
582
|
+
- A relay that answers nothing for 10 s, every try counted, fails every task not done
|
|
583
|
+
(`the relay is unreachable`); none runs here, which may be far smaller
|
|
584
|
+
than the workers. What finished is saved, so a rerun resumes from it.
|
|
585
|
+
A shorter fault (a `5xx` while the host restarts the relay, a dropped
|
|
586
|
+
connection) is asked through, by the orchestrator and workers alike.
|
|
587
|
+
- The relay stores nothing, so a restart forgets the run. The orchestrator
|
|
588
|
+
joins again as its host and each worker joins again as itself, for two
|
|
589
|
+
heartbeats (4 s on the default); tasks keep running meanwhile, and what
|
|
590
|
+
either side sent goes again once both are back, taken once. Leases
|
|
591
|
+
wait while either is away, and a worker sent a task it runs already
|
|
592
|
+
does not run it again, so no work repeats. One not back by then
|
|
593
|
+
ends: a worker's tasks run elsewhere, an orchestrator's run fails.
|
|
594
|
+
Joining has the same 10 s in all, its socket and request together: a
|
|
595
|
+
relay silent that long fails the run with `cannot connect to the relay
|
|
596
|
+
<origin>: no answer in 10 s`.
|
|
597
|
+
- A worker that never joins says why on its row (`failed exited 1:
|
|
598
|
+
…`); its job ending, a success too, is what says so. Once the first
|
|
599
|
+
worker of a size joins, each other of that size not joined within twice
|
|
600
|
+
that first join is given up (`did not join within 60 s`) and its job
|
|
601
|
+
stopped; before one has, none is. One its CI started anyway that joins
|
|
602
|
+
later is taken, as any worker the plan may let go. The first three such failures in a
|
|
603
|
+
run each start another worker in its place, as a checkout, install or
|
|
604
|
+
join can fail on the network; a fourth starts none. While a worker of
|
|
605
|
+
a size is past its warm-up unjoined, or given up so beside one of its
|
|
606
|
+
size online, the run asks no more of that size: its CI queued it, and
|
|
607
|
+
one asked then would queue behind it. When none is online
|
|
608
|
+
or starting and this machine has no share, the remaining tasks fail
|
|
609
|
+
with `no worker could start`.
|
|
610
|
+
- Only `leave` ends a participant at once (2026-10-25): the
|
|
611
|
+
orchestrator's at its run's end is the final call that closes the run,
|
|
612
|
+
and a worker's when it is done. Either whose socket drops without it
|
|
613
|
+
dials again, 3 times within 10 s, and the relay keeps it, and its run,
|
|
614
|
+
for those 10 s; past them the run is over. A peer quiet on a socket that never
|
|
615
|
+
closed is lost within two heartbeats (4 s on the default): only a
|
|
616
|
+
peer heard from for none of a heartbeat and a half is probed (an
|
|
617
|
+
empty numbered send), which it acks at once; either side hearing
|
|
618
|
+
nothing from the other for 1.9 heartbeats loses it, a worker by
|
|
619
|
+
ending. A quiet pair trades one probe and its ack each heartbeat and a
|
|
620
|
+
half, the relay answers neither, and nobody pings. An orchestrator
|
|
621
|
+
with no worker yet asks the relay an empty frame instead; a frame the
|
|
622
|
+
relay leaves unanswered a heartbeat closes the socket, which is dialed
|
|
623
|
+
again.
|
|
624
|
+
- Each send is numbered for its receiver and held until the receiver
|
|
625
|
+
acknowledges it, so one a socket or the relay dropped goes again after
|
|
626
|
+
a heartbeat, or at once on a new socket; the receiver takes each once,
|
|
627
|
+
in order. An ack rides the next send back, and those with none to ride
|
|
628
|
+
go together in one frame per half heartbeat: no message of its own on
|
|
629
|
+
a busy run.
|
|
630
|
+
- A run where no task began or ended for a minute warns what it waits
|
|
631
|
+
on, a line a task: which worker, and whether it runs or saves.
|
|
632
|
+
- A message the relay would not take is a warning naming it, its
|
|
633
|
+
receiver and the relay's answer; one to a participant the relay already
|
|
634
|
+
lost (404) is not, since its loss is reported on its own. Its number
|
|
635
|
+
still reaches the receiver, carrying nothing, so the sends after it are
|
|
636
|
+
not held up. A worker whose
|
|
637
|
+
OIDC token could not be checked is refused by that cause, not as a key
|
|
638
|
+
mismatch, and the run says so.
|
|
639
|
+
|
|
640
|
+
## What the run shows
|
|
641
|
+
|
|
642
|
+
Each task a worker ran says which one last on its row and after the id
|
|
643
|
+
on its frame, in that worker's own color, by size and the order the run
|
|
644
|
+
started it:
|
|
645
|
+
|
|
646
|
+
```
|
|
647
|
+
⏺︎ 308ms success miss c#build · small#1
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
An idle worker the rest of the run cannot keep busy leaves (`left`): one
|
|
651
|
+
beyond the peak of the cores the remaining tasks keep busy, so a lull
|
|
652
|
+
between two wide parts keeps it. One a remaining task needs for its size
|
|
653
|
+
stays, and so does one a later task needs when its size's `max` is
|
|
654
|
+
spent, since none could start in its place. A lost worker's begun tasks run again on
|
|
655
|
+
another, and the run starts one in its place. A worker is lost when its
|
|
656
|
+
socket closes, when it stops answering the orchestrator's probes, or at
|
|
657
|
+
once when its adapter sees its process or job end. Workers start at the first
|
|
658
|
+
task the cache does not answer, so a warm run starts none. Once one has
|
|
659
|
+
joined, its warm-up is what the next of its size takes at the least: work
|
|
660
|
+
that ends sooner starts no worker while others are online or starting, and
|
|
661
|
+
new workers are started for the cores the run keeps busy after that
|
|
662
|
+
warm-up, not before it. A run on CI keeps each size's warm-up in the
|
|
663
|
+
workspace's cache under its commit, and the next commit's run plans by it
|
|
664
|
+
before any worker of that size joins.
|
|
665
|
+
|
|
666
|
+
The `workers` rows, live while the run goes and again in the footer,
|
|
667
|
+
give each worker its status (`starting`, `online`, `left`, `gone`, or `failed`
|
|
668
|
+
with why, such as `exited 1: Cannot find package '@vzn/vx-dte'`; once the
|
|
669
|
+
run is over, one still starting is `not joined`), the tasks it
|
|
670
|
+
has done, failed or had aborted by a stop, and the ones it runs now; while the run goes, those that left
|
|
671
|
+
are one line (`3 workers left 9 done`). A worker the
|
|
672
|
+
run started is named by its size and the order it was started in; one
|
|
673
|
+
that joined on its own is `worker#N`. A task sent to a worker but not yet
|
|
674
|
+
begun there has no row; the tasks line counts it `scheduled`
|
|
675
|
+
(`30 success · 4 running · 12 scheduled · 62 total`). Its row and clock
|
|
676
|
+
start when the worker begins it. A worker is `online` while the run
|
|
677
|
+
goes; it ends `left` when the run let it go as not needed, `lost` when
|
|
678
|
+
it crashed or went silent, and `stopped` when the run's end stopped it.
|
|
679
|
+
Once the run is over, each worker adds its warm-up (from asking the
|
|
680
|
+
adapter to its join), and under it the cores and memory its size
|
|
681
|
+
declares and its machine reports, green when they agree and red when
|
|
682
|
+
not:
|
|
683
|
+
|
|
684
|
+
```
|
|
685
|
+
workers small#1 online 3 done · 2 running
|
|
686
|
+
small#2 starting
|
|
687
|
+
local online 1 done
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
```
|
|
691
|
+
workers small#1 stopped 5 done · warm-up 0.1s
|
|
692
|
+
declared 2c/8G · actual 4c/16G
|
|
693
|
+
small#2 lost 1 done · warm-up 0.2s
|
|
694
|
+
declared 2c/8G · actual 4c/16G
|
|
695
|
+
local online 1 done
|
|
696
|
+
actual 10c/32G
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
A run over that started none (every task a hit) says
|
|
700
|
+
`workers none started: no task needed one`.
|
|
701
|
+
|
|
702
|
+
Under each worker, and `local`, the footer then spells out what it did:
|
|
703
|
+
its time online, busy and idle; its CPU peak and average in cores and
|
|
704
|
+
the cores the run booked for the tasks it was running, on average (a
|
|
705
|
+
booked from a task's start to its command's end); its cpu utilization (the core-time its
|
|
706
|
+
tasks used over the core-time it had); its memory peak and average, when its tasks reported any; and, for a worker, the messages and
|
|
707
|
+
bytes the run sent it and received from it. The `probe` line says when
|
|
708
|
+
core's cache probe answered, how many tasks it left to run (held in no
|
|
709
|
+
cache, or not answered for) and how many of them it found in none. The `asked` line says each
|
|
710
|
+
time the run asked for workers: how many, when, for how many tasks that
|
|
711
|
+
missed, how many of them with a measured need (an unmeasured one counts
|
|
712
|
+
as `unmeasured`), and when its plan ended then. The `ideal` line is the run's ideal end
|
|
713
|
+
(below), so every log reads it beside core's `time` row. The first ask
|
|
714
|
+
waits for the needs another plugin fetches (`ctx.refining()`), such as
|
|
715
|
+
`@vzn/vx-history`'s default-branch timings. The `unmeasured`
|
|
716
|
+
line names the tasks the first ask had no measure of, and a worker that
|
|
717
|
+
ran two tasks or fewer names them on its row. `used avg` is the cores
|
|
718
|
+
its tasks used, `machine avg` the cores its machine was busy on average since
|
|
719
|
+
the worker started, any process's, as its last frame said. Where the
|
|
720
|
+
rest went, the worker says in its own log as it leaves: `cpu over 61.2s:
|
|
721
|
+
machine 2.80 cores · tasks 2.00 cores · this worker 0.15 cores · its other
|
|
722
|
+
children 0.05 cores · most besides: Runner.Worker 0.30 cores`, the last
|
|
723
|
+
from one `ps` as it starts and one as it leaves. The `total` line is the cpu utilization of every
|
|
724
|
+
machine together, and the share of their history the run's tasks used:
|
|
725
|
+
|
|
726
|
+
```
|
|
727
|
+
small#1 stopped 5 done · warm-up 0.2s
|
|
728
|
+
declared 4c/14G · actual 4c/14G
|
|
729
|
+
online 12.1s · busy 9.8s · idle 2.3s
|
|
730
|
+
cpu peak 3.6 · used avg 2.4 cores · machine avg 2.9 · booked avg 3.8 · cpu utilization 61%
|
|
731
|
+
memory peak 1.2 GB · avg 640.0 MB
|
|
732
|
+
sent 6 messages · 3.1 MB, received 15 messages · 8.4 MB
|
|
733
|
+
probe at 0.4s: 5 tasks left to run, 5 known misses
|
|
734
|
+
asked 1 at 0.5s for 5 tasks (5 measured), plan 12.0s
|
|
735
|
+
ideal 10.8s: each task as long as it took, placed perfectly
|
|
736
|
+
total cpu utilization 58%
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
`vx run --profile[=<path>]` adds the run in time to the profile it
|
|
740
|
+
writes (open it in https://ui.perfetto.dev or `chrome://tracing`).
|
|
741
|
+
Each worker is a process: its first row holds its warm-up (split, when it
|
|
742
|
+
says when its `vx` started, into its machine's start and vx's boot), its
|
|
743
|
+
idle stretches, how it ended, a `cores` counter of the cores booked
|
|
744
|
+
and used at once, and a `machine` counter of the cores its machine
|
|
745
|
+
reported busy, its tasks' or not (placement never fills a worker past
|
|
746
|
+
it), beside the cores its running tasks' process trees used, read each
|
|
747
|
+
second as core's `task.sample` reads them; each task takes the first lane free over its time
|
|
748
|
+
there, from its send through restoring its dependencies, its command
|
|
749
|
+
and its save, with its exit code, duration, CPU time and booked cores.
|
|
750
|
+
The `orchestrator` process holds the cache probe, each ask for workers, each
|
|
751
|
+
plan that started or let go of workers with what it saw of each worker
|
|
752
|
+
(one that started workers also with each task's planned time, cores and
|
|
753
|
+
platform and each size's warm-up, so it can be played back),
|
|
754
|
+
stalls, the time each task waited for room, and each restore. Times are
|
|
755
|
+
the orchestrator's: when it heard. Without `--profile` nothing is
|
|
756
|
+
written. On CI, upload the file as an artifact; this repo's `ci.yml`
|
|
757
|
+
does and links it from the job summary.
|
|
758
|
+
|
|
759
|
+
After the run, `dte()` replays it perfectly: each task as long as it held
|
|
760
|
+
its worker and with the cores it used meanwhile, on as many
|
|
761
|
+
workers as the sizes allow, each joining after its platform's fastest
|
|
762
|
+
real warm-up. That ideal end (`TaskExecutor.idealMs`) sits beside the
|
|
763
|
+
real time in the run summary and the footer's `ideal` line, and
|
|
764
|
+
`@vzn/vx-ci` prints it.
|
|
765
|
+
|
|
766
|
+
## Environment
|
|
767
|
+
|
|
768
|
+
What `@vzn/vx-dte` reads from the CI job it runs in:
|
|
769
|
+
|
|
770
|
+
- `GITHUB_SHA`: the commit every worker checks out.
|
|
771
|
+
- `GITHUB_HEAD_REF` or `GITHUB_REF_NAME`, then `GITHUB_BASE_REF`: the
|
|
772
|
+
ref `github()` dispatches the worker workflow on, the base when the
|
|
773
|
+
head has no workflow file.
|
|
774
|
+
- `GITHUB_RUN_ID` and `GITHUB_RUN_ATTEMPT`: the run's attempt, so a
|
|
775
|
+
worker that starts after it ended cancels itself.
|
|
776
|
+
- `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN`:
|
|
777
|
+
where the job asks GitHub for its OIDC token; `GITHUB_REPOSITORY_ID`:
|
|
778
|
+
the repository a token must name.
|
|
779
|
+
- `GITLAB_CI` and `CI_JOB_TOKEN`: `gitlab()` runs on GitLab CI.
|
|
780
|
+
- `CI_MERGE_REQUEST_SOURCE_BRANCH_NAME` or `CI_COMMIT_REF_NAME`, then
|
|
781
|
+
`CI_MERGE_REQUEST_TARGET_BRANCH_NAME`: the ref worker pipelines run on.
|
|
782
|
+
- `PATH`: a worker keeps the task's own bin directories, then its own
|
|
783
|
+
machine's `PATH`.
|
|
784
|
+
- `GIT_INDEX_FILE`: `local()` clears it before `git worktree add`, so a
|
|
785
|
+
run started from a git hook checks out the run's commit, not the hook's
|
|
786
|
+
index.
|
|
787
|
+
|
|
788
|
+
## API
|
|
789
|
+
|
|
790
|
+
- `dte(options?: DteOptions)` is the plugin. `DteOptions` is
|
|
791
|
+
`{ workers?, adapter?, relay?, local?, token?, scheduler?, unmeasured?, headers? }`;
|
|
792
|
+
a key it does not take, in a size or `unmeasured` too, is refused.
|
|
793
|
+
`headers` go on every dial to the relay, the orchestrator's and each
|
|
794
|
+
worker's (redials too), for a proxy in front of it, such as a
|
|
795
|
+
Cloudflare Access service token (`{ 'CF-Access-Client-Id':
|
|
796
|
+
process.env.CF_ACCESS_CLIENT_ID, … }`); an unset value is left out, and
|
|
797
|
+
a name the dial sets itself (`authorization`, `content-type`,
|
|
798
|
+
`relay-proof`) is refused. The rest:
|
|
799
|
+
- `workers` names each `WorkerSize`, `{ cpu, memory, max?, platform? }`: its cores
|
|
800
|
+
and memory (bytes, or `'8GB'`, `'512MiB'`), which the run counts on
|
|
801
|
+
when it starts one, until the worker reports what its machine has,
|
|
802
|
+
how many of it one run may start (absent, no cap), and its machines'
|
|
803
|
+
platform (`linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`;
|
|
804
|
+
absent, planned as this machine's and not checked on join). With no adapter, the sizes' platforms are
|
|
805
|
+
the ones joining workers may have. Only work counts:
|
|
806
|
+
a task that missed the cache, or waits on a cached one that did, and
|
|
807
|
+
every uncached task, which runs every time; groups and hits need no worker. At each miss, finish and join the run plays
|
|
808
|
+
that work out on its workers and on N more (`src/planner.ts`), each new
|
|
809
|
+
one joining after its size's warm-up: this run's first of that size,
|
|
810
|
+
else an earlier run's, else any size's this run, else 20 s,
|
|
811
|
+
and takes the fewest N that end within 5% (5 s at least) of the best.
|
|
812
|
+
Sizes of different platforms come in turns, so of those N it drops
|
|
813
|
+
each the plan ends as soon without, its platform's work no more than
|
|
814
|
+
5 s later.
|
|
815
|
+
That margin is spent once: later plans keep its deadline until new
|
|
816
|
+
work is known. A task waiting on one that runs waits until it ends.
|
|
817
|
+
What is left, running tasks too, takes as many times its history as
|
|
818
|
+
the run's finished tasks took theirs (once they had 20 s of it; never
|
|
819
|
+
less): a task past its forecast would otherwise be planned to end now,
|
|
820
|
+
and a plan that thinks the run nearly done cancels the worker it asked.
|
|
821
|
+
Before any worker can say what misses, the run counts as work each
|
|
822
|
+
cached task whose inputs changed since HEAD's parent (`git diff
|
|
823
|
+
HEAD^`), and every task after one (`src/forecast.ts`): a lockfile or
|
|
824
|
+
`.vx/` change counts every cached task. A hit it guessed wrong costs a
|
|
825
|
+
worker the plan cancels; with no parent, the run waits for its misses.
|
|
826
|
+
Each task core's up-front cache probe found in no store
|
|
827
|
+
(`TaskExecutor.demand`'s `missed`) counts as work too, the moment the
|
|
828
|
+
probe answers.
|
|
829
|
+
A measured task books its cores times the share of their history the
|
|
830
|
+
run's finished tasks used (once they used a minute of core-time; never
|
|
831
|
+
under two thirds), so a worker's bookings may reach 1.5 times its
|
|
832
|
+
cores when tasks use less than history says; plans book the same.
|
|
833
|
+
What a task used counts divided by its share of the CPU: a Linux
|
|
834
|
+
worker reads its run queue (`procs_running` in /proc/stat) every 200 ms,
|
|
835
|
+
a macOS one its load average while all its cores are busy (a GitHub Mac reads a load of 49 with a core idle), and a task whose threads got a third of a core each used a third of
|
|
836
|
+
its need, not its need. A task wanted at most what it used and the
|
|
837
|
+
cores left free beside it, so its share is never read below that. The run history keeps that share
|
|
838
|
+
(`ExecuteResult.machine.cpuShare`) and learns the need, at most the
|
|
839
|
+
machine's cores: learning the share packed the next run tighter, nine
|
|
840
|
+
1.1-core test shards on one 4-core worker. The need is the mean over
|
|
841
|
+
runs, and a task fills its worker only when it does on average: one
|
|
842
|
+
crowded run's 2.84 cores for a build using 1.3 to 1.9 booked it a whole
|
|
843
|
+
Mac.
|
|
844
|
+
A worker the plan needs later starts when its warm-up nears. An idle
|
|
845
|
+
worker leaves when the plan gives it no task (its warm-up is spent), or,
|
|
846
|
+
when its next task is two warm-ups away, with one started in its place
|
|
847
|
+
a warm-up after it left: none is let go and started again. A worker still
|
|
848
|
+
starting that the plan gives no task (asked, a worker stays while the
|
|
849
|
+
plan has work for it, though the run would end no later without) is
|
|
850
|
+
cancelled: the adapter
|
|
851
|
+
stops it before its setup if it can (`github()` cancels its workflow
|
|
852
|
+
run once no worker in it is needed and none of its jobs has a runner),
|
|
853
|
+
and one that joins anyway is told to exit. One the adapter leaves
|
|
854
|
+
running (its job had a runner, its run holds a worker still needed, or
|
|
855
|
+
the adapter stops none) is counted again, and the run takes it when it
|
|
856
|
+
joins: its setup is paid, and a cancel landing late could stop it
|
|
857
|
+
mid-task. The same signal changes nothing. The
|
|
858
|
+
run ends: a task the history has not measured books one free core on
|
|
859
|
+
any worker (`unmeasured` sizes the run); one that used all of its worker's cores or memory (90%) last time runs
|
|
860
|
+
alone on the smallest bigger size, which the run starts for it. Each
|
|
861
|
+
run's history keeps where every task ran and that machine's size, so
|
|
862
|
+
sizing improves run by run.
|
|
863
|
+
- `adapter` is the `DteAdapter` that starts workers, of the sizes in
|
|
864
|
+
`workers`: at least one with `max` above 0, or `dte()` is refused.
|
|
865
|
+
Absent, the run starts none and uses the workers that join `relay`.
|
|
866
|
+
- `relay` is the relay's URL, http(s); absent, the adapter's own.
|
|
867
|
+
- `token` answers the relay's bearer token (default `VX_DTE_TOKEN`). It
|
|
868
|
+
is asked again a minute before the token's `exp`, when it has one.
|
|
869
|
+
- `unmeasured`, `{ cpu, memory }` (default 2 cores, `'4GB'`), is what a
|
|
870
|
+
task with no measure counts for when the run decides how many workers
|
|
871
|
+
to start: a new task, or one over before the Linux sandbox's first
|
|
872
|
+
look (250 ms), which leaves no CPU. It books one free core on any
|
|
873
|
+
worker, so it waits for no worker of its own.
|
|
874
|
+
- `scheduler` (a partial `Strategy`) picks each layer of how the run schedules; a layer left
|
|
875
|
+
out is the default (first named):
|
|
876
|
+
- `order`, which waiting task goes first: `critical-path` (the
|
|
877
|
+
longest chain after it), `graphene` (the long and the wide first),
|
|
878
|
+
`fifo`;
|
|
879
|
+
- `place`, which worker takes it: `best-fit` (fewest cores left),
|
|
880
|
+
`tetris` (needs aligned with what is free), `worst-fit`,
|
|
881
|
+
`first-fit`;
|
|
882
|
+
- `book`, what a task holds: `measured` (its history), `margin`
|
|
883
|
+
(a fifth more), `whole` (a worker each);
|
|
884
|
+
- `scale`, how many workers: `plan` (above), `backlog` (cores, of each platform, for
|
|
885
|
+
what is ready or running, Spark's dynamic allocation).
|
|
886
|
+
`tests/sim.test.ts` plays main's runs with each.
|
|
887
|
+
- `vx dte worker` is a worker. It reads `VX_DTE_RUN`, `VX_DTE_RELAY` (unset or empty,
|
|
888
|
+
`relay`), `VX_DTE_SECRET` (the run's) and, when the run started it,
|
|
889
|
+
`VX_DTE_WORKER` (its name). It is
|
|
890
|
+
described only by the cores and memory its machine reports. It exits 0
|
|
891
|
+
when the run ended before it joined. On SIGINT or SIGTERM it stops its
|
|
892
|
+
tasks, leaves the run, which runs them again on another worker, and
|
|
893
|
+
exits 130 or 143; a second signal ends it at once.
|
|
894
|
+
- `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).
|
|
895
|
+
`WorkerSpawn` is `{ run, relay, size, max, cpu, memory, index, id, workspaceRoot, commit, cacheDir?, env }`,
|
|
896
|
+
with `memory` in bytes and `max` its size's (`Infinity` uncapped),
|
|
897
|
+
where `id` is the worker's name (`small#2`, numbered within its size), `commit` the
|
|
898
|
+
tracked files as the run saw them at its start (uncommitted changes included), whose
|
|
899
|
+
tree a worker must check out, `cacheDir` the run's cache dir (absolute) when the
|
|
900
|
+
workspace names one, for workers on this machine to save where the run reads, and
|
|
901
|
+
`env` holds the three variables the worker reads.
|
|
902
|
+
- `docker(options: DockerOptions)` starts workers as containers and a relay
|
|
903
|
+
of its own on loopback. `DockerOptions` is `{ image, prepare? }`.
|
|
904
|
+
- `github(options?: GithubOptions)` dispatches workers as matrix jobs of
|
|
905
|
+
one workflow, one dispatch per size and demand (at most 256 workers,
|
|
906
|
+
a matrix's limit). `GithubOptions` is `{ workflow?, runsOn?, env? }`:
|
|
907
|
+
`workflow` defaults to `vx-worker.yml`, a file name in
|
|
908
|
+
`.github/workflows` other than `ci.yml`, the run's own; `runsOn` is the runner label
|
|
909
|
+
of each size, which `vx dte init` writes; `env` is variables every
|
|
910
|
+
worker gets (one-line strings, no secret-named one: the worker job's
|
|
911
|
+
log shows them), sent as the
|
|
912
|
+
dispatch's `env` input only when set, which `vx dte init`'s workflow
|
|
913
|
+
exports. Its workers are `keyless`, and only jobs of the workflow runs
|
|
914
|
+
it dispatched join as them. It
|
|
915
|
+
needs `GITHUB_TOKEN` with `actions: write`. See [On GitHub](#on-github).
|
|
916
|
+
- `gitlab(options?: GitlabOptions)` triggers a pipeline of the project's
|
|
917
|
+
`.gitlab-ci.yml` per wave and size, with `CI_JOB_TOKEN`. `GitlabOptions` is
|
|
918
|
+
`{ tags? }`: runner tags by size, which `vx dte init` writes. See
|
|
919
|
+
[On GitLab](#on-gitlab).
|
|
920
|
+
- `local(options?: LocalOptions)` starts workers on this machine and a relay
|
|
921
|
+
of its own on loopback. `LocalOptions` is `{ prepare? }`: a shell line
|
|
922
|
+
each worktree runs before its worker starts.
|
|
923
|
+
|
|
924
|
+
`@vzn/vx-dte-server` is a dependency for `local()`'s and `docker()`'s relay, loaded only
|
|
925
|
+
when a DTE run starts.
|