@vzn/vx-history 0.0.651 → 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 +24 -6
- package/package.json +2 -2
- package/src/history-server.ts +271 -74
- package/src/index.ts +115 -62
- package/src/timings-file.ts +60 -30
package/README.md
CHANGED
|
@@ -36,7 +36,8 @@ A recorded p50 always wins over an assumption, and assumptions never feed the wo
|
|
|
36
36
|
`url` (or `VX_HISTORY_URL`), its base URL without `/v1`, names a
|
|
37
37
|
[`@vzn/vx-history-server`](https://github.com/vznjs/vx/tree/main/packages/vx-history-server),
|
|
38
38
|
over `https://` (`http://` only to `127.0.0.1`, `::1` or `localhost`: every
|
|
39
|
-
request carries the CI token)
|
|
39
|
+
request carries the CI token), with no `?query` or `#fragment` (each
|
|
40
|
+
route is appended to it).
|
|
40
41
|
On a CI job with a token (GitHub's OIDC token, which needs
|
|
41
42
|
`permissions: id-token: write` to set `ACTIONS_ID_TOKEN_REQUEST_URL`
|
|
42
43
|
and `ACTIONS_ID_TOKEN_REQUEST_TOKEN`, or a GitLab `id_tokens` entry
|
|
@@ -55,10 +56,14 @@ timings of the tasks it ran, once: under DTE that is the orchestrator,
|
|
|
55
56
|
with each worker's measurements. The upload starts as the last task
|
|
56
57
|
ends, beside core's own end of run, and the plugin's teardown waits at
|
|
57
58
|
most 2 s more for it (core bounds a teardown at 3 s); one not done by
|
|
58
|
-
then is dropped and said, with how many timings it held
|
|
59
|
+
then is dropped and said, with how many timings it held and the status
|
|
60
|
+
the server last answered, if one came. A task that shared a crowded worker's
|
|
59
61
|
cores is sent as long as it would have taken alone, and its cores at
|
|
60
62
|
most its machine's, as local history keeps them. A refusal is said with the server's
|
|
61
|
-
words
|
|
63
|
+
words, each token the request sent written `***`. Every warning shows the server's url with a `user:pass@` as
|
|
64
|
+
`***@`. An answer is read no further than the protocol's largest (64 KiB,
|
|
65
|
+
and 2 KiB more per hash a query asks): a longer one, a server's endless
|
|
66
|
+
stream, is refused as `answered over N bytes`. A refused token (401) also names the repository and issuer the
|
|
62
67
|
workspace declares and the audience it must carry, and the server is
|
|
63
68
|
off for the rest of the run. A CI job with no token says so once (on GitHub Actions,
|
|
64
69
|
`GITHUB_ACTIONS`, naming the permission).
|
|
@@ -77,6 +82,17 @@ unset value is left out, and a name the request sets itself
|
|
|
77
82
|
redirect is not followed (it would carry them on): it fails the request,
|
|
78
83
|
naming where it pointed.
|
|
79
84
|
|
|
85
|
+
The same upload carries the flaky record: each cached task the run
|
|
86
|
+
ran, by definition and cache key, with how it ended (`pass`, `fail`,
|
|
87
|
+
or `flaky` when it passed on a retry). A task that ran behind a failed
|
|
88
|
+
dependency (`--continue=always`) is left out: its result is not its
|
|
89
|
+
key's. The server names a task flaky
|
|
90
|
+
when one key failed and later passed, or a run saw it flake, on any
|
|
91
|
+
branch of the repository. The run's query answers which of its tasks
|
|
92
|
+
are flaky, and core gives such a task one retry when it fails, as it
|
|
93
|
+
does a task its local history knows as flaky. So a CI machine that
|
|
94
|
+
starts empty still retries a known flake.
|
|
95
|
+
|
|
80
96
|
```ts
|
|
81
97
|
export default {
|
|
82
98
|
plugins: [
|
|
@@ -127,9 +143,11 @@ The file is `{ "version": 1, "tasks": { "<task id>": <ms> } }`, keys
|
|
|
127
143
|
sorted; an entry that is not a number of ms from 0 to 2^53 is dropped. A p50 recorded on this machine wins over it, and it wins over
|
|
128
144
|
`assume`; like `assume`, it never feeds the workspace median. A task the
|
|
129
145
|
run did not see keeps its time, one another writer put there while the
|
|
130
|
-
run ran included. A
|
|
131
|
-
|
|
132
|
-
|
|
146
|
+
run ran included. A run that executed nothing and restored only tasks
|
|
147
|
+
the file already times leaves it as it was; an all-cached run writes a
|
|
148
|
+
missing one. A file that is not one warns, is read as empty and is never
|
|
149
|
+
written over; one it cannot write warns and the run goes on; `--dry`
|
|
150
|
+
writes nothing.
|
|
133
151
|
|
|
134
152
|
## Reservations learned from history
|
|
135
153
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-history",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.653",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "History-based scheduling for vx — a `schedule` plugin that orders ready tasks by their expected remaining critical path, learned from the local run history.",
|
|
6
6
|
"keywords": [
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@vzn/vx": "^0.0.
|
|
40
|
+
"@vzn/vx": "^0.0.653"
|
|
41
41
|
},
|
|
42
42
|
"repository": {
|
|
43
43
|
"type": "git",
|
package/src/history-server.ts
CHANGED
|
@@ -15,6 +15,35 @@ export interface ServerTiming {
|
|
|
15
15
|
readonly rssBytes?: number
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** One cached task's run, as the server's flaky record keeps it (PROTOCOL.md "Outcomes"). */
|
|
19
|
+
export interface ServerOutcome {
|
|
20
|
+
/** The task's definition hash. */
|
|
21
|
+
readonly hash: string
|
|
22
|
+
/** The run's cache key. */
|
|
23
|
+
readonly key: string
|
|
24
|
+
readonly outcome: 'pass' | 'fail' | 'flaky'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What a query answers: each held hash's numbers, and the hashes asked that are flaky. */
|
|
28
|
+
export interface Answer {
|
|
29
|
+
readonly timings: Map<string, ServerTiming>
|
|
30
|
+
readonly flaky: Set<string>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A cached task the run ran, as the flaky record takes it; else undefined.
|
|
35
|
+
* A hit ran nothing, and a task behind a failure ran on bytes its key never
|
|
36
|
+
* named: neither says anything of the key.
|
|
37
|
+
*/
|
|
38
|
+
export function outcomeOf(o: TaskOutcome, def: string): ServerOutcome | undefined {
|
|
39
|
+
if (o.node.config.cache !== true || o.hash === undefined || o.hash === '') return undefined
|
|
40
|
+
if (o.blockedBy !== undefined || o.taintedBy !== undefined) return undefined
|
|
41
|
+
if (o.status === 'failed') return { hash: def, key: o.hash, outcome: 'fail' }
|
|
42
|
+
if (o.status !== 'success') return undefined
|
|
43
|
+
const flaky = (o.attempts ?? 1) > 1 || o.flaky !== undefined
|
|
44
|
+
return { hash: def, key: o.hash, outcome: flaky ? 'flaky' : 'pass' }
|
|
45
|
+
}
|
|
46
|
+
|
|
18
47
|
/** Every request's deadline: a run never waits on history longer. */
|
|
19
48
|
const DEADLINE_MS = 10_000
|
|
20
49
|
/** Tries of a request, to the server or GitHub's OIDC endpoint, before it fails (`tried`). */
|
|
@@ -79,10 +108,56 @@ const printable = (s: string): string =>
|
|
|
79
108
|
// oxlint-disable-next-line no-control-regex
|
|
80
109
|
s.replace(/[\x00-\x1f\x7f-\x9f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
|
81
110
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
111
|
+
/**
|
|
112
|
+
* A body's first `max` bytes as text, the rest cancelled unread, and
|
|
113
|
+
* whether there was more: a server's endless answer grew vx to 3 GB in
|
|
114
|
+
* 10 s (vx-cache's `upTo`).
|
|
115
|
+
*/
|
|
116
|
+
async function upTo(res: Response, max: number): Promise<{ text: string; over: boolean }> {
|
|
117
|
+
const reader = res.body?.getReader()
|
|
118
|
+
if (reader === undefined) return { text: '', over: false }
|
|
119
|
+
const parts: Uint8Array[] = []
|
|
120
|
+
let n = 0
|
|
121
|
+
for (;;) {
|
|
122
|
+
const { done, value } = await reader.read()
|
|
123
|
+
if (done) break
|
|
124
|
+
parts.push(value)
|
|
125
|
+
n += value.byteLength
|
|
126
|
+
if (n > max) {
|
|
127
|
+
// quiet: the answer is refused for its size; its cancel adds nothing
|
|
128
|
+
await reader.cancel().catch(() => {})
|
|
129
|
+
return { text: Buffer.concat(parts).subarray(0, max).toString('utf8'), over: true }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { text: Buffer.concat(parts).toString('utf8'), over: false }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A CI token is a few hundred bytes: room to spare. */
|
|
136
|
+
const SMALL_ANSWER_BYTES = 64 * 1024
|
|
137
|
+
/**
|
|
138
|
+
* A query's answer per hash asked: its row (the hash, up to 512 bytes, and
|
|
139
|
+
* four numbers) and its place in `flaky` are under 1 200 bytes in JSON.
|
|
140
|
+
*/
|
|
141
|
+
const QUERY_BYTES_PER_HASH = 2048
|
|
142
|
+
|
|
143
|
+
/** Shorter values are not hidden, as core masks none under 6 characters: each would blank common words. */
|
|
144
|
+
const MIN_TOKEN_CHARS = 6
|
|
145
|
+
|
|
146
|
+
/** `text` with each of `tokens` written `***`. */
|
|
147
|
+
function hideTokens(text: string, tokens: Iterable<string>): string {
|
|
148
|
+
let out = text
|
|
149
|
+
for (const token of tokens)
|
|
150
|
+
if (token.length >= MIN_TOKEN_CHARS) out = out.replaceAll(token, '***')
|
|
151
|
+
return out
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A refusal, named by its status and the server's own `message`, each
|
|
156
|
+
* token `hide` knows written `***` before the cut, so none is left in part.
|
|
157
|
+
*/
|
|
158
|
+
async function refusal(res: Response, hide: (text: string) => string): Promise<Error> {
|
|
84
159
|
// quiet: with the body unread, the status alone names the refusal
|
|
85
|
-
const text =
|
|
160
|
+
const { text } = await upTo(res, 2048).catch(() => ({ text: '' }))
|
|
86
161
|
let said = text
|
|
87
162
|
try {
|
|
88
163
|
const m = (JSON.parse(text) as { message?: unknown }).message
|
|
@@ -90,13 +165,14 @@ async function refusal(res: Response): Promise<Error> {
|
|
|
90
165
|
} catch {
|
|
91
166
|
// quiet: not JSON, so the text is what it said
|
|
92
167
|
}
|
|
93
|
-
said = printable(said.replace(/\s+/g, ' ').trim().slice(0, 200))
|
|
168
|
+
said = printable(hide(said).replace(/\s+/g, ' ').trim().slice(0, 200))
|
|
94
169
|
return new Error(`HTTP ${res.status}${said === '' ? '' : `: ${said}`}`)
|
|
95
170
|
}
|
|
96
171
|
|
|
97
|
-
/** A 200's JSON; one that is none (a website's page) names the likely fault, the url. */
|
|
98
|
-
async function jsonOf(res: Response, path: string): Promise<unknown> {
|
|
99
|
-
const text = await res
|
|
172
|
+
/** A 200's JSON of at most `max` bytes; one that is none (a website's page) names the likely fault, the url. */
|
|
173
|
+
async function jsonOf(res: Response, path: string, max: number): Promise<unknown> {
|
|
174
|
+
const { text, over } = await upTo(res, max)
|
|
175
|
+
if (over) throw new Error(`${path} answered over ${max} bytes`)
|
|
100
176
|
try {
|
|
101
177
|
return JSON.parse(text)
|
|
102
178
|
} catch {
|
|
@@ -113,9 +189,15 @@ async function jsonOf(res: Response, path: string): Promise<unknown> {
|
|
|
113
189
|
*/
|
|
114
190
|
async function redirected(res: Response, from: string): Promise<Error> {
|
|
115
191
|
await res.body?.cancel()
|
|
116
|
-
const
|
|
192
|
+
const location = res.headers.get('location')
|
|
193
|
+
const to = location === null ? null : new URL(location, from)
|
|
194
|
+
// A relative Location keeps the url's `user:pass@`, and the error reaches the CI log.
|
|
195
|
+
if (to !== null && to.username + to.password !== '') {
|
|
196
|
+
to.username = '***'
|
|
197
|
+
to.password = ''
|
|
198
|
+
}
|
|
117
199
|
return new Error(
|
|
118
|
-
`HTTP ${res.status}${to === null ? '' : ` to ${
|
|
200
|
+
`HTTP ${res.status}${to === null ? '' : ` to ${to.href}`}: not followed, as it would carry the server's headers; name the server's own URL`,
|
|
119
201
|
)
|
|
120
202
|
}
|
|
121
203
|
|
|
@@ -138,17 +220,46 @@ function expiryOf(jwt: string): number {
|
|
|
138
220
|
const isCount = (n: unknown): n is number =>
|
|
139
221
|
typeof n === 'number' && Number.isSafeInteger(n) && n >= 0
|
|
140
222
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
223
|
+
/**
|
|
224
|
+
* A request refused for its size, its refusal naming a smaller limit
|
|
225
|
+
* (`vx-max-*`): sent again in parts of at most `max`.
|
|
226
|
+
*/
|
|
227
|
+
class TooMany extends Error {
|
|
228
|
+
constructor(readonly max: number) {
|
|
229
|
+
super(`at most ${max} a request`)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The limit `header` names, when it is a whole number above 0. */
|
|
234
|
+
function limitOf(res: Response, header: string): number | undefined {
|
|
235
|
+
const n = Number(res.headers.get(header) ?? NaN)
|
|
236
|
+
return Number.isSafeInteger(n) && n > 0 ? n : undefined
|
|
144
237
|
}
|
|
145
238
|
|
|
146
239
|
export class HistoryServer {
|
|
147
240
|
private token: Promise<string> | undefined
|
|
148
241
|
private held: { jwt: string; exp: number } | undefined
|
|
149
|
-
private limits: Promise<Limits> | undefined
|
|
150
242
|
// A refused token is refused again: the run's later calls stay unasked and unsaid.
|
|
151
243
|
private refused = false
|
|
244
|
+
/**
|
|
245
|
+
* Every token this client sent: a server that quoted a request's
|
|
246
|
+
* Authorization header in its refusal printed it to the CI log, as core
|
|
247
|
+
* masks only secret-named variables.
|
|
248
|
+
*/
|
|
249
|
+
private readonly sent = new Set<string>()
|
|
250
|
+
private readonly hidden = (text: string): string => hideTokens(text, this.sent)
|
|
251
|
+
|
|
252
|
+
/** `work`, each error it throws with every token sent hidden: the one way an error leaves the client. */
|
|
253
|
+
private async guard<T>(work: Promise<T>): Promise<T> {
|
|
254
|
+
try {
|
|
255
|
+
return await work
|
|
256
|
+
} catch (err) {
|
|
257
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
258
|
+
const hidden = this.hidden(message)
|
|
259
|
+
if (hidden === message) throw err
|
|
260
|
+
throw new Error(hidden)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
152
263
|
|
|
153
264
|
constructor(
|
|
154
265
|
readonly url: string,
|
|
@@ -180,9 +291,16 @@ export class HistoryServer {
|
|
|
180
291
|
const u = new URL(ask)
|
|
181
292
|
u.searchParams.set('audience', new URL(this.url).origin)
|
|
182
293
|
const res = await this.askGithub(u, signal)
|
|
183
|
-
if (!res.ok)
|
|
184
|
-
|
|
185
|
-
const
|
|
294
|
+
if (!res.ok)
|
|
295
|
+
throw new Error(`GitHub's OIDC endpoint: ${(await refusal(res, this.hidden)).message}`)
|
|
296
|
+
const { text, over } = await upTo(res, SMALL_ANSWER_BYTES)
|
|
297
|
+
if (over) throw new Error(`GitHub's OIDC endpoint: answered over ${SMALL_ANSWER_BYTES} bytes`)
|
|
298
|
+
let jwt: unknown
|
|
299
|
+
try {
|
|
300
|
+
jwt = (JSON.parse(text) as { value?: unknown } | null)?.value
|
|
301
|
+
} catch {
|
|
302
|
+
// quiet: a body that is not JSON holds no token, said below
|
|
303
|
+
}
|
|
186
304
|
if (typeof jwt !== 'string' || jwt === '') throw new Error("GitHub's OIDC endpoint: no token")
|
|
187
305
|
return { jwt, exp: expiryOf(jwt) }
|
|
188
306
|
})()
|
|
@@ -224,6 +342,7 @@ export class HistoryServer {
|
|
|
224
342
|
|
|
225
343
|
/** GitHub's OIDC endpoint: a 4xx is final. */
|
|
226
344
|
private askGithub(ask: URL, signal?: AbortSignal): Promise<Response> {
|
|
345
|
+
this.sent.add(this.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN ?? '')
|
|
227
346
|
return this.tried(
|
|
228
347
|
() =>
|
|
229
348
|
this.fetchImpl(ask, {
|
|
@@ -234,8 +353,20 @@ export class HistoryServer {
|
|
|
234
353
|
)
|
|
235
354
|
}
|
|
236
355
|
|
|
237
|
-
|
|
356
|
+
/**
|
|
357
|
+
* One request. `sized`: its body's count of items and the `vx-max-*`
|
|
358
|
+
* header that limits them; a refusal naming a smaller limit throws
|
|
359
|
+
* `TooMany`.
|
|
360
|
+
*/
|
|
361
|
+
private async call(
|
|
362
|
+
path: string,
|
|
363
|
+
body?: unknown,
|
|
364
|
+
signal?: AbortSignal,
|
|
365
|
+
sized?: { count: number; header: string },
|
|
366
|
+
answered?: (status: number | undefined) => void,
|
|
367
|
+
): Promise<Response> {
|
|
238
368
|
const bearer = this.writes ? await this.bearer(signal) : ''
|
|
369
|
+
if (bearer !== '') this.sent.add(bearer)
|
|
239
370
|
const json = body === undefined ? undefined : JSON.stringify(body)
|
|
240
371
|
// A 500 is the server's own failure, maybe after the fold: sent again, a
|
|
241
372
|
// run would count twice. The edge's 502, 503 and 504 and a 429 come
|
|
@@ -243,8 +374,9 @@ export class HistoryServer {
|
|
|
243
374
|
// would add seconds to every run's end.
|
|
244
375
|
const url = `${this.url}${path}`
|
|
245
376
|
const res = await this.tried(
|
|
246
|
-
() =>
|
|
247
|
-
|
|
377
|
+
() => {
|
|
378
|
+
answered?.(undefined)
|
|
379
|
+
return this.fetchImpl(url, {
|
|
248
380
|
method: json === undefined ? 'GET' : 'POST',
|
|
249
381
|
headers: {
|
|
250
382
|
...this.headers,
|
|
@@ -254,13 +386,17 @@ export class HistoryServer {
|
|
|
254
386
|
...(json === undefined ? {} : { body: json }),
|
|
255
387
|
signal: deadline(signal),
|
|
256
388
|
redirect: 'manual',
|
|
257
|
-
})
|
|
389
|
+
}).then((r) => {
|
|
390
|
+
answered?.(r.status)
|
|
391
|
+
return r
|
|
392
|
+
})
|
|
393
|
+
},
|
|
258
394
|
(status) => status !== undefined && (status === 429 || (status >= 502 && status <= 504)),
|
|
259
395
|
)
|
|
260
396
|
if (res.status >= 300 && res.status < 400) throw await redirected(res, url)
|
|
261
397
|
if (res.status === 401 && bearer === '') {
|
|
262
398
|
// Off CI: the bare 401 said nothing of why or what to do (2026-10-26).
|
|
263
|
-
const err = await refusal(res)
|
|
399
|
+
const err = await refusal(res, this.hidden)
|
|
264
400
|
const fix =
|
|
265
401
|
this.env.GITHUB_ACTIONS === 'true'
|
|
266
402
|
? 'give the job `permissions: id-token: write`'
|
|
@@ -272,65 +408,105 @@ export class HistoryServer {
|
|
|
272
408
|
if (res.status === 401) {
|
|
273
409
|
// The bare "HTTP 401: wrong audience" named neither what was sent nor what to check.
|
|
274
410
|
this.refused = true
|
|
275
|
-
const err = await refusal(res)
|
|
411
|
+
const err = await refusal(res, this.hidden)
|
|
276
412
|
throw new Error(
|
|
277
413
|
`${err.message}: the token was refused (this workspace names ${this.named.repository} at ${this.named.issuer}; the server's ALLOW must name it, and the token's audience be ${new URL(this.url).origin}); the history server is off for this run`,
|
|
278
414
|
)
|
|
279
415
|
}
|
|
280
|
-
if (!res.ok)
|
|
416
|
+
if (!res.ok) {
|
|
417
|
+
const max = sized === undefined ? undefined : limitOf(res, sized.header)
|
|
418
|
+
if (max !== undefined && sized!.count > max) {
|
|
419
|
+
await res.body?.cancel()
|
|
420
|
+
throw new TooMany(max)
|
|
421
|
+
}
|
|
422
|
+
throw await refusal(res, this.hidden)
|
|
423
|
+
}
|
|
281
424
|
return res
|
|
282
425
|
}
|
|
283
426
|
|
|
284
|
-
/**
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
427
|
+
/**
|
|
428
|
+
* `items` in requests of `send`, whole until an answer names the limit
|
|
429
|
+
* (`vx-max-*`): one refused for its size is sent again in parts of the
|
|
430
|
+
* limit it names, each a smaller one than the last. Nothing is asked apart: `/v1/capabilities` cost
|
|
431
|
+
* every run a request, and the query a round-trip (2026-11-06). A query
|
|
432
|
+
* sends its parts at once; an upload one after another, so a refused
|
|
433
|
+
* part resends no part the server folded.
|
|
434
|
+
*/
|
|
435
|
+
private async parts<T, R>(
|
|
436
|
+
items: readonly T[],
|
|
437
|
+
header: string,
|
|
438
|
+
atOnce: boolean,
|
|
439
|
+
send: (part: readonly T[], sized?: { count: number; header: string }) => Promise<R>,
|
|
440
|
+
): Promise<R[]> {
|
|
441
|
+
let per = this.limits.get(header) ?? items.length
|
|
442
|
+
const sized = (part: readonly T[]) => ({ count: part.length, header })
|
|
443
|
+
const learn = (err: unknown): void => {
|
|
444
|
+
if (!(err instanceof TooMany) || this.limits.get(header) === err.max) throw err
|
|
445
|
+
this.limits.set(header, err.max)
|
|
446
|
+
per = err.max
|
|
447
|
+
}
|
|
448
|
+
const split = (from: readonly T[]) =>
|
|
449
|
+
Array.from({ length: Math.ceil(from.length / per) }, (_, i) =>
|
|
450
|
+
from.slice(i * per, (i + 1) * per),
|
|
451
|
+
)
|
|
452
|
+
if (atOnce) {
|
|
453
|
+
const first = await Promise.allSettled(split(items).map((p) => send(p, sized(p))))
|
|
454
|
+
const refused = first.flatMap((r) => (r.status === 'rejected' ? [r.reason as unknown] : []))
|
|
455
|
+
const failed = refused.find((err) => !(err instanceof TooMany))
|
|
456
|
+
if (failed !== undefined) throw failed
|
|
457
|
+
if (refused.length === 0) return first.map((r) => (r as PromiseFulfilledResult<R>).value)
|
|
458
|
+
learn(refused[0])
|
|
459
|
+
return Promise.all(split(items).map((p) => send(p)))
|
|
460
|
+
}
|
|
461
|
+
const out: R[] = []
|
|
462
|
+
for (let at = 0; at < items.length;) {
|
|
463
|
+
const part = items.slice(at, at + per)
|
|
464
|
+
try {
|
|
465
|
+
out.push(await send(part, sized(part)))
|
|
466
|
+
at += part.length
|
|
467
|
+
} catch (err) {
|
|
468
|
+
learn(err)
|
|
298
469
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
// quiet: the awaited ask rethrows it; this only forgets it
|
|
302
|
-
asked.catch(() => {
|
|
303
|
-
if (this.limits === asked) this.limits = undefined
|
|
304
|
-
})
|
|
305
|
-
return asked
|
|
470
|
+
}
|
|
471
|
+
return out
|
|
306
472
|
}
|
|
307
473
|
|
|
308
|
-
/**
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
): Promise<
|
|
313
|
-
if (hashes.length === 0 || this.refused) return new Map()
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
474
|
+
/** The limits a refusal named this run, by header: later requests keep to them. */
|
|
475
|
+
private readonly limits = new Map<string, number>()
|
|
476
|
+
|
|
477
|
+
/** Each held hash's numbers under the first of `refs` that holds them, and the flaky ones: one request a run. */
|
|
478
|
+
async query(refs: readonly string[], hashes: readonly string[]): Promise<Answer> {
|
|
479
|
+
if (hashes.length === 0 || this.refused) return { timings: new Map(), flaky: new Set() }
|
|
480
|
+
const asked = this.parts(hashes, 'vx-max-query-hashes', true, async (part, sized) => {
|
|
481
|
+
const res = await this.call(
|
|
482
|
+
'/v1/timings/query',
|
|
483
|
+
{ ...this.named, refs, hashes: part },
|
|
484
|
+
undefined,
|
|
485
|
+
sized,
|
|
486
|
+
)
|
|
487
|
+
const max = SMALL_ANSWER_BYTES + part.length * QUERY_BYTES_PER_HASH
|
|
488
|
+
const body = (await jsonOf(res, '/v1/timings/query', max)) as {
|
|
489
|
+
timings?: unknown
|
|
490
|
+
flaky?: unknown
|
|
491
|
+
} | null
|
|
492
|
+
if (typeof body?.timings !== 'object' || body.timings === null) {
|
|
493
|
+
throw new Error('the query answered no `timings` object')
|
|
494
|
+
}
|
|
495
|
+
// A server before the flaky record answers none: nothing is flaky.
|
|
496
|
+
const flaky = Array.isArray(body.flaky)
|
|
497
|
+
? body.flaky.filter((h): h is string => typeof h === 'string')
|
|
498
|
+
: []
|
|
499
|
+
return {
|
|
500
|
+
timings: Object.entries(body.timings as Record<string, ServerTiming | null>),
|
|
501
|
+
flaky,
|
|
502
|
+
}
|
|
503
|
+
})
|
|
504
|
+
const parts = await this.guard(asked)
|
|
329
505
|
// Only numbers a server folds (PROTOCOL.md): `1e309` parses to Infinity,
|
|
330
506
|
// and kept as a forecast it made a NaN weight that failed every later
|
|
331
507
|
// run of the cache in `schedule`.
|
|
332
508
|
const out = new Map<string, ServerTiming>()
|
|
333
|
-
for (const [hash, t] of parts.
|
|
509
|
+
for (const [hash, t] of parts.flatMap((p) => p.timings)) {
|
|
334
510
|
if (!isCount(t?.durationMs)) continue
|
|
335
511
|
out.set(hash, {
|
|
336
512
|
durationMs: t.durationMs,
|
|
@@ -340,16 +516,37 @@ export class HistoryServer {
|
|
|
340
516
|
...(isCount(t.rssBytes) ? { rssBytes: t.rssBytes } : {}),
|
|
341
517
|
})
|
|
342
518
|
}
|
|
343
|
-
return out
|
|
519
|
+
return { timings: out, flaky: new Set(parts.flatMap((p) => p.flaky)) }
|
|
344
520
|
}
|
|
345
521
|
|
|
346
|
-
/**
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
522
|
+
/**
|
|
523
|
+
* The run's timings, by task definition hash, and its outcomes, in
|
|
524
|
+
* uploads the server takes; `stop` abandons them. `answered` hears each
|
|
525
|
+
* try's status, undefined as it is sent.
|
|
526
|
+
*/
|
|
527
|
+
async upload(
|
|
528
|
+
timings: ReadonlyMap<string, ServerTiming>,
|
|
529
|
+
stop?: AbortSignal,
|
|
530
|
+
outcomes: readonly ServerOutcome[] = [],
|
|
531
|
+
answered?: (status: number | undefined) => void,
|
|
532
|
+
): Promise<void> {
|
|
533
|
+
if ((timings.size === 0 && outcomes.length === 0) || this.refused) return
|
|
350
534
|
const all = [...timings].map(([hash, t]) => ({ hash, ...t }))
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
535
|
+
const at = Array.from({ length: Math.max(all.length, outcomes.length) }, (_, i) => i)
|
|
536
|
+
// A refused upload folded nothing: sent again in parts, none counts twice.
|
|
537
|
+
await this.guard(
|
|
538
|
+
this.parts(at, 'vx-max-upload-timings', false, (part, sized) => {
|
|
539
|
+
const from = part[0] ?? 0
|
|
540
|
+
const to = from + part.length
|
|
541
|
+
const some = outcomes.slice(from, to)
|
|
542
|
+
return this.call(
|
|
543
|
+
'/v1/timings/upload',
|
|
544
|
+
{ timings: all.slice(from, to), ...(some.length > 0 ? { outcomes: some } : {}) },
|
|
545
|
+
stop,
|
|
546
|
+
sized,
|
|
547
|
+
answered,
|
|
548
|
+
)
|
|
549
|
+
}),
|
|
550
|
+
)
|
|
354
551
|
}
|
|
355
552
|
}
|
package/src/index.ts
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
import type { CommandContext } from '@vzn/vx'
|
|
34
34
|
import path from 'node:path'
|
|
35
35
|
import type { HistoryRow } from './history-view.js'
|
|
36
|
-
import type { HistoryServer, Named, ServerTiming } from './history-server.js'
|
|
36
|
+
import type { HistoryServer, Named, ServerOutcome, ServerTiming } from './history-server.js'
|
|
37
37
|
|
|
38
38
|
export interface VxHistoryOptions {
|
|
39
39
|
/** How many recent invocations to learn from. Default 20. */
|
|
@@ -189,15 +189,16 @@ const headroomOf = (options: VxHistoryOptions): number => {
|
|
|
189
189
|
return usable(h) ? h : DEFAULT_HEADROOM
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
// The
|
|
193
|
-
|
|
192
|
+
// The file takes each task's p50 alone (`durationsFor`): `resourcesFor`'s
|
|
193
|
+
// grouped reads of the window were 40 of its 55 ms at 8,002 tasks.
|
|
194
|
+
const readDurations = (
|
|
194
195
|
cache: Cache,
|
|
195
196
|
ids: readonly string[],
|
|
196
197
|
options: VxHistoryOptions,
|
|
197
198
|
branch: string | null,
|
|
198
199
|
defs: ReadonlyMap<string, string>,
|
|
199
|
-
): Promise<ReadonlyMap<string,
|
|
200
|
-
localHistory(cache, { recent: windowOf(options), branch, defs }).
|
|
200
|
+
): Promise<ReadonlyMap<string, number>> =>
|
|
201
|
+
localHistory(cache, { recent: windowOf(options), branch, defs }).durationsFor(ids)
|
|
201
202
|
|
|
202
203
|
/** What each task reserves: learned from the history unless `resources: false`, a declared reservation over either. */
|
|
203
204
|
const reservationsFor = (
|
|
@@ -248,13 +249,6 @@ function assumptions(
|
|
|
248
249
|
const isDuration = (ms: unknown): ms is number =>
|
|
249
250
|
typeof ms === 'number' && ms >= 0 && ms <= Number.MAX_SAFE_INTEGER
|
|
250
251
|
|
|
251
|
-
/** The timings file's durations a run can take; the rest are not carried. */
|
|
252
|
-
function carriedOf(read: Readonly<Record<string, number>>): Record<string, number> {
|
|
253
|
-
const out: Record<string, number> = {}
|
|
254
|
-
for (const [id, ms] of Object.entries(read)) if (isDuration(ms)) out[id] = ms
|
|
255
|
-
return out
|
|
256
|
-
}
|
|
257
|
-
|
|
258
252
|
/** Each option `VxHistoryOptions` names, with its kind: derived from the type, so the two cannot drift. */
|
|
259
253
|
const VX_HISTORY_KEYS: PluginOptionKinds<VxHistoryOptions> = {
|
|
260
254
|
window: 'number',
|
|
@@ -290,10 +284,12 @@ function shown(url: string): string {
|
|
|
290
284
|
* http:// to another host sent it in clear.
|
|
291
285
|
*/
|
|
292
286
|
function secure(url: string): boolean {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
287
|
+
// Parsed as the request will be: a scheme in any case, no url it cannot carry.
|
|
288
|
+
const u = URL.parse(url)
|
|
289
|
+
if (u === null || u.host === '') return false
|
|
290
|
+
if (u.protocol === 'https:') return true
|
|
291
|
+
if (u.protocol !== 'http:') return false
|
|
292
|
+
return u.hostname === '127.0.0.1' || u.hostname === '[::1]' || u.hostname === 'localhost'
|
|
297
293
|
}
|
|
298
294
|
|
|
299
295
|
/** `VX_HISTORY_URL` under the option, trailing slashes dropped; undefined when neither names one. */
|
|
@@ -309,6 +305,12 @@ function serverUrlOf(
|
|
|
309
305
|
throw new UserError(
|
|
310
306
|
`vxHistory(): ${from} must be https (http only to 127.0.0.1, ::1 or localhost), got ${shown(url)}`,
|
|
311
307
|
)
|
|
308
|
+
// Each route is appended to the url: past a `?` or `#` it was query or
|
|
309
|
+
// fragment, and every request asked the server's root.
|
|
310
|
+
if (/[?#]/.test(url))
|
|
311
|
+
throw new UserError(
|
|
312
|
+
`vxHistory(): ${from} takes no ?query or #fragment: each route is appended to it, got ${shown(url)}`,
|
|
313
|
+
)
|
|
312
314
|
// Each route is the server's /v1/…: one that names /v1 asked /v1/v1/…, and every answer was a 404.
|
|
313
315
|
if (url.endsWith('/v1'))
|
|
314
316
|
throw new UserError(
|
|
@@ -403,7 +405,7 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
403
405
|
const carried =
|
|
404
406
|
file === undefined
|
|
405
407
|
? {}
|
|
406
|
-
:
|
|
408
|
+
: ((await import('./timings-file.js')).readTimings(file, ctx.warn) ?? {})
|
|
407
409
|
const defs = taskDefinitions(nodes.values())
|
|
408
410
|
if (file !== undefined)
|
|
409
411
|
scheduled = {
|
|
@@ -463,7 +465,8 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
463
465
|
stopImport = () => (late = true)
|
|
464
466
|
const weights = (async () => {
|
|
465
467
|
const hashes = [...new Set(ids.flatMap((id) => defs.get(id) ?? []))]
|
|
466
|
-
const got = await server.query(refs, hashes)
|
|
468
|
+
const { timings: got, flaky } = await server.query(refs, hashes)
|
|
469
|
+
if (flaky.size > 0 && !late) ctx.flaky(ids.filter((id) => flaky.has(defs.get(id) ?? '')))
|
|
467
470
|
const timings = new Map<string, number>()
|
|
468
471
|
const needs = new Map<string, TaskResources>()
|
|
469
472
|
for (const id of ids) {
|
|
@@ -486,7 +489,7 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
486
489
|
})().catch((err: unknown) => {
|
|
487
490
|
if (!late) {
|
|
488
491
|
ctx.warn(
|
|
489
|
-
`[vx] vx-history: the timings of the history server at ${server.url} were not applied: ${err instanceof Error ? err.message : String(err)}`,
|
|
492
|
+
`[vx] vx-history: the timings of the history server at ${shown(server.url)} were not applied: ${err instanceof Error ? err.message : String(err)}`,
|
|
490
493
|
)
|
|
491
494
|
}
|
|
492
495
|
return undefined
|
|
@@ -528,25 +531,39 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
528
531
|
defs: ReadonlyMap<string, string>
|
|
529
532
|
}
|
|
530
533
|
| undefined
|
|
531
|
-
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
let
|
|
535
|
-
|
|
534
|
+
// The run's side of the file, from `setup`: the tasks it decided, and
|
|
535
|
+
// what reads the history when core asked no `schedule` (every task a
|
|
536
|
+
// confirmed local hit), so a missing file is written then too.
|
|
537
|
+
let fileRun:
|
|
538
|
+
| {
|
|
539
|
+
file: string
|
|
540
|
+
root: string
|
|
541
|
+
warn: (m: string) => void
|
|
542
|
+
cacheDir: string
|
|
543
|
+
branch: string | null
|
|
544
|
+
nodes: Map<string, TaskNode>
|
|
545
|
+
ran: boolean
|
|
546
|
+
}
|
|
547
|
+
| undefined
|
|
536
548
|
hooks.setup = async (ctx) => {
|
|
537
549
|
remote ??= await serverOf()
|
|
538
550
|
if (remote?.writes === true) {
|
|
539
|
-
const { timingOf: timing } = await import('./history-server.js')
|
|
551
|
+
const { timingOf: timing, outcomeOf } = await import('./history-server.js')
|
|
540
552
|
// Only the run that ran the tasks hears them: under DTE, the
|
|
541
553
|
// orchestrator, with each worker's measurements.
|
|
542
554
|
const timings = new Map<string, ServerTiming>()
|
|
543
|
-
|
|
555
|
+
// Each task and key once, as the server takes them.
|
|
556
|
+
const outcomes = new Map<string, ServerOutcome>()
|
|
557
|
+
const sent = (reported = { timings, outcomes, warn: ctx.warn })
|
|
544
558
|
const server = remote
|
|
545
559
|
ctx.on('onTaskComplete', (node, outcome) => {
|
|
546
560
|
if (node.config.exec?.command === undefined) return
|
|
547
561
|
const def = taskDefinitions([node]).get(node.id)
|
|
562
|
+
if (def === undefined) return
|
|
548
563
|
const t = timing(outcome)
|
|
549
|
-
if (
|
|
564
|
+
if (t !== undefined) timings.set(def, t)
|
|
565
|
+
const o = outcomeOf(outcome, def)
|
|
566
|
+
if (o !== undefined) outcomes.set(`${o.hash}\0${o.key}`, o)
|
|
550
567
|
})
|
|
551
568
|
// Every outcome is known here, and core's own end of run (history
|
|
552
569
|
// rows, cache uploads, telemetry) is still ahead: sent from teardown,
|
|
@@ -567,16 +584,22 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
567
584
|
)
|
|
568
585
|
}
|
|
569
586
|
if (options.file === undefined) return
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
587
|
+
const run: NonNullable<typeof fileRun> = {
|
|
588
|
+
file: path.resolve(ctx.workspaceRoot, options.file),
|
|
589
|
+
root: ctx.workspaceRoot,
|
|
590
|
+
warn: ctx.warn,
|
|
591
|
+
cacheDir: ctx.cacheDir,
|
|
592
|
+
branch: ctx.branch,
|
|
593
|
+
nodes: new Map(),
|
|
594
|
+
ran: false,
|
|
595
|
+
}
|
|
596
|
+
fileRun = run
|
|
573
597
|
ctx.on('onTaskComplete', (node, outcome) => {
|
|
574
598
|
// A group runs nothing and is never timed: counting it rewrote the
|
|
575
599
|
// file on every all-cached run that went through one.
|
|
576
600
|
if (node.config.exec?.command === undefined) return
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
}
|
|
601
|
+
run.nodes.set(node.id, node)
|
|
602
|
+
if (outcome.status === 'success') run.ran = true
|
|
580
603
|
})
|
|
581
604
|
}
|
|
582
605
|
// After the run's history is recorded and before its cache closes.
|
|
@@ -591,33 +614,56 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
591
614
|
uploading = undefined
|
|
592
615
|
// Counted from here: the file's write below shares the bound.
|
|
593
616
|
const waited = server !== undefined && sent !== undefined ? waitFor(UPLOAD_WAIT_MS) : undefined
|
|
594
|
-
const
|
|
595
|
-
const
|
|
617
|
+
const sched = scheduled
|
|
618
|
+
const done = fileRun
|
|
596
619
|
scheduled = undefined
|
|
597
|
-
|
|
620
|
+
fileRun = undefined
|
|
598
621
|
// The file first: core bounds a teardown (3 s), and an upload ahead of
|
|
599
622
|
// it held the write hostage to the server's network. A failed write
|
|
600
623
|
// still sends.
|
|
601
624
|
try {
|
|
602
|
-
if (
|
|
603
|
-
const table = await readHistory(done.cache, done.ids, options, done.branch, done.defs)
|
|
625
|
+
if (done !== undefined && done.nodes.size > 0) {
|
|
604
626
|
const timings = await import('./timings-file.js')
|
|
605
|
-
//
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
//
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
627
|
+
// With no `schedule`, the file is first read here, and its fault said here.
|
|
628
|
+
let now = sched === undefined ? timings.readTimings(done.file, done.warn) : undefined
|
|
629
|
+
const carried = sched?.carried ?? now ?? {}
|
|
630
|
+
// A run that executed nothing and restored only tasks the file already
|
|
631
|
+
// times leaves it as it was: an all-cached CI run re-reads no history.
|
|
632
|
+
if (done.ran || [...done.nodes.keys()].some((id) => !(id in carried))) {
|
|
633
|
+
// Over the file as it is now: a time another writer (a run in another
|
|
634
|
+
// worktree sharing it) put there since `schedule` read it is kept. Its
|
|
635
|
+
// fault, if any, was said when the run read it.
|
|
636
|
+
if (sched !== undefined) now = timings.readTimings(done.file, () => {})
|
|
637
|
+
// Not a timings file (a mistyped `file`): never written over.
|
|
638
|
+
if (now !== undefined) {
|
|
639
|
+
// Asked now, answered beside the read and the write.
|
|
640
|
+
const ignored = timings.askIgnored(done.file, done.root)
|
|
641
|
+
const cache = sched?.cache ?? new Cache(done.cacheDir)
|
|
642
|
+
let table: ReadonlyMap<string, number>
|
|
643
|
+
try {
|
|
644
|
+
table = await readDurations(
|
|
645
|
+
cache,
|
|
646
|
+
sched?.ids ?? [...done.nodes.keys()],
|
|
647
|
+
options,
|
|
648
|
+
sched?.branch ?? done.branch,
|
|
649
|
+
sched?.defs ?? taskDefinitions(done.nodes.values()),
|
|
650
|
+
)
|
|
651
|
+
} finally {
|
|
652
|
+
if (sched === undefined) cache.close()
|
|
653
|
+
}
|
|
654
|
+
let written = true
|
|
655
|
+
try {
|
|
656
|
+
timings.writeTimings(done.file, { ...carried, ...now }, table)
|
|
657
|
+
} catch (err) {
|
|
658
|
+
// Only the file is lost: said as the read is, not as a plugin failure.
|
|
659
|
+
written = false
|
|
660
|
+
done.warn(
|
|
661
|
+
`[vx] vx-history: timings file ${done.file} not written: ${err instanceof Error ? err.message : String(err)}`,
|
|
662
|
+
)
|
|
663
|
+
}
|
|
664
|
+
if (written) await timings.warnUnignored(done.file, done.root, done.warn, ignored)
|
|
665
|
+
}
|
|
618
666
|
}
|
|
619
|
-
if (written && fileWarn !== undefined)
|
|
620
|
-
timings.warnUnignored(done.file, fileWarn.root, fileWarn.warn)
|
|
621
667
|
}
|
|
622
668
|
} finally {
|
|
623
669
|
if (server !== undefined && sent !== undefined && waited !== undefined) {
|
|
@@ -627,8 +673,9 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
627
673
|
if (late) {
|
|
628
674
|
u.stop.abort()
|
|
629
675
|
const n = sent.timings.size
|
|
676
|
+
const status = u.answered()
|
|
630
677
|
sent.warn(
|
|
631
|
-
`[vx] vx-history: the timings of ${n} task${n === 1 ? '' : 's'} were not sent to the history server at ${server.url}: no answer in the ${UPLOAD_WAIT_MS} ms a teardown waits`,
|
|
678
|
+
`[vx] vx-history: the timings of ${n} task${n === 1 ? '' : 's'} were not sent to the history server at ${shown(server.url)}: ${status === undefined ? `no answer in the ${UPLOAD_WAIT_MS} ms a teardown waits` : `it answered HTTP ${status}, and the ${UPLOAD_WAIT_MS} ms a teardown waits ran out before it was asked again`}`,
|
|
632
679
|
)
|
|
633
680
|
}
|
|
634
681
|
}
|
|
@@ -659,6 +706,7 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
659
706
|
|
|
660
707
|
interface Reported {
|
|
661
708
|
readonly timings: Map<string, ServerTiming>
|
|
709
|
+
readonly outcomes: ReadonlyMap<string, ServerOutcome>
|
|
662
710
|
readonly warn: (m: string) => void
|
|
663
711
|
}
|
|
664
712
|
|
|
@@ -666,6 +714,8 @@ interface Upload {
|
|
|
666
714
|
/** Settles when sent, or once a failure is said; never rejects. */
|
|
667
715
|
readonly done: Promise<void>
|
|
668
716
|
readonly stop: AbortController
|
|
717
|
+
/** The status the upload's latest try answered; undefined while it waits for one. */
|
|
718
|
+
readonly answered: () => number | undefined
|
|
669
719
|
}
|
|
670
720
|
|
|
671
721
|
/**
|
|
@@ -677,14 +727,17 @@ const UPLOAD_WAIT_MS = 2_000
|
|
|
677
727
|
|
|
678
728
|
function sendTimings(server: HistoryServer, sent: Reported): Upload {
|
|
679
729
|
const stop = new AbortController()
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
730
|
+
let answered: number | undefined
|
|
731
|
+
const done = server
|
|
732
|
+
.upload(sent.timings, stop.signal, [...sent.outcomes.values()], (s) => (answered = s))
|
|
733
|
+
.catch((err: unknown) => {
|
|
734
|
+
// Abandoned by teardown, which says so itself.
|
|
735
|
+
if (stop.signal.aborted) return
|
|
736
|
+
sent.warn(
|
|
737
|
+
`[vx] vx-history: the timings were not sent to the history server at ${shown(server.url)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
738
|
+
)
|
|
739
|
+
})
|
|
740
|
+
return { done, stop, answered: () => answered }
|
|
688
741
|
}
|
|
689
742
|
|
|
690
743
|
/** Resolves true after `ms`, unless cancelled first. */
|
package/src/timings-file.ts
CHANGED
|
@@ -4,12 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
6
6
|
import path from 'node:path'
|
|
7
|
-
import type { TaskResources } from '@vzn/vx'
|
|
8
7
|
|
|
9
8
|
const VERSION = 1
|
|
10
9
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The file's durations; none when it is absent or unread (with a warning),
|
|
12
|
+
* and undefined, with a warning, when it is a file but not a timings file:
|
|
13
|
+
* not one to write over.
|
|
14
|
+
*/
|
|
15
|
+
export function readTimings(
|
|
16
|
+
file: string,
|
|
17
|
+
warn: (m: string) => void,
|
|
18
|
+
): Record<string, number> | undefined {
|
|
13
19
|
let text: string
|
|
14
20
|
try {
|
|
15
21
|
text = readFileSync(file, 'utf8')
|
|
@@ -35,32 +41,34 @@ export function readTimings(file: string, warn: (m: string) => void): Record<str
|
|
|
35
41
|
warn(
|
|
36
42
|
`[vx] vx-history: timings file ${file} ignored: not { "version": ${VERSION}, "tasks": { id: ms } }`,
|
|
37
43
|
)
|
|
38
|
-
return
|
|
44
|
+
return undefined
|
|
39
45
|
}
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
// Filtered in place: the parse is this read's own, and a copy of an
|
|
47
|
+
// 8,002-task file, filtered again by the caller, was ~6 ms of each
|
|
48
|
+
// all-cached run's teardown (2026-11-07).
|
|
49
|
+
const out = tasks as Record<string, unknown>
|
|
50
|
+
for (const id in out) {
|
|
51
|
+
const ms = out[id]
|
|
42
52
|
// Bounded so no chain of them sums past a number: core refuses a
|
|
43
53
|
// weight that is not finite, and the run failed on a hint.
|
|
44
|
-
if (typeof ms === 'number' && ms >= 0 && ms <= Number.MAX_SAFE_INTEGER) out[id]
|
|
54
|
+
if (!(typeof ms === 'number' && ms >= 0 && ms <= Number.MAX_SAFE_INTEGER)) delete out[id]
|
|
45
55
|
}
|
|
46
|
-
return out
|
|
56
|
+
return out as Record<string, number>
|
|
47
57
|
}
|
|
48
58
|
|
|
49
59
|
/**
|
|
50
|
-
* The carried durations with this machine's p50s over
|
|
51
|
-
* a task this run did not see keeps its carried time.
|
|
52
|
-
* rename, so a reader never sees half a file. Its
|
|
53
|
-
* cache that missed restores none.
|
|
60
|
+
* The carried durations with this machine's p50s (`durationsFor`) over
|
|
61
|
+
* them, written whole: a task this run did not see keeps its carried time.
|
|
62
|
+
* Through a temp and a rename, so a reader never sees half a file. Its
|
|
63
|
+
* directory is made: a CI cache that missed restores none.
|
|
54
64
|
*/
|
|
55
65
|
export function writeTimings(
|
|
56
66
|
file: string,
|
|
57
67
|
carried: Readonly<Record<string, number>>,
|
|
58
|
-
|
|
68
|
+
p50s: ReadonlyMap<string, number>,
|
|
59
69
|
): void {
|
|
60
70
|
const merged: Record<string, number> = { ...carried }
|
|
61
|
-
for (const [id,
|
|
62
|
-
if (h.p50DurationMs !== undefined) merged[id] = Math.round(h.p50DurationMs)
|
|
63
|
-
}
|
|
71
|
+
for (const [id, ms] of p50s) merged[id] = Math.round(ms)
|
|
64
72
|
const tasks: Record<string, number> = {}
|
|
65
73
|
for (const id of Object.keys(merged).sort()) tasks[id] = merged[id]!
|
|
66
74
|
const tmp = `${file}.${process.pid}.tmp`
|
|
@@ -75,23 +83,45 @@ export function writeTimings(
|
|
|
75
83
|
}
|
|
76
84
|
|
|
77
85
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
86
|
+
* Whether git ignores `file`, asked now and answered when git exits: true
|
|
87
|
+
* for a file outside the root, no repository, or a git that cannot run,
|
|
88
|
+
* which say nothing of it. Started before the history read, the check runs
|
|
89
|
+
* beside it: asked after the write, it held the run's end 7-10 ms.
|
|
81
90
|
*/
|
|
82
|
-
export function
|
|
91
|
+
export function askIgnored(file: string, root: string): Promise<boolean> {
|
|
83
92
|
const rel = path.relative(root, file)
|
|
84
93
|
// `..x.json` is a name inside the root, not a step out of it.
|
|
85
|
-
if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))
|
|
95
|
+
return Promise.resolve(true)
|
|
96
|
+
try {
|
|
97
|
+
const p = Bun.spawn({
|
|
98
|
+
cmd: ['git', 'check-ignore', '-q', '--', rel],
|
|
99
|
+
cwd: root,
|
|
100
|
+
stdin: 'ignore',
|
|
101
|
+
stdout: 'ignore',
|
|
102
|
+
stderr: 'ignore',
|
|
103
|
+
})
|
|
104
|
+
// 0: ignored; 128: no repository or git refused.
|
|
105
|
+
return p.exited.then((code) => code !== 1)
|
|
106
|
+
} catch {
|
|
107
|
+
// quiet: no git to run is no workspace git, which ignores nothing to warn of.
|
|
108
|
+
return Promise.resolve(true)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Says so when git does not ignore the file it rewrote: a task whose
|
|
114
|
+
* inputs cover it (a root project's `**`) keyed each run's new timings,
|
|
115
|
+
* and missed the run after every write.
|
|
116
|
+
*/
|
|
117
|
+
export async function warnUnignored(
|
|
118
|
+
file: string,
|
|
119
|
+
root: string,
|
|
120
|
+
warn: (m: string) => void,
|
|
121
|
+
ignored: Promise<boolean> = askIgnored(file, root),
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
if (await ignored) return
|
|
94
124
|
warn(
|
|
95
|
-
`[vx] vx-history: timings file ${
|
|
125
|
+
`[vx] vx-history: timings file ${path.relative(root, file)} is not git-ignored, so a task whose inputs cover it re-keys on every rewrite — add it to .gitignore`,
|
|
96
126
|
)
|
|
97
127
|
}
|