v3.0.0: add settle/map/mapSettled and AbortSignal support#3
Merged
Conversation
…tible)
Expands the API while keeping v2 fully working — the default export and the
`parallel(jobs, limit)` call shape are unchanged; everything new is additive.
New named exports:
- `settle(jobs, limit?, options?)` — concurrency-limited `Promise.allSettled`;
returns per-job `{ status, value | reason }` in input order, never rejects on
a failing job.
- `map(items, mapper, limit?, options?)` — map over data with a
`(item, index) => value | Promise<value>` mapper instead of pre-built thunks;
fail-fast, results in input order.
- `mapSettled(items, mapper, limit?, options?)` — settle-all variant of `map`.
New options (3rd positional arg, `{ signal }`):
- `AbortSignal` support across all variants. An already-aborted signal rejects
immediately; aborting mid-run rejects with the signal's reason and stops
scheduling new jobs. In-flight jobs run to completion (their results are
discarded) and a late straggler rejection is swallowed rather than surfacing
as an unhandled rejection.
Internals:
- All four variants share one worker-pool core (sustained concurrency, ordered
writes). `AbortSignalLike` and `SettledResult` are declared locally so the
package still needs no DOM/es2020 lib and stays zero-dependency.
Tests: suite grows to 22 cases covering settle/map/mapSettled, sync mappers,
abort (pre-aborted, mid-flight, default AbortError, settle-honours-abort, and
no-leak on a non-aborted signal). Legacy `parallel(jobs, 30)` verified
byte-for-byte identical to published 2.1.0 on the @paperbits/core call shape.
README documents the full v3 surface and the 2.x-compatibility note.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013eq96Mbqa2dBCAN3YCyrDK
…bad input Two fixes from the PR #3 deep review: 1. Fail-fast now stops starting new jobs. Previously only the worker that hit the rejection died; the surviving workers kept pulling fresh jobs until the array drained, contradicting the documented behaviour ("jobs already in flight run to completion"). A shared `stopped` flag is now set on the first rejection and checked in the worker loop alongside `signal.aborted`, so not-yet-started jobs are abandoned once the caller has the rejection. This is a deliberate divergence from 2.x background side effects; the observable API (same rejection, same error, ordered results) is unchanged. 2. map/mapSettled now reject on non-array input instead of throwing synchronously, matching parallel/settle so `.catch()` protects callers on all four variants. Tests grow to 26: no-new-jobs-after-rejection, reject-not-throw on non-array for map/mapSettled, map honours abort, settle on empty input. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013eq96Mbqa2dBCAN3YCyrDK
…, coverage Correctness: - Sync-abort hole (critical): the abort listener was attached after workers had synchronously started, and real signals fire no 'abort' event for late listeners — a job aborting the signal synchronously made all four variants FULFILL with a holey results array instead of rejecting. Fixed by re-checking signal.aborted immediately after attaching the listener, and unifying cancellation into the single `stopped` flag (set by both the fail-fast catch and the abort listener). - Sparse-array inputs: map/mapSettled built thunks via items.map(), which skips holes, so jobs[i]() threw an internal TypeError presented as the item's outcome. Workers now invoke mapper(items[i], i) directly; holes reach the mapper as undefined, matching Promise.all semantics. - Published typings: AbortSignalLike's `reason` is now `any` instead of `unknown`, keeping the d.ts compilable on pre-3.0 TypeScript. Performance / structure: - run() takes (items, invoke) and calls the mapper/thunk directly in the worker loop instead of materialising a closure + extra promise per element: map over 100k items drops from ~185ms to ~13ms and no longer retains an O(N) throwaway thunk array. This also deletes the map/mapSettled duplication — all four variants are now one-line wrappers over run(), and the input-validation message has a single owner. - Abort machinery simplified from four interacting pieces (sentinel promise, definite-assignment assertion, Promise.race, standalone swallow-catch) to one wrapped promise whose rejection handler doubles as the straggler absorber; the provably-dead all.catch() is gone. Docs / packaging: - README Compatibility section now discloses the deliberate fail-fast change (3.x abandons not-yet-started jobs after a rejection; 2.x kept executing them in the background) and points side-effect-dependent callers at settle. - engines raised to >=16.14 — the test suite requires AbortController with abort(reason)/signal.reason; the previous ">=12" claim was untestable. Tests grow to 30 with a stub AbortSignalLike that pins the previously uncovered paths: library-fallback AbortError for reason-less signals, listener add/remove symmetry after completion and after abort, sync-abort rejection, and sparse-hole mapping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013eq96Mbqa2dBCAN3YCyrDK
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013eq96Mbqa2dBCAN3YCyrDK
…syntax) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013eq96Mbqa2dBCAN3YCyrDK
…+ CI gates
Correctness:
- limit=Infinity regression vs published 2.1.0: v2's `limit || 5` treated
Infinity as unbounded (all jobs at once); normalizeLimit silently degraded
it to 5. Infinity now means unbounded again (verified head-to-head: v2 peak
12/12, v3 was 5, now 12). Documented in README.
- Input-length snapshot: items.length is captured at call time, so mutating
the input array mid-run can no longer grow the result set or (worse) shrink
it into a silently holey fulfilled array. Elements stay lazily read.
Enforcement:
- test/fuzz.cjs: seeded (xorshift32, reproducible-by-seed) differential
fuzzer — randomized variants, sizes, limits (incl. Infinity and junk),
delays, failures, sync throws, and aborts (pre/timed/sync-from-a-job) —
checking concurrency ceiling, exact reference results/outcomes, legal
rejection provenance, and nothing-starts-after-settle. 2,000 scenarios
green under --unhandled-rejections=strict.
- test/types.test.ts: exact-type (not assignability) assertions for tuple
inference, settle narrowing, map/mapSettled generics, and the literal
DEFAULT_CONCURRENCY — compiled by npm test, so inference regressions fail
the build.
- npm test now runs the unit suite under --unhandled-rejections=strict.
- CI: matrix gains the exact engines floor ('16.14') and 24.x; new fuzz job
(500 scenarios); new package-boundary job that packs the tarball, installs
it into a fresh consumer, runs test/boundary/smoke.cjs against the
installed package, and compiles test/boundary/consumer.ts with both
TypeScript 3.4.5 (the documented floor) and typescript@latest.
- README: Behaviour section rewritten as explicit Guarantees (ceiling,
ordering, fail-fast, abort, no stray rejections, input snapshot).
Suite: 32 unit tests + type gate + 2,000-scenario fuzz + boundary smoke, all
green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013eq96Mbqa2dBCAN3YCyrDK
…r metadata Makes the package fully discoverable and correctly usable by AI coding agents (and humans) in every module system: - ESM interop fixed via an exports map + static wrapper (esm/index.mjs re-exports the CJS build — one implementation, no dual-package hazard). `import parallel from 'await-parallel-limit'` now yields the function in bare Node ESM instead of the exports object. Types route per-condition (esm/index.d.mts for import, dist/index.d.ts for require); legacy TS resolvers still use main/types, so the TS 3.4 floor is unchanged. - llms.txt ships in the npm tarball: complete API, semantics, recipes, and gotchas (thunks-not-promises, settle rejection rules, abort scope) in one context-window-sized file at node_modules/await-parallel-limit/llms.txt. - AGENTS.md + CLAUDE.md for agents working on the repo: build/test/fuzz commands, architecture map, and the frozen invariants. - @example blocks on all four functions' JSDoc — flows into dist/index.d.ts, so hover docs and d.ts-reading agents get working usage samples. - package.json: sharper description, expanded keywords (promise-pool, p-limit, allsettled, rate-limit, abortsignal, ...). - test/boundary/smoke.mjs: ESM boundary smoke (default-import-is-function, full surface, abort) run in CI next to the CJS smoke; verify skill updated. Gauntlet: 32/32 units + type gate, 300 fuzz scenarios, CJS+ESM boundary smokes, TS 3.4 floor gate, TS node16-resolution gate (exports-map types) — all green. Tarball: 8 files. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013eq96Mbqa2dBCAN3YCyrDK
Publishes to npm with --provenance from GitHub Actions (OIDC id-token), so the registry links the tarball to the exact workflow run and commit. Triggered by publishing a GitHub Release (or manually); guards that the release tag matches package.json's version, and re-runs the full gate first: unit suite + type gate, 500 fuzz scenarios, and the CJS+ESM package-boundary smokes against the freshly packed tarball. Requires an NPM_TOKEN automation-token secret. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013eq96Mbqa2dBCAN3YCyrDK
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Expands the API to
3.0.0while keeping the v2 surface: the default export and theparallel(jobs, limit)call shape are unchanged; everything new is additive. Follows up on #2 (merged).New named exports
settle(jobs, limit?, options?)Promise.allSettled— resolves to per-job{ status, value | reason }in input order; never rejects because of a failing jobmap(items, mapper, limit?, options?)(item, index) => value | Promise<value>instead of pre-built thunks; fail-fast, results in input ordermapSettled(items, mapper, limit?, options?)mapparallelremains the default export and is also re-exported by name. Both CJS and ESM are first-class — anexportsmap routes each module system to the right entry, soimport parallel from 'await-parallel-limit'works in bare Node ESM.New options argument (3rd positional,
{ signal })AbortSignalsupport across all four variants:reason(or a defaultAbortErrorfor minimal signals without reason support) and stops scheduling new jobs.Hardened by two review passes + verification + bulletproofing
Review pass 1 fixed fail-fast semantics (a rejection now stops new jobs from starting) and made
map/mapSettledreject on non-array input instead of throwing synchronously.Review pass 2 (8-angle deep review, every finding independently verified) surfaced 10 findings, all fixed — highlights: a critical sync-abort hole (a job aborting the signal synchronously made all variants fulfill with a holey array — fixed via post-attach re-check + one unified
stoppedflag); sparse-array holes now reach the mapper asundefined; workers invoke the mapper/thunk directly (100k-itemmap: ~185ms → ~13ms, no O(N) thunk array); abort machinery simplified;enginesraised to>=16.14; stub-signal tests pin the fallbackAbortErrorand listener add/remove symmetry.End-to-end verification at the package boundary (packed tarball → fresh consumer): full behavioral battery plus the typings floor pinned at TS ≥ 3.4 (verified with TS 3.4.5 and latest). Recipe persisted in
.claude/skills/verify/SKILL.md.Bulletproofing pass:
Infinityregression vs published 2.1.0 (v2 treatedInfinityas unbounded; validation had silently degraded it to 5).test/fuzz.cjs): randomized variants/sizes/limits/failures/aborts vs a per-job reference model — 2,000 scenarios green under--unhandled-rejections=strict(500 per CI run).test/types.test.ts) — tuple inference andsettlenarrowing are now build-gated.'16.14') through 24.x; fuzz job; package-boundary job (tarball → consumer → smoke + TS 3.4.5/latest gates).Agent-friendly packaging
exportsmap + static wrapper (esm/index.mjsre-exports the CJS build — one implementation, no dual-package hazard). The previously documented bare-Node-ESM default-import trap is fixed, not just documented. Types route per condition (esm/index.d.mts/dist/index.d.ts); legacy TS resolvers usemain/types, so the TS 3.4 floor holds (gated in CI, including anode16-resolution check).llms.txtships in the npm tarball — complete API, semantics, recipes, and gotchas (thunks-not-promises, settle rejection rules, abort scope) in one context-window-sized file atnode_modules/await-parallel-limit/llms.txt.AGENTS.md+CLAUDE.md— build/test/fuzz commands, architecture map, and frozen invariants for agents working on the repo.@exampleJSDoc on all four functions — flows intodist/index.d.tsfor hover docs and d.ts-reading agents.promise-pool,p-limit,allsettled,rate-limit,abortsignal, …).test/boundary/smoke.mjs) runs in CI beside the CJS smoke: asserts the default import is the function and drives the full surface..github/workflows/publish.yml) — release-triggerednpm publish --provenancevia OIDC, guarded by a tag↔version check and the full gate (unit + type suite, 500 fuzz scenarios, CJS+ESM tarball smokes) before anything ships. Requires anNPM_TOKENautomation-token secret.Internals
invoke(items[i], i)dispatch.AbortSignalLikeandSettledResultare declared locally, so the package compiles without DOM/es2020 libs and stays zero-dependency.Backward compatibility
parallel(tasks, 30)(the exact@paperbits/corecall shape) verified against published2.1.0head-to-head: happy path identical; error path rejects with the same error at the same point;Infinityparity restored.as constjob arrays preserved and regression-locked bytest/types.test.ts.^2.1.0range will not auto-adopt3.0.0— they opt in by widening their range.Testing
npm test→ 32/32 unit tests + exact-type gate, under--unhandled-rejections=strict.npm run fuzz→ seeded differential fuzzing; 2,000 scenarios validated locally, 500 per CI run.node16typings gates.npm publish --provenance.map~13–17ms. Tarball: 8 files (dist,esm,llms.txt, README, LICENSE, package.json).🤖 Generated with Claude Code