Skip to content

Commit 2837855

Browse files
committed
fix(ci): globally cap artifact download concurrency, fix duplicate-named lcov function merge
Addresses two Codex/Datadog Autotest review findings on PR #9197: - The per-`downloadArtifacts`-call worker pool let each concurrently processed sibling workflow open its own 10-download burst, recreating the aggregate GitHub API pressure the cap was meant to prevent. A module-scoped Semaphore now bounds every in-flight call together. - `mergeLcovRecord` keyed functions by name only, so two functions sharing a name at different lines (e.g. two closures both named `shared`) collapsed into one, undercounting FNF/FNH. FN/FNDA are now paired positionally and keyed by `line,name`. Generated by Claude Code.
1 parent 31ecd3d commit 2837855

4 files changed

Lines changed: 138 additions & 42 deletions

File tree

scripts/download-artifacts.mjs

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,44 @@ const execFileAsync = promisify(execFile)
1515
// GitHub's connection/rate limits and fail every request for a run with a generic `fetch failed`
1616
// (undici's error for a dropped connection), even though a smaller burst succeeds fine. Capping
1717
// concurrency keeps the burst size sane; retrying absorbs the transient failures that still slip
18-
// through.
18+
// through. All Green processes more than one sibling workflow's downloads concurrently (see
19+
// `all-green.mjs`'s `scheduleProcessing`), so the cap has to hold across every in-flight
20+
// `downloadArtifacts` call, not just within one of them — a per-call limit still lets N concurrent
21+
// runs each open their own 10, recreating the exact burst this exists to prevent.
1922
const MAX_CONCURRENT_DOWNLOADS = 10
2023

24+
/**
25+
* Bounds how many downloads run at once across every concurrent `downloadArtifacts` call in this
26+
* process, instead of per call.
27+
*/
28+
class Semaphore {
29+
#permits
30+
#queue = []
31+
32+
constructor (permits) {
33+
this.#permits = permits
34+
}
35+
36+
acquire () {
37+
if (this.#permits > 0) {
38+
this.#permits--
39+
return Promise.resolve()
40+
}
41+
return new Promise(resolve => this.#queue.push(resolve))
42+
}
43+
44+
release () {
45+
const next = this.#queue.shift()
46+
if (next) {
47+
next()
48+
} else {
49+
this.#permits++
50+
}
51+
}
52+
}
53+
54+
const downloadSemaphore = new Semaphore(MAX_CONCURRENT_DOWNLOADS)
55+
2156
/**
2257
* Download and unzip a single artifact, retrying on failure with a backoff delay.
2358
*
@@ -60,32 +95,6 @@ async function downloadOne ({ runId, artifact, owner, repo, token, retries, dela
6095
}
6196
}
6297

63-
/**
64-
* Pull tasks off the front of `tasks` one at a time until it's empty, so at most one task per
65-
* worker is ever in flight.
66-
*
67-
* @param {Array<() => Promise<void>>} tasks
68-
* @returns {Promise<void>}
69-
*/
70-
async function worker (tasks) {
71-
const task = tasks.shift()
72-
if (!task) return
73-
await task()
74-
return worker(tasks)
75-
}
76-
77-
/**
78-
* Run a bounded number of `tasks` at a time instead of firing every one at once.
79-
*
80-
* @param {Array<() => Promise<void>>} tasks
81-
* @param {number} limit
82-
* @returns {Promise<void>}
83-
*/
84-
async function runWithConcurrencyLimit (tasks, limit) {
85-
const queue = [...tasks]
86-
await Promise.all(Array.from({ length: Math.min(limit, queue.length) }, () => worker(queue)))
87-
}
88-
8998
/**
9099
* @param {import('octokit').Octokit} octokit
91100
* @param {object} opts
@@ -113,13 +122,19 @@ export async function downloadArtifacts (octokit, { owner, repo, token, runs, re
113122
)
114123

115124
let failed = 0
116-
await runWithConcurrencyLimit(
117-
toDownload.map(({ runId, artifact }) => async () => {
118-
const ok = await downloadOne({ runId, artifact, owner, repo, token, retries, delayMs })
119-
if (!ok) failed++
120-
}),
121-
MAX_CONCURRENT_DOWNLOADS
125+
await Promise.all(
126+
toDownload.map(async ({ runId, artifact }) => {
127+
await downloadSemaphore.acquire()
128+
try {
129+
const ok = await downloadOne({ runId, artifact, owner, repo, token, retries, delayMs })
130+
if (!ok) failed++
131+
} finally {
132+
downloadSemaphore.release()
133+
}
134+
})
122135
)
123136

124137
return { downloaded: toDownload.length - failed, failed }
125138
}
139+
140+
export { Semaphore }
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import assert from 'node:assert/strict'
2+
import { setTimeout as sleep } from 'node:timers/promises'
3+
4+
import { describe, it } from 'mocha'
5+
6+
import { Semaphore } from './download-artifacts.mjs'
7+
8+
// Acquires `semaphore`, records the peak number of concurrently active holders onto `peakTracker`,
9+
// holds briefly, then releases.
10+
async function acquireAndTrackPeak (semaphore, peakTracker) {
11+
await semaphore.acquire()
12+
peakTracker.active++
13+
peakTracker.peak = Math.max(peakTracker.peak, peakTracker.active)
14+
await sleep(5)
15+
peakTracker.active--
16+
semaphore.release()
17+
}
18+
19+
describe('download-artifacts', () => {
20+
describe('Semaphore', () => {
21+
it('never lets more than the given number of permits run at once', async () => {
22+
const semaphore = new Semaphore(2)
23+
const peakTracker = { active: 0, peak: 0 }
24+
25+
await Promise.all(Array.from({ length: 5 }, () => acquireAndTrackPeak(semaphore, peakTracker)))
26+
27+
assert.equal(peakTracker.peak, 2)
28+
})
29+
30+
it('holds the cap across independent batches acquiring concurrently', async () => {
31+
// Regression test: All Green calls `downloadArtifacts` once per sibling workflow, and more
32+
// than one call can be in flight at the same time (see `all-green.mjs`'s `scheduleProcessing`).
33+
// A per-call limiter would let each batch open its own pool of permits; sharing one `Semaphore`
34+
// module-wide is what keeps the aggregate bounded instead.
35+
const semaphore = new Semaphore(3)
36+
const peakTracker = { active: 0, peak: 0 }
37+
const task = () => acquireAndTrackPeak(semaphore, peakTracker)
38+
39+
const batchA = Promise.all(Array.from({ length: 4 }, task))
40+
const batchB = Promise.all(Array.from({ length: 4 }, task))
41+
await Promise.all([batchA, batchB])
42+
43+
assert.equal(peakTracker.peak, 3)
44+
})
45+
46+
it('runs every queued acquirer eventually', async () => {
47+
const semaphore = new Semaphore(1)
48+
const order = []
49+
50+
const task = async id => {
51+
await semaphore.acquire()
52+
order.push(id)
53+
semaphore.release()
54+
}
55+
56+
await Promise.all([task('a'), task('b'), task('c')])
57+
58+
assert.deepEqual(order.sort(), ['a', 'b', 'c'])
59+
})
60+
})
61+
})

scripts/group-coverage.mjs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,9 @@ function planCoverageGroups (files) {
104104
/**
105105
* @typedef {object} LcovFileRecord
106106
* @property {Map<string, number>} lines line number -> summed hit count
107-
* @property {Map<string, { line: string, count: number }>} functions function name -> declaration
108-
* line and summed hit count
107+
* @property {Map<string, { name: string, line: string, count: number }>} functions `line,name` ->
108+
* name, declaration line, and summed hit count — keyed by line as well as name because distinct
109+
* functions can share a name (e.g. two nested closures both named `shared`)
109110
* @property {Map<string, { hits: number, reached: boolean }>} branches `line,block,branch` -> summed
110111
* hit count and whether any cell reported the enclosing block as reached (lcov uses `-` for a
111112
* branch whose block was never reached, distinct from a reached block whose branch was never taken)
@@ -125,6 +126,9 @@ function splitLcovLine (line) {
125126
/**
126127
* Fold one `SF:`-delimited record's `DA:`/`FN:`/`FNDA:`/`BRDA:` lines into the running per-file
127128
* merge state, summing hit counts for lines/functions/branches already seen from earlier cells.
129+
* `FNDA:` lines carry only a function name, not its declaration line, so a same-named function
130+
* declared at two different lines can't be told apart by name alone; lcov writers emit `FNDA:` lines
131+
* in the same order as their `FN:` declarations, so they're paired positionally instead.
128132
*
129133
* @param {string[]} recordLines
130134
* @param {Map<string, LcovFileRecord>} files source file path -> merge state
@@ -142,19 +146,23 @@ function mergeLcovRecord (recordLines, files, order) {
142146
}
143147
const record = files.get(path)
144148

149+
const declaredFunctionKeys = []
150+
let functionDeclarationIndex = 0
145151
for (const line of recordLines) {
146152
const [tag, rest] = splitLcovLine(line)
147153
if (tag === 'DA') {
148154
const [lineNumber, count] = rest.split(',')
149155
record.lines.set(lineNumber, (record.lines.get(lineNumber) ?? 0) + Number(count))
150156
} else if (tag === 'FN') {
151157
const [lineNumber, name] = rest.split(',')
152-
if (!record.functions.has(name)) record.functions.set(name, { line: lineNumber, count: 0 })
158+
const key = `${lineNumber},${name}`
159+
declaredFunctionKeys.push(key)
160+
if (!record.functions.has(key)) record.functions.set(key, { name, line: lineNumber, count: 0 })
153161
} else if (tag === 'FNDA') {
154-
const [count, name] = rest.split(',')
155-
const fn = record.functions.get(name) ?? { line: '0', count: 0 }
156-
fn.count += Number(count)
157-
record.functions.set(name, fn)
162+
const [count] = rest.split(',')
163+
const key = declaredFunctionKeys[functionDeclarationIndex++]
164+
if (key === undefined) continue
165+
record.functions.get(key).count += Number(count)
158166
} else if (tag === 'BRDA') {
159167
const [lineNumber, block, branch, count] = rest.split(',')
160168
const key = `${lineNumber},${block},${branch}`
@@ -179,8 +187,8 @@ function mergeLcovRecord (recordLines, files, order) {
179187
function serializeLcovRecord (path, record) {
180188
const lines = [`SF:${path}`]
181189

182-
for (const [name, fn] of record.functions) lines.push(`FN:${fn.line},${name}`)
183-
for (const [name, fn] of record.functions) lines.push(`FNDA:${fn.count},${name}`)
190+
for (const fn of record.functions.values()) lines.push(`FN:${fn.line},${fn.name}`)
191+
for (const fn of record.functions.values()) lines.push(`FNDA:${fn.count},${fn.name}`)
184192
if (record.functions.size > 0) {
185193
const hit = [...record.functions.values()].filter(fn => fn.count > 0).length
186194
lines.push(`FNF:${record.functions.size}`, `FNH:${hit}`)

scripts/group-coverage.spec.mjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,18 @@ describe('group-coverage', () => {
112112
)
113113
})
114114

115+
it('keeps same-named functions declared at different lines distinct', () => {
116+
const a = join(dir, 'a.info')
117+
writeFileSync(
118+
a,
119+
'SF:shared.js\nFN:4,shared\nFN:9,shared\nFNF:2\nFNDA:2,shared\nFNDA:0,shared\nFNH:1\nend_of_record\n'
120+
)
121+
assert.equal(
122+
mergeLcov([a]),
123+
'SF:shared.js\nFN:4,shared\nFN:9,shared\nFNDA:2,shared\nFNDA:0,shared\nFNF:2\nFNH:1\nend_of_record\n'
124+
)
125+
})
126+
115127
it('sums BRDA hit counts for the same branch, treating "-" as an unreached block', () => {
116128
const a = join(dir, 'a.info')
117129
const b = join(dir, 'b.info')

0 commit comments

Comments
 (0)