@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 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.650",
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.650"
40
+ "@vzn/vx": "^0.0.652"
41
41
  },
42
42
  "repository": {
43
43
  "type": "git",
@@ -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
- const jwt = ((await res.json()) as { value?: unknown }).value
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
- refs: readonly string[],
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 { timings?: unknown } | null
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
- return Object.entries(body.timings as Record<string, ServerTiming | null>)
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.flat()) {
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
- /** The run's timings, by task definition hash, in uploads of at most `maxUploadTimings`; `stop` abandons them. */
346
- async upload(timings: ReadonlyMap<string, ServerTiming>, stop?: AbortSignal): Promise<void> {
347
- if (timings.size === 0 || this.refused) return
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
- await this.call('/v1/timings/upload', { timings: all.slice(i, i + per) }, stop)
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
- if (/^https:\/\/\S/.test(url)) return true
286
- if (!/^http:\/\/\S/.test(url)) return false
287
- const host = URL.parse(url)?.hostname
288
- return host === '127.0.0.1' || host === '[::1]' || host === 'localhost'
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
- const refs = ctx.refs
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([...new Set(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) ?? '')))
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
- const sent = (reported = { timings, warn: ctx.warn })
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 (def !== undefined && t !== undefined) timings.set(def, t)
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.upload(sent.timings, stop.signal).catch((err: unknown) => {
665
- // Abandoned by teardown, which says so itself.
666
- if (stop.signal.aborted) return
667
- sent.warn(
668
- `[vx] vx-history: the timings were not sent to the history server at ${server.url}: ${err instanceof Error ? err.message : String(err)}`,
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
- process.stdout.write('\n')
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
- process.stdout.write(`${JSON.stringify({ window, budgets, tasks: rows })}\n`)
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.