@vzn/vx-history 0.0.650 → 0.0.652
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 +13 -1
- package/package.json +2 -2
- package/src/history-server.ts +65 -16
- package/src/index.ts +54 -21
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
|
|
@@ -47,6 +48,8 @@ for its tasks' numbers, by task definition, in the refs the run reads,
|
|
|
47
48
|
as a remote cache does: its branch's, the branch it merges into, then
|
|
48
49
|
the default branch's (`baseBranch`, else git's or the CI's). A run
|
|
49
50
|
that reads no ref (a detached HEAD with no default branch) asks nothing.
|
|
51
|
+
A ref over the 512 characters a server takes is said and not read;
|
|
52
|
+
the run reads the others.
|
|
50
53
|
The run starts on its local history and re-ranks the tasks not yet
|
|
51
54
|
started when they land; it never waits for them. At its end it sends the
|
|
52
55
|
timings of the tasks it ran, once: under DTE that is the orchestrator,
|
|
@@ -75,6 +78,15 @@ unset value is left out, and a name the request sets itself
|
|
|
75
78
|
redirect is not followed (it would carry them on): it fails the request,
|
|
76
79
|
naming where it pointed.
|
|
77
80
|
|
|
81
|
+
The same upload carries the flaky record: each cached task the run
|
|
82
|
+
ran, by definition and cache key, with how it ended (`pass`, `fail`,
|
|
83
|
+
or `flaky` when it passed on a retry). The server names a task flaky
|
|
84
|
+
when one key failed and later passed, or a run saw it flake, on any
|
|
85
|
+
branch of the repository. The run's query answers which of its tasks
|
|
86
|
+
are flaky, and core gives such a task one retry when it fails, as it
|
|
87
|
+
does a task its local history knows as flaky. So a CI machine that
|
|
88
|
+
starts empty still retries a known flake.
|
|
89
|
+
|
|
78
90
|
```ts
|
|
79
91
|
export default {
|
|
80
92
|
plugins: [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-history",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.652",
|
|
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.652"
|
|
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 === undefined || o.hash === undefined || o.hash === '') return undefined
|
|
40
|
+
if (o.blockedBy !== 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`). */
|
|
@@ -181,7 +210,8 @@ export class HistoryServer {
|
|
|
181
210
|
u.searchParams.set('audience', new URL(this.url).origin)
|
|
182
211
|
const res = await this.askGithub(u, signal)
|
|
183
212
|
if (!res.ok) throw new Error(`GitHub's OIDC endpoint: ${(await refusal(res)).message}`)
|
|
184
|
-
|
|
213
|
+
// quiet: a body that is not JSON holds no token, said below
|
|
214
|
+
const jwt = ((await res.json().catch(() => null)) as { value?: unknown } | null)?.value
|
|
185
215
|
if (typeof jwt !== 'string' || jwt === '') throw new Error("GitHub's OIDC endpoint: no token")
|
|
186
216
|
return { jwt, exp: expiryOf(jwt) }
|
|
187
217
|
})()
|
|
@@ -304,12 +334,9 @@ export class HistoryServer {
|
|
|
304
334
|
return asked
|
|
305
335
|
}
|
|
306
336
|
|
|
307
|
-
/** Each held hash's numbers under the first of `refs` that holds them: one request a run. */
|
|
308
|
-
async query(
|
|
309
|
-
|
|
310
|
-
hashes: readonly string[],
|
|
311
|
-
): Promise<Map<string, ServerTiming>> {
|
|
312
|
-
if (hashes.length === 0 || this.refused) return new Map()
|
|
337
|
+
/** Each held hash's numbers under the first of `refs` that holds them, and the flaky ones: one request a run. */
|
|
338
|
+
async query(refs: readonly string[], hashes: readonly string[]): Promise<Answer> {
|
|
339
|
+
if (hashes.length === 0 || this.refused) return { timings: new Map(), flaky: new Set() }
|
|
313
340
|
const per = (await this.limitsNow()).maxQueryHashes
|
|
314
341
|
const parts = await Promise.all(
|
|
315
342
|
Array.from({ length: Math.ceil(hashes.length / per) }, async (_, i) => {
|
|
@@ -318,18 +345,28 @@ export class HistoryServer {
|
|
|
318
345
|
refs,
|
|
319
346
|
hashes: hashes.slice(i * per, (i + 1) * per),
|
|
320
347
|
})
|
|
321
|
-
const body = (await jsonOf(res, '/v1/timings/query')) as {
|
|
348
|
+
const body = (await jsonOf(res, '/v1/timings/query')) as {
|
|
349
|
+
timings?: unknown
|
|
350
|
+
flaky?: unknown
|
|
351
|
+
} | null
|
|
322
352
|
if (typeof body?.timings !== 'object' || body.timings === null) {
|
|
323
353
|
throw new Error('the query answered no `timings` object')
|
|
324
354
|
}
|
|
325
|
-
|
|
355
|
+
// A server before the flaky record answers none: nothing is flaky.
|
|
356
|
+
const flaky = Array.isArray(body.flaky)
|
|
357
|
+
? body.flaky.filter((h): h is string => typeof h === 'string')
|
|
358
|
+
: []
|
|
359
|
+
return {
|
|
360
|
+
timings: Object.entries(body.timings as Record<string, ServerTiming | null>),
|
|
361
|
+
flaky,
|
|
362
|
+
}
|
|
326
363
|
}),
|
|
327
364
|
)
|
|
328
365
|
// Only numbers a server folds (PROTOCOL.md): `1e309` parses to Infinity,
|
|
329
366
|
// and kept as a forecast it made a NaN weight that failed every later
|
|
330
367
|
// run of the cache in `schedule`.
|
|
331
368
|
const out = new Map<string, ServerTiming>()
|
|
332
|
-
for (const [hash, t] of parts.
|
|
369
|
+
for (const [hash, t] of parts.flatMap((p) => p.timings)) {
|
|
333
370
|
if (!isCount(t?.durationMs)) continue
|
|
334
371
|
out.set(hash, {
|
|
335
372
|
durationMs: t.durationMs,
|
|
@@ -339,16 +376,28 @@ export class HistoryServer {
|
|
|
339
376
|
...(isCount(t.rssBytes) ? { rssBytes: t.rssBytes } : {}),
|
|
340
377
|
})
|
|
341
378
|
}
|
|
342
|
-
return out
|
|
379
|
+
return { timings: out, flaky: new Set(parts.flatMap((p) => p.flaky)) }
|
|
343
380
|
}
|
|
344
381
|
|
|
345
|
-
/**
|
|
346
|
-
|
|
347
|
-
|
|
382
|
+
/**
|
|
383
|
+
* The run's timings, by task definition hash, and its outcomes, in
|
|
384
|
+
* uploads of at most `maxUploadTimings` of each; `stop` abandons them.
|
|
385
|
+
*/
|
|
386
|
+
async upload(
|
|
387
|
+
timings: ReadonlyMap<string, ServerTiming>,
|
|
388
|
+
stop?: AbortSignal,
|
|
389
|
+
outcomes: readonly ServerOutcome[] = [],
|
|
390
|
+
): Promise<void> {
|
|
391
|
+
if ((timings.size === 0 && outcomes.length === 0) || this.refused) return
|
|
348
392
|
const per = (await this.limitsNow()).maxUploadTimings
|
|
349
393
|
const all = [...timings].map(([hash, t]) => ({ hash, ...t }))
|
|
350
|
-
for (let i = 0; i < all.length; i += per) {
|
|
351
|
-
|
|
394
|
+
for (let i = 0; i < Math.max(all.length, outcomes.length); i += per) {
|
|
395
|
+
const part = outcomes.slice(i, i + per)
|
|
396
|
+
await this.call(
|
|
397
|
+
'/v1/timings/upload',
|
|
398
|
+
{ timings: all.slice(i, i + per), ...(part.length > 0 ? { outcomes: part } : {}) },
|
|
399
|
+
stop,
|
|
400
|
+
)
|
|
352
401
|
}
|
|
353
402
|
}
|
|
354
403
|
}
|
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. */
|
|
@@ -109,6 +109,9 @@ export interface VxHistoryOptions {
|
|
|
109
109
|
// hint needs less and reads a 2.5× smaller slice of the run history.
|
|
110
110
|
const DEFAULT_WINDOW = 20
|
|
111
111
|
|
|
112
|
+
/** The longest ref a history server takes in a query (its PROTOCOL.md). */
|
|
113
|
+
const REF_MAX = 512
|
|
114
|
+
|
|
112
115
|
// Each option is read in ONE place that a run's hooks and `vx history`
|
|
113
116
|
// share: two copies of each let either drift unseen (item 805).
|
|
114
117
|
const windowOf = (options: VxHistoryOptions): number =>
|
|
@@ -277,15 +280,22 @@ function namedOf(issuer: unknown, repository: unknown): Named {
|
|
|
277
280
|
return { issuer: issuer.replace(/\/$/, ''), repository: repository.toLowerCase() }
|
|
278
281
|
}
|
|
279
282
|
|
|
283
|
+
/** `url` as a refusal may print it: a `user:pass@` would reach the CI log. */
|
|
284
|
+
function shown(url: string): string {
|
|
285
|
+
return url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/?#]*@/i, '$1***@')
|
|
286
|
+
}
|
|
287
|
+
|
|
280
288
|
/**
|
|
281
289
|
* https, or http to this machine: every request carries the CI token, and
|
|
282
290
|
* http:// to another host sent it in clear.
|
|
283
291
|
*/
|
|
284
292
|
function secure(url: string): boolean {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
293
|
+
// Parsed as the request will be: a scheme in any case, no url it cannot carry.
|
|
294
|
+
const u = URL.parse(url)
|
|
295
|
+
if (u === null || u.host === '') return false
|
|
296
|
+
if (u.protocol === 'https:') return true
|
|
297
|
+
if (u.protocol !== 'http:') return false
|
|
298
|
+
return u.hostname === '127.0.0.1' || u.hostname === '[::1]' || u.hostname === 'localhost'
|
|
289
299
|
}
|
|
290
300
|
|
|
291
301
|
/** `VX_HISTORY_URL` under the option, trailing slashes dropped; undefined when neither names one. */
|
|
@@ -299,12 +309,18 @@ function serverUrlOf(
|
|
|
299
309
|
const from = option === undefined ? 'VX_HISTORY_URL' : 'url'
|
|
300
310
|
if (!secure(url))
|
|
301
311
|
throw new UserError(
|
|
302
|
-
`vxHistory(): ${from} must be https (http only to 127.0.0.1, ::1 or localhost), got ${url}`,
|
|
312
|
+
`vxHistory(): ${from} must be https (http only to 127.0.0.1, ::1 or localhost), got ${shown(url)}`,
|
|
313
|
+
)
|
|
314
|
+
// Each route is appended to the url: past a `?` or `#` it was query or
|
|
315
|
+
// fragment, and every request asked the server's root.
|
|
316
|
+
if (/[?#]/.test(url))
|
|
317
|
+
throw new UserError(
|
|
318
|
+
`vxHistory(): ${from} takes no ?query or #fragment: each route is appended to it, got ${shown(url)}`,
|
|
303
319
|
)
|
|
304
320
|
// Each route is the server's /v1/…: one that names /v1 asked /v1/v1/…, and every answer was a 404.
|
|
305
321
|
if (url.endsWith('/v1'))
|
|
306
322
|
throw new UserError(
|
|
307
|
-
`vxHistory(): ${from} is the server's base, without /v1: ${url.slice(0, -3)}`,
|
|
323
|
+
`vxHistory(): ${from} is the server's base, without /v1: ${shown(url.slice(0, -3))}`,
|
|
308
324
|
)
|
|
309
325
|
return url
|
|
310
326
|
}
|
|
@@ -440,14 +456,23 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
440
456
|
// branch it merges into, the default branch (`baseBranch`). `main`
|
|
441
457
|
// was assumed, so a `master` or `baseBranch: 'trunk'` repository
|
|
442
458
|
// read no trunk's numbers; a run with none asks nothing.
|
|
443
|
-
|
|
459
|
+
// A ref the server cannot name was a 400 for the whole query, and
|
|
460
|
+
// the run read no ref's numbers, its base branch's too.
|
|
461
|
+
const refs = [...new Set(ctx.refs)].filter((ref) => {
|
|
462
|
+
if (ref.length <= REF_MAX) return true
|
|
463
|
+
ctx.warn(
|
|
464
|
+
`[vx] vx-history: ${ref} is ${ref.length} characters, over the ${REF_MAX} a history server takes: the run does not read it`,
|
|
465
|
+
)
|
|
466
|
+
return false
|
|
467
|
+
})
|
|
444
468
|
if (remote !== undefined && refs.length > 0) {
|
|
445
469
|
const server = remote
|
|
446
470
|
let late = false
|
|
447
471
|
stopImport = () => (late = true)
|
|
448
472
|
const weights = (async () => {
|
|
449
473
|
const hashes = [...new Set(ids.flatMap((id) => defs.get(id) ?? []))]
|
|
450
|
-
const got = await server.query(
|
|
474
|
+
const { timings: got, flaky } = await server.query(refs, hashes)
|
|
475
|
+
if (flaky.size > 0 && !late) ctx.flaky(ids.filter((id) => flaky.has(defs.get(id) ?? '')))
|
|
451
476
|
const timings = new Map<string, number>()
|
|
452
477
|
const needs = new Map<string, TaskResources>()
|
|
453
478
|
for (const id of ids) {
|
|
@@ -520,17 +545,22 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
520
545
|
hooks.setup = async (ctx) => {
|
|
521
546
|
remote ??= await serverOf()
|
|
522
547
|
if (remote?.writes === true) {
|
|
523
|
-
const { timingOf: timing } = await import('./history-server.js')
|
|
548
|
+
const { timingOf: timing, outcomeOf } = await import('./history-server.js')
|
|
524
549
|
// Only the run that ran the tasks hears them: under DTE, the
|
|
525
550
|
// orchestrator, with each worker's measurements.
|
|
526
551
|
const timings = new Map<string, ServerTiming>()
|
|
527
|
-
|
|
552
|
+
// Each task and key once, as the server takes them.
|
|
553
|
+
const outcomes = new Map<string, ServerOutcome>()
|
|
554
|
+
const sent = (reported = { timings, outcomes, warn: ctx.warn })
|
|
528
555
|
const server = remote
|
|
529
556
|
ctx.on('onTaskComplete', (node, outcome) => {
|
|
530
557
|
if (node.config.exec?.command === undefined) return
|
|
531
558
|
const def = taskDefinitions([node]).get(node.id)
|
|
559
|
+
if (def === undefined) return
|
|
532
560
|
const t = timing(outcome)
|
|
533
|
-
if (
|
|
561
|
+
if (t !== undefined) timings.set(def, t)
|
|
562
|
+
const o = outcomeOf(outcome, def)
|
|
563
|
+
if (o !== undefined) outcomes.set(`${o.hash}\0${o.key}`, o)
|
|
534
564
|
})
|
|
535
565
|
// Every outcome is known here, and core's own end of run (history
|
|
536
566
|
// rows, cache uploads, telemetry) is still ahead: sent from teardown,
|
|
@@ -643,6 +673,7 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
643
673
|
|
|
644
674
|
interface Reported {
|
|
645
675
|
readonly timings: Map<string, ServerTiming>
|
|
676
|
+
readonly outcomes: ReadonlyMap<string, ServerOutcome>
|
|
646
677
|
readonly warn: (m: string) => void
|
|
647
678
|
}
|
|
648
679
|
|
|
@@ -661,13 +692,15 @@ const UPLOAD_WAIT_MS = 2_000
|
|
|
661
692
|
|
|
662
693
|
function sendTimings(server: HistoryServer, sent: Reported): Upload {
|
|
663
694
|
const stop = new AbortController()
|
|
664
|
-
const done = server
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
695
|
+
const done = server
|
|
696
|
+
.upload(sent.timings, stop.signal, [...sent.outcomes.values()])
|
|
697
|
+
.catch((err: unknown) => {
|
|
698
|
+
// Abandoned by teardown, which says so itself.
|
|
699
|
+
if (stop.signal.aborted) return
|
|
700
|
+
sent.warn(
|
|
701
|
+
`[vx] vx-history: the timings were not sent to the history server at ${server.url}: ${err instanceof Error ? err.message : String(err)}`,
|
|
702
|
+
)
|
|
703
|
+
})
|
|
671
704
|
return { done, stop }
|
|
672
705
|
}
|
|
673
706
|
|
|
@@ -800,7 +833,7 @@ async function historyCmd(
|
|
|
800
833
|
// A plugin verb owns its help; completions offer `--help` for every verb.
|
|
801
834
|
if (a === '--help' || a === '-h') {
|
|
802
835
|
ui.say('info', 'Usage: vx history [--format pretty|json]', 'out')
|
|
803
|
-
|
|
836
|
+
ui.print('out', ui.gap())
|
|
804
837
|
ui.say(
|
|
805
838
|
'info',
|
|
806
839
|
'What vx-history learned per task (p50, peak RSS, CPU parallelism) and the reservation it packs.',
|
|
@@ -876,7 +909,7 @@ async function historyCmd(
|
|
|
876
909
|
}
|
|
877
910
|
})
|
|
878
911
|
if (format === 'json') {
|
|
879
|
-
|
|
912
|
+
ui.data('out', `${JSON.stringify({ window, budgets, tasks: rows })}\n`)
|
|
880
913
|
return 0
|
|
881
914
|
}
|
|
882
915
|
// The table's module loads with the verb, off every run's path.
|