Skip to content

Commit f62a355

Browse files
committed
feat: request_bundler.ts
1 parent 90bf55a commit f62a355

4 files changed

Lines changed: 141 additions & 0 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ extendGlobally();
5757

5858
- `wait` - returns a promise that resolves after some time (the classic new promise -> set timeout that everyone had to write at least once)
5959

60+
### request_bundler
61+
62+
Contains the `RequestBundler` class, built on top of `ExtendedMap`.
63+
Provides a simple way to limit concurrent requests of the same type, while redirecting duplicates to existing tasks.
64+
6065
### semaphore
6166

6267
Contains a simple async `Semaphore` class.

lib/request_bundler.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
2+
// If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
3+
import { ExtendedMap } from "./map.ts";
4+
5+
/**
6+
* An async "lock" that redirects duplicate requests to currently running ones
7+
*/
8+
export class RequestBundler<K, R> {
9+
private state = new ExtendedMap<K, Promise<R>>();
10+
11+
/**
12+
* Runs a task within the context of the request bundler.
13+
*
14+
* It is guaranteed that only one task associated with a given key will be running at once.
15+
*
16+
* If there is no task running for a given key, this function will call the handler function and
17+
* will save the returned promise until it is resolved.
18+
* If there is a task already running for a given key, this function will NOT call the handler function,
19+
* and will instead return a promise for the already running task.
20+
*/
21+
public run(key: K, handler: () => Promise<R>): Promise<R> {
22+
return this.state.getOrInsertWith(key, () => {
23+
const promise = handler();
24+
void promise.finally(() => {
25+
this.state.delete(key);
26+
});
27+
return promise;
28+
});
29+
}
30+
}

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@
4747
"import": "./dist/promises.js",
4848
"require": "./dist/promises.cjs"
4949
},
50+
"./request_bundler": {
51+
"types": "./dist/request_bundler.d.ts",
52+
"import": "./dist/request_bundler.js",
53+
"require": "./dist/request_bundler.cjs"
54+
},
5055
"./semaphore": {
5156
"types": "./dist/semaphore.d.ts",
5257
"import": "./dist/semaphore.js",

test/request_bundler.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
2+
// If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
3+
import { wait } from "../lib/promises.ts";
4+
import { RequestBundler } from "../lib/request_bundler.ts";
5+
6+
describe("request_bundler.ts", function () {
7+
this.timeout(700);
8+
this.slow(450);
9+
10+
it("should bundle concurrent requests with the same key", async () => {
11+
const handler = chai.spy(async () => {
12+
await wait(100);
13+
return 1;
14+
});
15+
16+
const bundler = new RequestBundler();
17+
18+
const p1 = bundler.run("key1", handler);
19+
const p2 = bundler.run("key1", handler);
20+
21+
expect(handler).to.have.been.called.exactly(1);
22+
await expect(p1).to.eventually.equal(1);
23+
await expect(p2).to.eventually.equal(1);
24+
});
25+
26+
it("should not bundle concurrent requests with different keys", async () => {
27+
const handler = chai.spy(async () => {
28+
await wait(100);
29+
return 1;
30+
});
31+
32+
const bundler = new RequestBundler();
33+
34+
const p1 = bundler.run("key1", handler);
35+
const p2 = bundler.run("key2", handler);
36+
37+
expect(handler).to.have.been.called.exactly(2);
38+
await expect(p1).to.eventually.equal(1);
39+
await expect(p2).to.eventually.equal(1);
40+
});
41+
42+
it("should use the first handler for concurrent requests", async () => {
43+
const handler1 = chai.spy(async () => {
44+
await wait(100);
45+
return 1;
46+
});
47+
const handler2 = chai.spy(async () => {
48+
await wait(100);
49+
return 2;
50+
});
51+
52+
const bundler = new RequestBundler();
53+
54+
const p1 = bundler.run("key1", handler1);
55+
const p2 = bundler.run("key1", handler2);
56+
57+
expect(handler1).to.have.been.called.exactly(1);
58+
expect(handler2).to.not.have.been.called();
59+
await expect(p1).to.eventually.equal(1);
60+
await expect(p2).to.eventually.equal(1);
61+
});
62+
63+
it("should forget about the request after it finishes", async () => {
64+
const handler = chai.spy(async () => {
65+
await wait(100);
66+
return 1;
67+
});
68+
69+
const bundler = new RequestBundler();
70+
71+
const p1 = bundler.run("key1", handler);
72+
await expect(p1).to.eventually.equal(1);
73+
74+
const p2 = bundler.run("key1", handler);
75+
await expect(p2).to.eventually.equal(1);
76+
77+
expect(handler).to.have.been.called.exactly(2);
78+
});
79+
80+
it("should forget about the request if handler throws", async () => {
81+
const handler1 = chai.spy(async () => {
82+
await wait(100);
83+
throw new Error("elo żelo");
84+
});
85+
const handler2 = chai.spy(async () => {
86+
await wait(100);
87+
return 1;
88+
});
89+
90+
const bundler = new RequestBundler();
91+
92+
const p1 = bundler.run("key1", handler1);
93+
await expect(p1).to.be.rejectedWith("elo żelo");
94+
95+
const p2 = bundler.run("key1", handler2);
96+
await expect(p2).to.eventually.equal(1);
97+
98+
expect(handler1).to.have.been.called.exactly(1);
99+
expect(handler2).to.have.been.called.exactly(1);
100+
});
101+
});

0 commit comments

Comments
 (0)