Skip to content

Commit a86c1dc

Browse files
committed
Support partial benchmark retries and increase HTTP timeout
Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com>
1 parent 0ad9e64 commit a86c1dc

8 files changed

Lines changed: 158 additions & 25 deletions

File tree

‎.github/workflows/benchmark.yml‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ jobs:
6767
name: run-identity
6868
path: artifacts/run.json
6969
if-no-files-found: error
70+
overwrite: true
7071

7172
# Build guest inputs once on KVM for all measurement configurations.
7273
producer:
@@ -101,6 +102,7 @@ jobs:
101102
- uses: actions/upload-artifact@v7
102103
with:
103104
name: guest-inputs
105+
overwrite: true
104106
path: |
105107
js/src/wit/handler_wit.wasm
106108
js/default/handler.wasm
@@ -191,6 +193,7 @@ jobs:
191193
if: always()
192194
with:
193195
name: result-${{ matrix.platform }}-${{ matrix.runtime }}-${{ matrix.strategy }}
196+
overwrite: true
194197
path: results/
195198
if-no-files-found: error
196199
retention-days: 14
@@ -216,8 +219,8 @@ jobs:
216219
with:
217220
pattern: result-*
218221
path: results/
219-
merge-multiple: true
220-
- run: node scripts/merge-results.ts --output run.json results/*/bundle.json
222+
- name: Merge measurements
223+
run: node scripts/merge-results.ts --output run.json results/*/*/bundle.json
221224
- uses: actions/upload-artifact@v7
222225
with:
223226
name: run-${{ github.run_id }}-${{ github.run_attempt }}

‎docs/README.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,13 @@ complete benchmark run for the current candidate.
272272

273273
`Benchmark Publication` runs trusted `main` scripts on hosted runners. Successful
274274
PR attempts are checked against GitHub workflow and commit metadata. All jobs
275-
must succeed in one attempt. Use **Re-run all jobs** for publishable retries.
275+
must have a successful latest result through the selected attempt. Use
276+
**Re-run failed jobs** to retain successful configurations, or rerun an individual
277+
job and its dependents. Each retry replaces its configuration's result artifact.
278+
Collection combines the artifacts from that workflow run.
279+
A failed retry blocks publication even if an earlier
280+
attempt succeeded. Source revisions and benchmark definitions must match.
281+
Use **Re-run all jobs** when required artifacts have expired.
276282
The executed Benchmark workflow must match the trusted workflow. A workflow
277283
change can require archival recovery after maintainer review and merge.
278284

‎docs/results.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ parser when loading bundles.
99
Store one immutable JSON bundle per workflow run and attempt. The identity is
1010
`run.id` plus `run.attempt`. A retry has its own bundle. Commit SHAs may repeat.
1111

12+
`run.attempt` identifies the collection attempt. Partial retries retain earlier
13+
successful measurements from that workflow run. Source revisions and benchmark
14+
definitions must match across all shards. Each configuration has a stable artifact
15+
name. A retry replaces that artifact. Collection rejects duplicate configurations.
16+
1217
| Field | Contents |
1318
| --- | --- |
1419
| `schemaVersion` | Storage format version, currently `1` |
@@ -20,7 +25,7 @@ Store one immutable JSON bundle per workflow run and attempt. The identity is
2025
| `catalog.platforms` | Stable platform IDs and display labels |
2126
| `catalog.metrics` | Metric IDs, units, direction and measurement method versions |
2227
| `benchmark` | Workload ID, version, settings, required metrics and expected matrix |
23-
| `runners` | Runner records scoped to this workflow attempt |
28+
| `runners` | Runner records for the selected measurements |
2429
| `measurements` | Results linked to a runner and runtime |
2530

2631
Runtime and metric IDs are extensible strings. Lifecycle IDs are `reload`,

‎scripts/benchmark.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ mkdirSync(directory, { recursive: true })
2626
const local = values['local-bundle'] ? parseRunBundle(JSON.parse(readFileSync(values['local-bundle'], 'utf8'))) : undefined
2727
if (local && local.source !== 'local') throw new Error('Local collection requires a local bundle')
2828
const run = local?.run ?? JSON.parse(readFileSync(values.run, 'utf8')) as RunBundle['run']
29+
if (!local && process.env.GITHUB_ACTIONS) {
30+
if (run.id !== process.env.GITHUB_RUN_ID || run.commit.sha !== process.env.GITHUB_SHA) throw new Error('Run identity differs from the current workflow')
31+
run.attempt = z.coerce.number().int().positive().parse(process.env.GITHUB_RUN_ATTEMPT)
32+
run.workflow.url = `https://github.com/${run.pullRequest!.repository}/actions/runs/${run.id}/attempts/${run.attempt}`
33+
}
2934
const executable = resolve('artifacts/bin', serverFlavor(runtime.id), 'http-bench')
3035
const oha = 'oha'
3136
const runner = await runnerMetadata(platform, !!local)

‎scripts/merge-results.ts‎

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,42 @@
11
import { readFileSync, writeFileSync } from 'node:fs'
2+
import { resolve } from 'node:path'
3+
import { pathToFileURL } from 'node:url'
24
import { parseArgs } from 'node:util'
3-
import { canonicalJson, parseRunBundle, validatePublishableRun } from '../shared/results.ts'
5+
import { canonicalJson, parseRunBundle, validatePublishableRun, type RunBundle } from '../shared/results.ts'
46
import { publicationPolicy } from '../shared/catalog.ts'
57

6-
const { values, positionals } = parseArgs({ allowPositionals: true, options: { output: { type: 'string', default: 'run.json' } } })
7-
if (!positionals.length) throw new Error('Provide bundle files to merge')
8-
const bundles = positionals.map(path => parseRunBundle(JSON.parse(readFileSync(path, 'utf8'))))
9-
const merged = structuredClone(bundles[0]!)
10-
for (const bundle of bundles.slice(1)) {
11-
if (canonicalJson({ run: bundle.run, catalog: bundle.catalog, benchmark: bundle.benchmark, source: bundle.source }) !== canonicalJson({ run: merged.run, catalog: merged.catalog, benchmark: merged.benchmark, source: merged.source })) throw new Error('Shard definitions differ')
12-
merged.runners.push(...bundle.runners)
13-
merged.measurements.push(...bundle.measurements)
8+
export function mergeBundles(inputs: unknown[], attempt?: number, runId?: string): RunBundle {
9+
if (!inputs.length) throw new Error('Provide bundle files to merge')
10+
const bundles = inputs.map(parseRunBundle)
11+
const merged = structuredClone(bundles[0]!)
12+
const collectionAttempt = attempt ?? Math.max(...bundles.map(bundle => bundle.run.attempt))
13+
if (!Number.isSafeInteger(collectionAttempt) || collectionAttempt < 1) throw new Error('Invalid collection attempt')
14+
if (runId !== undefined && merged.run.id !== runId) throw new Error('Run identity differs from the current workflow')
15+
const definition = (bundle: RunBundle) => {
16+
const { attempt, createdAt, workflow, ...run } = bundle.run
17+
const { url, ...workflowDefinition } = workflow
18+
return canonicalJson({ run, workflow: workflowDefinition, catalog: bundle.catalog, benchmark: bundle.benchmark, source: bundle.source })
19+
}
20+
const expected = definition(merged)
21+
merged.runners = []
22+
merged.measurements = []
23+
for (const bundle of bundles) {
24+
if (definition(bundle) !== expected) throw new Error('Shard definitions differ')
25+
if (bundle.run.attempt > collectionAttempt) throw new Error('Shard attempt exceeds collection attempt')
26+
if (bundle.source === 'published' && bundle.run.workflow.url !== `https://github.com/${bundle.run.pullRequest?.repository}/actions/runs/${bundle.run.id}/attempts/${bundle.run.attempt}`) throw new Error('Shard workflow URL differs')
27+
if (bundle.run.createdAt < merged.run.createdAt) merged.run.createdAt = bundle.run.createdAt
28+
merged.runners.push(...bundle.runners)
29+
merged.measurements.push(...bundle.measurements)
30+
}
31+
merged.run.attempt = collectionAttempt
32+
if (merged.source === 'published') merged.run.workflow.url = `https://github.com/${merged.run.pullRequest!.repository}/actions/runs/${merged.run.id}/attempts/${collectionAttempt}`
33+
return validatePublishableRun(merged, publicationPolicy)
1434
}
15-
writeFileSync(values.output, `${JSON.stringify(validatePublishableRun(merged, publicationPolicy), null, 2)}\n`, { flag: 'wx' })
16-
console.log(`Merged ${merged.measurements.length} successful measurements`)
35+
36+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
37+
const { values, positionals } = parseArgs({ allowPositionals: true, options: { output: { type: 'string', default: 'run.json' } } })
38+
const attempt = process.env.GITHUB_ACTIONS ? Number(process.env.GITHUB_RUN_ATTEMPT) : undefined
39+
const merged = mergeBundles(positionals.map(path => JSON.parse(readFileSync(path, 'utf8'))), attempt, process.env.GITHUB_ACTIONS ? process.env.GITHUB_RUN_ID : undefined)
40+
writeFileSync(values.output, `${JSON.stringify(merged, null, 2)}\n`, { flag: 'wx' })
41+
console.log(`Merged ${merged.measurements.length} successful measurements`)
42+
}

‎scripts/publication.test.ts‎

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,59 @@ import { readJson, writeJson, withStoreLock } from './result-store.ts'
1313
import { assembleSite } from './assemble-site.ts'
1414
import { loadHistory } from '../src/data.ts'
1515
import { promoteRun, storeRun } from './publish-results.ts'
16+
import { mergeBundles } from './merge-results.ts'
1617

1718
const repository = 'example/benchmarks'
1819
const root = resolve(import.meta.dirname, '..')
1920

21+
test('partial retries retain successful measurements from the same source revision', () => {
22+
const bundle = fixture()
23+
const shards = bundle.measurements.map(measurement => {
24+
const runner = structuredClone(bundle.runners.find(entry => entry.id === measurement.runnerId)!)
25+
runner.id = measurement.id
26+
return { ...structuredClone(bundle), runners: [runner], measurements: [{ ...structuredClone(measurement), runnerId: runner.id }] }
27+
})
28+
const retry = structuredClone(shards[0]!)
29+
retry.run.attempt = 2
30+
retry.run.workflow.url = `https://github.com/${repository}/actions/runs/${retry.run.id}/attempts/2`
31+
retry.runners[0]!.name = 'retry-runner'
32+
const measurement = shards[0]!.measurements[0]!
33+
shards[0]!.measurements = [{ id: measurement.id, runnerId: measurement.runnerId, runtimeId: measurement.runtimeId, strategy: measurement.strategy, status: 'failed', reason: 'Transient failure', rawOutputs: [] }]
34+
const retained = shards.slice(1)
35+
const merged = mergeBundles([retry, ...retained], 2, bundle.run.id)
36+
assert.equal(merged.run.attempt, 2)
37+
assert.equal(merged.run.workflow.url, retry.run.workflow.url)
38+
assert.equal(merged.measurements.length, 108)
39+
assert.deepEqual(merged.measurements[0], retry.measurements[0])
40+
assert.deepEqual(merged.measurements[1], shards[1]!.measurements[0])
41+
assert.equal(merged.runners.find(runner => runner.id === retry.runners[0]!.id)!.name, 'retry-runner')
42+
assert.throws(() => mergeBundles([retry, ...retained, structuredClone(retry)], 2), /Duplicate/)
43+
assert.equal(mergeBundles([retry, ...retained], 3).run.attempt, 3)
44+
45+
const failedRetry = structuredClone(retry)
46+
failedRetry.measurements = structuredClone(shards[0]!.measurements)
47+
assert.throws(() => mergeBundles([failedRetry, ...retained], 2), /Missing successful configurations|Only successful/)
48+
assert.throws(() => mergeBundles(shards, 2), /Missing successful configurations|Only successful/)
49+
assert.throws(() => mergeBundles([...shards.slice(1, -1), retry], 2), /Missing successful configurations/)
50+
assert.throws(() => mergeBundles([...shards, retry], 2), /Duplicate/)
51+
assert.throws(() => mergeBundles([retry, ...retained], 1), /exceeds collection attempt/)
52+
assert.throws(() => mergeBundles([retry, ...retained], 2, 'another-run'), /Run identity differs/)
53+
for (const mutate of [
54+
(input: typeof retry) => { input.run.id = 'another-run' },
55+
(input: typeof retry) => { input.run.commit.sha = 'f'.repeat(40) },
56+
(input: typeof retry) => { input.run.commit.tree = 'f'.repeat(40) },
57+
(input: typeof retry) => { input.run.pullRequest!.base = 'f'.repeat(40) },
58+
(input: typeof retry) => { input.benchmark.settings.concurrency = 1 },
59+
]) {
60+
const invalid = structuredClone(retry)
61+
mutate(invalid)
62+
assert.throws(() => mergeBundles([...retained, invalid], 2), /Shard definitions differ/)
63+
}
64+
const invalidUrl = structuredClone(retry)
65+
invalidUrl.run.workflow.url = bundle.run.workflow.url
66+
assert.throws(() => mergeBundles([...retained, invalidUrl], 2), /Shard workflow URL differs/)
67+
})
68+
2069
test('local history loads measured results but cannot be published', async context => {
2170
const bundle = fixture()
2271
bundle.source = 'local'
@@ -327,13 +376,14 @@ test('CI archival and promotion with simulated GitHub and Git', async context =>
327376
...Array.from({ length: 2 }, (_, index) => `prepare (${index})`),
328377
...Array.from({ length: 108 }, (_, index) => `measure (${index})`),
329378
].map(name => ({ name, conclusion: 'success' }))
379+
let retryJobs: typeof jobs = []
330380
context.mock.method(globalThis, 'fetch', async (url: string) => {
331381
const parsed = new URL(url)
332382
const path = parsed.pathname.replace(`/repos/${repository}/`, '')
333383
if (path.endsWith('/jobs')) {
334384
const page = Number(parsed.searchParams.get('page'))
335-
const response = structuredClone(jobs)
336-
if (failedJob) response[0]!.conclusion = 'failure'
385+
const response = structuredClone(path.includes('/attempts/2/') ? retryJobs : jobs)
386+
if (failedJob && !path.includes('/attempts/2/')) response[0]!.conclusion = 'failure'
337387
return Response.json({ jobs: response.slice((page - 1) * 100, page * 100), total_count: response.length })
338388
}
339389
const responses: Record<string, unknown> = {
@@ -344,6 +394,7 @@ test('CI archival and promotion with simulated GitHub and Git', async context =>
344394
'issues/7/events': [{ event: 'labeled', label: { name: 'benchmarks: skip' }, actor: { login: 'maintainer' } }],
345395
'collaborators/maintainer/permission': { permission: 'write' },
346396
'actions/runs/12345/attempts/1': { id: 12345, event: 'pull_request', conclusion: 'success', path: '.github/workflows/benchmark.yml', head_sha: workflowHead, created_at: bundle.run.createdAt },
397+
'actions/runs/12345/attempts/2': { id: 12345, event: 'pull_request', conclusion: 'success', path: '.github/workflows/benchmark.yml', head_sha: workflowHead, created_at: bundle.run.createdAt },
347398
'contents/.github/workflows/benchmark.yml': { encoding: 'base64', content: Buffer.from(workflow).toString('base64') },
348399
[`git/commits/${bundle.run.commit.sha}`]: { tree: { sha: bundle.run.commit.tree }, parents: [{ sha: bundle.run.pullRequest!.base }, { sha: bundle.run.pullRequest!.head }] },
349400
[`git/commits/${pr.merge_commit_sha}`]: { tree: { sha: mergedTree } },
@@ -424,6 +475,36 @@ test('CI archival and promotion with simulated GitHub and Git', async context =>
424475
assert.equal(downloads, 0)
425476
assert.equal(pushes, 0)
426477
})
478+
await context.test('partial retry archives and promotes retained successful jobs', async () => {
479+
clearStore()
480+
const originalUrl = bundle.run.workflow.url
481+
bundle.run.attempt = 2
482+
bundle.run.workflow.url = `https://github.com/${repository}/actions/runs/12345/attempts/2`
483+
retryJobs = jobs.filter(job => ['eligibility', 'measure (0)', 'collect', 'Benchmark Status'].includes(job.name))
484+
failedJob = true
485+
writeJson(eventPath, { workflow_run: { ...notification.workflow_run, run_attempt: 2 } }, false)
486+
try {
487+
await main()
488+
assert.equal(downloads, 1)
489+
assert.equal(pushes, 2)
490+
assert.deepEqual(readJson(resolve(temporary, 'data-store/index.json')), { schemaVersion: 1, runs: [{ id: '12345', attempt: 2 }] })
491+
const stored = validateCompleteRun(readJson(resolve(temporary, 'data-store/runs/12345/2.json')))
492+
assert.deepEqual(stored.measurements, bundle.measurements)
493+
retryJobs = retryJobs.map(job => ({ ...job, conclusion: job.name === 'measure (0)' ? 'failure' : 'success' }))
494+
await assert.rejects(main(), /successful measure/)
495+
assert.equal(downloads, 1, 'A failed retry must not use an earlier successful job')
496+
retryJobs = retryJobs.filter(job => job.name !== 'eligibility')
497+
await assert.rejects(main(), /successful eligibility/)
498+
assert.equal(downloads, 1)
499+
} finally {
500+
bundle.run.attempt = 1
501+
bundle.run.workflow.url = originalUrl
502+
retryJobs = []
503+
failedJob = false
504+
writeJson(eventPath, notification, false)
505+
clearStore()
506+
}
507+
})
427508
await context.test('label changes prevent archival and promotion', async () => {
428509
pr.labels = [{ name: 'benchmarks: skip' }]
429510
await main()

‎scripts/publish-ci.ts‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,21 +48,28 @@ async function verifiedRun(runId: number, attempt: number) {
4848
if (workflow.encoding !== 'base64' || Buffer.from(workflow.content, 'base64').toString('utf8') !== readFileSync('.github/workflows/benchmark.yml', 'utf8')) {
4949
throw new Error('Benchmark workflow differs from the trusted workflow. Maintainer review and a fresh run are required.')
5050
}
51-
const jobs: any[] = []
52-
for (let page = 1; ; page++) {
53-
const response = await github(`actions/runs/${runId}/attempts/${attempt}/jobs?per_page=100&page=${page}`)
54-
jobs.push(...response.jobs)
55-
if (jobs.length >= response.total_count) break
56-
if (!response.jobs.length) throw new Error('Incomplete workflow job list')
51+
const latestJobs = new Map<string, any>()
52+
for (let currentAttempt = attempt; currentAttempt >= 1; currentAttempt--) {
53+
let fetched = 0
54+
for (let page = 1; ; page++) {
55+
const response = await github(`actions/runs/${runId}/attempts/${currentAttempt}/jobs?per_page=100&page=${page}`)
56+
for (const job of response.jobs) {
57+
if (!latestJobs.has(job.name)) latestJobs.set(job.name, job)
58+
}
59+
fetched += response.jobs.length
60+
if (fetched >= response.total_count) break
61+
if (!response.jobs.length) throw new Error('Incomplete workflow job list')
62+
}
5763
}
64+
const jobs = [...latestJobs.values()]
5865
const expected = new Map([
5966
['eligibility', 1], ['configure', 1], ['producer', 1], ['prepare', 2],
6067
['measure', publicationPolicy.benchmark.expectedConfigurations.length], ['collect', 1], ['Benchmark Status', 1],
6168
])
6269
for (const [name, count] of expected) {
6370
const matching = jobs.filter(job => job.name === name || job.name.startsWith(`${name} (`))
6471
if (matching.length !== count || matching.some(job => job.conclusion !== 'success')) {
65-
throw new Error(`Attempt ${runId}.${attempt} requires ${count} successful ${name} jobs. Rerun all jobs.`)
72+
throw new Error(`Run ${runId} through attempt ${attempt} requires ${count} successful ${name} jobs. Rerun the failed jobs.`)
6673
}
6774
}
6875
return run

‎shared/catalog.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export const benchmark = {
5050
id: 'http-redirect', version: 2,
5151
settings: {
5252
durationSeconds: 60, concurrency: 50, poolSize: 4, workerThreads: 'available-parallelism', workerReadiness: 'all-workers',
53-
sandboxTimeoutMs: 1000, timeoutCheckIntervalMs: 10, clientTimeoutSeconds: 10,
53+
sandboxTimeoutMs: 1000, timeoutCheckIntervalMs: 10, clientTimeoutSeconds: 30,
5454
memorySampleIntervalMs: 500, memorySampleAtShutdown: true, readinessRequests: 1, warmupRequests: 0,
5555
waitOngoingRequests: false, repetitions: 1, hyperlightAotMapping: 'linux-file-mapping-per-worker',
5656
},

0 commit comments

Comments
 (0)