├── .eslintignore ├── .eslintrc.json ├── .github ├── pull_request_template.md └── workflows │ └── ci.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.json ├── LICENSE ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── promise-pool.sublime-project ├── renovate.json ├── src └── index.ts ├── test └── index.test.ts ├── tsconfig-lint.json └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "mixmax/node", 4 | "mixmax/prettier" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | > **Note** - Since this is a public repository, make sure that we're not publishing private data in the code, commit comments, or this PR. 4 | 5 | > **Note for reviewers** - Please add a 2nd reviewer if the PR affects more than 15 files or 100 lines (not counting 6 | `package-lock.json`), if it incurs significant risk, or if it is going through a 2nd review+fix cycle. 7 | 8 | ## 📚 Context/Description Behind The Change 9 | 18 | 19 | ## 🚨 Potential Risks & What To Monitor After Deployment 20 | 28 | 29 | ## 🧑‍🔬 How Has This Been Tested? 30 | 36 | 37 | ## 🚚 Release Plan 38 | 44 | 45 | 46 | 47 | 71 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: continuous-integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | checks: 13 | uses: mixmaxhq/github-workflows-public/.github/workflows/checks.yml@main 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Dependency directories 18 | node_modules 19 | 20 | # Optional npm cache directory 21 | .npm 22 | 23 | # Optional REPL history 24 | .node_repl_history 25 | .DS_Store 26 | *.sublime-workspace 27 | /dist 28 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.20.0 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | "@mixmaxhq/prettier-config" 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Mixmax, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # promise-pool 2 | 3 | Concurrent control of async function invocation with a pool-like abstraction. Implicitly applies 4 | backpressure to the imperative task producer. 5 | 6 | Requires a version of Node that supports [`async`][async]/[`await`][await]. 7 | 8 | ```js 9 | import PromisePool from '@mixmaxhq/promise-pool'; 10 | 11 | async function sample() { 12 | // Cap the concurrent function execution at 10. 13 | const pool = new PromisePool({ numConcurrent: 10 }); 14 | 15 | // Call an async function 1000000 times. The pool ensures that no more than 16 | // 10 will be executing at a time. 17 | for (const i = 0; i < 1000000; ++i) { 18 | // The await suspends the for loop until the function has started. 19 | await pool.start(async (i) => { 20 | await sendResult(await getResult(i)); 21 | 22 | // When this function returns, the pool will allow the currently 23 | // suspended pool.start to complete, allowing the for loop to 24 | // resume. 25 | }, i); 26 | 27 | // Note that because we are using let in the for loop, we could rely on the 28 | // closured i binding instead of passing it through pool.start. 29 | } 30 | 31 | // Wait for all the queued and running functions to finish. 32 | const errors = await pool.flush(); 33 | 34 | // We only log this once every result has been sent. 35 | if (errors.length) { 36 | console.log('done with errors', errors); 37 | } else { 38 | console.log('done'); 39 | } 40 | } 41 | ``` 42 | 43 | If this strikes you as similar to [batch][], that's because it is. It differs, however, in that it 44 | has built-in backpressure, to simplify writing concurrent code that avoids loading everything into 45 | memory. This module is a rewrite of the [synchronize-pool][] module, but instead of using 46 | synchronize, it uses async/await. 47 | 48 | ## Install 49 | 50 | We're hoping to use the `promise-pool` package name, but it's currently occupied. 51 | 52 | ```sh 53 | $ npm install @mixmaxhq/promise-pool 54 | ``` 55 | 56 | or 57 | 58 | ```sh 59 | $ npm i @mixmaxhq/promise-pool 60 | ``` 61 | 62 | ## Changelog 63 | 64 | - 3.0.0 Migrate codebase to TypeScript for improved type safety and developer experience. 65 | 66 | - 2.0.0 Add `maxPending` option to avoid problematic usage (see new [Troubleshooting](#troubleshooting) section) 67 | 68 | - 1.1.1 Move `ava` and `ava-spec` to `devDependencies`. 69 | 70 | - 1.1.0 Adds transpilation so it can be used in Node 6 (and prior) environments. 71 | 72 | - 1.0.0 Initial release. 73 | 74 | ## Troubleshooting 75 | 76 | ### `cannot queue function in pool` 77 | 78 | If you're getting this error, then you're calling `start` too many times 79 | concurrently. Please read on - one of two things is happening: 80 | 81 | - you're not waiting for the previous `start` call to resolve before calling `start` again 82 | - you're adding items to the pool in multiple places 83 | 84 | In the former case, you probably have code that looks like this: 85 | 86 | ```js 87 | import _ from 'lodash'; 88 | import mongoist from 'mongoist'; 89 | 90 | const db = mongoist(...); 91 | 92 | async function startJobs() { 93 | const pool = new PromisePool({numConcurrent: 4}); 94 | 95 | // Pull in an array of users (high memory usage, poor performance 96 | // characteristics due to loading all instead of streaming with something 97 | // like promise-iterate). 98 | const users = await db.users.findAsCursor().toArray(); 99 | 100 | // _.each doesn't wait on the promise returned by each invocation, so it 101 | // won't apply backpressure to the loop in the manner pool.start expects. 102 | // This will also not catch any error raised by pool.start, and will cause 103 | // recent versions of Node to crash due to an unhandled rejection! 104 | _.each(users, async (user) => { 105 | await pool.start(async () => { 106 | await queue.publish(user); 107 | }); 108 | }); 109 | 110 | await pool.flush(); 111 | } 112 | ``` 113 | 114 | Instead, you need to use some iteration method that preserves backpressure, like the `for`-`of` loop: 115 | 116 | ```js 117 | async function startJobs() { 118 | const pool = new PromisePool({ numConcurrent: 4 }); 119 | 120 | // Still severely suboptimal. 121 | const users = await db.users.findAsCursor().toArray(); 122 | 123 | for (const user of users) { 124 | // Now the await applies to the `startJobs` async function instead of 125 | // the anonymous async function. 126 | await pool.start(async () => { 127 | await queue.publish(user); 128 | }); 129 | } 130 | 131 | await pool.flush(); 132 | } 133 | ``` 134 | 135 | Or even better, couple this with a call to `promise-iterate` to only load users as you need them: 136 | 137 | ```js 138 | import promiseIterate from 'promise-iterate'; 139 | 140 | async function startJobs() { 141 | const pool = new PromisePool({ numConcurrent: 4 }); 142 | 143 | const users = await db.users.findAsCursor(); 144 | 145 | // promise-iterate correctly applies backpressure, and helpfully iterates 146 | // the cursor without pulling in all results as an array. 147 | await promiseIterate(users, async (user) => { 148 | // The await still applies to the `startJobs` async function due to the 149 | // behavior of promise-iterate. 150 | await pool.start(async () => { 151 | await queue.publish(user); 152 | }); 153 | }); 154 | 155 | await pool.flush(); 156 | } 157 | ``` 158 | 159 | The other case is where you're using `promise-pool` in multiple places, and 160 | thus you'll need to use `maxPending` to tell `promise-pool` that you know what 161 | you're doing: 162 | 163 | ```js 164 | async function initializeAllUsers() { 165 | const pool = new PromisePool({ 166 | numConcurrent: 4, 167 | 168 | // Important: without this, we'll fail almost immediately after starting to 169 | // kick off the first job and starting to send the first email. 170 | maxPending: 2, 171 | }); 172 | 173 | const users = await db.users.findAsCursor(); 174 | 175 | // Some cursor-compatible tee implementation. 176 | const [usersA, usersB] = tee(users); 177 | 178 | await Promise.all([ 179 | startJobs(pool, usersA), 180 | sendEmails(pool, usersB), 181 | }); 182 | 183 | await pool.flush(); 184 | } 185 | 186 | async function startJobs(pool, users) { 187 | await promiseIterate(users, async (user) => { 188 | await pool.start(async () => { 189 | await queue.publish(user); 190 | }); 191 | }); 192 | } 193 | 194 | async function sendEmails(pool, users) { 195 | await promiseIterate(users, async (user) => { 196 | await pool.start(async () => { 197 | await sendEmail(user); 198 | }); 199 | }); 200 | } 201 | ``` 202 | 203 | ## License 204 | 205 | > The MIT License (MIT) 206 | > 207 | > Copyright © 2017 Mixmax, Inc ([mixmax.com](https://mixmax.com)) 208 | > 209 | > Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 210 | > 211 | > The above copyright notice and this permission notice shall be included in allcopies or substantial portions of the Software. 212 | > 213 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 214 | 215 | [async]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function 216 | [await]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await 217 | [batch]: https://github.com/visionmedia/batch/ 218 | [synchronize-pool]: https://github.com/mixmaxhq/synchronize-pool 219 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | moduleFileExtensions: ['ts', 'js', 'json', 'node'], 7 | clearMocks: true, 8 | collectCoverageFrom: ['src/**/*.js'], 9 | coverageDirectory: 'coverage', 10 | testRegex: '/((test|spec)s?|src)/.*([Tt]est|[Ss]pec)\\.(ts|js)$', 11 | testEnvironment: 'node', 12 | moduleNameMapper: { 13 | '^mongodbMapped$': `mongodb${process.env.DRIVER_VERSION || ''}`, 14 | }, 15 | testTimeout: 15000, 16 | }; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mixmaxhq/promise-pool", 3 | "version": "3.0.0", 4 | "description": "Concurrent control of functions on a per-pool basis with async/await", 5 | "main": "./dist/node/index.js", 6 | "scripts": { 7 | "ci": "npm run lint && npm test", 8 | "lint": "eslint . && tsc --noEmit", 9 | "prepublishOnly": "npm run build && if [ \"$CI\" = '' ] && [ \"$npm_config_dry_run\" != true ]; then node -p 'JSON.parse(process.env.npm_package_config_manualPublishMessage)'; exit 1; fi", 10 | "test": "jest", 11 | "report": "jest --coverage --maxWorkers 4", 12 | "build": "rm -rf dist/ && tsc", 13 | "semantic-release": "SEMANTIC_COMMITLINT_SKIP=517e18d,06b81a8 semantic-release" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/mixmaxhq/promise-pool.git" 18 | }, 19 | "keywords": [ 20 | "async", 21 | "await", 22 | "backpressure", 23 | "iteration", 24 | "pool", 25 | "pressure", 26 | "promise" 27 | ], 28 | "files": [ 29 | "index.js", 30 | "src", 31 | "dist" 32 | ], 33 | "author": "Eli Skeggs (https://eliskeggs.com)", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/mixmaxhq/promise-pool/issues" 37 | }, 38 | "homepage": "https://github.com/mixmaxhq/promise-pool#readme", 39 | "dependencies": { 40 | "events": "^3.3.0", 41 | "promise-callbacks": "^3.0.0", 42 | "semver": "^5.4.1" 43 | }, 44 | "devDependencies": { 45 | "@mixmaxhq/prettier-config": "^1.0.0", 46 | "@mixmaxhq/semantic-release-config": "^2.0.3", 47 | "@mixmaxhq/ts-config": "^1.2.1", 48 | "@types/node": "^22.7.4", 49 | "@types/jest": "^28.1.3", 50 | "@typescript-eslint/eslint-plugin": "^4.33.0", 51 | "@typescript-eslint/parser": "^4.33.0", 52 | "eslint": "^6.8.0", 53 | "eslint-config-mixmax": "^3.4.0", 54 | "eslint-config-prettier": "^6.15.0", 55 | "eslint-plugin-prettier": "^3.3.1", 56 | "jest": "^28.1.3", 57 | "jest-junit": "^12.3.0", 58 | "prettier": "^2.8.8", 59 | "semantic-release": "^17.4.7", 60 | "typescript": "^4.9.5", 61 | "ts-jest": "^28.0.8" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /promise-pool.sublime-project: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [{ 3 | "path": ".", 4 | "folder_exclude_patterns": [ 5 | "node_modules", 6 | "dist" 7 | ], 8 | "file_exclude_patterns": [ 9 | // Yarn lock file 10 | "yarn.lock" 11 | ] 12 | }], 13 | "settings": { 14 | "tab_size": 2, 15 | "ensure_newline_at_eof_on_save": true, 16 | "translate_tabs_to_spaces": true, 17 | "trim_trailing_white_space_on_save": true, 18 | "detect_indentation": false, 19 | "rulers": [100] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>mixmaxhq/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { deferred } from 'promise-callbacks'; 3 | 4 | type PromisePoolOptions = 5 | | { 6 | numConcurrent?: number; 7 | maxPending?: number; 8 | } 9 | | number; 10 | 11 | class PromisePool extends EventEmitter { 12 | private _numConcurrent: number; 13 | private _maxPending: number; 14 | private _numActive: number; 15 | private _isDone: boolean; 16 | private _waitingDone: boolean; 17 | private _pending: Array<() => void>; 18 | private _errors: Array; 19 | 20 | /** 21 | * Creates a new PromisePool instance to manage concurrent function execution. 22 | * 23 | * @description 24 | * This class limits the number of concurrent functions that can be executed 25 | * simultaneously. It's useful for controlling resource usage and managing 26 | * concurrency in asynchronous operations. 27 | * 28 | * @param {PromisePoolOptions} options - Configuration options for the pool. 29 | * @param {number} [options.numConcurrent=4] - The maximum number of functions to run concurrently. 30 | * @param {number} [options.maxPending=1] - The maximum number of functions that can be queued for execution. 31 | * 32 | * @example 33 | * const pool = new PromisePool({ numConcurrent: 3, maxPending: 5 }); 34 | * 35 | * @throws {Error} If the pool is used incorrectly (e.g., too many pending functions). 36 | * 37 | * @note 38 | * Always call #flush() after you've finished adding all functions to ensure proper cleanup. 39 | */ 40 | constructor(options: PromisePoolOptions) { 41 | super(); 42 | 43 | if (!options) { 44 | options = {}; 45 | } 46 | 47 | if (typeof options === 'number') { 48 | options = { numConcurrent: options }; 49 | } 50 | 51 | this._maxPending = asPositiveInteger(options.maxPending, 1, 1); 52 | this._numConcurrent = asPositiveInteger(options.numConcurrent, 4, 1); 53 | this._numActive = 0; 54 | this._isDone = false; 55 | this._waitingDone = false; 56 | this._pending = []; 57 | this._errors = []; 58 | } 59 | 60 | /** 61 | * Get the number of concurrent functions that can be executed. 62 | * 63 | * @returns {number} The number of concurrent functions. 64 | */ 65 | get numConcurrent(): number { 66 | return this._numConcurrent; 67 | } 68 | 69 | /** 70 | * Get the maximum number of pending functions that can be queued for execution. 71 | * 72 | * @returns {number} The maximum number of pending functions. 73 | */ 74 | get maxPending(): number { 75 | return this._maxPending; 76 | } 77 | 78 | /** 79 | * Attempts to start the function. If there are already `concurrentFactor` functions running that 80 | * are managed by this pool, this function will yield until the function has been started. 81 | * 82 | * @param {Function} fn The function to run. 83 | * @param {...*} args The arguments to call the function with. 84 | * @return {Promise} Resolved when the function actually starts. 85 | */ 86 | async start(fn: (...args: any[]) => Promise, ...args: any[]): Promise { 87 | if (this._isDone) { 88 | throw new Error('cannot start function, this pool is done'); 89 | } 90 | 91 | if (typeof fn !== 'function') { 92 | throw new TypeError('expected a function'); 93 | } 94 | 95 | if (this._numActive >= this._numConcurrent) { 96 | if (this._pending.length >= this._maxPending) { 97 | // the pool is likely being used in a manner that does not propagate backpressure 98 | throw new Error( 99 | 'too many pending invocations: please look for "cannot queue function in pool" in the documentation' 100 | ); 101 | } 102 | const task = deferred(); 103 | this._pending.push(task.defer()); 104 | await task; 105 | } 106 | 107 | ++this._numActive; 108 | setImmediate(() => { 109 | fn(...args).then( 110 | () => this._onJoin(null), 111 | (err) => this._onJoin(err) 112 | ); 113 | }); 114 | } 115 | 116 | /** 117 | * Yields until all started or queued tasks complete, then returns an array of 118 | * errors encountered during processing, or an empty array if no errors were 119 | * encountered. 120 | * 121 | * @return {Promise} The errors encountered by the functions. 122 | */ 123 | async flush(): Promise { 124 | if (!this._numActive) { 125 | this._isDone = true; 126 | } else if (!this._isDone) { 127 | this._waitingDone = true; 128 | const done = deferred(); 129 | this.once('done', done.defer()); 130 | await done; 131 | } 132 | return this._errors; 133 | } 134 | 135 | private _onJoin(err: Error | null) { 136 | if (err) this._errors.push(err); 137 | 138 | --this._numActive; 139 | if (this._pending.length) { 140 | const taskDone = this._pending.shift(); 141 | if (taskDone) taskDone(); 142 | } else if (!this._numActive && this._waitingDone) { 143 | this._isDone = true; 144 | this._waitingDone = false; 145 | this.emit('done'); 146 | } 147 | } 148 | } 149 | 150 | /** 151 | * Coerce the input to a positive integer. 152 | * 153 | * @param {*} value 154 | * @param {Number} defaultValue 155 | * @param {Number} minValue 156 | * @return {Number} 157 | */ 158 | function asPositiveInteger(value: any, defaultValue: number, minValue: number): number { 159 | if (typeof value !== 'number') { 160 | return defaultValue; 161 | } 162 | 163 | if (value <= 0) { 164 | return minValue; 165 | } 166 | 167 | value = value | 0; 168 | if (value <= 0) { 169 | return minValue; 170 | } 171 | 172 | if (value > 0) { 173 | return value; 174 | } 175 | 176 | // NaN 177 | return defaultValue; 178 | } 179 | 180 | export default PromisePool; 181 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { deferred, delay } from 'promise-callbacks'; 2 | import PromisePool from '../src'; 3 | 4 | class Cursor { 5 | private _array: any[]; 6 | private _index: number; 7 | 8 | constructor(array: any[]) { 9 | this._array = array; 10 | this._index = 0; 11 | } 12 | 13 | async next(): Promise { 14 | return this._array.length === this._index ? null : this._array[this._index++]; 15 | } 16 | } 17 | 18 | describe('PromisePool', () => { 19 | it('should keep compability with constructor number', async () => { 20 | const pool = new PromisePool(2); 21 | expect(pool.numConcurrent).toBe(2); 22 | expect(pool.maxPending).toBe(1); 23 | }); 24 | 25 | it('should limit concurrent execution to 1', async () => { 26 | const ops: number[] = []; 27 | 28 | const pool = new PromisePool({ numConcurrent: 1 }); 29 | 30 | await pool.start(async () => { 31 | ops.push(1); 32 | await immediately(); 33 | ops.push(2); 34 | }); 35 | 36 | ops.push(0); 37 | 38 | await pool.start(async () => { 39 | ops.push(4); 40 | await immediately(); 41 | ops.push(5); 42 | }); 43 | 44 | ops.push(3); 45 | 46 | expect(await pool.flush()).toEqual([]); 47 | 48 | ops.push(6); 49 | 50 | expect(ops).toEqual([0, 1, 2, 3, 4, 5, 6]); 51 | }); 52 | 53 | it('should limit concurrent execution to 3', async () => { 54 | const startOrder: number[] = []; 55 | const endOrder: number[] = []; 56 | 57 | const actions = new Map void>(); 58 | 59 | function pause(n: number): Promise { 60 | expect(actions.has(n)).toBe(false); 61 | const action = deferred(); 62 | actions.set(n, action.defer()); 63 | return action; 64 | } 65 | 66 | function resume(n: number): void { 67 | expect(actions.has(n)).toBe(true); 68 | const fn = actions.get(n); 69 | expect(typeof fn).toBe('function'); 70 | actions.delete(n); 71 | fn?.(); 72 | } 73 | 74 | const pool = new PromisePool({ numConcurrent: 3 }); 75 | 76 | function waiter(id: number) { 77 | return async () => { 78 | startOrder.push(id); 79 | await pause(id).then(() => endOrder.push(id)); 80 | }; 81 | } 82 | 83 | await pool.start(waiter(0)); 84 | await pool.start(waiter(1)); 85 | await pool.start(waiter(2)); 86 | 87 | setImmediate(() => { 88 | resume(1); 89 | resume(2); 90 | resume(0); 91 | }); 92 | 93 | await pool.start(waiter(3)); 94 | await pool.start(waiter(4)); 95 | 96 | setImmediate(() => { 97 | resume(4); 98 | resume(3); 99 | }); 100 | 101 | expect(await pool.flush()).toEqual([]); 102 | 103 | endOrder.push(5); 104 | 105 | expect(startOrder).toEqual([0, 1, 2, 3, 4]); 106 | expect(endOrder).toEqual([1, 2, 0, 4, 3, 5]); 107 | }); 108 | 109 | it('should work with a cursor', async () => { 110 | const array = [1, 2, 3, 4, 5, 6, 7, 8, 9]; 111 | const cursor = new Cursor(array); 112 | 113 | let hits = 0; 114 | 115 | const pool = new PromisePool({ numConcurrent: 4 }); 116 | 117 | for (let value: any; (value = await cursor.next()); ) { 118 | await pool.start(async () => { 119 | expect(value).toBe(array[hits++]); 120 | await delay((Math.random() * 10) | 0); 121 | }); 122 | } 123 | 124 | expect(await pool.flush()).toEqual([]); 125 | expect(hits).toBe(9); 126 | }); 127 | 128 | it('should guard against lack of backpressure', async () => { 129 | const array = [1, 2, 3, 4, 5]; 130 | 131 | const pool = new PromisePool({ numConcurrent: 2 }); 132 | 133 | const res: Promise[] = []; 134 | 135 | res.push( 136 | (async () => { 137 | array.forEach(() => res.push(pool.start(async () => {}))); 138 | })() 139 | ); 140 | 141 | await expect(Promise.all(res)).rejects.toThrow(/cannot queue function in pool/); 142 | }); 143 | 144 | it('should allow more than one pending start', async () => { 145 | const array = [1, 2, 3, 4, 5]; 146 | 147 | const pool = new PromisePool({ numConcurrent: 2, maxPending: 2 }); 148 | 149 | async function useArray() { 150 | for (const item of array) { 151 | await pool.start(async () => {}, item); 152 | } 153 | } 154 | 155 | await expect(Promise.all([useArray(), useArray()])).resolves.not.toThrow(); 156 | }); 157 | 158 | it('should restrict excessive pending usage', async () => { 159 | const array = [1, 2, 3, 4, 5]; 160 | 161 | const pool = new PromisePool({ numConcurrent: 2, maxPending: 2 }); 162 | 163 | async function useArray() { 164 | for (const item of array) { 165 | await pool.start(async () => {}, item); 166 | } 167 | } 168 | 169 | await expect(Promise.all([useArray(), useArray(), useArray()])).rejects.toThrow( 170 | /cannot queue function in pool/ 171 | ); 172 | }); 173 | }); 174 | 175 | function immediately(): Promise { 176 | return new Promise((resolve) => setImmediate(resolve)); 177 | } 178 | -------------------------------------------------------------------------------- /tsconfig-lint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@mixmaxhq/ts-config/tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist/node" 5 | }, 6 | "include": [ 7 | "./src/**/*", 8 | "./test/**/*" 9 | ], 10 | "exclude": [ 11 | "node_modules", 12 | "dist" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@mixmaxhq/ts-config/tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist/node" 5 | }, 6 | "include": [ 7 | "./src/**/*" 8 | ], 9 | "exclude": [ 10 | "node_modules", 11 | "dist", 12 | "**/*.test.ts" 13 | ] 14 | } 15 | --------------------------------------------------------------------------------