@vzn/vx-history-server 0.0.651 → 0.0.653
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 +40 -13
- package/README.md +6 -3
- package/package.json +2 -2
- package/src/bun.ts +4 -2
- package/src/index.ts +1 -0
- package/src/server.ts +70 -11
- 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
|
|
|
@@ -37,8 +38,9 @@ an upload with none is `403`.
|
|
|
37
38
|
`POST /v1/timings/upload` (a token that writes) with
|
|
38
39
|
`{ "timings": [{ "hash", "durationMs", "cores"?, "rssBytes"? }] }`, up to
|
|
39
40
|
10 000, each task once: `204`. `hash` names the task's definition, a
|
|
40
|
-
string of
|
|
41
|
-
|
|
41
|
+
string the server never reads of 1 to 512 ASCII characters, none a
|
|
42
|
+
space, control, `"` or `\`: each is one byte in JSON, which the body
|
|
43
|
+
limits count on. Each folds into its row of the token's repository and ref:
|
|
42
44
|
|
|
43
45
|
- `durationMs` and `cores`: `old × 0.7 + new × 0.3`. A median needs the
|
|
44
46
|
samples and a mean never forgets; this follows a task that got slower
|
|
@@ -54,24 +56,49 @@ each write costs, and the fold would move it under 1.5%.
|
|
|
54
56
|
The first report sets the row; one without `cores` or `rssBytes` keeps
|
|
55
57
|
the row's. A row not reported for `TIMING_DAYS` (30) is deleted.
|
|
56
58
|
|
|
59
|
+
### Outcomes
|
|
60
|
+
|
|
61
|
+
An upload may also carry `"outcomes": [{ "hash", "key", "outcome" }]`,
|
|
62
|
+
up to 10 000, each task and key once: every cached task the run ran.
|
|
63
|
+
`hash` is the task's definition, `key` its cache key (each as `hash`
|
|
64
|
+
above), `outcome` one of:
|
|
65
|
+
|
|
66
|
+
- `pass`, `fail`: how it ended.
|
|
67
|
+
- `flaky`: it passed on a retry, so the run saw it flake.
|
|
68
|
+
|
|
69
|
+
The server keeps, per repository (not per ref: a key is the same inputs
|
|
70
|
+
on every branch), only the task and keys that failed, each with a flaky
|
|
71
|
+
flag. A pass of a failed key, or a run's `flaky`, sets the flag; any
|
|
72
|
+
other pass is stored nowhere. A task is **flaky** when one of its keys
|
|
73
|
+
is. A key with nothing for `TIMING_DAYS` is deleted.
|
|
74
|
+
|
|
57
75
|
## Query
|
|
58
76
|
|
|
59
77
|
One request a run: `POST /v1/timings/query` with
|
|
60
78
|
`{ "issuer", "repository", "refs": ["refs/heads/feature", "refs/heads/main"], "hashes": [ … ] }`,
|
|
61
79
|
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
80
|
named that holds one. A hash none holds is absent. The numbers are the
|
|
63
|
-
scheduler's as they are
|
|
81
|
+
scheduler's as they are, and `flaky` lists the hashes asked that are
|
|
82
|
+
flaky, by any ref:
|
|
64
83
|
|
|
65
84
|
```json
|
|
66
|
-
{
|
|
85
|
+
{
|
|
86
|
+
"timings": { "3b0d…": { "durationMs": 4200, "cores": 1.8, "rssBytes": 310000000, "runs": 12 } },
|
|
87
|
+
"flaky": ["3b0d…"]
|
|
88
|
+
}
|
|
67
89
|
```
|
|
68
90
|
|
|
69
91
|
## Limits and errors
|
|
70
92
|
|
|
71
93
|
`GET /v1/capabilities`: `{ "maxUploadTimings": 10000, "maxQueryHashes": 10000, "maxQueryRefs": 8 }`.
|
|
72
|
-
|
|
73
|
-
upload
|
|
74
|
-
|
|
94
|
+
Every `/v1/timings` answer, a refusal too, names them as
|
|
95
|
+
`vx-max-upload-timings`, `vx-max-query-hashes` and `vx-max-query-refs`,
|
|
96
|
+
so a client need not ask them apart: vx sends a run's query and upload
|
|
97
|
+
whole, and one refused for its size again in parts of the limit its
|
|
98
|
+
refusal names.
|
|
99
|
+
A server takes both at their limits, each hash and key 512 characters
|
|
100
|
+
and every ref 512: an upload body up to 17 960 100 bytes, a query's up
|
|
101
|
+
to 6 010 000. Past that, `413`.
|
|
75
102
|
|
|
76
103
|
Every refusal is `{ code, message }`, the message saying what to fix. A
|
|
77
104
|
JSON body without `Content-Type: application/json` is `400`, an unknown
|
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.
|
|
@@ -28,7 +30,8 @@ sends one query: its repository, the refs it reads (its branch, then the
|
|
|
28
30
|
branch it merges into, then the default branch) and every task hash;
|
|
29
31
|
the server answers each from the first ref that holds it. A token of
|
|
30
32
|
another repository than the query names is refused. `AUDIENCE` is the
|
|
31
|
-
`aud` a token must name: the origin clients reach the server at
|
|
33
|
+
`aud` a token must name: the origin clients reach the server at (a URL
|
|
34
|
+
is read as its origin; one that is no URL stops the server). On
|
|
32
35
|
Workers, unset, it is that origin (Cloudflare routes only the Worker's
|
|
33
36
|
own hostnames to it); on Bun and Docker it is required, since there the
|
|
34
37
|
origin is the request's `Host` header, which a client picks.
|
|
@@ -83,7 +86,7 @@ The same server as a `fetch` handler, over storage of your own
|
|
|
83
86
|
`oidcTokenAuth` from `@vzn/vx-devtools`.
|
|
84
87
|
- `sqliteStore(db: Db)` is the `Store` over a SQLite `Db`
|
|
85
88
|
(`{ 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`
|
|
89
|
+
A `Store` merges `Report`s, keeps `Outcome`s and reads `Timing`s, each of a `Repo`
|
|
87
90
|
(`{ issuer, repository }`) and a ref.
|
|
88
91
|
- `WEIGHT` (0.3) and `RSS_DECAY` (0.9) are the merge's constants;
|
|
89
92
|
`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.653",
|
|
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.653"
|
|
49
49
|
},
|
|
50
50
|
"repository": {
|
|
51
51
|
"type": "git",
|
package/src/bun.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { type Db, sqliteStore } from './store.js'
|
|
|
12
12
|
const USAGE = `usage: vx-history-server [--db vx-history.sqlite] [--port 8080] [--host 0.0.0.0]
|
|
13
13
|
ALLOW who may take part, space separated, each issuer:owner/repo:workflow
|
|
14
14
|
(README.md § CI tokens); only their jobs' OIDC tokens are taken
|
|
15
|
-
AUDIENCE the aud a CI token names: the
|
|
15
|
+
AUDIENCE the aud a CI token names: the origin clients reach this server at
|
|
16
16
|
READ_ACCESS public: a request with no token reads a repository ALLOW names
|
|
17
17
|
TIMING_DAYS days a task's timings outlive its last report (default 30)`
|
|
18
18
|
|
|
@@ -78,7 +78,9 @@ function main(argv: string[]): void {
|
|
|
78
78
|
// header, which the client picks. Workers know their own hostnames.
|
|
79
79
|
const audience = env.AUDIENCE?.trim() ?? ''
|
|
80
80
|
if (audience === '')
|
|
81
|
-
fail(
|
|
81
|
+
fail(
|
|
82
|
+
`set AUDIENCE: the aud a CI token names, the origin clients reach this server at\n${USAGE}`,
|
|
83
|
+
)
|
|
82
84
|
const handle = createHistoryServer({
|
|
83
85
|
store: sqliteStore(db),
|
|
84
86
|
authorize: oidcTokenAuth({
|
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
|
|
|
@@ -90,12 +99,15 @@ export function createHistoryServer(opts: ServerOptions): (req: Request) => Prom
|
|
|
90
99
|
hashes.length > MAX_QUERY_HASHES ||
|
|
91
100
|
!hashes.every((h) => isOpaque(h))
|
|
92
101
|
) {
|
|
93
|
-
throw new Refusal(400, `hashes: a list of up to ${MAX_QUERY_HASHES}
|
|
102
|
+
throw new Refusal(400, `hashes: a list of up to ${MAX_QUERY_HASHES}, each ${OPAQUE_IS}`)
|
|
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
|
|
@@ -121,6 +133,15 @@ export function createHistoryServer(opts: ServerOptions): (req: Request) => Prom
|
|
|
121
133
|
}
|
|
122
134
|
|
|
123
135
|
return async (req) => {
|
|
136
|
+
const res = await answer(req)
|
|
137
|
+
// Every /v1/timings answer, a refusal too: a client learns the limits
|
|
138
|
+
// from its first request, and was asked none apart (2026-11-06).
|
|
139
|
+
if (new URL(req.url).pathname.startsWith('/v1/timings/'))
|
|
140
|
+
for (const [header, value] of limitHeaders) res.headers.set(header, value)
|
|
141
|
+
return res
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function answer(req: Request): Promise<Response> {
|
|
124
145
|
const path = new URL(req.url).pathname
|
|
125
146
|
try {
|
|
126
147
|
const search = new URL(req.url).search
|
|
@@ -156,13 +177,25 @@ export function createHistoryServer(opts: ServerOptions): (req: Request) => Prom
|
|
|
156
177
|
}
|
|
157
178
|
}
|
|
158
179
|
|
|
180
|
+
/** The limits as every `/v1/timings` answer names them (PROTOCOL.md § Limits and errors). */
|
|
181
|
+
const limitHeaders = [
|
|
182
|
+
['vx-max-upload-timings', String(MAX_UPLOAD_TIMINGS)],
|
|
183
|
+
['vx-max-query-hashes', String(MAX_QUERY_HASHES)],
|
|
184
|
+
['vx-max-query-refs', String(MAX_QUERY_REFS)],
|
|
185
|
+
] as const
|
|
186
|
+
|
|
159
187
|
const field = (body: unknown, key: string): unknown =>
|
|
160
188
|
typeof body === 'object' && body !== null ? (body as Record<string, unknown>)[key] : undefined
|
|
161
189
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Each character one byte in JSON, so the body limits hold `MAX_STRING`
|
|
192
|
+
* of them: 10 000 hashes of 512 `é` (two bytes each) were a 413 at the
|
|
193
|
+
* limits the protocol promised, and a control is six (`\u0001`).
|
|
194
|
+
*/
|
|
195
|
+
const OPAQUE = new RegExp(`^[\\x21\\x23-\\x5b\\x5d-\\x7e]{1,${MAX_STRING}}$`)
|
|
196
|
+
const OPAQUE_IS = `1 to ${MAX_STRING} ASCII characters, none a space, control, " or \\`
|
|
197
|
+
|
|
198
|
+
const isOpaque = (s: unknown): s is string => typeof s === 'string' && OPAQUE.test(s)
|
|
166
199
|
|
|
167
200
|
const isCount = (n: unknown): n is number =>
|
|
168
201
|
typeof n === 'number' && Number.isSafeInteger(n) && n >= 0
|
|
@@ -197,7 +230,7 @@ function parseReports(body: unknown): Report[] {
|
|
|
197
230
|
const durationMs = field(x, 'durationMs')
|
|
198
231
|
const cores = field(x, 'cores')
|
|
199
232
|
const rssBytes = field(x, 'rssBytes')
|
|
200
|
-
if (!isOpaque(hash)) bad(`hash:
|
|
233
|
+
if (!isOpaque(hash)) bad(`hash: ${OPAQUE_IS}`)
|
|
201
234
|
// One row per task: a second report of it in one upload would fold twice.
|
|
202
235
|
if (seen.has(hash as string)) bad('hash: each task once')
|
|
203
236
|
seen.add(hash as string)
|
|
@@ -218,6 +251,32 @@ function parseReports(body: unknown): Report[] {
|
|
|
218
251
|
})
|
|
219
252
|
}
|
|
220
253
|
|
|
254
|
+
/** A run's outcomes of cached tasks (optional), as the flaky record keeps them. */
|
|
255
|
+
function parseOutcomes(body: unknown): Outcome[] {
|
|
256
|
+
const list = field(body, 'outcomes')
|
|
257
|
+
if (list === undefined) return []
|
|
258
|
+
if (!Array.isArray(list) || list.length > MAX_UPLOAD_TIMINGS) {
|
|
259
|
+
throw new Refusal(400, `outcomes: a list of up to ${MAX_UPLOAD_TIMINGS}`)
|
|
260
|
+
}
|
|
261
|
+
const seen = new Set<string>()
|
|
262
|
+
return list.map((x: unknown, i): Outcome => {
|
|
263
|
+
const bad = (what: string): never => {
|
|
264
|
+
throw new Refusal(400, `outcomes[${i}].${what}`)
|
|
265
|
+
}
|
|
266
|
+
const hash = field(x, 'hash')
|
|
267
|
+
const key = field(x, 'key')
|
|
268
|
+
const outcome = field(x, 'outcome')
|
|
269
|
+
if (!isOpaque(hash)) bad(`hash: ${OPAQUE_IS}`)
|
|
270
|
+
if (!isOpaque(key)) bad(`key: ${OPAQUE_IS}`)
|
|
271
|
+
const id = `${hash as string}\0${key as string}`
|
|
272
|
+
if (seen.has(id)) bad('key: each task and key once')
|
|
273
|
+
seen.add(id)
|
|
274
|
+
if (outcome !== 'pass' && outcome !== 'fail' && outcome !== 'flaky')
|
|
275
|
+
bad("outcome: 'pass', 'fail' or 'flaky'")
|
|
276
|
+
return { hash: hash as string, key: key as string, outcome: outcome as Outcome['outcome'] }
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
221
280
|
/**
|
|
222
281
|
* The body as text, refused past `max` bytes as they arrive: a chunked body
|
|
223
282
|
* 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],
|