@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 +30 -9
- package/README.md +4 -2
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/server.ts +43 -5
- package/src/store.ts +53 -0
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
|
|
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
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
48
|
+
"@vzn/vx-devtools": "0.0.652"
|
|
49
49
|
},
|
|
50
50
|
"repository": {
|
|
51
51
|
"type": "git",
|
package/src/index.ts
CHANGED
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
|
|
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
|
-
|
|
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
|
|
98
|
-
|
|
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],
|