@vzn/vx-history-server 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/PROTOCOL.md CHANGED
@@ -1,15 +1,16 @@
1
1
  # vx-history protocol, v1
2
2
 
3
- An HTTP API for the task timings a scheduler asks for. It is a history,
3
+ An HTTP API for the task timings a scheduler asks for, and the flaky
4
+ record a retry asks for. It is a history,
4
5
  not a log: a server keeps one row per task and ref, and folds each
5
6
  report into it. `@vzn/vx-history-server` speaks it on Bun, on Cloudflare
6
7
  Workers and as a Docker image; any server that does can take its place.
7
8
 
8
- | Route | What it does |
9
- | ------------------------- | --------------------------------- |
10
- | `POST /v1/timings/upload` | a run's timings, once at its end |
11
- | `POST /v1/timings/query` | each task's numbers, ready to use |
12
- | `GET /v1/capabilities` | the server's limits |
9
+ | Route | What it does |
10
+ | ------------------------- | --------------------------------------------- |
11
+ | `POST /v1/timings/upload` | a run's timings and outcomes, once at its end |
12
+ | `POST /v1/timings/query` | each task's numbers and the flaky ones |
13
+ | `GET /v1/capabilities` | the server's limits |
13
14
 
14
15
  ## Repositories and tokens
15
16
 
@@ -54,23 +55,43 @@ each write costs, and the fold would move it under 1.5%.
54
55
  The first report sets the row; one without `cores` or `rssBytes` keeps
55
56
  the row's. A row not reported for `TIMING_DAYS` (30) is deleted.
56
57
 
58
+ ### Outcomes
59
+
60
+ An upload may also carry `"outcomes": [{ "hash", "key", "outcome" }]`,
61
+ up to 10 000, each task and key once: every cached task the run ran.
62
+ `hash` is the task's definition, `key` its cache key (both up to 512
63
+ characters), `outcome` one of:
64
+
65
+ - `pass`, `fail`: how it ended.
66
+ - `flaky`: it passed on a retry, so the run saw it flake.
67
+
68
+ The server keeps, per repository (not per ref: a key is the same inputs
69
+ on every branch), only the task and keys that failed, each with a flaky
70
+ flag. A pass of a failed key, or a run's `flaky`, sets the flag; any
71
+ other pass is stored nowhere. A task is **flaky** when one of its keys
72
+ is. A key with nothing for `TIMING_DAYS` is deleted.
73
+
57
74
  ## Query
58
75
 
59
76
  One request a run: `POST /v1/timings/query` with
60
77
  `{ "issuer", "repository", "refs": ["refs/heads/feature", "refs/heads/main"], "hashes": [ … ] }`,
61
78
  1 to 8 refs of up to 512 characters each and up to 10 000 hashes: each hash's row in the first ref
62
79
  named that holds one. A hash none holds is absent. The numbers are the
63
- scheduler's as they are:
80
+ scheduler's as they are, and `flaky` lists the hashes asked that are
81
+ flaky, by any ref:
64
82
 
65
83
  ```json
66
- { "timings": { "3b0d…": { "durationMs": 4200, "cores": 1.8, "rssBytes": 310000000, "runs": 12 } } }
84
+ {
85
+ "timings": { "3b0d…": { "durationMs": 4200, "cores": 1.8, "rssBytes": 310000000, "runs": 12 } },
86
+ "flaky": ["3b0d…"]
87
+ }
67
88
  ```
68
89
 
69
90
  ## Limits and errors
70
91
 
71
92
  `GET /v1/capabilities`: `{ "maxUploadTimings": 10000, "maxQueryHashes": 10000, "maxQueryRefs": 8 }`.
72
93
  A server takes both at their limits, each hash 512 characters: an
73
- upload body up to 7 120 000 bytes, a query's up to 6 010 000. Past
94
+ upload body up to 17 960 100 bytes, a query's up to 6 010 000. Past
74
95
  that, `413`.
75
96
 
76
97
  Every refusal is `{ code, message }`, the message saying what to fix. A
package/README.md CHANGED
@@ -5,7 +5,9 @@ Website: <https://vznjs.github.io/vx/> · source: <https://github.com/vznjs/vx>
5
5
  A server for the task timings a vx scheduler asks for, that you host. It
6
6
  keeps one row per task, already the numbers the scheduler uses (duration,
7
7
  CPU cores, peak memory), and merges each CI run's report into it: a
8
- history, not a log. It speaks the [vx-history protocol](./PROTOCOL.md)
8
+ history, not a log. It also keeps the flaky record every CI machine
9
+ shares: only the cache keys that failed, each with a flaky flag,
10
+ so a run retries a task that flaked on another machine. It speaks the [vx-history protocol](./PROTOCOL.md)
9
11
  (`/v1`) on three backends that answer alike:
10
12
 
11
13
  - **Bun**, with a SQLite file.
@@ -83,7 +85,7 @@ The same server as a `fetch` handler, over storage of your own
83
85
  `oidcTokenAuth` from `@vzn/vx-devtools`.
84
86
  - `sqliteStore(db: Db)` is the `Store` over a SQLite `Db`
85
87
  (`{ all(sql, params), run(sql, params) }`), as bun:sqlite and D1 are.
86
- A `Store` merges `Report`s and reads `Timing`s, each of a `Repo`
88
+ A `Store` merges `Report`s, keeps `Outcome`s and reads `Timing`s, each of a `Repo`
87
89
  (`{ issuer, repository }`) and a ref.
88
90
  - `WEIGHT` (0.3) and `RSS_DECAY` (0.9) are the merge's constants;
89
91
  `MAX_UPLOAD_TIMINGS`, `MAX_QUERY_HASHES` and `MAX_QUERY_REFS` its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-history-server",
3
- "version": "0.0.651",
3
+ "version": "0.0.652",
4
4
  "license": "MIT",
5
5
  "description": "The task timings a vx scheduler asks for, one folded row per task, that you host: on Bun with SQLite, on Cloudflare Workers with D1, or as a Docker image.",
6
6
  "keywords": [
@@ -45,7 +45,7 @@
45
45
  "miniflare: tests/worker.test.ts runs src/worker.ts in workerd with D1, as Workers deploy it."
46
46
  ],
47
47
  "dependencies": {
48
- "@vzn/vx-devtools": "0.0.651"
48
+ "@vzn/vx-devtools": "0.0.652"
49
49
  },
50
50
  "repository": {
51
51
  "type": "git",
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export {
10
10
  } from './server.js'
11
11
  export {
12
12
  type Db,
13
+ type Outcome,
13
14
  type Report,
14
15
  type Repo,
15
16
  RSS_DECAY,
package/src/server.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // `Store`: Bun and Workers serve the same one.
3
3
 
4
4
  import { type Access, errorJson, notAllowed, Refusal } from '@vzn/vx-devtools'
5
- import type { Report, Repo, Store } from './store.js'
5
+ import type { Outcome, Report, Repo, Store } from './store.js'
6
6
 
7
7
  /** Reports one upload folds in: a run's tasks. */
8
8
  export const MAX_UPLOAD_TIMINGS = 10_000
@@ -13,6 +13,8 @@ export const MAX_QUERY_REFS = 8
13
13
  const MAX_STRING = 512
14
14
  /** A report's bytes at most: its hash at `MAX_STRING`, its numbers at their longest. */
15
15
  const MAX_REPORT_BYTES = MAX_STRING + 200
16
+ /** An outcome's bytes at most: its hash and key at `MAX_STRING`. */
17
+ const MAX_OUTCOME_BYTES = 2 * MAX_STRING + 60
16
18
  const DAY_MS = 86_400_000
17
19
 
18
20
  export interface ServerOptions {
@@ -47,9 +49,16 @@ export function createHistoryServer(opts: ServerOptions): (req: Request) => Prom
47
49
  async function upload(req: Request): Promise<Response> {
48
50
  const access = await authorize(req)
49
51
  if (access?.write === undefined) throw new Refusal(403, 'this request reads only')
50
- const reports = parseReports(await readJson(req, MAX_UPLOAD_TIMINGS * MAX_REPORT_BYTES))
52
+ const body = await readJson(
53
+ req,
54
+ MAX_UPLOAD_TIMINGS * (MAX_REPORT_BYTES + MAX_OUTCOME_BYTES) + 100,
55
+ )
56
+ const reports = parseReports(body)
57
+ const outcomes = parseOutcomes(body)
51
58
  await schema()
52
- await store.fold(access, access.write, now(), reports)
59
+ const t = now()
60
+ await store.fold(access, access.write, t, reports)
61
+ if (outcomes.length > 0) await store.record(access, t, outcomes)
53
62
  return new Response(null, { status: 204 })
54
63
  }
55
64
 
@@ -94,8 +103,11 @@ export function createHistoryServer(opts: ServerOptions): (req: Request) => Prom
94
103
  }
95
104
  const repo = (await authorize(req, { issuer, repository }))!
96
105
  await schema()
97
- const rows = await store.read(repo, refs as string[], hashes as string[])
98
- return Response.json({ timings: Object.fromEntries(rows) })
106
+ const [rows, flaky] = await Promise.all([
107
+ store.read(repo, refs as string[], hashes as string[]),
108
+ store.flaky(repo, hashes as string[]),
109
+ ])
110
+ return Response.json({ timings: Object.fromEntries(rows), flaky })
99
111
  }
100
112
 
101
113
  let swept = 0
@@ -218,6 +230,32 @@ function parseReports(body: unknown): Report[] {
218
230
  })
219
231
  }
220
232
 
233
+ /** A run's outcomes of cached tasks (optional), as the flaky record keeps them. */
234
+ function parseOutcomes(body: unknown): Outcome[] {
235
+ const list = field(body, 'outcomes')
236
+ if (list === undefined) return []
237
+ if (!Array.isArray(list) || list.length > MAX_UPLOAD_TIMINGS) {
238
+ throw new Refusal(400, `outcomes: a list of up to ${MAX_UPLOAD_TIMINGS}`)
239
+ }
240
+ const seen = new Set<string>()
241
+ return list.map((x: unknown, i): Outcome => {
242
+ const bad = (what: string): never => {
243
+ throw new Refusal(400, `outcomes[${i}].${what}`)
244
+ }
245
+ const hash = field(x, 'hash')
246
+ const key = field(x, 'key')
247
+ const outcome = field(x, 'outcome')
248
+ if (!isOpaque(hash)) bad(`hash: a string of up to ${MAX_STRING} characters`)
249
+ if (!isOpaque(key)) bad(`key: a string of up to ${MAX_STRING} characters`)
250
+ const id = `${hash as string}\0${key as string}`
251
+ if (seen.has(id)) bad('key: each task and key once')
252
+ seen.add(id)
253
+ if (outcome !== 'pass' && outcome !== 'fail' && outcome !== 'flaky')
254
+ bad("outcome: 'pass', 'fail' or 'flaky'")
255
+ return { hash: hash as string, key: key as string, outcome: outcome as Outcome['outcome'] }
256
+ })
257
+ }
258
+
221
259
  /**
222
260
  * The body as text, refused past `max` bytes as they arrive: a chunked body
223
261
  * names no length, and was read whole before it was measured.
package/src/store.ts CHANGED
@@ -21,6 +21,16 @@ export interface Report {
21
21
  readonly rssBytes?: number
22
22
  }
23
23
 
24
+ /** One run of a cached task, pass or fail, as a client reports it: its flaky record. */
25
+ export interface Outcome {
26
+ /** The task's definition hash, as its timing names it. */
27
+ readonly hash: string
28
+ /** The run's cache key: one key that failed, then passed, is a flake. */
29
+ readonly key: string
30
+ /** `flaky`: the run saw the task flake itself (a retry that passed). */
31
+ readonly outcome: 'pass' | 'fail' | 'flaky'
32
+ }
33
+
24
34
  /** What a scheduler reads for a task: the folded numbers and how many runs made them. */
25
35
  export interface Timing {
26
36
  readonly durationMs: number
@@ -59,6 +69,12 @@ export interface Repo {
59
69
  const DROP_OLD = 'DROP TABLE IF EXISTS task_timings'
60
70
  const SCHEMA = `CREATE TABLE IF NOT EXISTS timings (issuer TEXT NOT NULL, repository TEXT NOT NULL, ref TEXT NOT NULL, hash TEXT NOT NULL, duration_ms INTEGER NOT NULL, cores REAL, rss_bytes INTEGER, runs INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (issuer, repository, ref, hash)) WITHOUT ROWID`
61
71
 
72
+ // The flaky record (2026-11-06): a row only for a task definition and
73
+ // cache key that failed, and whether it flaked since. A pass with no row
74
+ // writes nothing: passes are most outcomes. Kept by repository, not by
75
+ // ref: a key is the same inputs on every branch.
76
+ const OUTCOMES = `CREATE TABLE IF NOT EXISTS outcomes (issuer TEXT NOT NULL, repository TEXT NOT NULL, hash TEXT NOT NULL, key TEXT NOT NULL, flaky INTEGER NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (issuer, repository, hash, key)) WITHOUT ROWID`
77
+
62
78
  // When the database was last swept, by whichever node: a Worker runs many
63
79
  // isolates and starts new ones all day, and each swept on its first upload.
64
80
  const SWEEPS =
@@ -83,6 +99,23 @@ WHERE abs(excluded.duration_ms - duration_ms) > duration_ms * ${NOISE}
83
99
  OR (excluded.rss_bytes IS NOT NULL AND (rss_bytes IS NULL OR excluded.rss_bytes > rss_bytes))
84
100
  OR excluded.updated_at - updated_at >= ${DAY_MS}`
85
101
 
102
+ // A failure or a run's flake writes its row; a row already as it would
103
+ // be is rewritten only when a day old, since each write is billed.
104
+ const RECORD = `INSERT INTO outcomes (issuer, repository, hash, key, flaky, at)
105
+ SELECT ?1, ?2, j.value ->> '$.hash', j.value ->> '$.key', j.value ->> '$.outcome' = 'flaky', ?3
106
+ FROM json_each(?4) AS j WHERE j.value ->> '$.outcome' <> 'pass'
107
+ ON CONFLICT (issuer, repository, hash, key) DO UPDATE SET flaky = max(flaky, excluded.flaky), at = excluded.at
108
+ WHERE excluded.flaky > flaky OR excluded.at - at >= ${DAY_MS}`
109
+
110
+ /** A pass of a key that failed: the key flaked. */
111
+ const PASSED = `UPDATE outcomes SET flaky = 1, at = ?3
112
+ WHERE issuer = ?1 AND repository = ?2 AND flaky = 0 AND (hash, key) IN
113
+ (SELECT j.value ->> '$.hash', j.value ->> '$.key' FROM json_each(?4) AS j WHERE j.value ->> '$.outcome' = 'pass')`
114
+
115
+ /** The hashes asked that flaked: a key that failed and then passed, or a run that saw it. */
116
+ const FLAKY = `SELECT DISTINCT hash FROM outcomes
117
+ WHERE issuer = ?1 AND repository = ?2 AND hash IN (SELECT value FROM json_each(?3)) AND flaky = 1`
118
+
86
119
  /** The rows of `refs` for the hashes asked; a hash the first ref lacks reads the next. */
87
120
  const READ = `SELECT hash, ref, duration_ms, cores, rss_bytes, runs FROM timings
88
121
  WHERE issuer = ?1 AND repository = ?2 AND ref IN (SELECT value FROM json_each(?3)) AND hash IN (SELECT value FROM json_each(?4))`
@@ -93,6 +126,10 @@ export interface Store {
93
126
  fold(repo: Repo, ref: string, now: number, reports: readonly Report[]): Promise<void>
94
127
  /** Each hash's row in the first of `refs` that holds one. */
95
128
  read(repo: Repo, refs: readonly string[], hashes: readonly string[]): Promise<Map<string, Timing>>
129
+ /** Records each outcome in the repository's flaky record. */
130
+ record(repo: Repo, now: number, outcomes: readonly Outcome[]): Promise<void>
131
+ /** The hashes asked that the flaky record names flaky. */
132
+ flaky(repo: Repo, hashes: readonly string[]): Promise<string[]>
96
133
  /** When a node last swept the database; 0 before the first sweep. */
97
134
  sweptAt(): Promise<number>
98
135
  /** Deletes the rows not reported since `before`, and records the sweep at `now`; how many. */
@@ -131,6 +168,21 @@ export function sqliteStore(db: Db): Store {
131
168
  await db.run(DROP_OLD, [])
132
169
  await db.run(SCHEMA, [])
133
170
  await db.run(SWEEPS, [])
171
+ await db.run(OUTCOMES, [])
172
+ },
173
+ async record(repo, now, outcomes) {
174
+ for (const part of jsonParams(outcomes)) {
175
+ await db.run(PASSED, [repo.issuer, repo.repository, now, part])
176
+ await db.run(RECORD, [repo.issuer, repo.repository, now, part])
177
+ }
178
+ },
179
+ async flaky(repo, hashes) {
180
+ const rows = await Promise.all(
181
+ jsonParams(hashes).map((part) =>
182
+ db.all<{ hash: string }>(FLAKY, [repo.issuer, repo.repository, part]),
183
+ ),
184
+ )
185
+ return rows.flat().map((r) => r.hash)
134
186
  },
135
187
  async fold(repo, ref, now, reports) {
136
188
  for (const part of jsonParams(reports))
@@ -168,6 +220,7 @@ export function sqliteStore(db: Db): Store {
168
220
  // A report that would not move its row is not written for up to a
169
221
  // day (FOLD): a row's write time lags its last report by up to that.
170
222
  const deleted = await db.run('DELETE FROM timings WHERE updated_at < ?1', [before - DAY_MS])
223
+ await db.run('DELETE FROM outcomes WHERE at < ?1', [before - DAY_MS])
171
224
  await db.run(
172
225
  'INSERT INTO sweeps (id, at) VALUES (1, ?1) ON CONFLICT (id) DO UPDATE SET at = excluded.at',
173
226
  [now],