Skip to content

Commit 54c1fd2

Browse files
committed
test(scripts): add bounded-concurrency map helper
Generating the versions/ workspace tree is thousands of mkdir/writeFile calls; an unbounded Promise.all over the whole tree exhausts file descriptors (EMFILE). This adds a reusable worker-pool map that bounds in-flight work while preserving input order, and wires `test:scripts` so it runs in CI.
1 parent 2da11d0 commit 54c1fd2

4 files changed

Lines changed: 98 additions & 0 deletions

File tree

.github/workflows/project.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ jobs:
5959
- uses: ./.github/actions/node/latest
6060
- uses: ./.github/actions/install
6161
- run: npm run test:release
62+
- run: npm run test:scripts
6263

6364
generated-config-types:
6465
runs-on: ubuntu-latest

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
"test:integration:plugins:coverage": "yarn services && node ./integration-tests/coverage/run-suite.js \"packages/datadog-plugin-@(${PLUGINS})/test/integration-test/**/${SPEC:-*}*.spec.js\"",
105105
"test:unit:plugins": "mocha \"packages/datadog-instrumentations/test/@(${PLUGINS}).spec.js\" \"packages/datadog-plugin-@(${PLUGINS})/test/**/*.spec.js\" --exclude \"packages/datadog-plugin-@(${PLUGINS})/test/integration-test/**/*.spec.js\"",
106106
"test:release": "mocha \"scripts/release/**/*.spec.js\"",
107+
"test:scripts": "mocha \"scripts/helpers/**/*.spec.js\"",
107108
"test:shimmer": "mocha \"packages/datadog-shimmer/test/**/*.spec.js\"",
108109
"test:shimmer:ci": "nyc --silent node init && nyc -- npm run test:shimmer",
109110
"verify:workflow-job-names": "node scripts/verify-workflow-job-names.js",

scripts/helpers/concurrency.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
'use strict'
2+
3+
/**
4+
* Run an async worker over every item with at most `concurrency` workers in flight. Used to bound the number of open
5+
* file handles while generating workspace folders and patching peer dependencies (an unbounded `Promise.all` over the
6+
* whole `versions/` tree exhausts file descriptors / `EMFILE`). Results preserve input order; the first worker error
7+
* rejects the returned promise.
8+
*
9+
* @template T, R
10+
* @param {T[]} items
11+
* @param {number} concurrency Maximum number of workers running at once; must be >= 1.
12+
* @param {(item: T, index: number) => Promise<R> | R} worker
13+
* @returns {Promise<R[]>}
14+
*/
15+
async function mapWithConcurrency (items, concurrency, worker) {
16+
if (concurrency < 1) throw new RangeError(`concurrency must be >= 1, got ${concurrency}`)
17+
18+
const results = new Array(items.length)
19+
let nextIndex = 0
20+
21+
const runWorker = async () => {
22+
while (nextIndex < items.length) {
23+
const index = nextIndex
24+
nextIndex += 1
25+
// Sequential within a worker is intended: concurrency comes from running several workers in parallel.
26+
// eslint-disable-next-line no-await-in-loop
27+
results[index] = await worker(items[index], index)
28+
}
29+
}
30+
31+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, runWorker))
32+
33+
return results
34+
}
35+
36+
module.exports = mapWithConcurrency
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
'use strict'
2+
3+
const assert = require('node:assert/strict')
4+
const { setTimeout: delay } = require('node:timers/promises')
5+
6+
const { describe, it } = require('mocha')
7+
8+
const mapWithConcurrency = require('./concurrency')
9+
10+
describe('mapWithConcurrency', () => {
11+
it('resolves to an empty array for no items', async () => {
12+
assert.deepEqual(await mapWithConcurrency([], 4, () => assert.fail('should not run')), [])
13+
})
14+
15+
it('preserves input order regardless of completion order', async () => {
16+
const results = await mapWithConcurrency([30, 10, 20], 3, async (ms, index) => {
17+
await delay(ms)
18+
return index
19+
})
20+
assert.deepEqual(results, [0, 1, 2])
21+
})
22+
23+
it('runs every item exactly once', async () => {
24+
const seen = []
25+
await mapWithConcurrency([1, 2, 3, 4, 5], 2, item => {
26+
seen.push(item)
27+
})
28+
assert.deepEqual(seen.sort(), [1, 2, 3, 4, 5])
29+
})
30+
31+
it('never exceeds the concurrency limit', async () => {
32+
let active = 0
33+
let peak = 0
34+
await mapWithConcurrency(Array.from({ length: 10 }, (_, i) => i), 3, async () => {
35+
active += 1
36+
peak = Math.max(peak, active)
37+
await delay(5)
38+
active -= 1
39+
})
40+
assert.equal(peak, 3)
41+
})
42+
43+
it('rejects with the first error and stops scheduling new work', async () => {
44+
let started = 0
45+
await assert.rejects(
46+
mapWithConcurrency([1, 2, 3, 4, 5, 6], 1, async item => {
47+
started += 1
48+
if (item === 2) throw new Error('boom')
49+
await delay(1)
50+
}),
51+
/boom/
52+
)
53+
// With concurrency 1 the failure on item 2 prevents items 3-6 from starting.
54+
assert.equal(started, 2)
55+
})
56+
57+
it('rejects for an invalid concurrency', async () => {
58+
await assert.rejects(mapWithConcurrency([1], 0, () => {}), RangeError)
59+
})
60+
})

0 commit comments

Comments
 (0)