├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── benchmark ├── README.md ├── baseline.ts ├── common.ts ├── memcached-batch-fetch.ts ├── memcached-single-fetch.ts ├── mysql-batch-fetch.ts ├── mysql-single-fetch.ts ├── run.sh └── setup.sql ├── jest.config.js ├── package.json ├── src └── index.ts ├── test ├── BurstValve.test.ts └── setup.ts ├── tsconfig.benchmark.json ├── tsconfig.dist.json ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [16.x, 18.x, 19.x] 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: yarn install 22 | - run: yarn test 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | dist/ 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## v1.4.0 4 | 5 | - [#5](https://github.com/codenothing/burst-valve/pull/5) Propagating exceptions instead of wrapping them when possible 6 | 7 | ## v1.3.0 8 | 9 | - [#4](https://github.com/codenothing/burst-valve/pull/4) unsafeBatch: Batch fetching results only, throwing any errors 10 | 11 | ## v1.2.0 12 | 13 | - [#3](https://github.com/codenothing/burst-valve/pull/3) Streams 14 | 15 | ## v1.1.0 16 | 17 | - [#2](https://github.com/codenothing/burst-valve/pull/2) Batch Fetching 18 | 19 | ## v1.0.0 20 | 21 | - Initial implementation 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Corey Hart 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BurstValve 2 | 3 | An in memory queue for async processes in high concurrency code paths. 4 | 5 | ## How it works 6 | 7 | Wrap any async method in a fetcher process to create a buffer where there will only ever be a single active request for that method at any given time. 8 | 9 | ![BurstValve](https://user-images.githubusercontent.com/204407/200234474-bf8d8d46-2551-41db-b3cb-ae289bd25c22.jpg) 10 | 11 | _A very crude example_: given an application that displays public customer information, a common service method would be one that fetches the base customer information. 12 | 13 | ```ts 14 | export const getCustomer = async (id: string) => { 15 | return await sql.query("SELECT id, name FROM customers WHERE id = ?", [id]); 16 | }; 17 | ``` 18 | 19 | With this function, every request would hit the database directly. Given the data is unlikely to change while multiple requests are active at the same time, the database call can be wrapped inside a BurstValve instance so that only a single concurrent query is ever active for the specified customer. 20 | 21 | ```ts 22 | const valve = new BurstValve(async (id: string) => { 23 | return await sql.query("SELECT id, name FROM customers WHERE id = ?", [id]); 24 | }); 25 | 26 | export const getCustomer = async (id: string) => { 27 | return await valve.fetch(id); 28 | }; 29 | ``` 30 | 31 | To better visualize the performance gain, a simple benchmark run was setup to test various levels of concurrency (2022 MacBook Air M2). 32 | 33 | | [Suite](benchmark/mysql-single-fetch.ts) | 5 Concurrent | 25 Concurrent | 50 Concurrent | 34 | | ---------------------------------------- | --------------------- | --------------------- | --------------------- | 35 | | MySQL Direct | 5,490 ops/sec ±0.50% | 1,150 ops/sec ±1.93% | 523 ops/sec ±1.58% | 36 | | BurstValve | 11,571 ops/sec ±1.05% | 11,307 ops/sec ±1.03% | 11,408 ops/sec ±1.08% | 37 | 38 | Again, this is a very crude example. Adding caching layer in front of the database call would improve the initial performance significantly. Even then, adding BurstValve would still add a layer of improvement as traffic rate increases. 39 | 40 | ```ts 41 | const valve = new BurstValve(async (id: string) => { 42 | const customer = await cache.get(`customer:${id}`); 43 | if (customer) { 44 | return customer; 45 | } 46 | 47 | return await sql.query("SELECT id, name FROM customers WHERE id = ?", [id]); 48 | }); 49 | ``` 50 | 51 | | [Suite](benchmark/memcached-single-fetch.ts) | 5 Concurrent | 25 Concurrent | 50 Concurrent | 52 | | -------------------------------------------- | --------------------- | --------------------- | --------------------- | 53 | | Memcached Direct | 23,220 ops/sec ±0.75% | 7,971 ops/sec ±0.14% | 4,193 ops/sec ±1.76% | 54 | | BurstValve | 38,834 ops/sec ±0.72% | 34,557 ops/sec ±1.01% | 32,193 ops/sec ±1.03% | 55 | 56 | ## Batching 57 | 58 | BurstValve comes with a unique batching approach, where requests for multiple unique identifiers can occur individually with parallelism. Consider the following: 59 | 60 | ```ts 61 | const valve = new BurstValve({ 62 | batch: async (ids) => { 63 | await sleep(50); 64 | return ids.map((id) => id * 2); 65 | }, 66 | }); 67 | 68 | const [run1, run2, run3, run4] = await Promise.all([ 69 | valve.batch([1, 2, 3]), 70 | valve.batch([3, 4, 5]), 71 | valve.fetch(4), // When batch fetcher is defined, all fetch requests route through there 72 | valve.fetch(8), 73 | ]); 74 | 75 | run1; // [1, 2, 3] -> [2, 4, 6] 76 | run2; // [3(queued), 4, 5] -> [6, 8, 10] 77 | run3; // [4(queued)] -> 8 78 | run4; // [8] -> 16 79 | ``` 80 | 81 | In the above example, the valve was able to detect that the identifiers `3` & `4` were already requested (active) by previous batch/fetch calls, which means they are not passed along to the batch fetcher for another query. Only inactive identifiers are requested, all active identifiers are queued to wait for a previous run to complete. 82 | 83 | ### Early Writing 84 | 85 | To further the concept of individual queues for batch runs, the batch fetcher process provides an early writing mechanism for broadcasting results as they come in. This gives the ability for queues to be drained as quickly as possible. 86 | 87 | ```ts 88 | const valve = new BurstValve({ 89 | batch: async (ids, earlyWrite) => { 90 | await sleep(50); 91 | earlyWrite(1, 50); 92 | await sleep(50); 93 | earlyWrite(2, 100); 94 | await sleep(50); 95 | earlyWrite(3, 150); 96 | }, 97 | }); 98 | 99 | const [run1, run2, run3] = await Promise.all([ 100 | valve.batch([1, 2, 3]), 101 | valve.fetch(1), 102 | valve.fetch(2), 103 | ]); 104 | 105 | // Resolution Order: run2, run3, run1 106 | ``` 107 | 108 | **Note:** While early writing may be used in conjunction with overall batch process returned results, anything early written will take priority over returned results. 109 | 110 | ### Benchmark 111 | 112 | Performance for batch fetching will vary depending on the number of overlapping identifiers being requested, but in an optimal scenario (high bursty traffic for specific data), the gains are significant. 113 | 114 | | [MySQL Suite](benchmark/mysql-batch-fetch.ts) | 5 Concurrent | 25 Concurrent | 50 Concurrent | 115 | | --------------------------------------------- | --------------------- | -------------------- | -------------------- | 116 | | Direct Call | 5,101 ops/sec ±0.84% | 1,127 ops/sec ±0.98% | 492 ops/sec ±1.88% | 117 | | BurstValve | 10,491 ops/sec ±0.75% | 9,499 ops/sec ±0.74% | 8,091 ops/sec ±0.83% | 118 | 119 | And similar to the fetch suite at the top, gains are amplified when putting a memcached layer in front 120 | 121 | | [Memcached Suite](benchmark/memcached-batch-fetch.ts) | 5 Concurrent | 25 Concurrent | 50 Concurrent | 122 | | ----------------------------------------------------- | --------------------- | --------------------- | --------------------- | 123 | | Direct Call | 16,735 ops/sec ±2.25% | 7,090 ops/sec ±1.84% | 3,911 ops/sec ±0.76% | 124 | | BurstValve | 31,030 ops/sec ±1.24% | 23,106 ops/sec ±1.27% | 16,360 ops/sec ±1.02% | 125 | 126 | ## Unsafe Batch 127 | 128 | The `unsafeBatch` method is for cases where batch fetching will throw errors instead of returning them. This provides a typesafe way to fetch an array of only results and not have to do error checks on each entry. `unsafeBatch` uses the same internal mechanism as `batch`, giving it the same performance, just passing a modifier to trigger raising of exceptions instead of returning. 129 | 130 | ## Streaming 131 | 132 | The stream method provides a callback style mechanism to obtain access to data as soon at it is available (anything that leverages early writing). Any identifiers requested through the stream interface will follow the batch paradigm, where overlapping ids will share responses to reduce active requests down to a single concurrency. 133 | 134 | ```ts 135 | const valve = new BurstValve({ 136 | batch: async (ids, earlyWrite) => { 137 | await sleep(50); 138 | earlyWrite(1, 50); 139 | await sleep(50); 140 | earlyWrite(2, 100); 141 | await sleep(50); 142 | earlyWrite(3, 150); 143 | }, 144 | }); 145 | 146 | await valve.stream([1, 2, 3], async (id, result) => { 147 | response.write({ id, result }); // Some external request/response stream 148 | }); 149 | ``` 150 | -------------------------------------------------------------------------------- /benchmark/README.md: -------------------------------------------------------------------------------- 1 | ## Running benchmark suite 2 | 3 | To run the suite, a few packages will need to be installed first: 4 | 5 | 1. [Memcached](https://formulae.brew.sh/formula/memcached) 6 | 2. [MySQL](https://formulae.brew.sh/formula/mysql) 7 | 3. Add the following env variables to the path: 8 | 9 | ``` 10 | export MYSQL_BENCHMARK_USER="[USER_HERE]"; 11 | export MYSQL_BENCHMARK_PASSWORD="[PASSWORD_HERE]"; 12 | export MYSQL_BENCHMARK_DATABASE="[DB_NAME_HERE]"; 13 | ``` 14 | 15 | Once all is setup, run the following command from the root: 16 | 17 | ```sh 18 | $ yarn benchmark 19 | ``` 20 | -------------------------------------------------------------------------------- /benchmark/baseline.ts: -------------------------------------------------------------------------------- 1 | import Benchmark from "benchmark"; 2 | import { BurstValve } from "../src"; 3 | 4 | const tick = () => new Promise((resolve) => process.nextTick(resolve)); 5 | 6 | const singleFetch = new BurstValve({ 7 | displayName: "Early Write", 8 | fetch: async (id) => { 9 | await tick(); 10 | return (id as number) * 2; 11 | }, 12 | }); 13 | 14 | const arrayResult = new BurstValve({ 15 | displayName: "Early Write", 16 | batch: async (ids) => { 17 | await tick(); 18 | return ids.map((id) => id * 2); 19 | }, 20 | }); 21 | 22 | const mapResult = new BurstValve({ 23 | displayName: "Early Write", 24 | batch: async (ids) => { 25 | await tick(); 26 | return new Map(ids.map((id) => [id, id * 2])); 27 | }, 28 | }); 29 | 30 | const earlyWriteValve = new BurstValve({ 31 | displayName: "Early Write", 32 | batch: async (ids, earlyWrite) => { 33 | await tick(); 34 | ids.forEach((id) => earlyWrite(id, id * 2)); 35 | }, 36 | }); 37 | 38 | const suite = new Benchmark.Suite(); 39 | 40 | suite.add("Tick Baseline", { 41 | defer: true, 42 | fn: async (deferred: Benchmark.Deferred) => { 43 | await tick(); 44 | deferred.resolve(); 45 | }, 46 | }); 47 | 48 | suite 49 | .add(`singleFetch single call`, { 50 | defer: true, 51 | fn: async (deferred: Benchmark.Deferred) => { 52 | await singleFetch.fetch(1); 53 | deferred.resolve(); 54 | }, 55 | }) 56 | .add(`arrayResult single call`, { 57 | defer: true, 58 | fn: async (deferred: Benchmark.Deferred) => { 59 | await arrayResult.batch([1, 2, 3, 4, 5]); 60 | deferred.resolve(); 61 | }, 62 | }) 63 | .add(`mapResult single call`, { 64 | defer: true, 65 | fn: async (deferred: Benchmark.Deferred) => { 66 | await mapResult.batch([1, 2, 3, 4, 5]); 67 | deferred.resolve(); 68 | }, 69 | }) 70 | .add(`earlyWriteValve single call`, { 71 | defer: true, 72 | fn: async (deferred: Benchmark.Deferred) => { 73 | await earlyWriteValve.batch([1, 2, 3, 4, 5]); 74 | deferred.resolve(); 75 | }, 76 | }) 77 | .add(`unsafeBatch single call`, { 78 | defer: true, 79 | fn: async (deferred: Benchmark.Deferred) => { 80 | await earlyWriteValve.unsafeBatch([1, 2, 3, 4, 5]); 81 | deferred.resolve(); 82 | }, 83 | }); 84 | 85 | [5, 25, 100].forEach((concurrent) => { 86 | suite 87 | .add(`singleFetch / ${concurrent} concurrent`, { 88 | defer: true, 89 | fn: async (deferred: Benchmark.Deferred) => { 90 | const stack: Promise[] = []; 91 | for (let i = 0; i < concurrent; i++) { 92 | stack.push(singleFetch.fetch(1)); 93 | } 94 | await Promise.all(stack); 95 | deferred.resolve(); 96 | }, 97 | }) 98 | .add(`arrayResult / ${concurrent} concurrent`, { 99 | defer: true, 100 | fn: async (deferred: Benchmark.Deferred) => { 101 | const stack: Promise<(number | Error)[]>[] = []; 102 | for (let i = 0; i < concurrent; i++) { 103 | stack.push(arrayResult.batch([1, 2, 3, 4, 5])); 104 | } 105 | await Promise.all(stack); 106 | deferred.resolve(); 107 | }, 108 | }) 109 | .add(`mapResult / ${concurrent} concurrent`, { 110 | defer: true, 111 | fn: async (deferred: Benchmark.Deferred) => { 112 | const stack: Promise<(number | Error)[]>[] = []; 113 | for (let i = 0; i < concurrent; i++) { 114 | stack.push(mapResult.batch([1, 2, 3, 4, 5])); 115 | } 116 | await Promise.all(stack); 117 | deferred.resolve(); 118 | }, 119 | }) 120 | .add(`earlyWriteValve / ${concurrent} concurrent`, { 121 | defer: true, 122 | fn: async (deferred: Benchmark.Deferred) => { 123 | const stack: Promise<(number | Error)[]>[] = []; 124 | for (let i = 0; i < concurrent; i++) { 125 | stack.push(earlyWriteValve.batch([1, 2, 3, 4, 5])); 126 | } 127 | await Promise.all(stack); 128 | deferred.resolve(); 129 | }, 130 | }) 131 | .add(`unsafeBatch / ${concurrent} concurrent`, { 132 | defer: true, 133 | fn: async (deferred: Benchmark.Deferred) => { 134 | const stack: Promise<(number | Error)[]>[] = []; 135 | for (let i = 0; i < concurrent; i++) { 136 | stack.push(earlyWriteValve.unsafeBatch([1, 2, 3, 4, 5])); 137 | } 138 | await Promise.all(stack); 139 | deferred.resolve(); 140 | }, 141 | }); 142 | }); 143 | 144 | suite.on("cycle", (event: Benchmark.Event) => { 145 | if ((event.target.name as string).startsWith("singleFetch")) { 146 | console.log("----"); 147 | } 148 | console.log(String(event.target)); 149 | }); 150 | suite.run({ async: true }); 151 | -------------------------------------------------------------------------------- /benchmark/common.ts: -------------------------------------------------------------------------------- 1 | import { createPool } from "mysql"; 2 | import Memcached from "memcached"; 3 | 4 | export interface Customer { 5 | id: string; 6 | name: string; 7 | } 8 | 9 | export const cache = new Memcached("127.0.0.1:11211"); 10 | 11 | export const pool = createPool({ 12 | connectionLimit: 10, 13 | host: process.env.MYSQL_BENCHMARK_HOST || "127.0.0.1", 14 | user: process.env.MYSQL_BENCHMARK_USER, 15 | password: process.env.MYSQL_BENCHMARK_PASSWORD, 16 | database: process.env.MYSQL_BENCHMARK_DATABASE, 17 | }); 18 | 19 | export const getCustomer = async (id: string) => { 20 | return new Promise((resolve, reject) => { 21 | pool.query( 22 | `SELECT id, name FROM customers WHERE id = ?`, 23 | [id], 24 | (e, results?: Customer[]) => { 25 | if (e || !results || !results[0]) { 26 | reject(e || new Error(`Customer ${id} not found`)); 27 | } else { 28 | resolve(results[0]); 29 | } 30 | } 31 | ); 32 | }); 33 | }; 34 | 35 | export const getCustomers = async (ids: string[]) => { 36 | return new Promise((resolve, reject) => { 37 | pool.query( 38 | `SELECT id, name FROM customers WHERE id IN (?)`, 39 | [ids], 40 | (e, results?: Customer[]) => { 41 | if (e || !results || !results.length) { 42 | reject(e || new Error(`Customers not found`)); 43 | } else { 44 | resolve(results); 45 | } 46 | } 47 | ); 48 | }); 49 | }; 50 | -------------------------------------------------------------------------------- /benchmark/memcached-batch-fetch.ts: -------------------------------------------------------------------------------- 1 | import Benchmark from "benchmark"; 2 | import Memcached from "memcached"; 3 | import { promisify } from "util"; 4 | import { BurstValve } from "../src"; 5 | 6 | interface Customer { 7 | id: string; 8 | name: string; 9 | } 10 | 11 | const cache = new Memcached("127.0.0.1:11211"); 12 | 13 | const getCustomers = async (ids: string[]): Promise => { 14 | return new Promise((resolve) => { 15 | cache.getMulti(ids, (_e, data) => { 16 | const results: Record = {}; 17 | if (data) { 18 | for (const id in data) { 19 | results[id] = JSON.parse(data[id]); 20 | } 21 | } 22 | 23 | resolve(ids.map((id) => results[id])); 24 | }); 25 | }); 26 | }; 27 | 28 | const batchValve = new BurstValve({ 29 | displayName: "Memcached Batch Fetch", 30 | batch: async (ids, earlyWrite) => { 31 | return new Promise((resolve) => { 32 | cache.getMulti(ids, (_e, data) => { 33 | if (data) { 34 | for (const id in data) { 35 | earlyWrite(id, JSON.parse(data[id])); 36 | } 37 | } 38 | 39 | resolve(); 40 | }); 41 | }); 42 | }, 43 | }); 44 | 45 | const suite = new Benchmark.Suite(); 46 | 47 | suite 48 | .add("Memcached Direct / 5 concurrent", { 49 | defer: true, 50 | fn: async (deferred: Benchmark.Deferred) => { 51 | await Promise.all([ 52 | getCustomers([`1`, `2`, `3`]), 53 | getCustomers([`1`, `2`, `3`]), 54 | getCustomers([`1`, `2`, `3`]), 55 | getCustomers([`1`, `2`, `3`]), 56 | getCustomers([`1`, `2`, `3`]), 57 | ]); 58 | deferred.resolve(); 59 | }, 60 | }) 61 | .add("Memcached Direct / 25 Concurrent", { 62 | defer: true, 63 | fn: async (deferred: Benchmark.Deferred) => { 64 | const stack: Promise[] = []; 65 | for (let i = 0; i < 25; i++) { 66 | stack.push(getCustomers([`1`])); 67 | } 68 | await Promise.all(stack); 69 | deferred.resolve(); 70 | }, 71 | }) 72 | .add("Memcached Direct / 50 Concurrent", { 73 | defer: true, 74 | fn: async (deferred: Benchmark.Deferred) => { 75 | const stack: Promise[] = []; 76 | for (let i = 0; i < 50; i++) { 77 | stack.push(getCustomers([`1`])); 78 | } 79 | await Promise.all(stack); 80 | deferred.resolve(); 81 | }, 82 | }) 83 | .add("Burst Valve - Batch / 5 Concurrent", { 84 | defer: true, 85 | fn: async (deferred: Benchmark.Deferred) => { 86 | await Promise.all([ 87 | batchValve.batch([`1`, `2`, `3`]), 88 | batchValve.batch([`1`, `2`, `3`]), 89 | batchValve.batch([`1`, `2`, `3`]), 90 | batchValve.batch([`1`, `2`, `3`]), 91 | batchValve.batch([`1`, `2`, `3`]), 92 | ]); 93 | deferred.resolve(); 94 | }, 95 | }) 96 | .add("Burst Valve - Batch / 25 Concurrent", { 97 | defer: true, 98 | fn: async (deferred: Benchmark.Deferred) => { 99 | const stack: Promise<(Customer | Error)[]>[] = []; 100 | for (let i = 0; i < 25; i++) { 101 | stack.push(batchValve.batch([`1`, `2`, `3`])); 102 | } 103 | await Promise.all(stack); 104 | deferred.resolve(); 105 | }, 106 | }) 107 | .add("Burst Valve - Batch / 50 Concurrent", { 108 | defer: true, 109 | fn: async (deferred: Benchmark.Deferred) => { 110 | const stack: Promise<(Customer | Error)[]>[] = []; 111 | for (let i = 0; i < 50; i++) { 112 | stack.push(batchValve.batch([`1`, `2`, `3`])); 113 | } 114 | await Promise.all(stack); 115 | deferred.resolve(); 116 | }, 117 | }) 118 | .add("Burst Valve - Unsafe Batch / 5 Concurrent", { 119 | defer: true, 120 | fn: async (deferred: Benchmark.Deferred) => { 121 | await Promise.all([ 122 | batchValve.unsafeBatch([`1`, `2`, `3`]), 123 | batchValve.unsafeBatch([`1`, `2`, `3`]), 124 | batchValve.unsafeBatch([`1`, `2`, `3`]), 125 | batchValve.unsafeBatch([`1`, `2`, `3`]), 126 | batchValve.unsafeBatch([`1`, `2`, `3`]), 127 | ]); 128 | deferred.resolve(); 129 | }, 130 | }) 131 | .add("Burst Valve - Unsafe Batch / 25 Concurrent", { 132 | defer: true, 133 | fn: async (deferred: Benchmark.Deferred) => { 134 | const stack: Promise<(Customer | Error)[]>[] = []; 135 | for (let i = 0; i < 25; i++) { 136 | stack.push(batchValve.unsafeBatch([`1`, `2`, `3`])); 137 | } 138 | await Promise.all(stack); 139 | deferred.resolve(); 140 | }, 141 | }) 142 | .add("Burst Valve - Unsafe Batch / 50 Concurrent", { 143 | defer: true, 144 | fn: async (deferred: Benchmark.Deferred) => { 145 | const stack: Promise<(Customer | Error)[]>[] = []; 146 | for (let i = 0; i < 50; i++) { 147 | stack.push(batchValve.unsafeBatch([`1`, `2`, `3`])); 148 | } 149 | await Promise.all(stack); 150 | deferred.resolve(); 151 | }, 152 | }) 153 | .on("cycle", (event: Benchmark.Event) => { 154 | console.log(String(event.target)); 155 | }) 156 | .on("complete", () => cache.end()); 157 | 158 | // Setup before running the suite 159 | (async () => { 160 | await promisify(cache.set.bind(cache))( 161 | `1`, 162 | JSON.stringify({ id: "1", name: `foo` }), 163 | 3600 164 | ); 165 | await promisify(cache.set.bind(cache))( 166 | `2`, 167 | JSON.stringify({ id: "2", name: `bar` }), 168 | 3600 169 | ); 170 | await promisify(cache.set.bind(cache))( 171 | `3`, 172 | JSON.stringify({ id: "3", name: `baz` }), 173 | 3600 174 | ); 175 | await Promise.all([ 176 | getCustomers([`1`, `2`, `3`]), 177 | getCustomers([`1`, `2`, `3`]), 178 | getCustomers([`1`, `2`, `3`]), 179 | getCustomers([`1`, `2`, `3`]), 180 | getCustomers([`1`, `2`, `3`]), 181 | ]); 182 | 183 | // Run the suite 184 | console.log("Cache pool is primed, running the Memcached suite"); 185 | suite.run({ async: true }); 186 | })(); 187 | -------------------------------------------------------------------------------- /benchmark/memcached-single-fetch.ts: -------------------------------------------------------------------------------- 1 | import Benchmark from "benchmark"; 2 | import { BurstValve } from "../src"; 3 | import { cache, Customer, getCustomer } from "./common"; 4 | 5 | const getCachedCustomer = async (id: string, skipCache?: boolean) => { 6 | return new Promise((resolve, reject) => { 7 | const cacheKey = `customer:${id}`; 8 | 9 | cache.get(cacheKey, (_e, data) => { 10 | if (data && !skipCache) { 11 | return resolve(JSON.parse(data)); 12 | } 13 | 14 | getCustomer(id) 15 | .then((value) => { 16 | cache.set(cacheKey, JSON.stringify(value), 3600, () => { 17 | resolve(value); 18 | }); 19 | }) 20 | .catch((e) => reject(e)); 21 | }); 22 | }); 23 | }; 24 | 25 | const fetchValve = new BurstValve({ 26 | displayName: "Single Fetch", 27 | fetch: async (id) => { 28 | if (id) { 29 | return await getCachedCustomer(id); 30 | } else { 31 | throw new Error(`No subqueue id found`); 32 | } 33 | }, 34 | }); 35 | 36 | const suite = new Benchmark.Suite(); 37 | 38 | suite 39 | .add("Memcached Direct / 5 Concurrent", { 40 | defer: true, 41 | fn: async (deferred: Benchmark.Deferred) => { 42 | await Promise.all([ 43 | getCachedCustomer(`1`), 44 | getCachedCustomer(`1`), 45 | getCachedCustomer(`1`), 46 | getCachedCustomer(`1`), 47 | getCachedCustomer(`1`), 48 | ]); 49 | deferred.resolve(); 50 | }, 51 | }) 52 | .add("Memcached Direct / 25 Concurrent", { 53 | defer: true, 54 | fn: async (deferred: Benchmark.Deferred) => { 55 | const stack: Promise[] = []; 56 | for (let i = 0; i < 25; i++) { 57 | stack.push(getCachedCustomer(`1`)); 58 | } 59 | await Promise.all(stack); 60 | deferred.resolve(); 61 | }, 62 | }) 63 | .add("Memcached Direct / 50 Concurrent", { 64 | defer: true, 65 | fn: async (deferred: Benchmark.Deferred) => { 66 | const stack: Promise[] = []; 67 | for (let i = 0; i < 50; i++) { 68 | stack.push(getCachedCustomer(`1`)); 69 | } 70 | await Promise.all(stack); 71 | deferred.resolve(); 72 | }, 73 | }) 74 | .add("Burst Valve / 5 Concurrent", { 75 | defer: true, 76 | fn: async (deferred: Benchmark.Deferred) => { 77 | await Promise.all([ 78 | fetchValve.fetch(`1`), 79 | fetchValve.fetch(`1`), 80 | fetchValve.fetch(`1`), 81 | fetchValve.fetch(`1`), 82 | fetchValve.fetch(`1`), 83 | ]); 84 | deferred.resolve(); 85 | }, 86 | }) 87 | .add("Burst Valve / 25 Concurrent", { 88 | defer: true, 89 | fn: async (deferred: Benchmark.Deferred) => { 90 | const stack: Promise[] = []; 91 | for (let i = 0; i < 25; i++) { 92 | stack.push(fetchValve.fetch(`1`)); 93 | } 94 | await Promise.all(stack); 95 | deferred.resolve(); 96 | }, 97 | }) 98 | .add("Burst Valve / 50 Concurrent", { 99 | defer: true, 100 | fn: async (deferred: Benchmark.Deferred) => { 101 | const stack: Promise[] = []; 102 | for (let i = 0; i < 50; i++) { 103 | stack.push(fetchValve.fetch(`1`)); 104 | } 105 | await Promise.all(stack); 106 | deferred.resolve(); 107 | }, 108 | }) 109 | .on("cycle", (event: Benchmark.Event) => { 110 | console.log(String(event.target)); 111 | }) 112 | .on("complete", () => process.exit()); 113 | 114 | // Setup before running the suite 115 | (async () => { 116 | await getCachedCustomer(`1`, true); 117 | 118 | await Promise.all([ 119 | getCachedCustomer(`1`), 120 | getCachedCustomer(`1`), 121 | getCachedCustomer(`1`), 122 | getCachedCustomer(`1`), 123 | getCachedCustomer(`1`), 124 | getCachedCustomer(`1`), 125 | getCachedCustomer(`1`), 126 | getCachedCustomer(`1`), 127 | getCachedCustomer(`1`), 128 | getCachedCustomer(`1`), 129 | ]); 130 | 131 | // Run the suite 132 | console.log("Query pool is primed, running the single fetch suite"); 133 | suite.run({ async: true }); 134 | })(); 135 | -------------------------------------------------------------------------------- /benchmark/mysql-batch-fetch.ts: -------------------------------------------------------------------------------- 1 | import Benchmark from "benchmark"; 2 | import { BurstValve } from "../src"; 3 | import { Customer, getCustomers } from "./common"; 4 | 5 | const batchValve = new BurstValve({ 6 | displayName: "Batch Fetch", 7 | batch: async (ids, earlyWrite) => { 8 | const results = await getCustomers(ids); 9 | results.forEach((row) => { 10 | earlyWrite(row.id, row); 11 | }); 12 | }, 13 | }); 14 | 15 | const suite = new Benchmark.Suite(); 16 | 17 | suite 18 | .add("MySQL Direct / 5 Concurrent", { 19 | defer: true, 20 | fn: async (deferred: Benchmark.Deferred) => { 21 | await Promise.all([ 22 | getCustomers([`1`, `2`, `3`]), 23 | getCustomers([`1`, `2`, `3`]), 24 | getCustomers([`1`, `2`, `3`]), 25 | getCustomers([`1`, `2`, `3`]), 26 | getCustomers([`1`, `2`, `3`]), 27 | ]); 28 | deferred.resolve(); 29 | }, 30 | }) 31 | .add("MySQL Direct / 25 Concurrent", { 32 | defer: true, 33 | fn: async (deferred: Benchmark.Deferred) => { 34 | const stack: Promise[] = []; 35 | for (let i = 0; i < 25; i++) { 36 | stack.push(getCustomers([`1`, `2`, `3`])); 37 | } 38 | await Promise.all(stack); 39 | deferred.resolve(); 40 | }, 41 | }) 42 | .add("MySQL Direct / 50 Concurrent", { 43 | defer: true, 44 | fn: async (deferred: Benchmark.Deferred) => { 45 | const stack: Promise[] = []; 46 | for (let i = 0; i < 50; i++) { 47 | stack.push(getCustomers([`1`, `2`, `3`])); 48 | } 49 | await Promise.all(stack); 50 | deferred.resolve(); 51 | }, 52 | }) 53 | .add("Burst Valve - Batch / 5 Concurrent", { 54 | defer: true, 55 | fn: async (deferred: Benchmark.Deferred) => { 56 | await Promise.all([ 57 | batchValve.batch([`1`, `2`, `3`]), 58 | batchValve.batch([`1`, `2`, `3`]), 59 | batchValve.batch([`1`, `2`, `3`]), 60 | batchValve.batch([`1`, `2`, `3`]), 61 | batchValve.batch([`1`, `2`, `3`]), 62 | ]); 63 | deferred.resolve(); 64 | }, 65 | }) 66 | .add("Burst Valve - Batch / 25 Concurrent", { 67 | defer: true, 68 | fn: async (deferred: Benchmark.Deferred) => { 69 | const stack: Promise<(Customer | Error)[]>[] = []; 70 | for (let i = 0; i < 25; i++) { 71 | stack.push(batchValve.batch([`1`, `2`, `3`])); 72 | } 73 | await Promise.all(stack); 74 | deferred.resolve(); 75 | }, 76 | }) 77 | .add("Burst Valve - Batch / 50 Concurrent", { 78 | defer: true, 79 | fn: async (deferred: Benchmark.Deferred) => { 80 | const stack: Promise<(Customer | Error)[]>[] = []; 81 | for (let i = 0; i < 50; i++) { 82 | stack.push(batchValve.batch([`1`, `2`, `3`])); 83 | } 84 | await Promise.all(stack); 85 | deferred.resolve(); 86 | }, 87 | }) 88 | .add("Burst Valve - Unsafe Batch / 5 Concurrent", { 89 | defer: true, 90 | fn: async (deferred: Benchmark.Deferred) => { 91 | await Promise.all([ 92 | batchValve.unsafeBatch([`1`, `2`, `3`]), 93 | batchValve.unsafeBatch([`1`, `2`, `3`]), 94 | batchValve.unsafeBatch([`1`, `2`, `3`]), 95 | batchValve.unsafeBatch([`1`, `2`, `3`]), 96 | batchValve.unsafeBatch([`1`, `2`, `3`]), 97 | ]); 98 | deferred.resolve(); 99 | }, 100 | }) 101 | .add("Burst Valve - Unsafe Batch / 25 Concurrent", { 102 | defer: true, 103 | fn: async (deferred: Benchmark.Deferred) => { 104 | const stack: Promise<(Customer | Error)[]>[] = []; 105 | for (let i = 0; i < 25; i++) { 106 | stack.push(batchValve.unsafeBatch([`1`, `2`, `3`])); 107 | } 108 | await Promise.all(stack); 109 | deferred.resolve(); 110 | }, 111 | }) 112 | .add("Burst Valve - Unsafe Batch / 50 Concurrent", { 113 | defer: true, 114 | fn: async (deferred: Benchmark.Deferred) => { 115 | const stack: Promise<(Customer | Error)[]>[] = []; 116 | for (let i = 0; i < 50; i++) { 117 | stack.push(batchValve.unsafeBatch([`1`, `2`, `3`])); 118 | } 119 | await Promise.all(stack); 120 | deferred.resolve(); 121 | }, 122 | }) 123 | .on("cycle", (event: Benchmark.Event) => { 124 | console.log(String(event.target)); 125 | }) 126 | .on("complete", () => process.exit()); 127 | 128 | // Setup before running the suite 129 | (async () => { 130 | await Promise.all([ 131 | getCustomers([`1`, `2`, `3`]), 132 | getCustomers([`1`, `2`, `3`]), 133 | getCustomers([`1`, `2`, `3`]), 134 | getCustomers([`1`, `2`, `3`]), 135 | getCustomers([`1`, `2`, `3`]), 136 | getCustomers([`1`, `2`, `3`]), 137 | getCustomers([`1`, `2`, `3`]), 138 | getCustomers([`1`, `2`, `3`]), 139 | getCustomers([`1`, `2`, `3`]), 140 | getCustomers([`1`, `2`, `3`]), 141 | ]); 142 | 143 | // Run the suite 144 | console.log("Query pool is primed, running batch fetch suite"); 145 | suite.run({ async: true }); 146 | })(); 147 | -------------------------------------------------------------------------------- /benchmark/mysql-single-fetch.ts: -------------------------------------------------------------------------------- 1 | import Benchmark from "benchmark"; 2 | import { BurstValve } from "../src"; 3 | import { Customer, getCustomer } from "./common"; 4 | 5 | const fetchValve = new BurstValve({ 6 | displayName: "Single Fetch", 7 | fetch: async (id) => { 8 | if (id) { 9 | return await getCustomer(id); 10 | } else { 11 | throw new Error(`No subqueue id found`); 12 | } 13 | }, 14 | }); 15 | 16 | const suite = new Benchmark.Suite(); 17 | 18 | suite 19 | .add("MySQL Direct / 5 Concurrent", { 20 | defer: true, 21 | fn: async (deferred: Benchmark.Deferred) => { 22 | await Promise.all([ 23 | getCustomer(`1`), 24 | getCustomer(`1`), 25 | getCustomer(`1`), 26 | getCustomer(`1`), 27 | getCustomer(`1`), 28 | ]); 29 | deferred.resolve(); 30 | }, 31 | }) 32 | .add("MySQL Direct / 25 Concurrent", { 33 | defer: true, 34 | fn: async (deferred: Benchmark.Deferred) => { 35 | const stack: Promise[] = []; 36 | for (let i = 0; i < 25; i++) { 37 | stack.push(getCustomer(`1`)); 38 | } 39 | await Promise.all(stack); 40 | deferred.resolve(); 41 | }, 42 | }) 43 | .add("MySQL Direct / 50 Concurrent", { 44 | defer: true, 45 | fn: async (deferred: Benchmark.Deferred) => { 46 | const stack: Promise[] = []; 47 | for (let i = 0; i < 50; i++) { 48 | stack.push(getCustomer(`1`)); 49 | } 50 | await Promise.all(stack); 51 | deferred.resolve(); 52 | }, 53 | }) 54 | .add("Burst Valve / 5 Concurrent", { 55 | defer: true, 56 | fn: async (deferred: Benchmark.Deferred) => { 57 | await Promise.all([ 58 | fetchValve.fetch(`1`), 59 | fetchValve.fetch(`1`), 60 | fetchValve.fetch(`1`), 61 | fetchValve.fetch(`1`), 62 | fetchValve.fetch(`1`), 63 | ]); 64 | deferred.resolve(); 65 | }, 66 | }) 67 | .add("Burst Valve / 25 Concurrent", { 68 | defer: true, 69 | fn: async (deferred: Benchmark.Deferred) => { 70 | const stack: Promise[] = []; 71 | for (let i = 0; i < 25; i++) { 72 | stack.push(fetchValve.fetch(`1`)); 73 | } 74 | await Promise.all(stack); 75 | deferred.resolve(); 76 | }, 77 | }) 78 | .add("Burst Valve / 50 Concurrent", { 79 | defer: true, 80 | fn: async (deferred: Benchmark.Deferred) => { 81 | const stack: Promise[] = []; 82 | for (let i = 0; i < 50; i++) { 83 | stack.push(fetchValve.fetch(`1`)); 84 | } 85 | await Promise.all(stack); 86 | deferred.resolve(); 87 | }, 88 | }) 89 | .on("cycle", (event: Benchmark.Event) => { 90 | console.log(String(event.target)); 91 | }) 92 | .on("complete", () => process.exit()); 93 | 94 | // Setup before running the suite 95 | (async () => { 96 | await Promise.all([ 97 | getCustomer(`1`), 98 | getCustomer(`1`), 99 | getCustomer(`1`), 100 | getCustomer(`1`), 101 | getCustomer(`1`), 102 | getCustomer(`1`), 103 | getCustomer(`1`), 104 | getCustomer(`1`), 105 | getCustomer(`1`), 106 | getCustomer(`1`), 107 | ]); 108 | 109 | // Run the suite 110 | console.log("Query pool is primed, running the single fetch suite"); 111 | suite.run({ async: true }); 112 | })(); 113 | -------------------------------------------------------------------------------- /benchmark/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" 3 | 4 | mysql -u $MYSQL_BENCHMARK_USER -p$MYSQL_BENCHMARK_PASSWORD $MYSQL_BENCHMARK_DATABASE < setup.sql 5 | 6 | echo "Running Baseline..." 7 | ../node_modules/.bin/ts-node --project ../tsconfig.benchmark.json ./baseline.ts 8 | 9 | echo "" 10 | echo "Running MySQL single id fetching..." 11 | ../node_modules/.bin/ts-node --project ../tsconfig.benchmark.json ./mysql-single-fetch.ts 12 | 13 | echo "" 14 | echo "Running MySQL batch id fetching..." 15 | ../node_modules/.bin/ts-node --project ../tsconfig.benchmark.json ./mysql-batch-fetch.ts 16 | 17 | echo "" 18 | echo "Running Memcached single id fetching..." 19 | ../node_modules/.bin/ts-node --project ../tsconfig.benchmark.json ./memcached-single-fetch.ts 20 | 21 | echo "" 22 | echo "Running Memcached batch fetching..." 23 | ../node_modules/.bin/ts-node --project ../tsconfig.benchmark.json ./memcached-batch-fetch.ts -------------------------------------------------------------------------------- /benchmark/setup.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS customers; 2 | CREATE TABLE customers ( 3 | id varchar(255) not null, 4 | name varchar(255) not null, 5 | PRIMARY KEY (id) 6 | ); 7 | 8 | INSERT INTO customers (id, name) VALUES ('1', 'foo'); 9 | INSERT INTO customers (id, name) VALUES ('2', 'bar'); 10 | INSERT INTO customers (id, name) VALUES ('3', 'baz'); -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/test"], 3 | transform: { 4 | "^.+\\.ts$": "ts-jest", 5 | }, 6 | testEnvironment: "node", 7 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.ts$", 8 | moduleFileExtensions: ["ts", "js", "json", "node"], 9 | setupFilesAfterEnv: ["/test/setup.ts"], 10 | }; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "burst-valve", 3 | "version": "1.4.0", 4 | "description": "An in memory queue for async processes in high concurrency code paths", 5 | "author": "Corey Hart ", 6 | "main": "./dist/index.js", 7 | "types": "./dist/index.d.ts", 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/codenothing/burst-valve.git" 12 | }, 13 | "scripts": { 14 | "clean": "rm -rf dist", 15 | "build": "yarn clean && tsc -p tsconfig.dist.json", 16 | "lint": "eslint . --ext .ts", 17 | "pretest": "yarn build && yarn lint", 18 | "test": "jest --verbose --coverage", 19 | "prepublish": "yarn test", 20 | "benchmark": "./benchmark/run.sh" 21 | }, 22 | "keywords": [ 23 | "concurrency", 24 | "util" 25 | ], 26 | "files": [ 27 | "dist", 28 | "package.json", 29 | "README.md", 30 | "LICENSE" 31 | ], 32 | "devDependencies": { 33 | "@types/benchmark": "^2.1.2", 34 | "@types/jest": "^29.2.1", 35 | "@types/memcached": "^2.2.7", 36 | "@types/mysql": "^2.15.21", 37 | "@types/node": "^18.11.9", 38 | "@typescript-eslint/eslint-plugin": "^5.42.0", 39 | "@typescript-eslint/parser": "^5.42.0", 40 | "benchmark": "^2.1.4", 41 | "eslint": "^8.26.0", 42 | "jest": "^29.2.2", 43 | "memcached": "^2.2.2", 44 | "mysql": "^2.18.1", 45 | "ts-jest": "^29.0.3", 46 | "ts-node": "^10.9.1", 47 | "typescript": "^4.8.4" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Promise callback storage format 3 | */ 4 | interface PromiseStore { 5 | resolve(value: Result): void; 6 | reject(error: Error): void; 7 | } 8 | 9 | /** 10 | * Method for running a single process 11 | * @param {SubqueueKeyType} [subqueue] Unique key of the subqueue being run 12 | * @returns {FetchResult} Result of the process 13 | */ 14 | export type FetcherProcess< 15 | FetchResult, 16 | SubqueueKeyType = string | number | symbol 17 | > = (subqueue?: SubqueueKeyType) => Promise; 18 | 19 | /** 20 | * Method for running a batch fetch process 21 | * @param {SubqueueKeyType[]} subqueues Unique keys of the subqueues being run 22 | * @param {Function} earlyWrite Mechanism for unblocking subqueues as the data is available 23 | * @returns {FetchResult[] | Error[] | Map | void} Results in array/map format. Nothing should be returned when using the earlyWrite mechanism 24 | */ 25 | export type BatchFetcherProcess< 26 | FetchResult, 27 | SubqueueKeyType = string | number | symbol 28 | > = ( 29 | subqueues: SubqueueKeyType[], 30 | earlyWrite: (subqueue: SubqueueKeyType, result: FetchResult | Error) => void 31 | ) => Promise< 32 | Array | Map | void 33 | >; 34 | 35 | /** 36 | * Configurable parameters when creating a new instance of BurstValve 37 | */ 38 | export interface BurstValveParams { 39 | /** 40 | * Display name for the valve (useful for debugging exceptions) 41 | */ 42 | displayName?: string; 43 | 44 | /** 45 | * Fetcher process for single concurrency process running 46 | */ 47 | fetch?: FetcherProcess; 48 | 49 | /** 50 | * Fetcher process for single concurrency on a list 51 | * of unique identifiers 52 | */ 53 | batch?: BatchFetcherProcess; 54 | } 55 | 56 | /** 57 | * Only wraps non Error instances in an exception 58 | * @param {unknown} error Unknown error raised 59 | * @param {string} messagePrefix Prefix string when error is not an exception 60 | * @returns {Error} Error instance of the exception passed, wrapped if non exception 61 | */ 62 | const optionallyWrapError = (error: unknown, messagePrefix: string): Error => 63 | error instanceof Error 64 | ? error 65 | : new Error(`${messagePrefix}: ${error}`, { cause: error }); 66 | 67 | /** 68 | * Concurrent queue for a single (or batch) asynchronous action 69 | */ 70 | export class BurstValve< 71 | DrainResult, 72 | SubqueueKeyType = string | number | symbol 73 | > { 74 | /** 75 | * Display name for the valve 76 | * @type {string} 77 | * @readonly 78 | */ 79 | public readonly displayName: string; 80 | 81 | /** 82 | * Fetcher for single concurrent running of a function 83 | * @type {FetcherProcess | undefined} 84 | * @readonly 85 | * @private 86 | */ 87 | private readonly fetcher?: FetcherProcess; 88 | 89 | /** 90 | * Fetcher for batch of unique identifiers to run once 91 | * @type {BatchFetcherProcess | undefined} 92 | * @readonly 93 | * @private 94 | */ 95 | private readonly batchFetcher?: BatchFetcherProcess< 96 | DrainResult, 97 | SubqueueKeyType 98 | >; 99 | 100 | /** 101 | * Queue of promise callbacks 102 | * @type {PromiseStore[]} 103 | * @private 104 | */ 105 | private queue?: PromiseStore[]; 106 | 107 | /** 108 | * Keyed subqueues of promise callbacks 109 | * @type {Map} 110 | * @private 111 | */ 112 | private subqueues = new Map[]>(); 113 | 114 | /** 115 | * Creates an instance of BurstValve with a custom fetcher 116 | * @param {FetcherProcess} fetcher Fetcher process for single concurrency process running 117 | */ 118 | constructor(fetcher: FetcherProcess); 119 | 120 | /** 121 | * Creates an instance of BurstValve with a custom display name and fetcher 122 | * @param {string} displayName Name for the valve 123 | * @param {FetcherProcess} fetcher Fetcher process for single concurrency process running 124 | */ 125 | constructor( 126 | displayName: string, 127 | fetcher: FetcherProcess 128 | ); 129 | 130 | /** 131 | * Creates an instance of BurstValve with configurable parameters 132 | * @param {BurstValveParams} config Burst value configuration 133 | */ 134 | constructor(config: BurstValveParams); 135 | 136 | /** 137 | * Creates an instance of BurstValve with a custom display name and fetcher 138 | * @param {string | FetcherProcess | BurstValveParams} displayName Name for the valve, fetcher process, or burst configuration 139 | * @param {FetcherProcess} [fetcher] Fetcher process for single concurrency process running 140 | */ 141 | constructor( 142 | displayName: 143 | | string 144 | | FetcherProcess 145 | | BurstValveParams, 146 | fetcher?: FetcherProcess 147 | ) { 148 | // (displayName, fetcher) 149 | if (typeof displayName === "string") { 150 | this.displayName = displayName; 151 | this.fetcher = fetcher; 152 | } 153 | // (fetcher) 154 | else if (typeof displayName === "function") { 155 | this.displayName = "Burst Valve"; 156 | this.fetcher = displayName; 157 | } 158 | // (params) 159 | else { 160 | this.displayName = displayName.displayName || "Burst Valve"; 161 | this.fetcher = displayName.fetch; 162 | this.batchFetcher = displayName.batch; 163 | } 164 | 165 | // Ensure some fetching process is defined 166 | if (!this.fetcher && !this.batchFetcher) { 167 | throw new Error(`No fetcher process defined on ${this.displayName}`); 168 | } 169 | // Ensure there is only one fetcher process 170 | else if (this.fetcher && this.batchFetcher) { 171 | throw new Error( 172 | `Cannot define both a batch fetcher and a single fetcher at the same time for ${this.displayName}` 173 | ); 174 | } 175 | } 176 | 177 | /** 178 | * Determines if queue (or subqueue) has an active action being taken 179 | * @param {SubqueueKeyType} [subqueue] Unique identifier of the subqueue to check activity. 180 | * @returns {Boolean} True/False indicating if queue (or subqueue) is active 181 | */ 182 | public isActive(subqueue?: SubqueueKeyType): boolean { 183 | if (subqueue !== undefined) { 184 | return this.subqueues.has(subqueue); 185 | } else { 186 | return this.queue ? true : false; 187 | } 188 | } 189 | 190 | /** 191 | * Leverages the current valve to only have a single running process of a function 192 | * @param {SubqueueKeyType} [subqueue] Unique identifier of the subqueue to fetch data for 193 | * @returns {DrainResult} Result of the fetch 194 | */ 195 | public async fetch(subqueue?: SubqueueKeyType): Promise { 196 | if (this.batchFetcher) { 197 | if (subqueue === undefined) { 198 | throw new Error( 199 | `Cannot make un-identified fetch requests when batching is enabled for ${this.displayName}` 200 | ); 201 | } 202 | 203 | return (await this.unsafeBatch([subqueue]))[0]; 204 | } 205 | 206 | // Type safety for fetcher process 207 | const fetcher = this.fetcher; 208 | if (!fetcher) { 209 | throw new Error(`Fetch process not defined for ${this.displayName}`); 210 | } 211 | 212 | return new Promise((resolve, reject) => { 213 | // Subqueue defined 214 | if (subqueue) { 215 | const list = this.subqueues.get(subqueue); 216 | if (list) { 217 | return list.push({ resolve, reject }); 218 | } else { 219 | this.subqueues.set(subqueue, [{ resolve, reject }]); 220 | } 221 | } 222 | // Global queue 223 | else { 224 | if (this.queue) { 225 | return this.queue.push({ resolve, reject }); 226 | } else { 227 | this.queue = [{ resolve, reject }]; 228 | } 229 | } 230 | 231 | // Run the fetcher process and flush the results 232 | fetcher(subqueue) 233 | .then((value) => this.flushResult(subqueue, value)) 234 | .catch((e) => 235 | this.flushResult( 236 | subqueue, 237 | optionallyWrapError(e, `Fetcher error for ${this.displayName}`) 238 | ) 239 | ); 240 | }); 241 | } 242 | 243 | /** 244 | * Batches fetching of unique identifiers into a single process, waiting 245 | * for existing queues if they already exist 246 | * @param {SubqueueKeyType[]} subqueues List of unique identifiers to fetch at once 247 | * @returns {Array} List of fetch results or exceptions 248 | */ 249 | public async batch( 250 | subqueues: SubqueueKeyType[] 251 | ): Promise> { 252 | return this.runBatch(subqueues); 253 | } 254 | 255 | /** 256 | * Same as batch, except throws any errors that are found during the fetching 257 | * process rather returning them. Simplifies the return array to only results 258 | * @param {SubqueueKeyType[]} subqueues List of unique identifiers to fetch at once 259 | * @returns {DrainResult[]} List of batch results 260 | */ 261 | public async unsafeBatch( 262 | subqueues: SubqueueKeyType[] 263 | ): Promise> { 264 | return this.runBatch(subqueues, true); 265 | } 266 | 267 | /** 268 | * Exposes results for fetching each unique identifier as the data becomes available 269 | * @param {SubqueueKeyType[]} subqueues List of unique identifiers to fetch at once 270 | * @param {Function} streamResultCallback Iterative callback for each result as it is available 271 | */ 272 | public async stream( 273 | subqueues: SubqueueKeyType[], 274 | streamResultCallback: ( 275 | subqueue: SubqueueKeyType, 276 | result: DrainResult | Error 277 | ) => Promise 278 | ): Promise { 279 | const uniqueKeys = new Set(subqueues); 280 | const fetchBatchKeys: SubqueueKeyType[] = []; 281 | const streamResponses: Promise[] = []; 282 | 283 | // Look for active subqueue for each identifier before creating one 284 | for (const id of uniqueKeys) { 285 | let list = this.subqueues.get(id); 286 | 287 | if (!list) { 288 | this.subqueues.set(id, (list = [])); 289 | fetchBatchKeys.push(id); 290 | } 291 | 292 | streamResponses.push( 293 | new Promise((resolve, reject) => { 294 | (list as PromiseStore[]).push({ 295 | resolve: (value) => { 296 | streamResultCallback(id, value).then(resolve).catch(reject); 297 | }, 298 | reject: (error) => { 299 | streamResultCallback(id, error).then(resolve).catch(reject); 300 | }, 301 | }); 302 | }) 303 | ); 304 | } 305 | 306 | // Only trigger batch fetcher if there are inactive keys to fetch 307 | const batchPromise = 308 | fetchBatchKeys.length > 0 309 | ? this.runBatchFetcher(fetchBatchKeys) 310 | : Promise.resolve(); 311 | 312 | // Wait for all queues to resolve 313 | await Promise.all([batchPromise, ...streamResponses]); 314 | } 315 | 316 | /** 317 | * Normalized runner for batch and batchUnsafe 318 | * @param {SubqueueKeyType[]} subqueues List of unique identifiers to fetch at once 319 | * @returns {Array} List of batch results or exceptions 320 | */ 321 | private async runBatch( 322 | subqueues: SubqueueKeyType[] 323 | ): Promise>; 324 | 325 | /** 326 | * Normalized runner for batch and batchUnsafe 327 | * @param {SubqueueKeyType} subqueues List of unique identifiers to fetch at once 328 | * @param {Boolean} raiseExceptions Indicates if exceptions should be raised when found 329 | * @returns {DrainResult[]} List of batch results 330 | */ 331 | private async runBatch( 332 | subqueues: SubqueueKeyType[], 333 | raiseExceptions: true 334 | ): Promise>; 335 | 336 | /** 337 | * Normalized runner for batch and batchUnsafe 338 | * @param {SubqueueKeyType[]} subqueues List of unique identifiers to fetch at once 339 | * @param {Boolean} [raiseExceptions] Indicates if exceptions should be raised when found 340 | * @returns {Array} List of batch results or exceptions 341 | */ 342 | private async runBatch( 343 | subqueues: SubqueueKeyType[], 344 | raiseExceptions?: true 345 | ): Promise> { 346 | const results = new Map(); 347 | const fetchBatchKeys: SubqueueKeyType[] = []; 348 | const fetchPromises: Promise[] = []; 349 | 350 | // Look for active subqueue for each identifier before creating one 351 | for (const id of subqueues) { 352 | const list = this.subqueues.get(id); 353 | 354 | // Dedupe fetch keys 355 | if (fetchBatchKeys.includes(id)) { 356 | continue; 357 | } 358 | // Wait for existing queue if it exists 359 | else if (list) { 360 | fetchPromises.push( 361 | new Promise((queuedResolve, queuedReject) => { 362 | list.push({ 363 | resolve: (value) => { 364 | if (!results.has(id)) { 365 | results.set(id, value); 366 | } 367 | queuedResolve(); 368 | }, 369 | reject: (error) => { 370 | if (raiseExceptions) { 371 | return queuedReject(error); 372 | } else if (!results.has(id)) { 373 | results.set(id, error); 374 | } 375 | queuedResolve(); 376 | }, 377 | }); 378 | }) 379 | ); 380 | } 381 | // Mark subqueue as active before adding fetch key 382 | else { 383 | this.subqueues.set(id, []); 384 | fetchBatchKeys.push(id); 385 | } 386 | } 387 | 388 | // Only trigger batch fetcher if there are inactive keys to fetch 389 | const batcherPromise = 390 | fetchBatchKeys.length > 0 391 | ? this.runBatchFetcher(fetchBatchKeys, results, raiseExceptions) 392 | : Promise.resolve(); 393 | 394 | // Wait for all queues to resolve 395 | await Promise.all([...fetchPromises, batcherPromise]); 396 | 397 | // Return the results 398 | return subqueues.map((id) => results.get(id) as DrainResult | Error); 399 | } 400 | 401 | /** 402 | * Runs the user defined batch fetcher process 403 | * @param {SubqueueKeyType[]} subqueues List of unique identifiers to fetch at once 404 | * @param {Map} [results] Optional list of shared results 405 | * @param {Boolean} [raiseExceptions] Indicates if errors should be thrown rather than returned 406 | */ 407 | private async runBatchFetcher( 408 | subqueues: SubqueueKeyType[], 409 | results?: Map, 410 | raiseExceptions?: true 411 | ): Promise { 412 | return new Promise((resolve, reject) => { 413 | if (!this.batchFetcher) { 414 | return reject( 415 | new Error(`Batch Fetcher Process not defined for ${this.displayName}`) 416 | ); 417 | } 418 | 419 | // Keep reference to completed queues 420 | const responses = new Set(); 421 | 422 | // Trigger the batch fetching process 423 | let finished = false; 424 | this.batchFetcher(subqueues, (key, value) => { 425 | // Ignore any writes once the actual fetch process has completed 426 | if (finished) { 427 | throw new Error( 428 | `Batch fetcher process has already completed for ${this.displayName}` 429 | ); 430 | } 431 | // Do not override previous results as they have already been flushed 432 | else if (!responses.has(key)) { 433 | if (raiseExceptions && value instanceof Error) { 434 | throw value; 435 | } 436 | 437 | responses.add(key); 438 | results?.set(key, value); 439 | this.flushResult(key, value); 440 | } 441 | }) 442 | .then((batchResult) => { 443 | finished = true; 444 | 445 | if (batchResult) { 446 | // Batch process returns array of results matching the index list it was sent 447 | if (Array.isArray(batchResult)) { 448 | // Enforce array results length must match number of keys passed 449 | if (batchResult.length !== subqueues.length) { 450 | return reject( 451 | new Error( 452 | `Batch fetcher result array length does not match key length for ${this.displayName}` 453 | ) 454 | ); 455 | } 456 | 457 | // Assign results 458 | subqueues.forEach((id, index) => { 459 | if (!responses.has(id)) { 460 | const value = batchResult[index]; 461 | 462 | responses.add(id); 463 | results?.set(id, value); 464 | this.flushResult(id, value); 465 | } 466 | }); 467 | 468 | return resolve(); 469 | } 470 | // Batch process returns map of results 471 | else if (batchResult instanceof Map) { 472 | batchResult.forEach((value, id) => { 473 | if (!responses.has(id)) { 474 | responses.add(id); 475 | results?.set(id, value); 476 | this.flushResult(id, value); 477 | } 478 | }); 479 | } 480 | } 481 | 482 | // Mark error for each unresolved subqueue key 483 | subqueues.forEach((id) => { 484 | if (!responses.has(id)) { 485 | const error = new Error( 486 | `Batch fetcher result not found for '${id}' subqueue in ${this.displayName}` 487 | ); 488 | responses.add(id); 489 | results?.set(id, error); 490 | this.flushResult(id, error); 491 | } 492 | }); 493 | 494 | resolve(); 495 | }) 496 | .catch((e) => { 497 | finished = true; 498 | 499 | const error = optionallyWrapError( 500 | e, 501 | `Batch fetcher error for ${this.displayName}` 502 | ); 503 | 504 | subqueues.forEach((id) => { 505 | if (!responses.has(id)) { 506 | responses.add(id); 507 | results?.set(id, error); 508 | this.flushResult(id, error); 509 | } 510 | }); 511 | 512 | if (raiseExceptions) { 513 | reject(error); 514 | } else { 515 | resolve(); 516 | } 517 | }); 518 | }); 519 | } 520 | 521 | /** 522 | * Flushes the queue specified with the result passed 523 | * @param {SubqueueKeyType | undefined} subqueue Unique identifier tied to the fetch process 524 | * @param {DrainResult | Error} result Successful/Failed result of the fetch process 525 | */ 526 | private flushResult( 527 | subqueue: SubqueueKeyType | undefined, 528 | result: DrainResult | Error 529 | ): void { 530 | // Find the relevant queue 531 | let list: PromiseStore[] = []; 532 | if (subqueue !== undefined) { 533 | const sublist = this.subqueues.get(subqueue); 534 | if (sublist) { 535 | list = sublist; 536 | this.subqueues.delete(subqueue); 537 | } 538 | } else if (this.queue) { 539 | list = this.queue; 540 | this.queue = undefined; 541 | } 542 | 543 | // Send result/error 544 | if (result instanceof Error) { 545 | list.forEach(({ reject }) => reject(result)); 546 | } else { 547 | list.forEach(({ resolve }) => resolve(result)); 548 | } 549 | } 550 | } 551 | -------------------------------------------------------------------------------- /test/BurstValve.test.ts: -------------------------------------------------------------------------------- 1 | import { BurstValve, FetcherProcess } from "../src"; 2 | 3 | const wait = () => new Promise((resolve) => setTimeout(resolve, 10)); 4 | 5 | interface FetchResult { 6 | foo?: string; 7 | bar?: boolean; 8 | } 9 | 10 | jest.setTimeout(250); 11 | 12 | describe("BurstValve", () => { 13 | const defaultFetcher: FetcherProcess = async () => ({ 14 | foo: "bar", 15 | bar: false, 16 | }); 17 | 18 | describe("constructor", () => { 19 | test("should use the display name passed in", () => { 20 | const valve = new BurstValve( 21 | `Custom Display Name`, 22 | defaultFetcher 23 | ); 24 | expect(valve.displayName).toEqual(`Custom Display Name`); 25 | }); 26 | 27 | test("should use the display name passed in the config object", () => { 28 | const valve = new BurstValve({ 29 | displayName: `Custom Display Name`, 30 | fetch: defaultFetcher, 31 | }); 32 | expect(valve.displayName).toEqual(`Custom Display Name`); 33 | }); 34 | 35 | test("should throw an error if fetcher process is not defined", async () => { 36 | expect(() => new BurstValve({})).toThrow( 37 | `No fetcher process defined on Burst Valve` 38 | ); 39 | }); 40 | 41 | test("should throw an error if both a batch and single fetcher process is defined", async () => { 42 | expect( 43 | () => 44 | new BurstValve({ 45 | fetch: async () => 5, 46 | batch: async () => undefined, 47 | }) 48 | ).toThrow( 49 | `Cannot define both a batch fetcher and a single fetcher at the same time` 50 | ); 51 | }); 52 | }); 53 | 54 | describe("isActive", () => { 55 | test("should mark the global queue activity based on active fetches", async () => { 56 | const resultValue: FetchResult = { foo: "foobar" }; 57 | const valve = new BurstValve(async () => { 58 | await wait(); 59 | return resultValue; 60 | }); 61 | 62 | expect(valve.isActive()).toStrictEqual(false); 63 | const fetchPromise = valve.fetch(); 64 | expect(valve.isActive()).toStrictEqual(true); 65 | await fetchPromise; 66 | expect(valve.isActive()).toStrictEqual(false); 67 | }); 68 | 69 | test("should mark individual subqueue's activity based on active fetches", async () => { 70 | const resultValue: FetchResult = { foo: "foobar" }; 71 | const valve = new BurstValve(async () => { 72 | await wait(); 73 | return resultValue; 74 | }); 75 | 76 | expect(valve.isActive("foo")).toStrictEqual(false); 77 | expect(valve.isActive("bar")).toStrictEqual(false); 78 | const fooFetchPromise = valve.fetch("foo"); 79 | expect(valve.isActive("foo")).toStrictEqual(true); 80 | expect(valve.isActive("bar")).toStrictEqual(false); 81 | const barFetchPromise = valve.fetch("bar"); 82 | expect(valve.isActive("foo")).toStrictEqual(true); 83 | expect(valve.isActive("bar")).toStrictEqual(true); 84 | await fooFetchPromise; 85 | expect(valve.isActive("foo")).toStrictEqual(false); 86 | expect(valve.isActive("bar")).toStrictEqual(true); 87 | await barFetchPromise; 88 | expect(valve.isActive("foo")).toStrictEqual(false); 89 | expect(valve.isActive("bar")).toStrictEqual(false); 90 | }); 91 | }); 92 | 93 | describe("fetch", () => { 94 | describe("global queue", () => { 95 | test("should only run the fetcher once during it's execution", async () => { 96 | let ran = 0; 97 | const resultValue: FetchResult = { foo: "foobar" }; 98 | const valve = new BurstValve(async () => { 99 | ran++; 100 | await wait(); 101 | return resultValue; 102 | }); 103 | 104 | const [run1, run2] = await Promise.all([valve.fetch(), valve.fetch()]); 105 | expect(run1).toEqual(resultValue); 106 | expect(run2).toEqual(resultValue); 107 | expect(ran).toStrictEqual(1); 108 | }); 109 | 110 | test("should propagate exceptions raised from fetcher processes", async () => { 111 | let ran = 0; 112 | const error = new Error(`Drain Error`); 113 | const valve = new BurstValve(async () => { 114 | ran++; 115 | await wait(); 116 | throw error; 117 | }); 118 | 119 | const [run1, run2] = await Promise.all([ 120 | valve.fetch().catch((reason) => reason), 121 | valve.fetch().catch((reason) => reason), 122 | ]); 123 | expect(run1).toBeInstanceOf(Error); 124 | expect(run1.message).toStrictEqual(`Drain Error`); 125 | expect(run1.cause).toStrictEqual(undefined); 126 | expect(run2).toBeInstanceOf(Error); 127 | expect(run2.message).toStrictEqual(`Drain Error`); 128 | expect(run2.cause).toStrictEqual(undefined); 129 | expect(ran).toStrictEqual(1); 130 | }); 131 | 132 | test("should wrap any fetcher non-Error instances before raising exceptions", async () => { 133 | let ran = 0; 134 | const valve = new BurstValve(async () => { 135 | ran++; 136 | await wait(); 137 | throw `Drain Error`; 138 | }); 139 | 140 | const [run1, run2] = await Promise.all([ 141 | valve.fetch().catch((reason) => reason), 142 | valve.fetch().catch((reason) => reason), 143 | ]); 144 | expect(run1).toBeInstanceOf(Error); 145 | expect(run1.message).toStrictEqual( 146 | `Fetcher error for Burst Valve: Drain Error` 147 | ); 148 | expect(run1.cause).toStrictEqual(`Drain Error`); 149 | expect(run2).toBeInstanceOf(Error); 150 | expect(run2.message).toStrictEqual( 151 | `Fetcher error for Burst Valve: Drain Error` 152 | ); 153 | expect(run2.cause).toStrictEqual(`Drain Error`); 154 | expect(ran).toStrictEqual(1); 155 | }); 156 | }); 157 | 158 | describe("subqueue", () => { 159 | test("should only run the fetcher once per subqueue during it's execution", async () => { 160 | const runners: Record = {}; 161 | const valve = new BurstValve(async (subqueue) => { 162 | if (!subqueue) { 163 | throw new Error(`Subqueue not defined`); 164 | } else if (runners[subqueue]) { 165 | runners[subqueue]++; 166 | } else { 167 | runners[subqueue] = 1; 168 | } 169 | 170 | await wait(); 171 | 172 | return { 173 | foo: subqueue, 174 | }; 175 | }); 176 | 177 | const [run1, run2, run3, run4] = await Promise.all([ 178 | valve.fetch(`subqueue1`), 179 | valve.fetch(`subqueue1`), 180 | valve.fetch(`subqueue2`), 181 | valve.fetch(`subqueue2`), 182 | ]); 183 | expect(run1).toEqual({ foo: "subqueue1" }); 184 | expect(run2).toEqual({ foo: "subqueue1" }); 185 | expect(run3).toEqual({ foo: "subqueue2" }); 186 | expect(run4).toEqual({ foo: "subqueue2" }); 187 | expect(runners).toEqual({ 188 | subqueue1: 1, 189 | subqueue2: 1, 190 | }); 191 | }); 192 | 193 | test("should propagate all exceptions raised from fetcher processes", async () => { 194 | const runners: Record = {}; 195 | const valve = new BurstValve( 196 | "Base Fetcher", 197 | async (subqueue?: string) => { 198 | if (!subqueue) { 199 | throw new Error(`Subqueue not defined`); 200 | } else if (runners[subqueue]) { 201 | runners[subqueue]++; 202 | } else { 203 | runners[subqueue] = 1; 204 | } 205 | 206 | await wait(); 207 | throw new Error(`Drain ${subqueue} Error`); 208 | } 209 | ); 210 | 211 | const [run1, run2, run3, run4] = await Promise.all([ 212 | valve.fetch(`subqueue1`).catch((reason) => reason), 213 | valve.fetch(`subqueue1`).catch((reason) => reason), 214 | valve.fetch(`subqueue2`).catch((reason) => reason), 215 | valve.fetch(`subqueue2`).catch((reason) => reason), 216 | ]); 217 | expect(run1).toBeInstanceOf(Error); 218 | expect(run1.message).toStrictEqual(`Drain subqueue1 Error`); 219 | expect(run1.cause).toStrictEqual(undefined); 220 | 221 | expect(run2).toBeInstanceOf(Error); 222 | expect(run2.message).toEqual(`Drain subqueue1 Error`); 223 | expect(run2.cause).toStrictEqual(undefined); 224 | 225 | expect(run3).toBeInstanceOf(Error); 226 | expect(run3.message).toEqual(`Drain subqueue2 Error`); 227 | expect(run3.cause).toStrictEqual(undefined); 228 | 229 | expect(run4).toBeInstanceOf(Error); 230 | expect(run4.message).toEqual(`Drain subqueue2 Error`); 231 | expect(run4.cause).toStrictEqual(undefined); 232 | }); 233 | 234 | test("should wrap all fetcher non-Error instances before raising exceptions", async () => { 235 | const runners: Record = {}; 236 | const valve = new BurstValve( 237 | "Base Fetcher", 238 | async (subqueue?: string) => { 239 | if (!subqueue) { 240 | throw new Error(`Subqueue not defined`); 241 | } else if (runners[subqueue]) { 242 | runners[subqueue]++; 243 | } else { 244 | runners[subqueue] = 1; 245 | } 246 | 247 | await wait(); 248 | throw `Drain ${subqueue} Error`; 249 | } 250 | ); 251 | 252 | const [run1, run2, run3, run4] = await Promise.all([ 253 | valve.fetch(`subqueue1`).catch((reason) => reason), 254 | valve.fetch(`subqueue1`).catch((reason) => reason), 255 | valve.fetch(`subqueue2`).catch((reason) => reason), 256 | valve.fetch(`subqueue2`).catch((reason) => reason), 257 | ]); 258 | expect(run1).toBeInstanceOf(Error); 259 | expect(run1.message).toStrictEqual( 260 | `Fetcher error for Base Fetcher: Drain subqueue1 Error` 261 | ); 262 | expect(run1.cause).toStrictEqual(`Drain subqueue1 Error`); 263 | 264 | expect(run2).toBeInstanceOf(Error); 265 | expect(run2.message).toStrictEqual( 266 | `Fetcher error for Base Fetcher: Drain subqueue1 Error` 267 | ); 268 | expect(run2.cause).toStrictEqual(`Drain subqueue1 Error`); 269 | 270 | expect(run3).toBeInstanceOf(Error); 271 | expect(run3.message).toStrictEqual( 272 | `Fetcher error for Base Fetcher: Drain subqueue2 Error` 273 | ); 274 | expect(run3.cause).toStrictEqual(`Drain subqueue2 Error`); 275 | 276 | expect(run4).toBeInstanceOf(Error); 277 | expect(run4.message).toStrictEqual( 278 | `Fetcher error for Base Fetcher: Drain subqueue2 Error` 279 | ); 280 | expect(run4.cause).toStrictEqual(`Drain subqueue2 Error`); 281 | }); 282 | }); 283 | }); 284 | 285 | describe("batch", () => { 286 | test("should only run the batch fetcher once for all the keys", async () => { 287 | let ran = 0; 288 | const resultValue: FetchResult = { foo: "foobar" }; 289 | const valve = new BurstValve({ 290 | batch: async (ids) => { 291 | expect(ids).toEqual(["a", "b", "c"]); 292 | ran++; 293 | return ids.map(() => resultValue); 294 | }, 295 | }); 296 | 297 | expect(await valve.batch(["a", "b", "c"])).toEqual([ 298 | { foo: "foobar" }, 299 | { foo: "foobar" }, 300 | { foo: "foobar" }, 301 | ]); 302 | expect(ran).toStrictEqual(1); 303 | }); 304 | 305 | test("should allow returning of results in a Map for subqueue->result assignment", async () => { 306 | const runs: number[][] = []; 307 | const valve = new BurstValve({ 308 | batch: async (ids) => { 309 | runs.push([...ids]); 310 | await wait(); 311 | return new Map(ids.map((id) => [id, id * 2])); 312 | }, 313 | }); 314 | 315 | expect(await valve.batch([1, 2, 3])).toEqual([2, 4, 6]); 316 | expect(runs).toEqual([[1, 2, 3]]); 317 | }); 318 | 319 | test("should allow early writing of results, of which can not be overwritten", async () => { 320 | const runs: number[][] = []; 321 | const valve = new BurstValve({ 322 | batch: async (ids, earlyWrite) => { 323 | runs.push([...ids]); 324 | await wait(); 325 | earlyWrite(1, 10); 326 | await wait(); 327 | earlyWrite(2, 20); 328 | await wait(); 329 | return [2, 4, 6]; 330 | }, 331 | }); 332 | 333 | expect(await valve.batch([1, 2, 3])).toEqual([10, 20, 6]); 334 | expect(runs).toEqual([[1, 2, 3]]); 335 | }); 336 | 337 | test("should not duplicate fetching when multiple batch keys overlap", async () => { 338 | const runs: number[][] = []; 339 | const valve = new BurstValve({ 340 | batch: async (ids) => { 341 | runs.push([...ids]); 342 | await wait(); 343 | return new Map(ids.map((id) => [id, id * 2])); 344 | }, 345 | }); 346 | 347 | const [run1, run2, run3] = await Promise.all([ 348 | valve.batch([1, 2, 3]), 349 | valve.batch([3, 5, 8]), 350 | valve.batch([1, 5, 10]), 351 | ]); 352 | expect(run1).toEqual([2, 4, 6]); 353 | expect(run2).toEqual([6, 10, 16]); 354 | expect(run3).toEqual([2, 10, 20]); 355 | expect(runs).toEqual([[1, 2, 3], [5, 8], [10]]); 356 | }); 357 | 358 | test("should not intermix and proxy fetch calls to the batch", async () => { 359 | const runs: number[][] = []; 360 | const valve = new BurstValve({ 361 | batch: async (ids, earlyWrite) => { 362 | runs.push([...ids]); 363 | await wait(); 364 | ids.forEach((id) => earlyWrite(id, id * 2)); 365 | }, 366 | }); 367 | 368 | const [run1, run2, run3, run4] = await Promise.all([ 369 | valve.batch([1, 2, 3]), 370 | valve.fetch(2), 371 | valve.fetch(8), 372 | valve.batch([6, 2, 8]), 373 | ]); 374 | expect(run1).toEqual([2, 4, 6]); 375 | expect(run2).toEqual(4); 376 | expect(run3).toEqual(16); 377 | expect(run4).toEqual([12, 4, 16]); 378 | expect(runs).toEqual([[1, 2, 3], [8], [6]]); 379 | }); 380 | 381 | test("should ignore duplicate keys", async () => { 382 | const runs: number[][] = []; 383 | const valve = new BurstValve({ 384 | batch: async (ids, earlyWrite) => { 385 | runs.push([...ids]); 386 | await wait(); 387 | ids.forEach((id) => earlyWrite(id, id * 2)); 388 | }, 389 | }); 390 | 391 | expect(await valve.batch([5, 2, 5, 8, 2])).toEqual([10, 4, 10, 16, 4]); 392 | expect(runs).toEqual([[5, 2, 8]]); 393 | }); 394 | 395 | test("should stream results as soon as they are ready", async () => { 396 | const runs: number[][] = []; 397 | const results: string[] = []; 398 | const valve = new BurstValve({ 399 | batch: async (ids, earlyWrite) => { 400 | runs.push([...ids]); 401 | await wait(); 402 | earlyWrite(3, 6); 403 | await wait(); 404 | earlyWrite(5, 10); 405 | await wait(); 406 | earlyWrite(1, 2); 407 | await wait(); 408 | }, 409 | }); 410 | 411 | await Promise.all([ 412 | valve.batch([5, 3, 1]).then(() => results.push("batch")), 413 | valve.fetch(5).then(() => results.push("fetch:5")), 414 | valve.fetch(3).then(() => results.push("fetch:3")), 415 | valve.fetch(1).then(() => results.push("fetch:1")), 416 | ]); 417 | 418 | expect(runs).toEqual([[5, 3, 1]]); 419 | expect(results).toEqual([`fetch:3`, `fetch:5`, `fetch:1`, `batch`]); 420 | }); 421 | 422 | test("should propagate batch fetcher process exceptions", async () => { 423 | const runs: number[][] = []; 424 | const valve = new BurstValve({ 425 | batch: async (ids) => { 426 | runs.push([...ids]); 427 | const runCount = runs.length; 428 | await wait(); 429 | throw new Error(`Mock Run Error: ${runCount}`); 430 | }, 431 | }); 432 | 433 | const [run1, run2] = await Promise.all([ 434 | valve.batch([1, 2, 3]), 435 | valve.batch([6, 2, 8]), 436 | ]); 437 | const run1Error = run1[0]; 438 | const run2Error = run2[0]; 439 | 440 | expect(run1Error).toBeInstanceOf(Error); 441 | expect((run1Error as Error).message).toEqual(`Mock Run Error: 1`); 442 | expect((run1Error as Error).cause).toStrictEqual(undefined); 443 | 444 | expect(run2Error).toBeInstanceOf(Error); 445 | expect((run2Error as Error).message).toEqual(`Mock Run Error: 2`); 446 | expect((run2Error as Error).cause).toStrictEqual(undefined); 447 | 448 | expect(run1.length).toStrictEqual(3); 449 | expect(run1[0] === run1Error).toBeTruthy(); 450 | expect(run1[1] === run1Error).toBeTruthy(); 451 | expect(run1[2] === run1Error).toBeTruthy(); 452 | 453 | expect(run2.length).toStrictEqual(3); 454 | expect(run2[0] === run2Error).toBeTruthy(); 455 | expect(run2[1] === run1Error).toBeTruthy(); // Reused from the first batch run 456 | expect(run2[2] === run2Error).toBeTruthy(); 457 | 458 | expect(runs).toEqual([ 459 | [1, 2, 3], 460 | [6, 8], 461 | ]); 462 | }); 463 | 464 | test("should wrap batch fetcher non-Error instances before raising exceptions", async () => { 465 | const runs: number[][] = []; 466 | const valve = new BurstValve({ 467 | batch: async (ids) => { 468 | runs.push([...ids]); 469 | const runCount = runs.length; 470 | await wait(); 471 | throw `Mock Run Error: ${runCount}`; 472 | }, 473 | }); 474 | 475 | const [run1, run2] = await Promise.all([ 476 | valve.batch([1, 2, 3]), 477 | valve.batch([6, 2, 8]), 478 | ]); 479 | const run1Error = run1[0]; 480 | const run2Error = run2[0]; 481 | 482 | expect(run1Error).toBeInstanceOf(Error); 483 | expect((run1Error as Error).message).toEqual( 484 | `Batch fetcher error for Burst Valve: Mock Run Error: 1` 485 | ); 486 | expect((run1Error as Error).cause).toStrictEqual(`Mock Run Error: 1`); 487 | 488 | expect(run2Error).toBeInstanceOf(Error); 489 | expect((run2Error as Error).message).toEqual( 490 | `Batch fetcher error for Burst Valve: Mock Run Error: 2` 491 | ); 492 | expect((run2Error as Error).cause).toStrictEqual(`Mock Run Error: 2`); 493 | 494 | expect(run1.length).toStrictEqual(3); 495 | expect(run1[0] === run1Error).toBeTruthy(); 496 | expect(run1[1] === run1Error).toBeTruthy(); 497 | expect(run1[2] === run1Error).toBeTruthy(); 498 | 499 | expect(run2.length).toStrictEqual(3); 500 | expect(run2[0] === run2Error).toBeTruthy(); 501 | expect(run2[1] === run1Error).toBeTruthy(); // Reused from the first batch run 502 | expect(run2[2] === run2Error).toBeTruthy(); 503 | 504 | expect(runs).toEqual([ 505 | [1, 2, 3], 506 | [6, 8], 507 | ]); 508 | }); 509 | 510 | test("should reject fetch when error is found during a batch triggered process", async () => { 511 | const mockError = new Error(`Mock Error`); 512 | const runs: number[][] = []; 513 | const valve = new BurstValve({ 514 | batch: async (ids, earlyWrite) => { 515 | runs.push([...ids]); 516 | await wait(); 517 | ids.forEach((id) => earlyWrite(id, id === 2 ? mockError : id * 2)); 518 | }, 519 | }); 520 | 521 | const [run1, run2, run3] = await Promise.all([ 522 | valve.batch([1, 2, 3]), 523 | valve.fetch(2).catch((reason) => reason), 524 | valve.batch([6, 2, 8]), 525 | ]); 526 | expect(run1).toEqual([2, mockError, 6]); 527 | expect(run2).toEqual(mockError); 528 | expect(run3).toEqual([12, mockError, 16]); 529 | expect(runs).toEqual([ 530 | [1, 2, 3], 531 | [6, 8], 532 | ]); 533 | }); 534 | 535 | test("should send thrown error as rejection in fetch calls", async () => { 536 | const runs: number[][] = []; 537 | const valve = new BurstValve({ 538 | batch: async (ids) => { 539 | runs.push([...ids]); 540 | await wait(); 541 | throw new Error(`Mock Batch Fetch Error`); 542 | }, 543 | }); 544 | 545 | await expect(valve.fetch(5)).rejects.toThrow(`Mock Batch Fetch Error`); 546 | expect(runs).toEqual([[5]]); 547 | }); 548 | 549 | test("should throw error when returned array length does not match key length", async () => { 550 | const valve = new BurstValve({ 551 | batch: async () => { 552 | await wait(); 553 | return [2, 4]; 554 | }, 555 | }); 556 | 557 | await expect(valve.batch([1, 2, 3])).rejects.toThrow( 558 | `Batch fetcher result array length does not match key length for Burst Valve` 559 | ); 560 | }); 561 | 562 | test("should throw error for global fetch when fetcher is not defined", async () => { 563 | const runs: number[][] = []; 564 | const valve = new BurstValve({ 565 | batch: async (ids, earlyWrite) => { 566 | runs.push([...ids]); 567 | await wait(); 568 | ids.forEach((id) => earlyWrite(id, id * 2)); 569 | }, 570 | }); 571 | 572 | await expect(valve.fetch()).rejects.toThrow( 573 | `Cannot make un-identified fetch requests when batching is enabled` 574 | ); 575 | expect(runs).toEqual([]); 576 | }); 577 | 578 | test("should throw error for batch when only fetcher is defined", async () => { 579 | let runs = 0; 580 | const valve = new BurstValve({ 581 | fetch: async () => { 582 | runs++; 583 | await wait(); 584 | return 25; 585 | }, 586 | }); 587 | 588 | await expect(valve.batch([1, 2, 3])).rejects.toThrow( 589 | `Batch Fetcher Process not defined` 590 | ); 591 | expect(runs).toStrictEqual(0); 592 | }); 593 | 594 | test("should throw error when attempting to early write a fetch process that has already completed", async () => { 595 | return new Promise((resolve, reject) => { 596 | const valve = new BurstValve({ 597 | batch: async (_ids, earlyWrite) => { 598 | return new Promise((fetchResolve) => { 599 | earlyWrite(1, 2); 600 | fetchResolve(); 601 | wait().then(() => { 602 | try { 603 | expect(() => earlyWrite(2, 4)).toThrow( 604 | `Batch fetcher process has already completed for Burst Valve` 605 | ); 606 | } catch (e) { 607 | reject(e); 608 | } 609 | }); 610 | }); 611 | }, 612 | }); 613 | 614 | valve 615 | .batch([1, 2]) 616 | .catch(reject) 617 | .then((results) => { 618 | wait() 619 | .then(() => wait()) 620 | .then(() => { 621 | try { 622 | expect(results).toEqual([2, expect.any(Error)]); 623 | expect((results as Error[])[1].message).toEqual( 624 | `Batch fetcher result not found for '2' subqueue in Burst Valve` 625 | ); 626 | resolve(); 627 | } catch (e) { 628 | reject(e); 629 | } 630 | }); 631 | }); 632 | }); 633 | }); 634 | }); 635 | 636 | describe("unsafeBatch", () => { 637 | test("should only run the batch fetcher once for all the keys", async () => { 638 | let ran = 0; 639 | const resultValue: FetchResult = { foo: "foobar" }; 640 | const valve = new BurstValve({ 641 | batch: async (ids) => { 642 | expect(ids).toEqual(["a", "b", "c"]); 643 | ran++; 644 | return ids.map(() => resultValue); 645 | }, 646 | }); 647 | 648 | expect(await valve.unsafeBatch(["a", "b", "c"])).toEqual([ 649 | { foo: "foobar" }, 650 | { foo: "foobar" }, 651 | { foo: "foobar" }, 652 | ]); 653 | expect(ran).toStrictEqual(1); 654 | }); 655 | 656 | test("should raise exception thrown in the batch fetcher", async () => { 657 | const valve = new BurstValve({ 658 | batch: async () => { 659 | throw new Error(`Batch Unsafe Mock Error`); 660 | }, 661 | }); 662 | 663 | await expect(valve.unsafeBatch(["a", "b", "c"])).rejects.toThrow( 664 | `Batch Unsafe Mock Error` 665 | ); 666 | }); 667 | 668 | test("should throw any exceptions that are early written", async () => { 669 | const valve = new BurstValve({ 670 | batch: async (ids, earlyWrite) => { 671 | await wait(); 672 | earlyWrite(ids[0], new Error(`Batch Unsafe Mock Error`)); 673 | }, 674 | }); 675 | 676 | await expect(valve.unsafeBatch(["a", "b", "c"])).rejects.toThrow( 677 | `Batch Unsafe Mock Error` 678 | ); 679 | }); 680 | 681 | test("should throw any exceptions raised by an earlier queue", async () => { 682 | let counter = 0; 683 | const valve = new BurstValve({ 684 | batch: async (ids, earlyWrite) => { 685 | await wait(); 686 | counter++; 687 | ids.forEach((id) => 688 | earlyWrite( 689 | id, 690 | new Error(`Batch Unsafe Mock Error id:${id} - count:${counter}`) 691 | ) 692 | ); 693 | }, 694 | }); 695 | 696 | // Trigger first fetch to build the queues 697 | valve.unsafeBatch([1, 2, 3]).catch(() => undefined); 698 | 699 | await expect(valve.unsafeBatch([1, 5, 6])).rejects.toThrow( 700 | `Batch Unsafe Mock Error id:1 - count:1` 701 | ); 702 | }); 703 | }); 704 | 705 | describe("stream", () => { 706 | test("should stream results as they come in", async () => { 707 | const responses: Array<{ 708 | id: number; 709 | result: number | Error; 710 | }> = []; 711 | 712 | let earlyWrite: (subqueue: number, result: number | Error) => void = () => 713 | undefined; 714 | let batchResolve: () => void = () => undefined; 715 | 716 | const valve = new BurstValve({ 717 | batch: async (_ids, ew) => { 718 | earlyWrite = ew; 719 | return new Promise((resolve) => { 720 | batchResolve = resolve; 721 | }); 722 | }, 723 | }); 724 | 725 | const valveStreamPromise = valve.stream( 726 | [1, 2, 3, 4], 727 | async (id, result) => { 728 | responses.push({ id, result }); 729 | } 730 | ); 731 | 732 | // Confirm no auto responses 733 | expect(responses).toEqual([]); 734 | 735 | // Write out first response 736 | earlyWrite(2, 200); 737 | await wait(); 738 | expect(responses).toEqual([ 739 | { 740 | id: 2, 741 | result: 200, 742 | }, 743 | ]); 744 | 745 | // Write out another response 746 | earlyWrite(4, 400); 747 | await wait(); 748 | expect(responses).toEqual([ 749 | { 750 | id: 2, 751 | result: 200, 752 | }, 753 | { 754 | id: 4, 755 | result: 400, 756 | }, 757 | ]); 758 | 759 | // Write out an error 760 | const mockError = new Error(`Mock Write Error`); 761 | earlyWrite(3, mockError); 762 | await wait(); 763 | expect(responses).toEqual([ 764 | { 765 | id: 2, 766 | result: 200, 767 | }, 768 | { 769 | id: 4, 770 | result: 400, 771 | }, 772 | { 773 | id: 3, 774 | result: mockError, 775 | }, 776 | ]); 777 | 778 | // Confirm any unwritten 779 | batchResolve(); 780 | await wait(); 781 | expect(responses).toEqual([ 782 | { 783 | id: 2, 784 | result: 200, 785 | }, 786 | { 787 | id: 4, 788 | result: 400, 789 | }, 790 | { 791 | id: 3, 792 | result: mockError, 793 | }, 794 | { 795 | id: 1, 796 | result: new Error( 797 | `Batch fetcher result not found for '1' subqueue in Burst Valve` 798 | ), 799 | }, 800 | ]); 801 | 802 | // Resolve the batch process, and make sure the stream is resolved 803 | await valveStreamPromise; 804 | }); 805 | 806 | test("should only send batch fetch request for keys that are not already active", async () => { 807 | const fetchIds: number[][] = []; 808 | const valve = new BurstValve({ 809 | batch: async (ids) => { 810 | fetchIds.push(ids); 811 | return new Promise(() => undefined); 812 | }, 813 | }); 814 | 815 | // Open up batch stream for 2 & 4 keys 816 | valve.batch([2, 4]); 817 | expect(fetchIds).toEqual([[2, 4]]); 818 | 819 | // Stream results for 1-4 keys, expecting only 1 & 3 to be requested 820 | // as 2 & 4 are already from above 821 | valve.stream([1, 2, 3, 4], async () => undefined); 822 | expect(fetchIds).toEqual([ 823 | [2, 4], 824 | [1, 3], 825 | ]); 826 | }); 827 | }); 828 | }); 829 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | afterEach(() => { 2 | jest.resetAllMocks(); 3 | jest.restoreAllMocks(); 4 | }); 5 | -------------------------------------------------------------------------------- /tsconfig.benchmark.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src", "benchmark"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNEXT", 4 | "module": "NodeNext", 5 | "outDir": "dist/", 6 | "alwaysStrict": true, 7 | "diagnostics": true, 8 | "strict": true, 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | "declaration": true, 12 | "declarationMap": true, 13 | "sourceMap": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "esModuleInterop": true, 18 | "skipLibCheck": true, 19 | "forceConsistentCasingInFileNames": true 20 | }, 21 | "include": ["src", "test"] 22 | } 23 | --------------------------------------------------------------------------------