@vzn/vx-history 0.0.651 → 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 +11 -1
- package/package.json +2 -2
- package/src/history-server.ts +63 -15
- package/src/index.ts +33 -16
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
|
|
@@ -77,6 +78,15 @@ unset value is left out, and a name the request sets itself
|
|
|
77
78
|
redirect is not followed (it would carry them on): it fails the request,
|
|
78
79
|
naming where it pointed.
|
|
79
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
|
+
|
|
80
90
|
```ts
|
|
81
91
|
export default {
|
|
82
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`). */
|
|
@@ -305,12 +334,9 @@ export class HistoryServer {
|
|
|
305
334
|
return asked
|
|
306
335
|
}
|
|
307
336
|
|
|
308
|
-
/** Each held hash's numbers under the first of `refs` that holds them: one request a run. */
|
|
309
|
-
async query(
|
|
310
|
-
|
|
311
|
-
hashes: readonly string[],
|
|
312
|
-
): Promise<Map<string, ServerTiming>> {
|
|
313
|
-
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() }
|
|
314
340
|
const per = (await this.limitsNow()).maxQueryHashes
|
|
315
341
|
const parts = await Promise.all(
|
|
316
342
|
Array.from({ length: Math.ceil(hashes.length / per) }, async (_, i) => {
|
|
@@ -319,18 +345,28 @@ export class HistoryServer {
|
|
|
319
345
|
refs,
|
|
320
346
|
hashes: hashes.slice(i * per, (i + 1) * per),
|
|
321
347
|
})
|
|
322
|
-
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
|
|
323
352
|
if (typeof body?.timings !== 'object' || body.timings === null) {
|
|
324
353
|
throw new Error('the query answered no `timings` object')
|
|
325
354
|
}
|
|
326
|
-
|
|
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
|
+
}
|
|
327
363
|
}),
|
|
328
364
|
)
|
|
329
365
|
// Only numbers a server folds (PROTOCOL.md): `1e309` parses to Infinity,
|
|
330
366
|
// and kept as a forecast it made a NaN weight that failed every later
|
|
331
367
|
// run of the cache in `schedule`.
|
|
332
368
|
const out = new Map<string, ServerTiming>()
|
|
333
|
-
for (const [hash, t] of parts.
|
|
369
|
+
for (const [hash, t] of parts.flatMap((p) => p.timings)) {
|
|
334
370
|
if (!isCount(t?.durationMs)) continue
|
|
335
371
|
out.set(hash, {
|
|
336
372
|
durationMs: t.durationMs,
|
|
@@ -340,16 +376,28 @@ export class HistoryServer {
|
|
|
340
376
|
...(isCount(t.rssBytes) ? { rssBytes: t.rssBytes } : {}),
|
|
341
377
|
})
|
|
342
378
|
}
|
|
343
|
-
return out
|
|
379
|
+
return { timings: out, flaky: new Set(parts.flatMap((p) => p.flaky)) }
|
|
344
380
|
}
|
|
345
381
|
|
|
346
|
-
/**
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
349
392
|
const per = (await this.limitsNow()).maxUploadTimings
|
|
350
393
|
const all = [...timings].map(([hash, t]) => ({ hash, ...t }))
|
|
351
|
-
for (let i = 0; i < all.length; i += per) {
|
|
352
|
-
|
|
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
|
+
)
|
|
353
401
|
}
|
|
354
402
|
}
|
|
355
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. */
|
|
@@ -290,10 +290,12 @@ function shown(url: string): string {
|
|
|
290
290
|
* http:// to another host sent it in clear.
|
|
291
291
|
*/
|
|
292
292
|
function secure(url: string): boolean {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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'
|
|
297
299
|
}
|
|
298
300
|
|
|
299
301
|
/** `VX_HISTORY_URL` under the option, trailing slashes dropped; undefined when neither names one. */
|
|
@@ -309,6 +311,12 @@ function serverUrlOf(
|
|
|
309
311
|
throw new UserError(
|
|
310
312
|
`vxHistory(): ${from} must be https (http only to 127.0.0.1, ::1 or localhost), got ${shown(url)}`,
|
|
311
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)}`,
|
|
319
|
+
)
|
|
312
320
|
// Each route is the server's /v1/…: one that names /v1 asked /v1/v1/…, and every answer was a 404.
|
|
313
321
|
if (url.endsWith('/v1'))
|
|
314
322
|
throw new UserError(
|
|
@@ -463,7 +471,8 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
463
471
|
stopImport = () => (late = true)
|
|
464
472
|
const weights = (async () => {
|
|
465
473
|
const hashes = [...new Set(ids.flatMap((id) => defs.get(id) ?? []))]
|
|
466
|
-
const got = await server.query(refs, hashes)
|
|
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) ?? '')))
|
|
467
476
|
const timings = new Map<string, number>()
|
|
468
477
|
const needs = new Map<string, TaskResources>()
|
|
469
478
|
for (const id of ids) {
|
|
@@ -536,17 +545,22 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
536
545
|
hooks.setup = async (ctx) => {
|
|
537
546
|
remote ??= await serverOf()
|
|
538
547
|
if (remote?.writes === true) {
|
|
539
|
-
const { timingOf: timing } = await import('./history-server.js')
|
|
548
|
+
const { timingOf: timing, outcomeOf } = await import('./history-server.js')
|
|
540
549
|
// Only the run that ran the tasks hears them: under DTE, the
|
|
541
550
|
// orchestrator, with each worker's measurements.
|
|
542
551
|
const timings = new Map<string, ServerTiming>()
|
|
543
|
-
|
|
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 })
|
|
544
555
|
const server = remote
|
|
545
556
|
ctx.on('onTaskComplete', (node, outcome) => {
|
|
546
557
|
if (node.config.exec?.command === undefined) return
|
|
547
558
|
const def = taskDefinitions([node]).get(node.id)
|
|
559
|
+
if (def === undefined) return
|
|
548
560
|
const t = timing(outcome)
|
|
549
|
-
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)
|
|
550
564
|
})
|
|
551
565
|
// Every outcome is known here, and core's own end of run (history
|
|
552
566
|
// rows, cache uploads, telemetry) is still ahead: sent from teardown,
|
|
@@ -659,6 +673,7 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
659
673
|
|
|
660
674
|
interface Reported {
|
|
661
675
|
readonly timings: Map<string, ServerTiming>
|
|
676
|
+
readonly outcomes: ReadonlyMap<string, ServerOutcome>
|
|
662
677
|
readonly warn: (m: string) => void
|
|
663
678
|
}
|
|
664
679
|
|
|
@@ -677,13 +692,15 @@ const UPLOAD_WAIT_MS = 2_000
|
|
|
677
692
|
|
|
678
693
|
function sendTimings(server: HistoryServer, sent: Reported): Upload {
|
|
679
694
|
const stop = new AbortController()
|
|
680
|
-
const done = server
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
+
})
|
|
687
704
|
return { done, stop }
|
|
688
705
|
}
|
|
689
706
|
|