@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 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.651",
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.651"
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`). */
@@ -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
- refs: readonly string[],
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 { timings?: unknown } | null
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
- 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
+ }
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.flat()) {
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
- /** The run's timings, by task definition hash, in uploads of at most `maxUploadTimings`; `stop` abandons them. */
347
- async upload(timings: ReadonlyMap<string, ServerTiming>, stop?: AbortSignal): Promise<void> {
348
- 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
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
- 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
+ )
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
- if (/^https:\/\/\S/.test(url)) return true
294
- if (!/^http:\/\/\S/.test(url)) return false
295
- const host = URL.parse(url)?.hostname
296
- 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'
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
- 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 })
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 (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)
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.upload(sent.timings, stop.signal).catch((err: unknown) => {
681
- // Abandoned by teardown, which says so itself.
682
- if (stop.signal.aborted) return
683
- sent.warn(
684
- `[vx] vx-history: the timings were not sent to the history server at ${server.url}: ${err instanceof Error ? err.message : String(err)}`,
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