async-bulkhead-ts 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -51
- package/dist/index.cjs +82 -32
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +82 -32
- package/dist/index.js.map +1 -1
- package/package.json +2 -4
package/README.md
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
# async-bulkhead-ts
|
|
2
2
|
|
|
3
|
-
Fail-fast **admission control**
|
|
3
|
+
Fail-fast **admission control** (async bulkheads) for Node.js / TypeScript.
|
|
4
4
|
|
|
5
|
-
Designed for services that prefer **rejecting work early** over
|
|
5
|
+
Designed for services that prefer **rejecting work early** over silently degrading via growing queues and timeouts.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
|
-
- ✅ **
|
|
12
|
-
- ✅
|
|
13
|
-
- ✅
|
|
14
|
-
- ✅
|
|
15
|
-
- ✅
|
|
16
|
-
- ✅
|
|
11
|
+
- ✅ Hard **max in-flight** concurrency (`maxConcurrent`)
|
|
12
|
+
- ✅ Optional **bounded FIFO waiting** (`maxQueue`)
|
|
13
|
+
- ✅ **Fail-fast by default** (`maxQueue: 0`)
|
|
14
|
+
- ✅ Explicit acquire + release via a **token**
|
|
15
|
+
- ✅ `bulkhead.run(fn)` helper (acquire + `finally` release)
|
|
16
|
+
- ✅ Optional waiting **timeout** and **AbortSignal** cancellation
|
|
17
17
|
- ✅ Zero dependencies
|
|
18
18
|
- ✅ ESM + CJS support
|
|
19
19
|
- ✅ Node.js **20+**
|
|
@@ -22,7 +22,6 @@ Non-goals (by design):
|
|
|
22
22
|
- ❌ No background workers
|
|
23
23
|
- ❌ No retry logic
|
|
24
24
|
- ❌ No distributed coordination
|
|
25
|
-
- ❌ No built-in metrics backend (hooks only)
|
|
26
25
|
|
|
27
26
|
---
|
|
28
27
|
|
|
@@ -32,49 +31,55 @@ Non-goals (by design):
|
|
|
32
31
|
npm install async-bulkhead-ts
|
|
33
32
|
```
|
|
34
33
|
|
|
35
|
-
## Basic Usage (Manual)
|
|
34
|
+
## Basic Usage (Manual acquire/release)
|
|
36
35
|
|
|
37
36
|
```ts
|
|
38
37
|
import { createBulkhead } from 'async-bulkhead-ts';
|
|
39
38
|
|
|
40
|
-
const bulkhead = createBulkhead({
|
|
41
|
-
|
|
39
|
+
const bulkhead = createBulkhead({
|
|
40
|
+
maxConcurrent: 10,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const r = await bulkhead.acquire();
|
|
42
44
|
|
|
43
|
-
if (!
|
|
45
|
+
if (!r.ok) {
|
|
44
46
|
// Fail fast — shed load, return 503, etc.
|
|
45
|
-
|
|
47
|
+
// r.reason is one of:
|
|
48
|
+
// 'concurrency_limit' | 'queue_limit' | 'timeout' | 'aborted'
|
|
49
|
+
throw new Error(`Rejected: ${r.reason}`);
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
try {
|
|
49
53
|
await doWork();
|
|
50
54
|
} finally {
|
|
51
|
-
|
|
55
|
+
r.token.release();
|
|
52
56
|
}
|
|
53
57
|
```
|
|
54
58
|
|
|
55
|
-
You must call `release()` exactly once if
|
|
56
|
-
Failing to release permanently reduces capacity.
|
|
59
|
+
You must call `token.release()` exactly once if acquisition succeeds.
|
|
60
|
+
Failing to release permanently reduces available capacity.
|
|
57
61
|
|
|
58
62
|
## Convenience Helper
|
|
59
63
|
|
|
60
64
|
For most use cases, prefer `bulkhead.run(fn)`:
|
|
61
65
|
|
|
62
66
|
```ts
|
|
63
|
-
await bulkhead.run(async () =>
|
|
64
|
-
await doWork();
|
|
65
|
-
});
|
|
67
|
+
await bulkhead.run(async () => doWork());
|
|
66
68
|
```
|
|
67
69
|
|
|
68
70
|
Behavior:
|
|
69
71
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
- Acquire + release handled automatically (finally release)
|
|
73
|
+
- Still fail-fast (unless you configure `maxQueue`)
|
|
74
|
+
- Rejections throw a typed `BulkheadRejectedError` when using `run()`
|
|
75
|
+
- The provided `AbortSignal` is passed through to the function
|
|
76
|
+
- Supports waiting cancellation via `AbortSignal` and `timeoutMs`
|
|
77
|
+
|
|
78
|
+
> The signal passed to `run()` only affects admission and observation; in-flight work is not forcibly cancelled.
|
|
74
79
|
|
|
75
80
|
## With a Queue (Optional)
|
|
76
81
|
|
|
77
|
-
|
|
82
|
+
Waiting is opt-in and bounded (FIFO).
|
|
78
83
|
|
|
79
84
|
```ts
|
|
80
85
|
const bulkhead = createBulkhead({
|
|
@@ -83,28 +88,45 @@ const bulkhead = createBulkhead({
|
|
|
83
88
|
});
|
|
84
89
|
```
|
|
85
90
|
|
|
86
|
-
|
|
91
|
+
> Note: bounded waiting is optional.
|
|
92
|
+
> Future major versions may focus on fail-fast admission only.
|
|
87
93
|
|
|
88
|
-
|
|
94
|
+
|
|
95
|
+
Semantics:
|
|
96
|
+
- If `inFlight` < `maxConcurrent`: `acquire()` succeeds immediately.
|
|
97
|
+
- Else if `maxQueue` > 0 and queue has space: `acquire()` waits FIFO.
|
|
98
|
+
- Else: rejected immediately.
|
|
89
99
|
|
|
90
100
|
## Cancellation
|
|
91
101
|
|
|
92
|
-
|
|
102
|
+
Waiting can be cancelled with an `AbortSignal`:
|
|
93
103
|
|
|
94
104
|
```ts
|
|
95
105
|
await bulkhead.run(
|
|
96
|
-
async () =>
|
|
97
|
-
await doWork();
|
|
98
|
-
},
|
|
106
|
+
async () => doWork(),
|
|
99
107
|
{ signal }
|
|
100
108
|
);
|
|
101
109
|
```
|
|
102
110
|
|
|
103
111
|
Cancellation guarantees:
|
|
112
|
+
- Work that is waiting in the queue can be cancelled before it starts.
|
|
113
|
+
- In-flight work is not forcibly terminated (your function may observe the signal).
|
|
114
|
+
- Capacity is always released correctly for acquired tokens.
|
|
115
|
+
- Cancelled or timed-out waiters do not permanently consume queue capacity.
|
|
116
|
+
- Cancelled waiters will not block subsequent admissions.
|
|
117
|
+
- FIFO order is preserved for non-cancelled waiters.
|
|
118
|
+
|
|
119
|
+
You can also bound waiting time:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
await bulkhead.run(async () => doWork(), { timeoutMs: 50 });
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Behavioral Guarantees
|
|
104
126
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
127
|
+
- maxConcurrent is never exceeded.
|
|
128
|
+
- pending never exceeds maxQueue.
|
|
129
|
+
- Under cancellation or timeout churn, admission remains bounded and deterministic.
|
|
108
130
|
|
|
109
131
|
## API
|
|
110
132
|
|
|
@@ -113,7 +135,7 @@ Cancellation guarantees:
|
|
|
113
135
|
```ts
|
|
114
136
|
type BulkheadOptions = {
|
|
115
137
|
maxConcurrent: number;
|
|
116
|
-
maxQueue?: number;
|
|
138
|
+
maxQueue?: number; // pending waiters allowed (0 => no waiting)
|
|
117
139
|
};
|
|
118
140
|
```
|
|
119
141
|
|
|
@@ -121,38 +143,62 @@ Returns:
|
|
|
121
143
|
|
|
122
144
|
```ts
|
|
123
145
|
{
|
|
124
|
-
|
|
125
|
-
|
|
146
|
+
tryAcquire(): TryAcquireResult;
|
|
147
|
+
acquire(options?): Promise<AcquireResult>;
|
|
148
|
+
run<T>(fn: (signal?: AbortSignal) => Promise<T>, options?): Promise<T>;
|
|
126
149
|
stats(): {
|
|
127
150
|
inFlight: number;
|
|
128
|
-
|
|
151
|
+
pending: number;
|
|
129
152
|
maxConcurrent: number;
|
|
130
153
|
maxQueue: number;
|
|
154
|
+
aborted?: number;
|
|
155
|
+
timedOut?: number;
|
|
156
|
+
rejected?: number;
|
|
157
|
+
doubleRelease?: number;
|
|
131
158
|
};
|
|
132
159
|
}
|
|
133
160
|
```
|
|
134
161
|
|
|
135
|
-
`
|
|
162
|
+
`tryAcquire()`
|
|
136
163
|
|
|
137
164
|
```ts
|
|
138
|
-
type
|
|
139
|
-
| { ok: true;
|
|
140
|
-
| { ok: false;
|
|
165
|
+
export type TryAcquireResult =
|
|
166
|
+
| { ok: true; token: Token }
|
|
167
|
+
| { ok: false; reason: 'concurrency_limit' };
|
|
141
168
|
```
|
|
142
169
|
|
|
143
|
-
|
|
170
|
+
> `tryAcquire()` never waits and never enqueues; it either acquires immediately or fails fast.
|
|
144
171
|
|
|
145
|
-
|
|
172
|
+
`acquire(options?)`
|
|
146
173
|
|
|
147
|
-
|
|
174
|
+
```ts
|
|
175
|
+
type AcquireOptions = {
|
|
176
|
+
signal?: AbortSignal;
|
|
177
|
+
timeoutMs?: number; // waiting timeout only
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
type AcquireResult =
|
|
181
|
+
| { ok: true; token: Token }
|
|
182
|
+
| { ok: false; reason: 'concurrency_limit' | 'queue_limit' | 'timeout' | 'aborted' };
|
|
183
|
+
```
|
|
148
184
|
|
|
149
|
-
|
|
150
|
-
* queued
|
|
151
|
-
* execution started
|
|
152
|
-
* released
|
|
153
|
-
* rejected
|
|
185
|
+
`run(fn, options?)`
|
|
154
186
|
|
|
155
|
-
|
|
187
|
+
Throws on rejection:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
class BulkheadRejectedError extends Error {
|
|
191
|
+
readonly code = 'BULKHEAD_REJECTED';
|
|
192
|
+
readonly reason: 'concurrency_limit' | 'queue_limit' | 'timeout' | 'aborted';
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The function passed to `run()` receives the same `AbortSignal` (if provided), allowing
|
|
197
|
+
in-flight work to observe cancellation:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
await bulkhead.run(async (signal) => doWork(signal), { signal });
|
|
201
|
+
```
|
|
156
202
|
|
|
157
203
|
## Design Philosophy
|
|
158
204
|
|
|
@@ -171,6 +217,7 @@ If you need retries, buffering, scheduling, or persistence—compose those aroun
|
|
|
171
217
|
* Node.js: 20+ (24 LTS recommended)
|
|
172
218
|
* Module formats: ESM and CommonJS
|
|
173
219
|
|
|
220
|
+
|
|
174
221
|
## License
|
|
175
222
|
|
|
176
223
|
MIT © 2026
|
package/dist/index.cjs
CHANGED
|
@@ -24,6 +24,47 @@ __export(index_exports, {
|
|
|
24
24
|
createBulkhead: () => createBulkhead
|
|
25
25
|
});
|
|
26
26
|
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
var RingDeque = class {
|
|
28
|
+
buf;
|
|
29
|
+
head = 0;
|
|
30
|
+
tail = 0;
|
|
31
|
+
size = 0;
|
|
32
|
+
constructor(capacity) {
|
|
33
|
+
const cap = Math.max(4, capacity | 0);
|
|
34
|
+
this.buf = new Array(cap);
|
|
35
|
+
}
|
|
36
|
+
get length() {
|
|
37
|
+
return this.size;
|
|
38
|
+
}
|
|
39
|
+
pushBack(item) {
|
|
40
|
+
if (this.size === this.buf.length) {
|
|
41
|
+
this.grow();
|
|
42
|
+
}
|
|
43
|
+
const idx = (this.head + this.size) % this.buf.length;
|
|
44
|
+
this.buf[idx] = item;
|
|
45
|
+
this.size++;
|
|
46
|
+
}
|
|
47
|
+
peekFront() {
|
|
48
|
+
if (this.size === 0) return void 0;
|
|
49
|
+
return this.buf[this.head];
|
|
50
|
+
}
|
|
51
|
+
popFront() {
|
|
52
|
+
if (this.size === 0) return void 0;
|
|
53
|
+
const item = this.buf[this.head];
|
|
54
|
+
this.buf[this.head] = void 0;
|
|
55
|
+
this.head = (this.head + 1) % this.buf.length;
|
|
56
|
+
this.size--;
|
|
57
|
+
return item;
|
|
58
|
+
}
|
|
59
|
+
grow() {
|
|
60
|
+
const newBuf = new Array(this.buf.length * 2);
|
|
61
|
+
for (let i = 0; i < this.size; i++) {
|
|
62
|
+
newBuf[i] = this.buf[(this.head + i) % this.buf.length];
|
|
63
|
+
}
|
|
64
|
+
this.buf = newBuf;
|
|
65
|
+
this.head = 0;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
27
68
|
var BulkheadRejectedError = class extends Error {
|
|
28
69
|
constructor(reason) {
|
|
29
70
|
super(`Bulkhead rejected: ${reason}`);
|
|
@@ -41,13 +82,11 @@ function createBulkhead(opts) {
|
|
|
41
82
|
throw new Error("maxQueue must be an integer >= 0");
|
|
42
83
|
}
|
|
43
84
|
let inFlight = 0;
|
|
44
|
-
const q =
|
|
45
|
-
let qHead = 0;
|
|
85
|
+
const q = new RingDeque(maxQueue + 1);
|
|
46
86
|
let aborted = 0;
|
|
47
87
|
let timedOut = 0;
|
|
48
88
|
let rejected = 0;
|
|
49
89
|
let doubleRelease = 0;
|
|
50
|
-
const pendingCount = () => q.length - qHead;
|
|
51
90
|
const makeToken = () => {
|
|
52
91
|
let released = false;
|
|
53
92
|
return {
|
|
@@ -63,26 +102,46 @@ function createBulkhead(opts) {
|
|
|
63
102
|
}
|
|
64
103
|
};
|
|
65
104
|
};
|
|
105
|
+
const pruneCancelledFront = () => {
|
|
106
|
+
while (q.length > 0) {
|
|
107
|
+
const w = q.peekFront();
|
|
108
|
+
if (w.cancelled || w.settled) {
|
|
109
|
+
cleanupWaiter(w);
|
|
110
|
+
q.popFront();
|
|
111
|
+
continue;
|
|
112
|
+
} else {
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const pendingCount = () => {
|
|
118
|
+
pruneCancelledFront();
|
|
119
|
+
return q.length;
|
|
120
|
+
};
|
|
66
121
|
const cleanupWaiter = (w) => {
|
|
67
122
|
if (w.abortListener) w.abortListener();
|
|
68
123
|
if (w.timeoutId) clearTimeout(w.timeoutId);
|
|
69
124
|
w.abortListener = void 0;
|
|
70
125
|
w.timeoutId = void 0;
|
|
71
126
|
};
|
|
127
|
+
const settle = (w, r) => {
|
|
128
|
+
if (w.settled) return;
|
|
129
|
+
w.settled = true;
|
|
130
|
+
if (!w.cancelled && !r.ok) w.cancelled = true;
|
|
131
|
+
cleanupWaiter(w);
|
|
132
|
+
w.resolve(r);
|
|
133
|
+
};
|
|
72
134
|
const drain = () => {
|
|
73
|
-
|
|
74
|
-
|
|
135
|
+
pruneCancelledFront();
|
|
136
|
+
while (inFlight < opts.maxConcurrent && q.length > 0) {
|
|
137
|
+
const w = q.popFront();
|
|
75
138
|
if (w.cancelled) {
|
|
76
139
|
cleanupWaiter(w);
|
|
140
|
+
pruneCancelledFront();
|
|
77
141
|
continue;
|
|
78
142
|
}
|
|
79
143
|
inFlight++;
|
|
80
|
-
|
|
81
|
-
w.resolve({ ok: true, token: makeToken() });
|
|
82
|
-
}
|
|
83
|
-
if (qHead > 1024 && qHead * 2 > q.length) {
|
|
84
|
-
q.splice(0, qHead);
|
|
85
|
-
qHead = 0;
|
|
144
|
+
settle(w, { ok: true, token: makeToken() });
|
|
86
145
|
}
|
|
87
146
|
};
|
|
88
147
|
const tryAcquire = () => {
|
|
@@ -90,12 +149,7 @@ function createBulkhead(opts) {
|
|
|
90
149
|
inFlight++;
|
|
91
150
|
return { ok: true, token: makeToken() };
|
|
92
151
|
}
|
|
93
|
-
|
|
94
|
-
rejected++;
|
|
95
|
-
return { ok: false, reason: "queue_limit" };
|
|
96
|
-
}
|
|
97
|
-
rejected++;
|
|
98
|
-
return { ok: false, reason: maxQueue > 0 ? "queue_limit" : "concurrency_limit" };
|
|
152
|
+
return { ok: false, reason: "concurrency_limit" };
|
|
99
153
|
};
|
|
100
154
|
const acquire = (ao = {}) => {
|
|
101
155
|
if (inFlight < opts.maxConcurrent) {
|
|
@@ -114,22 +168,20 @@ function createBulkhead(opts) {
|
|
|
114
168
|
const w = {
|
|
115
169
|
resolve,
|
|
116
170
|
cancelled: false,
|
|
171
|
+
settled: false,
|
|
117
172
|
abortListener: void 0,
|
|
118
173
|
timeoutId: void 0
|
|
119
174
|
};
|
|
120
175
|
if (ao.signal) {
|
|
121
176
|
if (ao.signal.aborted) {
|
|
122
177
|
aborted++;
|
|
123
|
-
|
|
178
|
+
settle(w, { ok: false, reason: "aborted" });
|
|
124
179
|
return;
|
|
125
180
|
}
|
|
126
181
|
const onAbort = () => {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
cleanupWaiter(w);
|
|
131
|
-
resolve({ ok: false, reason: "aborted" });
|
|
132
|
-
}
|
|
182
|
+
aborted++;
|
|
183
|
+
w.cancelled = true;
|
|
184
|
+
settle(w, { ok: false, reason: "aborted" });
|
|
133
185
|
};
|
|
134
186
|
ao.signal.addEventListener("abort", onAbort, { once: true });
|
|
135
187
|
w.abortListener = () => ao.signal.removeEventListener("abort", onAbort);
|
|
@@ -137,19 +189,17 @@ function createBulkhead(opts) {
|
|
|
137
189
|
if (ao.timeoutMs != null) {
|
|
138
190
|
if (!Number.isFinite(ao.timeoutMs) || ao.timeoutMs < 0) {
|
|
139
191
|
timedOut++;
|
|
140
|
-
|
|
192
|
+
settle(w, { ok: false, reason: "timeout" });
|
|
141
193
|
return;
|
|
142
194
|
}
|
|
143
195
|
w.timeoutId = setTimeout(() => {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
cleanupWaiter(w);
|
|
148
|
-
resolve({ ok: false, reason: "timeout" });
|
|
149
|
-
}
|
|
196
|
+
timedOut++;
|
|
197
|
+
w.cancelled = true;
|
|
198
|
+
settle(w, { ok: false, reason: "timeout" });
|
|
150
199
|
}, ao.timeoutMs);
|
|
151
200
|
}
|
|
152
|
-
q.
|
|
201
|
+
q.pushBack(w);
|
|
202
|
+
drain();
|
|
153
203
|
});
|
|
154
204
|
};
|
|
155
205
|
const run = async (fn, ao = {}) => {
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export type BulkheadOptions = {\n maxConcurrent: number;\n maxQueue?: number; // pending waiters allowed (0 => no waiting)\n};\n\nexport type AcquireOptions = {\n signal?: AbortSignal;\n timeoutMs?: number; // waiting timeout only\n};\n\nexport type Stats = {\n inFlight: number;\n pending: number;\n maxConcurrent: number;\n maxQueue: number;\n // optional debug counters:\n aborted?: number;\n timedOut?: number;\n rejected?: number;\n doubleRelease?: number;\n};\n\nexport type Token = { release(): void };\n\nexport type TryAcquireResult =\n | { ok: true; token: Token }\n | { ok: false; reason: 'concurrency_limit' | 'queue_limit' };\n\nexport type AcquireResult =\n | { ok: true; token: Token }\n | { ok: false; reason: 'concurrency_limit' | 'queue_limit' | 'timeout' | 'aborted' };\n\ntype Waiter = {\n resolve: (r: AcquireResult) => void;\n cancelled: boolean;\n\n abortListener: (() => void) | undefined;\n timeoutId: ReturnType<typeof setTimeout> | undefined;\n};\n\nexport type RejectReason = 'concurrency_limit' | 'queue_limit' | 'timeout' | 'aborted';\n\nexport class BulkheadRejectedError extends Error {\n readonly code = 'BULKHEAD_REJECTED' as const;\n\n constructor(readonly reason: RejectReason) {\n super(`Bulkhead rejected: ${reason}`);\n this.name = 'BulkheadRejectedError';\n }\n}\n\nexport function createBulkhead(opts: BulkheadOptions) {\n // ---- validate ----\n if (!Number.isInteger(opts.maxConcurrent) || opts.maxConcurrent <= 0) {\n throw new Error('maxConcurrent must be a positive integer');\n }\n const maxQueue = opts.maxQueue ?? 0;\n if (!Number.isInteger(maxQueue) || maxQueue < 0) {\n throw new Error('maxQueue must be an integer >= 0');\n }\n\n // ---- state ----\n let inFlight = 0;\n\n // FIFO queue as array with head index (cheap shift)\n const q: Waiter[] = [];\n let qHead = 0;\n\n // optional counters\n let aborted = 0;\n let timedOut = 0;\n let rejected = 0;\n let doubleRelease = 0;\n\n const pendingCount = () => q.length - qHead;\n\n // ---- token factory ----\n const makeToken = (): Token => {\n let released = false;\n return {\n release() {\n if (released) {\n doubleRelease++;\n return; // idempotent; consider throw in dev builds if you prefer\n }\n released = true;\n inFlight--;\n if (inFlight < 0) inFlight = 0; // defensive, should never happen\n drain();\n },\n };\n };\n\n const cleanupWaiter = (w: Waiter) => {\n if (w.abortListener) w.abortListener();\n if (w.timeoutId) clearTimeout(w.timeoutId);\n w.abortListener = undefined;\n w.timeoutId = undefined;\n };\n\n // ---- drain algorithm ----\n const drain = () => {\n while (inFlight < opts.maxConcurrent && pendingCount() > 0) {\n const w = q[qHead++]!;\n // skip cancelled waiters\n if (w.cancelled) {\n cleanupWaiter(w);\n continue;\n }\n\n // grant slot\n inFlight++;\n cleanupWaiter(w);\n w.resolve({ ok: true, token: makeToken() });\n }\n\n // occasional compaction to avoid unbounded growth\n if (qHead > 1024 && qHead * 2 > q.length) {\n q.splice(0, qHead);\n qHead = 0;\n }\n };\n\n // ---- public APIs ----\n\n const tryAcquire = (): TryAcquireResult => {\n if (inFlight < opts.maxConcurrent) {\n inFlight++;\n return { ok: true, token: makeToken() };\n }\n // tryAcquire never waits; queue_limit matters only if maxQueue configured\n if (maxQueue > 0 && pendingCount() >= maxQueue) {\n rejected++;\n return { ok: false, reason: 'queue_limit' };\n }\n rejected++;\n return { ok: false, reason: maxQueue > 0 ? 'queue_limit' : 'concurrency_limit' };\n };\n\n const acquire = (ao: AcquireOptions = {}): Promise<AcquireResult> => {\n // immediate fast path\n if (inFlight < opts.maxConcurrent) {\n inFlight++;\n return Promise.resolve({ ok: true, token: makeToken() });\n }\n\n // no waiting allowed\n if (maxQueue === 0) {\n rejected++;\n return Promise.resolve({ ok: false, reason: 'concurrency_limit' });\n }\n\n // bounded waiting\n if (pendingCount() >= maxQueue) {\n rejected++;\n return Promise.resolve({ ok: false, reason: 'queue_limit' });\n }\n\n // enqueue\n return new Promise<AcquireResult>((resolve) => {\n const w: Waiter = {\n resolve,\n cancelled: false,\n abortListener: undefined,\n timeoutId: undefined,\n };\n\n // abort support\n if (ao.signal) {\n if (ao.signal.aborted) {\n aborted++;\n resolve({ ok: false, reason: 'aborted' });\n return;\n }\n const onAbort = () => {\n // mark cancelled; drain() will skip it\n if (!w.cancelled) {\n w.cancelled = true;\n aborted++;\n cleanupWaiter(w);\n resolve({ ok: false, reason: 'aborted' });\n }\n };\n ao.signal.addEventListener('abort', onAbort, { once: true });\n w.abortListener = () => ao.signal!.removeEventListener('abort', onAbort);\n }\n\n // timeout support (waiting only)\n if (ao.timeoutMs != null) {\n if (!Number.isFinite(ao.timeoutMs) || ao.timeoutMs < 0) {\n // invalid => treat as immediate timeout\n timedOut++;\n resolve({ ok: false, reason: 'timeout' });\n return;\n }\n w.timeoutId = setTimeout(() => {\n if (!w.cancelled) {\n w.cancelled = true;\n timedOut++;\n cleanupWaiter(w);\n resolve({ ok: false, reason: 'timeout' });\n }\n }, ao.timeoutMs);\n }\n\n q.push(w);\n // NOTE: we do NOT reserve capacity here.\n // A slot is consumed only when drain() grants a token.\n\n // No need to call drain() here because we already know we’re at capacity,\n // but it doesn’t hurt if you want (for races with release).\n });\n };\n\n const run = async <T>(\n fn: (signal?: AbortSignal) => Promise<T>,\n ao: AcquireOptions = {},\n ): Promise<T> => {\n const r = await acquire(ao);\n if (!r.ok) {\n throw new BulkheadRejectedError(r.reason);\n }\n try {\n return await fn(ao.signal);\n } finally {\n r.token.release();\n }\n };\n\n const stats = (): Stats => ({\n inFlight,\n pending: pendingCount(),\n maxConcurrent: opts.maxConcurrent,\n maxQueue,\n aborted,\n timedOut,\n rejected,\n doubleRelease,\n });\n\n return { tryAcquire, acquire, run, stats };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * A small ring-buffer queue.\n * - O(1) pushBack / popFront\n * - avoids array shift and head-index + compaction heuristics \n */\nclass RingDeque<T> {\n private buf: Array<T | undefined>;\n private head = 0;\n private tail = 0;\n private size = 0;\n\n constructor(capacity: number) {\n const cap = Math.max(4, capacity | 0);\n this.buf = new Array<T | undefined>(cap);\n }\n\n get length() {\n return this.size;\n }\n\n pushBack(item: T) {\n if (this.size === this.buf.length) {\n this.grow();\n }\n const idx = (this.head + this.size) % this.buf.length;\n this.buf[idx] = item;\n this.size++;\n }\n\n peekFront(): T | undefined {\n if (this.size === 0) return undefined;\n return this.buf[this.head];\n }\n\n popFront(): T | undefined {\n if (this.size === 0) return undefined;\n const item = this.buf[this.head];\n this.buf[this.head] = undefined; // help GC\n this.head = (this.head + 1) % this.buf.length;\n this.size--;\n return item;\n }\n\n private grow() {\n const newBuf = new Array<T | undefined>(this.buf.length * 2);\n for (let i = 0; i < this.size; i++) {\n newBuf[i] = this.buf[(this.head + i) % this.buf.length];\n }\n this.buf = newBuf;\n this.head = 0;\n }\n}\n\nexport type BulkheadOptions = {\n maxConcurrent: number;\n maxQueue?: number; // pending waiters allowed (0 => no waiting)\n};\n\nexport type AcquireOptions = {\n signal?: AbortSignal;\n timeoutMs?: number; // waiting timeout only\n};\n\nexport type Stats = {\n inFlight: number;\n pending: number;\n maxConcurrent: number;\n maxQueue: number;\n // optional debug counters:\n aborted?: number;\n timedOut?: number;\n rejected?: number;\n doubleRelease?: number;\n};\n\nexport type Token = { release(): void };\n\nexport type TryAcquireResult =\n | { ok: true; token: Token }\n | { ok: false; reason: 'concurrency_limit' };\n\nexport type AcquireResult =\n | { ok: true; token: Token }\n | { ok: false; reason: 'concurrency_limit' | 'queue_limit' | 'timeout' | 'aborted' };\n\ntype Waiter = {\n resolve: (r: AcquireResult) => void;\n cancelled: boolean;\n settled: boolean;\n\n abortListener: (() => void) | undefined;\n timeoutId: ReturnType<typeof setTimeout> | undefined;\n};\n\nexport type RejectReason = 'concurrency_limit' | 'queue_limit' | 'timeout' | 'aborted';\n\nexport class BulkheadRejectedError extends Error {\n readonly code = 'BULKHEAD_REJECTED' as const;\n\n constructor(readonly reason: RejectReason) {\n super(`Bulkhead rejected: ${reason}`);\n this.name = 'BulkheadRejectedError';\n }\n}\n\nexport function createBulkhead(opts: BulkheadOptions) {\n // ---- validate ----\n if (!Number.isInteger(opts.maxConcurrent) || opts.maxConcurrent <= 0) {\n throw new Error('maxConcurrent must be a positive integer');\n }\n const maxQueue = opts.maxQueue ?? 0;\n if (!Number.isInteger(maxQueue) || maxQueue < 0) {\n throw new Error('maxQueue must be an integer >= 0');\n }\n\n // ---- state ----\n let inFlight = 0;\n\n // FIFO queue as deque (no head index, just pushBack / popFront)\n const q = new RingDeque<Waiter>(maxQueue + 1); // +1 to avoid full queue edge case\n\n // optional counters\n let aborted = 0;\n let timedOut = 0;\n let rejected = 0;\n let doubleRelease = 0;\n\n // ---- token factory ----\n const makeToken = (): Token => {\n let released = false;\n return {\n release() {\n if (released) {\n doubleRelease++;\n return; // idempotent; consider throw in dev builds if you prefer\n }\n released = true;\n inFlight--;\n if (inFlight < 0) inFlight = 0; // defensive, should never happen\n drain();\n },\n };\n };\n\n const pruneCancelledFront = () => {\n // Remove cancelled/settled waiters at the front so they stop consuming maxQueue.\n while (q.length > 0) {\n const w = q.peekFront()!;\n if (w.cancelled || w.settled) {\n cleanupWaiter(w);\n q.popFront();\n continue;\n } else {\n break;\n }\n }\n }\n\n const pendingCount = () => {\n pruneCancelledFront();\n return q.length;\n }\n\n const cleanupWaiter = (w: Waiter) => {\n if (w.abortListener) w.abortListener();\n if (w.timeoutId) clearTimeout(w.timeoutId);\n w.abortListener = undefined;\n w.timeoutId = undefined;\n };\n\n const settle = (w: Waiter, r: AcquireResult) => {\n if (w.settled) return;\n w.settled = true;\n // Once settled, it's effectively cancelled for drain-skipping purposes.\n // (We keep cancelled separate because drain checks it.)\n if (!w.cancelled && !r.ok) w.cancelled = true;\n cleanupWaiter(w);\n w.resolve(r);\n };\n\n // ---- drain algorithm ----\n const drain = () => {\n // Prune first so cancelled/settled waiters don't block the head.\n pruneCancelledFront();\n while (inFlight < opts.maxConcurrent && q.length > 0) {\n const w = q.popFront()!; \n // If it was cancelled after peek/prune but before pop, skip it.\n if (w.cancelled) {\n cleanupWaiter(w);\n pruneCancelledFront(); // in case there are more cancelled after this one\n continue;\n }\n inFlight++;\n settle(w, { ok: true, token: makeToken() });\n }\n };\n\n // ---- public APIs ----\n\n const tryAcquire = (): TryAcquireResult => {\n if (inFlight < opts.maxConcurrent) {\n inFlight++;\n return { ok: true, token: makeToken() };\n }\n return { ok: false, reason: 'concurrency_limit' };\n };\n\n const acquire = (ao: AcquireOptions = {}): Promise<AcquireResult> => {\n // immediate fast path\n if (inFlight < opts.maxConcurrent) {\n inFlight++;\n return Promise.resolve({ ok: true, token: makeToken() });\n }\n\n // no waiting allowed\n if (maxQueue === 0) {\n rejected++;\n return Promise.resolve({ ok: false, reason: 'concurrency_limit' });\n }\n\n // bounded waiting\n if (pendingCount() >= maxQueue) {\n rejected++;\n return Promise.resolve({ ok: false, reason: 'queue_limit' });\n }\n\n // enqueue\n return new Promise<AcquireResult>((resolve) => {\n const w: Waiter = {\n resolve,\n cancelled: false,\n settled: false,\n abortListener: undefined,\n timeoutId: undefined,\n };\n\n // abort support\n if (ao.signal) {\n if (ao.signal.aborted) {\n aborted++;\n settle(w, { ok: false, reason: 'aborted' });\n return;\n }\n const onAbort = () => {\n // mark cancelled; drain() will skip it\n aborted++;\n w.cancelled = true;\n settle(w, { ok: false, reason: 'aborted' });\n };\n\n ao.signal.addEventListener('abort', onAbort, { once: true });\n w.abortListener = () => ao.signal!.removeEventListener('abort', onAbort);\n }\n\n // timeout support (waiting only)\n if (ao.timeoutMs != null) {\n if (!Number.isFinite(ao.timeoutMs) || ao.timeoutMs < 0) {\n // invalid => treat as immediate timeout\n timedOut++;\n settle(w, { ok: false, reason: 'timeout' });\n return;\n }\n w.timeoutId = setTimeout(() => {\n timedOut++;\n w.cancelled = true;\n settle(w, { ok: false, reason: 'timeout' });\n }, ao.timeoutMs);\n }\n\n q.pushBack(w);\n drain(); // required: capacity may have freed after the fast-path check but before enqueue\n });\n };\n\n const run = async <T>(\n fn: (signal?: AbortSignal) => Promise<T>,\n ao: AcquireOptions = {},\n ): Promise<T> => {\n const r = await acquire(ao);\n if (!r.ok) {\n throw new BulkheadRejectedError(r.reason);\n }\n try {\n return await fn(ao.signal);\n } finally {\n r.token.release();\n }\n };\n\n const stats = (): Stats => ({\n inFlight,\n pending: pendingCount(),\n maxConcurrent: opts.maxConcurrent,\n maxQueue,\n aborted,\n timedOut,\n rejected,\n doubleRelease,\n });\n\n return { tryAcquire, acquire, run, stats };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAKA,IAAM,YAAN,MAAmB;AAAA,EACT;AAAA,EACA,OAAO;AAAA,EACP,OAAO;AAAA,EACP,OAAO;AAAA,EAEf,YAAY,UAAkB;AAC5B,UAAM,MAAM,KAAK,IAAI,GAAG,WAAW,CAAC;AACpC,SAAK,MAAM,IAAI,MAAqB,GAAG;AAAA,EACzC;AAAA,EAEA,IAAI,SAAS;AACX,WAAO,KAAK;AAAA,EACd;AAAA,EAEA,SAAS,MAAS;AAChB,QAAI,KAAK,SAAS,KAAK,IAAI,QAAQ;AACjC,WAAK,KAAK;AAAA,IACZ;AACA,UAAM,OAAO,KAAK,OAAO,KAAK,QAAQ,KAAK,IAAI;AAC/C,SAAK,IAAI,GAAG,IAAI;AAChB,SAAK;AAAA,EACP;AAAA,EAEA,YAA2B;AACzB,QAAI,KAAK,SAAS,EAAG,QAAO;AAC5B,WAAO,KAAK,IAAI,KAAK,IAAI;AAAA,EAC3B;AAAA,EAEA,WAA0B;AACxB,QAAI,KAAK,SAAS,EAAG,QAAO;AAC5B,UAAM,OAAO,KAAK,IAAI,KAAK,IAAI;AAC/B,SAAK,IAAI,KAAK,IAAI,IAAI;AACtB,SAAK,QAAQ,KAAK,OAAO,KAAK,KAAK,IAAI;AACvC,SAAK;AACL,WAAO;AAAA,EACT;AAAA,EAEQ,OAAO;AACb,UAAM,SAAS,IAAI,MAAqB,KAAK,IAAI,SAAS,CAAC;AAC3D,aAAS,IAAI,GAAG,IAAI,KAAK,MAAM,KAAK;AAClC,aAAO,CAAC,IAAI,KAAK,KAAK,KAAK,OAAO,KAAK,KAAK,IAAI,MAAM;AAAA,IACxD;AACA,SAAK,MAAM;AACX,SAAK,OAAO;AAAA,EACd;AACF;AA6CO,IAAM,wBAAN,cAAoC,MAAM;AAAA,EAG/C,YAAqB,QAAsB;AACzC,UAAM,sBAAsB,MAAM,EAAE;AADjB;AAEnB,SAAK,OAAO;AAAA,EACd;AAAA,EALS,OAAO;AAMlB;AAEO,SAAS,eAAe,MAAuB;AAEpD,MAAI,CAAC,OAAO,UAAU,KAAK,aAAa,KAAK,KAAK,iBAAiB,GAAG;AACpE,UAAM,IAAI,MAAM,0CAA0C;AAAA,EAC5D;AACA,QAAM,WAAW,KAAK,YAAY;AAClC,MAAI,CAAC,OAAO,UAAU,QAAQ,KAAK,WAAW,GAAG;AAC/C,UAAM,IAAI,MAAM,kCAAkC;AAAA,EACpD;AAGA,MAAI,WAAW;AAGf,QAAM,IAAI,IAAI,UAAkB,WAAW,CAAC;AAG5C,MAAI,UAAU;AACd,MAAI,WAAW;AACf,MAAI,WAAW;AACf,MAAI,gBAAgB;AAGpB,QAAM,YAAY,MAAa;AAC7B,QAAI,WAAW;AACf,WAAO;AAAA,MACL,UAAU;AACR,YAAI,UAAU;AACZ;AACA;AAAA,QACF;AACA,mBAAW;AACX;AACA,YAAI,WAAW,EAAG,YAAW;AAC7B,cAAM;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAEA,QAAM,sBAAsB,MAAM;AAEhC,WAAO,EAAE,SAAS,GAAG;AACnB,YAAM,IAAI,EAAE,UAAU;AACtB,UAAI,EAAE,aAAa,EAAE,SAAS;AAC5B,sBAAc,CAAC;AACf,UAAE,SAAS;AACX;AAAA,MACF,OAAO;AACL;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,QAAM,eAAe,MAAM;AACzB,wBAAoB;AACpB,WAAO,EAAE;AAAA,EACX;AAEA,QAAM,gBAAgB,CAAC,MAAc;AACnC,QAAI,EAAE,cAAe,GAAE,cAAc;AACrC,QAAI,EAAE,UAAW,cAAa,EAAE,SAAS;AACzC,MAAE,gBAAgB;AAClB,MAAE,YAAY;AAAA,EAChB;AAEA,QAAM,SAAS,CAAC,GAAW,MAAqB;AAC9C,QAAI,EAAE,QAAS;AACf,MAAE,UAAU;AAGZ,QAAI,CAAC,EAAE,aAAa,CAAC,EAAE,GAAI,GAAE,YAAY;AACzC,kBAAc,CAAC;AACf,MAAE,QAAQ,CAAC;AAAA,EACb;AAGA,QAAM,QAAQ,MAAM;AAElB,wBAAoB;AACpB,WAAO,WAAW,KAAK,iBAAiB,EAAE,SAAS,GAAG;AACpD,YAAM,IAAI,EAAE,SAAS;AAErB,UAAI,EAAE,WAAW;AACf,sBAAc,CAAC;AACf,4BAAoB;AACpB;AAAA,MACF;AACA;AACA,aAAO,GAAG,EAAE,IAAI,MAAM,OAAO,UAAU,EAAE,CAAC;AAAA,IAC5C;AAAA,EACF;AAIA,QAAM,aAAa,MAAwB;AACzC,QAAI,WAAW,KAAK,eAAe;AACjC;AACA,aAAO,EAAE,IAAI,MAAM,OAAO,UAAU,EAAE;AAAA,IACxC;AACA,WAAO,EAAE,IAAI,OAAO,QAAQ,oBAAoB;AAAA,EAClD;AAEA,QAAM,UAAU,CAAC,KAAqB,CAAC,MAA8B;AAEnE,QAAI,WAAW,KAAK,eAAe;AACjC;AACA,aAAO,QAAQ,QAAQ,EAAE,IAAI,MAAM,OAAO,UAAU,EAAE,CAAC;AAAA,IACzD;AAGA,QAAI,aAAa,GAAG;AAClB;AACA,aAAO,QAAQ,QAAQ,EAAE,IAAI,OAAO,QAAQ,oBAAoB,CAAC;AAAA,IACnE;AAGA,QAAI,aAAa,KAAK,UAAU;AAC9B;AACA,aAAO,QAAQ,QAAQ,EAAE,IAAI,OAAO,QAAQ,cAAc,CAAC;AAAA,IAC7D;AAGA,WAAO,IAAI,QAAuB,CAAC,YAAY;AAC7C,YAAM,IAAY;AAAA,QAChB;AAAA,QACA,WAAW;AAAA,QACX,SAAS;AAAA,QACT,eAAe;AAAA,QACf,WAAW;AAAA,MACb;AAGA,UAAI,GAAG,QAAQ;AACb,YAAI,GAAG,OAAO,SAAS;AACrB;AACA,iBAAO,GAAG,EAAE,IAAI,OAAO,QAAQ,UAAU,CAAC;AAC1C;AAAA,QACF;AACA,cAAM,UAAU,MAAM;AAEpB;AACA,YAAE,YAAY;AACd,iBAAO,GAAG,EAAE,IAAI,OAAO,QAAQ,UAAU,CAAC;AAAA,QAC5C;AAEA,WAAG,OAAO,iBAAiB,SAAS,SAAS,EAAE,MAAM,KAAK,CAAC;AAC3D,UAAE,gBAAgB,MAAM,GAAG,OAAQ,oBAAoB,SAAS,OAAO;AAAA,MACzE;AAGA,UAAI,GAAG,aAAa,MAAM;AACxB,YAAI,CAAC,OAAO,SAAS,GAAG,SAAS,KAAK,GAAG,YAAY,GAAG;AAEtD;AACA,iBAAO,GAAG,EAAE,IAAI,OAAO,QAAQ,UAAU,CAAC;AAC1C;AAAA,QACF;AACA,UAAE,YAAY,WAAW,MAAM;AAC7B;AACA,YAAE,YAAY;AACd,iBAAO,GAAG,EAAE,IAAI,OAAO,QAAQ,UAAU,CAAC;AAAA,QAC5C,GAAG,GAAG,SAAS;AAAA,MACjB;AAEA,QAAE,SAAS,CAAC;AACZ,YAAM;AAAA,IACR,CAAC;AAAA,EACH;AAEA,QAAM,MAAM,OACV,IACA,KAAqB,CAAC,MACP;AACf,UAAM,IAAI,MAAM,QAAQ,EAAE;AAC1B,QAAI,CAAC,EAAE,IAAI;AACT,YAAM,IAAI,sBAAsB,EAAE,MAAM;AAAA,IAC1C;AACA,QAAI;AACF,aAAO,MAAM,GAAG,GAAG,MAAM;AAAA,IAC3B,UAAE;AACA,QAAE,MAAM,QAAQ;AAAA,IAClB;AAAA,EACF;AAEA,QAAM,QAAQ,OAAc;AAAA,IAC1B;AAAA,IACA,SAAS,aAAa;AAAA,IACtB,eAAe,KAAK;AAAA,IACpB;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAEA,SAAO,EAAE,YAAY,SAAS,KAAK,MAAM;AAC3C;","names":[]}
|
package/dist/index.d.cts
CHANGED
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,4 +1,45 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
+
var RingDeque = class {
|
|
3
|
+
buf;
|
|
4
|
+
head = 0;
|
|
5
|
+
tail = 0;
|
|
6
|
+
size = 0;
|
|
7
|
+
constructor(capacity) {
|
|
8
|
+
const cap = Math.max(4, capacity | 0);
|
|
9
|
+
this.buf = new Array(cap);
|
|
10
|
+
}
|
|
11
|
+
get length() {
|
|
12
|
+
return this.size;
|
|
13
|
+
}
|
|
14
|
+
pushBack(item) {
|
|
15
|
+
if (this.size === this.buf.length) {
|
|
16
|
+
this.grow();
|
|
17
|
+
}
|
|
18
|
+
const idx = (this.head + this.size) % this.buf.length;
|
|
19
|
+
this.buf[idx] = item;
|
|
20
|
+
this.size++;
|
|
21
|
+
}
|
|
22
|
+
peekFront() {
|
|
23
|
+
if (this.size === 0) return void 0;
|
|
24
|
+
return this.buf[this.head];
|
|
25
|
+
}
|
|
26
|
+
popFront() {
|
|
27
|
+
if (this.size === 0) return void 0;
|
|
28
|
+
const item = this.buf[this.head];
|
|
29
|
+
this.buf[this.head] = void 0;
|
|
30
|
+
this.head = (this.head + 1) % this.buf.length;
|
|
31
|
+
this.size--;
|
|
32
|
+
return item;
|
|
33
|
+
}
|
|
34
|
+
grow() {
|
|
35
|
+
const newBuf = new Array(this.buf.length * 2);
|
|
36
|
+
for (let i = 0; i < this.size; i++) {
|
|
37
|
+
newBuf[i] = this.buf[(this.head + i) % this.buf.length];
|
|
38
|
+
}
|
|
39
|
+
this.buf = newBuf;
|
|
40
|
+
this.head = 0;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
2
43
|
var BulkheadRejectedError = class extends Error {
|
|
3
44
|
constructor(reason) {
|
|
4
45
|
super(`Bulkhead rejected: ${reason}`);
|
|
@@ -16,13 +57,11 @@ function createBulkhead(opts) {
|
|
|
16
57
|
throw new Error("maxQueue must be an integer >= 0");
|
|
17
58
|
}
|
|
18
59
|
let inFlight = 0;
|
|
19
|
-
const q =
|
|
20
|
-
let qHead = 0;
|
|
60
|
+
const q = new RingDeque(maxQueue + 1);
|
|
21
61
|
let aborted = 0;
|
|
22
62
|
let timedOut = 0;
|
|
23
63
|
let rejected = 0;
|
|
24
64
|
let doubleRelease = 0;
|
|
25
|
-
const pendingCount = () => q.length - qHead;
|
|
26
65
|
const makeToken = () => {
|
|
27
66
|
let released = false;
|
|
28
67
|
return {
|
|
@@ -38,26 +77,46 @@ function createBulkhead(opts) {
|
|
|
38
77
|
}
|
|
39
78
|
};
|
|
40
79
|
};
|
|
80
|
+
const pruneCancelledFront = () => {
|
|
81
|
+
while (q.length > 0) {
|
|
82
|
+
const w = q.peekFront();
|
|
83
|
+
if (w.cancelled || w.settled) {
|
|
84
|
+
cleanupWaiter(w);
|
|
85
|
+
q.popFront();
|
|
86
|
+
continue;
|
|
87
|
+
} else {
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const pendingCount = () => {
|
|
93
|
+
pruneCancelledFront();
|
|
94
|
+
return q.length;
|
|
95
|
+
};
|
|
41
96
|
const cleanupWaiter = (w) => {
|
|
42
97
|
if (w.abortListener) w.abortListener();
|
|
43
98
|
if (w.timeoutId) clearTimeout(w.timeoutId);
|
|
44
99
|
w.abortListener = void 0;
|
|
45
100
|
w.timeoutId = void 0;
|
|
46
101
|
};
|
|
102
|
+
const settle = (w, r) => {
|
|
103
|
+
if (w.settled) return;
|
|
104
|
+
w.settled = true;
|
|
105
|
+
if (!w.cancelled && !r.ok) w.cancelled = true;
|
|
106
|
+
cleanupWaiter(w);
|
|
107
|
+
w.resolve(r);
|
|
108
|
+
};
|
|
47
109
|
const drain = () => {
|
|
48
|
-
|
|
49
|
-
|
|
110
|
+
pruneCancelledFront();
|
|
111
|
+
while (inFlight < opts.maxConcurrent && q.length > 0) {
|
|
112
|
+
const w = q.popFront();
|
|
50
113
|
if (w.cancelled) {
|
|
51
114
|
cleanupWaiter(w);
|
|
115
|
+
pruneCancelledFront();
|
|
52
116
|
continue;
|
|
53
117
|
}
|
|
54
118
|
inFlight++;
|
|
55
|
-
|
|
56
|
-
w.resolve({ ok: true, token: makeToken() });
|
|
57
|
-
}
|
|
58
|
-
if (qHead > 1024 && qHead * 2 > q.length) {
|
|
59
|
-
q.splice(0, qHead);
|
|
60
|
-
qHead = 0;
|
|
119
|
+
settle(w, { ok: true, token: makeToken() });
|
|
61
120
|
}
|
|
62
121
|
};
|
|
63
122
|
const tryAcquire = () => {
|
|
@@ -65,12 +124,7 @@ function createBulkhead(opts) {
|
|
|
65
124
|
inFlight++;
|
|
66
125
|
return { ok: true, token: makeToken() };
|
|
67
126
|
}
|
|
68
|
-
|
|
69
|
-
rejected++;
|
|
70
|
-
return { ok: false, reason: "queue_limit" };
|
|
71
|
-
}
|
|
72
|
-
rejected++;
|
|
73
|
-
return { ok: false, reason: maxQueue > 0 ? "queue_limit" : "concurrency_limit" };
|
|
127
|
+
return { ok: false, reason: "concurrency_limit" };
|
|
74
128
|
};
|
|
75
129
|
const acquire = (ao = {}) => {
|
|
76
130
|
if (inFlight < opts.maxConcurrent) {
|
|
@@ -89,22 +143,20 @@ function createBulkhead(opts) {
|
|
|
89
143
|
const w = {
|
|
90
144
|
resolve,
|
|
91
145
|
cancelled: false,
|
|
146
|
+
settled: false,
|
|
92
147
|
abortListener: void 0,
|
|
93
148
|
timeoutId: void 0
|
|
94
149
|
};
|
|
95
150
|
if (ao.signal) {
|
|
96
151
|
if (ao.signal.aborted) {
|
|
97
152
|
aborted++;
|
|
98
|
-
|
|
153
|
+
settle(w, { ok: false, reason: "aborted" });
|
|
99
154
|
return;
|
|
100
155
|
}
|
|
101
156
|
const onAbort = () => {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
cleanupWaiter(w);
|
|
106
|
-
resolve({ ok: false, reason: "aborted" });
|
|
107
|
-
}
|
|
157
|
+
aborted++;
|
|
158
|
+
w.cancelled = true;
|
|
159
|
+
settle(w, { ok: false, reason: "aborted" });
|
|
108
160
|
};
|
|
109
161
|
ao.signal.addEventListener("abort", onAbort, { once: true });
|
|
110
162
|
w.abortListener = () => ao.signal.removeEventListener("abort", onAbort);
|
|
@@ -112,19 +164,17 @@ function createBulkhead(opts) {
|
|
|
112
164
|
if (ao.timeoutMs != null) {
|
|
113
165
|
if (!Number.isFinite(ao.timeoutMs) || ao.timeoutMs < 0) {
|
|
114
166
|
timedOut++;
|
|
115
|
-
|
|
167
|
+
settle(w, { ok: false, reason: "timeout" });
|
|
116
168
|
return;
|
|
117
169
|
}
|
|
118
170
|
w.timeoutId = setTimeout(() => {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
cleanupWaiter(w);
|
|
123
|
-
resolve({ ok: false, reason: "timeout" });
|
|
124
|
-
}
|
|
171
|
+
timedOut++;
|
|
172
|
+
w.cancelled = true;
|
|
173
|
+
settle(w, { ok: false, reason: "timeout" });
|
|
125
174
|
}, ao.timeoutMs);
|
|
126
175
|
}
|
|
127
|
-
q.
|
|
176
|
+
q.pushBack(w);
|
|
177
|
+
drain();
|
|
128
178
|
});
|
|
129
179
|
};
|
|
130
180
|
const run = async (fn, ao = {}) => {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export type BulkheadOptions = {\n maxConcurrent: number;\n maxQueue?: number; // pending waiters allowed (0 => no waiting)\n};\n\nexport type AcquireOptions = {\n signal?: AbortSignal;\n timeoutMs?: number; // waiting timeout only\n};\n\nexport type Stats = {\n inFlight: number;\n pending: number;\n maxConcurrent: number;\n maxQueue: number;\n // optional debug counters:\n aborted?: number;\n timedOut?: number;\n rejected?: number;\n doubleRelease?: number;\n};\n\nexport type Token = { release(): void };\n\nexport type TryAcquireResult =\n | { ok: true; token: Token }\n | { ok: false; reason: 'concurrency_limit' | 'queue_limit' };\n\nexport type AcquireResult =\n | { ok: true; token: Token }\n | { ok: false; reason: 'concurrency_limit' | 'queue_limit' | 'timeout' | 'aborted' };\n\ntype Waiter = {\n resolve: (r: AcquireResult) => void;\n cancelled: boolean;\n\n abortListener: (() => void) | undefined;\n timeoutId: ReturnType<typeof setTimeout> | undefined;\n};\n\nexport type RejectReason = 'concurrency_limit' | 'queue_limit' | 'timeout' | 'aborted';\n\nexport class BulkheadRejectedError extends Error {\n readonly code = 'BULKHEAD_REJECTED' as const;\n\n constructor(readonly reason: RejectReason) {\n super(`Bulkhead rejected: ${reason}`);\n this.name = 'BulkheadRejectedError';\n }\n}\n\nexport function createBulkhead(opts: BulkheadOptions) {\n // ---- validate ----\n if (!Number.isInteger(opts.maxConcurrent) || opts.maxConcurrent <= 0) {\n throw new Error('maxConcurrent must be a positive integer');\n }\n const maxQueue = opts.maxQueue ?? 0;\n if (!Number.isInteger(maxQueue) || maxQueue < 0) {\n throw new Error('maxQueue must be an integer >= 0');\n }\n\n // ---- state ----\n let inFlight = 0;\n\n // FIFO queue as array with head index (cheap shift)\n const q: Waiter[] = [];\n let qHead = 0;\n\n // optional counters\n let aborted = 0;\n let timedOut = 0;\n let rejected = 0;\n let doubleRelease = 0;\n\n const pendingCount = () => q.length - qHead;\n\n // ---- token factory ----\n const makeToken = (): Token => {\n let released = false;\n return {\n release() {\n if (released) {\n doubleRelease++;\n return; // idempotent; consider throw in dev builds if you prefer\n }\n released = true;\n inFlight--;\n if (inFlight < 0) inFlight = 0; // defensive, should never happen\n drain();\n },\n };\n };\n\n const cleanupWaiter = (w: Waiter) => {\n if (w.abortListener) w.abortListener();\n if (w.timeoutId) clearTimeout(w.timeoutId);\n w.abortListener = undefined;\n w.timeoutId = undefined;\n };\n\n // ---- drain algorithm ----\n const drain = () => {\n while (inFlight < opts.maxConcurrent && pendingCount() > 0) {\n const w = q[qHead++]!;\n // skip cancelled waiters\n if (w.cancelled) {\n cleanupWaiter(w);\n continue;\n }\n\n // grant slot\n inFlight++;\n cleanupWaiter(w);\n w.resolve({ ok: true, token: makeToken() });\n }\n\n // occasional compaction to avoid unbounded growth\n if (qHead > 1024 && qHead * 2 > q.length) {\n q.splice(0, qHead);\n qHead = 0;\n }\n };\n\n // ---- public APIs ----\n\n const tryAcquire = (): TryAcquireResult => {\n if (inFlight < opts.maxConcurrent) {\n inFlight++;\n return { ok: true, token: makeToken() };\n }\n // tryAcquire never waits; queue_limit matters only if maxQueue configured\n if (maxQueue > 0 && pendingCount() >= maxQueue) {\n rejected++;\n return { ok: false, reason: 'queue_limit' };\n }\n rejected++;\n return { ok: false, reason: maxQueue > 0 ? 'queue_limit' : 'concurrency_limit' };\n };\n\n const acquire = (ao: AcquireOptions = {}): Promise<AcquireResult> => {\n // immediate fast path\n if (inFlight < opts.maxConcurrent) {\n inFlight++;\n return Promise.resolve({ ok: true, token: makeToken() });\n }\n\n // no waiting allowed\n if (maxQueue === 0) {\n rejected++;\n return Promise.resolve({ ok: false, reason: 'concurrency_limit' });\n }\n\n // bounded waiting\n if (pendingCount() >= maxQueue) {\n rejected++;\n return Promise.resolve({ ok: false, reason: 'queue_limit' });\n }\n\n // enqueue\n return new Promise<AcquireResult>((resolve) => {\n const w: Waiter = {\n resolve,\n cancelled: false,\n abortListener: undefined,\n timeoutId: undefined,\n };\n\n // abort support\n if (ao.signal) {\n if (ao.signal.aborted) {\n aborted++;\n resolve({ ok: false, reason: 'aborted' });\n return;\n }\n const onAbort = () => {\n // mark cancelled; drain() will skip it\n if (!w.cancelled) {\n w.cancelled = true;\n aborted++;\n cleanupWaiter(w);\n resolve({ ok: false, reason: 'aborted' });\n }\n };\n ao.signal.addEventListener('abort', onAbort, { once: true });\n w.abortListener = () => ao.signal!.removeEventListener('abort', onAbort);\n }\n\n // timeout support (waiting only)\n if (ao.timeoutMs != null) {\n if (!Number.isFinite(ao.timeoutMs) || ao.timeoutMs < 0) {\n // invalid => treat as immediate timeout\n timedOut++;\n resolve({ ok: false, reason: 'timeout' });\n return;\n }\n w.timeoutId = setTimeout(() => {\n if (!w.cancelled) {\n w.cancelled = true;\n timedOut++;\n cleanupWaiter(w);\n resolve({ ok: false, reason: 'timeout' });\n }\n }, ao.timeoutMs);\n }\n\n q.push(w);\n // NOTE: we do NOT reserve capacity here.\n // A slot is consumed only when drain() grants a token.\n\n // No need to call drain() here because we already know we’re at capacity,\n // but it doesn’t hurt if you want (for races with release).\n });\n };\n\n const run = async <T>(\n fn: (signal?: AbortSignal) => Promise<T>,\n ao: AcquireOptions = {},\n ): Promise<T> => {\n const r = await acquire(ao);\n if (!r.ok) {\n throw new BulkheadRejectedError(r.reason);\n }\n try {\n return await fn(ao.signal);\n } finally {\n r.token.release();\n }\n };\n\n const stats = (): Stats => ({\n inFlight,\n pending: pendingCount(),\n maxConcurrent: opts.maxConcurrent,\n maxQueue,\n aborted,\n timedOut,\n rejected,\n doubleRelease,\n });\n\n return { tryAcquire, acquire, run, stats };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * A small ring-buffer queue.\n * - O(1) pushBack / popFront\n * - avoids array shift and head-index + compaction heuristics \n */\nclass RingDeque<T> {\n private buf: Array<T | undefined>;\n private head = 0;\n private tail = 0;\n private size = 0;\n\n constructor(capacity: number) {\n const cap = Math.max(4, capacity | 0);\n this.buf = new Array<T | undefined>(cap);\n }\n\n get length() {\n return this.size;\n }\n\n pushBack(item: T) {\n if (this.size === this.buf.length) {\n this.grow();\n }\n const idx = (this.head + this.size) % this.buf.length;\n this.buf[idx] = item;\n this.size++;\n }\n\n peekFront(): T | undefined {\n if (this.size === 0) return undefined;\n return this.buf[this.head];\n }\n\n popFront(): T | undefined {\n if (this.size === 0) return undefined;\n const item = this.buf[this.head];\n this.buf[this.head] = undefined; // help GC\n this.head = (this.head + 1) % this.buf.length;\n this.size--;\n return item;\n }\n\n private grow() {\n const newBuf = new Array<T | undefined>(this.buf.length * 2);\n for (let i = 0; i < this.size; i++) {\n newBuf[i] = this.buf[(this.head + i) % this.buf.length];\n }\n this.buf = newBuf;\n this.head = 0;\n }\n}\n\nexport type BulkheadOptions = {\n maxConcurrent: number;\n maxQueue?: number; // pending waiters allowed (0 => no waiting)\n};\n\nexport type AcquireOptions = {\n signal?: AbortSignal;\n timeoutMs?: number; // waiting timeout only\n};\n\nexport type Stats = {\n inFlight: number;\n pending: number;\n maxConcurrent: number;\n maxQueue: number;\n // optional debug counters:\n aborted?: number;\n timedOut?: number;\n rejected?: number;\n doubleRelease?: number;\n};\n\nexport type Token = { release(): void };\n\nexport type TryAcquireResult =\n | { ok: true; token: Token }\n | { ok: false; reason: 'concurrency_limit' };\n\nexport type AcquireResult =\n | { ok: true; token: Token }\n | { ok: false; reason: 'concurrency_limit' | 'queue_limit' | 'timeout' | 'aborted' };\n\ntype Waiter = {\n resolve: (r: AcquireResult) => void;\n cancelled: boolean;\n settled: boolean;\n\n abortListener: (() => void) | undefined;\n timeoutId: ReturnType<typeof setTimeout> | undefined;\n};\n\nexport type RejectReason = 'concurrency_limit' | 'queue_limit' | 'timeout' | 'aborted';\n\nexport class BulkheadRejectedError extends Error {\n readonly code = 'BULKHEAD_REJECTED' as const;\n\n constructor(readonly reason: RejectReason) {\n super(`Bulkhead rejected: ${reason}`);\n this.name = 'BulkheadRejectedError';\n }\n}\n\nexport function createBulkhead(opts: BulkheadOptions) {\n // ---- validate ----\n if (!Number.isInteger(opts.maxConcurrent) || opts.maxConcurrent <= 0) {\n throw new Error('maxConcurrent must be a positive integer');\n }\n const maxQueue = opts.maxQueue ?? 0;\n if (!Number.isInteger(maxQueue) || maxQueue < 0) {\n throw new Error('maxQueue must be an integer >= 0');\n }\n\n // ---- state ----\n let inFlight = 0;\n\n // FIFO queue as deque (no head index, just pushBack / popFront)\n const q = new RingDeque<Waiter>(maxQueue + 1); // +1 to avoid full queue edge case\n\n // optional counters\n let aborted = 0;\n let timedOut = 0;\n let rejected = 0;\n let doubleRelease = 0;\n\n // ---- token factory ----\n const makeToken = (): Token => {\n let released = false;\n return {\n release() {\n if (released) {\n doubleRelease++;\n return; // idempotent; consider throw in dev builds if you prefer\n }\n released = true;\n inFlight--;\n if (inFlight < 0) inFlight = 0; // defensive, should never happen\n drain();\n },\n };\n };\n\n const pruneCancelledFront = () => {\n // Remove cancelled/settled waiters at the front so they stop consuming maxQueue.\n while (q.length > 0) {\n const w = q.peekFront()!;\n if (w.cancelled || w.settled) {\n cleanupWaiter(w);\n q.popFront();\n continue;\n } else {\n break;\n }\n }\n }\n\n const pendingCount = () => {\n pruneCancelledFront();\n return q.length;\n }\n\n const cleanupWaiter = (w: Waiter) => {\n if (w.abortListener) w.abortListener();\n if (w.timeoutId) clearTimeout(w.timeoutId);\n w.abortListener = undefined;\n w.timeoutId = undefined;\n };\n\n const settle = (w: Waiter, r: AcquireResult) => {\n if (w.settled) return;\n w.settled = true;\n // Once settled, it's effectively cancelled for drain-skipping purposes.\n // (We keep cancelled separate because drain checks it.)\n if (!w.cancelled && !r.ok) w.cancelled = true;\n cleanupWaiter(w);\n w.resolve(r);\n };\n\n // ---- drain algorithm ----\n const drain = () => {\n // Prune first so cancelled/settled waiters don't block the head.\n pruneCancelledFront();\n while (inFlight < opts.maxConcurrent && q.length > 0) {\n const w = q.popFront()!; \n // If it was cancelled after peek/prune but before pop, skip it.\n if (w.cancelled) {\n cleanupWaiter(w);\n pruneCancelledFront(); // in case there are more cancelled after this one\n continue;\n }\n inFlight++;\n settle(w, { ok: true, token: makeToken() });\n }\n };\n\n // ---- public APIs ----\n\n const tryAcquire = (): TryAcquireResult => {\n if (inFlight < opts.maxConcurrent) {\n inFlight++;\n return { ok: true, token: makeToken() };\n }\n return { ok: false, reason: 'concurrency_limit' };\n };\n\n const acquire = (ao: AcquireOptions = {}): Promise<AcquireResult> => {\n // immediate fast path\n if (inFlight < opts.maxConcurrent) {\n inFlight++;\n return Promise.resolve({ ok: true, token: makeToken() });\n }\n\n // no waiting allowed\n if (maxQueue === 0) {\n rejected++;\n return Promise.resolve({ ok: false, reason: 'concurrency_limit' });\n }\n\n // bounded waiting\n if (pendingCount() >= maxQueue) {\n rejected++;\n return Promise.resolve({ ok: false, reason: 'queue_limit' });\n }\n\n // enqueue\n return new Promise<AcquireResult>((resolve) => {\n const w: Waiter = {\n resolve,\n cancelled: false,\n settled: false,\n abortListener: undefined,\n timeoutId: undefined,\n };\n\n // abort support\n if (ao.signal) {\n if (ao.signal.aborted) {\n aborted++;\n settle(w, { ok: false, reason: 'aborted' });\n return;\n }\n const onAbort = () => {\n // mark cancelled; drain() will skip it\n aborted++;\n w.cancelled = true;\n settle(w, { ok: false, reason: 'aborted' });\n };\n\n ao.signal.addEventListener('abort', onAbort, { once: true });\n w.abortListener = () => ao.signal!.removeEventListener('abort', onAbort);\n }\n\n // timeout support (waiting only)\n if (ao.timeoutMs != null) {\n if (!Number.isFinite(ao.timeoutMs) || ao.timeoutMs < 0) {\n // invalid => treat as immediate timeout\n timedOut++;\n settle(w, { ok: false, reason: 'timeout' });\n return;\n }\n w.timeoutId = setTimeout(() => {\n timedOut++;\n w.cancelled = true;\n settle(w, { ok: false, reason: 'timeout' });\n }, ao.timeoutMs);\n }\n\n q.pushBack(w);\n drain(); // required: capacity may have freed after the fast-path check but before enqueue\n });\n };\n\n const run = async <T>(\n fn: (signal?: AbortSignal) => Promise<T>,\n ao: AcquireOptions = {},\n ): Promise<T> => {\n const r = await acquire(ao);\n if (!r.ok) {\n throw new BulkheadRejectedError(r.reason);\n }\n try {\n return await fn(ao.signal);\n } finally {\n r.token.release();\n }\n };\n\n const stats = (): Stats => ({\n inFlight,\n pending: pendingCount(),\n maxConcurrent: opts.maxConcurrent,\n maxQueue,\n aborted,\n timedOut,\n rejected,\n doubleRelease,\n });\n\n return { tryAcquire, acquire, run, stats };\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "async-bulkhead-ts",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Fail-fast admission control + bulkheads for async workloads",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -10,19 +10,17 @@
|
|
|
10
10
|
],
|
|
11
11
|
"main": "./dist/index.cjs",
|
|
12
12
|
"module": "./dist/index.js",
|
|
13
|
-
"types": "./dist/index.d.ts",
|
|
14
13
|
"exports": {
|
|
15
14
|
".": {
|
|
16
15
|
"import": {
|
|
17
|
-
"types": "./dist/index.d.ts",
|
|
18
16
|
"default": "./dist/index.js"
|
|
19
17
|
},
|
|
20
18
|
"require": {
|
|
21
|
-
"types": "./dist/index.d.cts",
|
|
22
19
|
"default": "./dist/index.cjs"
|
|
23
20
|
}
|
|
24
21
|
}
|
|
25
22
|
},
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
26
24
|
"engines": {
|
|
27
25
|
"node": ">=20"
|
|
28
26
|
},
|