async-bulkhead-ts 0.1.0 → 0.2.0

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 CHANGED
@@ -11,6 +11,9 @@ Designed for services that prefer **rejecting work early** over queueing and deg
11
11
  - ✅ **Fail-fast by default** (no hidden queues)
12
12
  - ✅ Simple **bulkhead / concurrency limits**
13
13
  - ✅ Explicit admission + release lifecycle
14
+ - ✅ `bulkhead.run(fn)` helper for safe execution
15
+ - ✅ Optional cancellation via `AbortSignal`
16
+ - ✅ Lightweight lifecycle hooks for metrics
14
17
  - ✅ Zero dependencies
15
18
  - ✅ ESM + CJS support
16
19
  - ✅ Node.js **20+**
@@ -19,7 +22,7 @@ Non-goals (by design):
19
22
  - ❌ No background workers
20
23
  - ❌ No retry logic
21
24
  - ❌ No distributed coordination
22
- - ❌ No metrics backend (hooks later)
25
+ - ❌ No built-in metrics backend (hooks only)
23
26
 
24
27
  ---
25
28
 
@@ -29,7 +32,7 @@ Non-goals (by design):
29
32
  npm install async-bulkhead-ts
30
33
  ```
31
34
 
32
- ## Basic Usage
35
+ ## Basic Usage (Manual)
33
36
 
34
37
  ```ts
35
38
  import { createBulkhead } from 'async-bulkhead-ts';
@@ -39,28 +42,69 @@ const admission = bulkhead.admit();
39
42
 
40
43
  if (!admission.ok) {
41
44
  // Fail fast — shed load, return 503, etc.
42
- throw new Error('Service overloaded');
45
+ throw admission.error;
43
46
  }
44
47
 
45
48
  try {
46
- // Do async work
47
49
  await doWork();
48
50
  } finally {
49
51
  admission.release();
50
52
  }
51
53
  ```
52
54
 
55
+ You must call `release()` exactly once if admission succeeds.
56
+ Failing to release permanently reduces capacity.
57
+
58
+ ## Convenience Helper
59
+
60
+ For most use cases, prefer `bulkhead.run(fn)`:
61
+
62
+ ```ts
63
+ await bulkhead.run(async () => {
64
+ await doWork();
65
+ });
66
+ ```
67
+
68
+ Behavior:
69
+
70
+ * Admission + release handled automatically
71
+ * Still fail-fast
72
+ * Admission failures throw typed errors
73
+ * Supports cancellation via AbortSignal
74
+
53
75
  ## With a Queue (Optional)
54
76
 
55
77
  Queues are opt-in and bounded.
56
- ```ts
78
+
79
+ ```ts
57
80
  const bulkhead = createBulkhead({
58
81
  maxConcurrent: 10,
59
82
  maxQueue: 20,
60
83
  });
61
84
  ```
62
85
 
63
- If both concurrency and queue limits are exceeded, admission fails immediately.
86
+ When both concurrency and queue limits are exceeded, admission fails immediately.
87
+
88
+ Queue ordering is FIFO.
89
+
90
+ ## Cancellation
91
+
92
+ Bulkhead operations can be bound to an `AbortSignal`:
93
+
94
+ ```ts
95
+ await bulkhead.run(
96
+ async () => {
97
+ await doWork();
98
+ },
99
+ { signal }
100
+ );
101
+ ```
102
+
103
+ Cancellation guarantees:
104
+
105
+ * Queued work can be cancelled before execution
106
+ * In-flight work observes the signal but is not forcibly terminated
107
+ * Capacity is always released correctly
64
108
 
65
109
  ## API
66
110
 
@@ -75,9 +119,10 @@ type BulkheadOptions = {
75
119
 
76
120
  Returns:
77
121
 
78
- ```json
122
+ ```ts
79
123
  {
80
124
  admit(): AdmitResult;
125
+ run<T>(fn: () => Promise<T>, options?): Promise<T>;
81
126
  stats(): {
82
127
  inFlight: number;
83
128
  queued: number;
@@ -92,37 +137,40 @@ Returns:
92
137
  ```ts
93
138
  type AdmitResult =
94
139
  | { ok: true; release: () => void }
95
- | { ok: false; reason: 'concurrency_limit' | 'queue_limit' };
140
+ | { ok: false; error: BulkheadError };
96
141
  ```
97
142
 
98
- You must call `release()` exactly once if admission succeeds.
143
+ Admission failures are returned as typed errors, not strings.
144
+
145
+ ## Metrics Hooks
146
+
147
+ Optional lifecycle hooks allow integration with metrics systems:
99
148
 
100
- Failing to release will permanently reduce capacity.
149
+ * admission accepted
150
+ * queued
151
+ * execution started
152
+ * released
153
+ * rejected
154
+
155
+ Hooks are synchronous and side-effect–free by design.
101
156
 
102
157
  ## Design Philosophy
103
158
 
104
159
  This library is intentionally small.
105
160
 
106
- It exists to enforce **backpressure at the boundary** of your system:
161
+ It exists to enforce backpressure at the boundary of your system:
107
162
 
108
163
  * before request fan-out
109
164
  * before hitting downstream dependencies
110
165
  * before saturation cascades
111
166
 
112
- If you need retries, buffering, scheduling, or persistence—compose those *around* this, not inside it.
167
+ If you need retries, buffering, scheduling, or persistence—compose those around this, not inside it.
113
168
 
114
169
  ## Compatibility
115
170
 
116
171
  * Node.js: 20+ (24 LTS recommended)
117
172
  * Module formats: ESM and CommonJS
118
173
 
119
- ## Roadmap (Short)
120
-
121
- * `bulkhead.run(fn)` helper
122
- * Structured metrics hooks
123
- * `AbortSignal` / cancellation support
124
- * Optional time-based admission windows
125
-
126
174
  ## License
127
175
 
128
176
  MIT © 2026
package/dist/index.cjs CHANGED
@@ -20,59 +20,164 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ BulkheadRejectedError: () => BulkheadRejectedError,
23
24
  createBulkhead: () => createBulkhead
24
25
  });
25
26
  module.exports = __toCommonJS(index_exports);
27
+ var BulkheadRejectedError = class extends Error {
28
+ constructor(reason) {
29
+ super(`Bulkhead rejected: ${reason}`);
30
+ this.reason = reason;
31
+ this.name = "BulkheadRejectedError";
32
+ }
33
+ code = "BULKHEAD_REJECTED";
34
+ };
26
35
  function createBulkhead(opts) {
27
36
  if (!Number.isInteger(opts.maxConcurrent) || opts.maxConcurrent <= 0) {
28
37
  throw new Error("maxConcurrent must be a positive integer");
29
38
  }
30
39
  const maxQueue = opts.maxQueue ?? 0;
40
+ if (!Number.isInteger(maxQueue) || maxQueue < 0) {
41
+ throw new Error("maxQueue must be an integer >= 0");
42
+ }
31
43
  let inFlight = 0;
32
- const queue = [];
33
- const tryStartNext = () => {
34
- while (inFlight < opts.maxConcurrent && queue.length > 0) {
35
- const start = queue.shift();
44
+ const q = [];
45
+ let qHead = 0;
46
+ let aborted = 0;
47
+ let timedOut = 0;
48
+ let rejected = 0;
49
+ let doubleRelease = 0;
50
+ const pendingCount = () => q.length - qHead;
51
+ const makeToken = () => {
52
+ let released = false;
53
+ return {
54
+ release() {
55
+ if (released) {
56
+ doubleRelease++;
57
+ return;
58
+ }
59
+ released = true;
60
+ inFlight--;
61
+ if (inFlight < 0) inFlight = 0;
62
+ drain();
63
+ }
64
+ };
65
+ };
66
+ const cleanupWaiter = (w) => {
67
+ if (w.abortListener) w.abortListener();
68
+ if (w.timeoutId) clearTimeout(w.timeoutId);
69
+ w.abortListener = void 0;
70
+ w.timeoutId = void 0;
71
+ };
72
+ const drain = () => {
73
+ while (inFlight < opts.maxConcurrent && pendingCount() > 0) {
74
+ const w = q[qHead++];
75
+ if (w.cancelled) {
76
+ cleanupWaiter(w);
77
+ continue;
78
+ }
36
79
  inFlight++;
37
- start();
80
+ cleanupWaiter(w);
81
+ w.resolve({ ok: true, token: makeToken() });
82
+ }
83
+ if (qHead > 1024 && qHead * 2 > q.length) {
84
+ q.splice(0, qHead);
85
+ qHead = 0;
38
86
  }
39
87
  };
40
- const release = () => {
41
- inFlight = Math.max(0, inFlight - 1);
42
- tryStartNext();
88
+ const tryAcquire = () => {
89
+ if (inFlight < opts.maxConcurrent) {
90
+ inFlight++;
91
+ return { ok: true, token: makeToken() };
92
+ }
93
+ if (maxQueue > 0 && pendingCount() >= maxQueue) {
94
+ rejected++;
95
+ return { ok: false, reason: "queue_limit" };
96
+ }
97
+ rejected++;
98
+ return { ok: false, reason: maxQueue > 0 ? "queue_limit" : "concurrency_limit" };
43
99
  };
44
- const admit = () => {
100
+ const acquire = (ao = {}) => {
45
101
  if (inFlight < opts.maxConcurrent) {
46
102
  inFlight++;
47
- return { ok: true, release };
103
+ return Promise.resolve({ ok: true, token: makeToken() });
104
+ }
105
+ if (maxQueue === 0) {
106
+ rejected++;
107
+ return Promise.resolve({ ok: false, reason: "concurrency_limit" });
108
+ }
109
+ if (pendingCount() >= maxQueue) {
110
+ rejected++;
111
+ return Promise.resolve({ ok: false, reason: "queue_limit" });
48
112
  }
49
- if (maxQueue > 0 && queue.length < maxQueue) {
50
- let started = false;
51
- const gate = () => {
52
- started = true;
113
+ return new Promise((resolve) => {
114
+ const w = {
115
+ resolve,
116
+ cancelled: false,
117
+ abortListener: void 0,
118
+ timeoutId: void 0
53
119
  };
54
- queue.push(gate);
55
- return {
56
- ok: true,
57
- release: () => {
58
- if (!started) {
59
- const idx = queue.indexOf(gate);
60
- if (idx >= 0) queue.splice(idx, 1);
61
- } else {
62
- release();
120
+ if (ao.signal) {
121
+ if (ao.signal.aborted) {
122
+ aborted++;
123
+ resolve({ ok: false, reason: "aborted" });
124
+ return;
125
+ }
126
+ const onAbort = () => {
127
+ if (!w.cancelled) {
128
+ w.cancelled = true;
129
+ aborted++;
130
+ cleanupWaiter(w);
131
+ resolve({ ok: false, reason: "aborted" });
63
132
  }
133
+ };
134
+ ao.signal.addEventListener("abort", onAbort, { once: true });
135
+ w.abortListener = () => ao.signal.removeEventListener("abort", onAbort);
136
+ }
137
+ if (ao.timeoutMs != null) {
138
+ if (!Number.isFinite(ao.timeoutMs) || ao.timeoutMs < 0) {
139
+ timedOut++;
140
+ resolve({ ok: false, reason: "timeout" });
141
+ return;
64
142
  }
65
- };
66
- }
67
- return { ok: false, reason: maxQueue > 0 ? "queue_limit" : "concurrency_limit" };
143
+ w.timeoutId = setTimeout(() => {
144
+ if (!w.cancelled) {
145
+ w.cancelled = true;
146
+ timedOut++;
147
+ cleanupWaiter(w);
148
+ resolve({ ok: false, reason: "timeout" });
149
+ }
150
+ }, ao.timeoutMs);
151
+ }
152
+ q.push(w);
153
+ });
68
154
  };
69
- return {
70
- admit,
71
- stats: () => ({ inFlight, queued: queue.length, maxConcurrent: opts.maxConcurrent, maxQueue })
155
+ const run = async (fn, ao = {}) => {
156
+ const r = await acquire(ao);
157
+ if (!r.ok) {
158
+ throw new BulkheadRejectedError(r.reason);
159
+ }
160
+ try {
161
+ return await fn(ao.signal);
162
+ } finally {
163
+ r.token.release();
164
+ }
72
165
  };
166
+ const stats = () => ({
167
+ inFlight,
168
+ pending: pendingCount(),
169
+ maxConcurrent: opts.maxConcurrent,
170
+ maxQueue,
171
+ aborted,
172
+ timedOut,
173
+ rejected,
174
+ doubleRelease
175
+ });
176
+ return { tryAcquire, acquire, run, stats };
73
177
  }
74
178
  // Annotate the CommonJS export names for ESM import in node:
75
179
  0 && (module.exports = {
180
+ BulkheadRejectedError,
76
181
  createBulkhead
77
182
  });
78
183
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export type BulkheadOptions = {\n maxConcurrent: number;\n maxQueue?: number; // if undefined => no queue (fail fast)\n};\n\nexport type AdmitResult =\n | { ok: true; release: () => void }\n | { ok: false; reason: \"concurrency_limit\" | \"queue_limit\" };\n\nexport function createBulkhead(opts: BulkheadOptions) {\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\n let inFlight = 0;\n const queue: Array<() => void> = [];\n\n const tryStartNext = () => {\n while (inFlight < opts.maxConcurrent && queue.length > 0) {\n const start = queue.shift()!;\n inFlight++;\n start();\n }\n };\n\n const release = () => {\n inFlight = Math.max(0, inFlight - 1);\n tryStartNext();\n };\n\n const admit = (): AdmitResult => {\n if (inFlight < opts.maxConcurrent) {\n inFlight++;\n return { ok: true, release };\n }\n if (maxQueue > 0 && queue.length < maxQueue) {\n let started = false;\n const gate = () => {\n started = true;\n };\n queue.push(gate);\n\n return {\n ok: true,\n release: () => {\n // If not started yet, remove from queue and free slot reserved by queue entry.\n if (!started) {\n const idx = queue.indexOf(gate);\n if (idx >= 0) queue.splice(idx, 1);\n } else {\n release();\n }\n }\n };\n }\n return { ok: false, reason: maxQueue > 0 ? \"queue_limit\" : \"concurrency_limit\" };\n };\n\n return {\n admit,\n stats: () => ({ inFlight, queued: queue.length, maxConcurrent: opts.maxConcurrent, maxQueue })\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AASO,SAAS,eAAe,MAAuB;AACpD,MAAI,CAAC,OAAO,UAAU,KAAK,aAAa,KAAK,KAAK,iBAAiB,GAAG;AACpE,UAAM,IAAI,MAAM,0CAA0C;AAAA,EAC5D;AACA,QAAM,WAAW,KAAK,YAAY;AAElC,MAAI,WAAW;AACf,QAAM,QAA2B,CAAC;AAElC,QAAM,eAAe,MAAM;AACzB,WAAO,WAAW,KAAK,iBAAiB,MAAM,SAAS,GAAG;AACxD,YAAM,QAAQ,MAAM,MAAM;AAC1B;AACA,YAAM;AAAA,IACR;AAAA,EACF;AAEA,QAAM,UAAU,MAAM;AACpB,eAAW,KAAK,IAAI,GAAG,WAAW,CAAC;AACnC,iBAAa;AAAA,EACf;AAEA,QAAM,QAAQ,MAAmB;AAC/B,QAAI,WAAW,KAAK,eAAe;AACjC;AACA,aAAO,EAAE,IAAI,MAAM,QAAQ;AAAA,IAC7B;AACA,QAAI,WAAW,KAAK,MAAM,SAAS,UAAU;AAC3C,UAAI,UAAU;AACd,YAAM,OAAO,MAAM;AACjB,kBAAU;AAAA,MACZ;AACA,YAAM,KAAK,IAAI;AAEf,aAAO;AAAA,QACL,IAAI;AAAA,QACJ,SAAS,MAAM;AAEb,cAAI,CAAC,SAAS;AACZ,kBAAM,MAAM,MAAM,QAAQ,IAAI;AAC9B,gBAAI,OAAO,EAAG,OAAM,OAAO,KAAK,CAAC;AAAA,UACnC,OAAO;AACL,oBAAQ;AAAA,UACV;AAAA,QACF;AAAA,MACF;AAAA,IACF;AACA,WAAO,EAAE,IAAI,OAAO,QAAQ,WAAW,IAAI,gBAAgB,oBAAoB;AAAA,EACjF;AAEA,SAAO;AAAA,IACL;AAAA,IACA,OAAO,OAAO,EAAE,UAAU,QAAQ,MAAM,QAAQ,eAAe,KAAK,eAAe,SAAS;AAAA,EAC9F;AACF;","names":[]}
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":[]}
package/dist/index.d.cts CHANGED
@@ -2,21 +2,48 @@ type BulkheadOptions = {
2
2
  maxConcurrent: number;
3
3
  maxQueue?: number;
4
4
  };
5
- type AdmitResult = {
5
+ type AcquireOptions = {
6
+ signal?: AbortSignal;
7
+ timeoutMs?: number;
8
+ };
9
+ type Stats = {
10
+ inFlight: number;
11
+ pending: number;
12
+ maxConcurrent: number;
13
+ maxQueue: number;
14
+ aborted?: number;
15
+ timedOut?: number;
16
+ rejected?: number;
17
+ doubleRelease?: number;
18
+ };
19
+ type Token = {
20
+ release(): void;
21
+ };
22
+ type TryAcquireResult = {
23
+ ok: true;
24
+ token: Token;
25
+ } | {
26
+ ok: false;
27
+ reason: 'concurrency_limit' | 'queue_limit';
28
+ };
29
+ type AcquireResult = {
6
30
  ok: true;
7
- release: () => void;
31
+ token: Token;
8
32
  } | {
9
33
  ok: false;
10
- reason: "concurrency_limit" | "queue_limit";
34
+ reason: 'concurrency_limit' | 'queue_limit' | 'timeout' | 'aborted';
11
35
  };
36
+ type RejectReason = 'concurrency_limit' | 'queue_limit' | 'timeout' | 'aborted';
37
+ declare class BulkheadRejectedError extends Error {
38
+ readonly reason: RejectReason;
39
+ readonly code: "BULKHEAD_REJECTED";
40
+ constructor(reason: RejectReason);
41
+ }
12
42
  declare function createBulkhead(opts: BulkheadOptions): {
13
- admit: () => AdmitResult;
14
- stats: () => {
15
- inFlight: number;
16
- queued: number;
17
- maxConcurrent: number;
18
- maxQueue: number;
19
- };
43
+ tryAcquire: () => TryAcquireResult;
44
+ acquire: (ao?: AcquireOptions) => Promise<AcquireResult>;
45
+ run: <T>(fn: (signal?: AbortSignal) => Promise<T>, ao?: AcquireOptions) => Promise<T>;
46
+ stats: () => Stats;
20
47
  };
21
48
 
22
- export { type AdmitResult, type BulkheadOptions, createBulkhead };
49
+ export { type AcquireOptions, type AcquireResult, type BulkheadOptions, BulkheadRejectedError, type RejectReason, type Stats, type Token, type TryAcquireResult, createBulkhead };
package/dist/index.d.ts CHANGED
@@ -2,21 +2,48 @@ type BulkheadOptions = {
2
2
  maxConcurrent: number;
3
3
  maxQueue?: number;
4
4
  };
5
- type AdmitResult = {
5
+ type AcquireOptions = {
6
+ signal?: AbortSignal;
7
+ timeoutMs?: number;
8
+ };
9
+ type Stats = {
10
+ inFlight: number;
11
+ pending: number;
12
+ maxConcurrent: number;
13
+ maxQueue: number;
14
+ aborted?: number;
15
+ timedOut?: number;
16
+ rejected?: number;
17
+ doubleRelease?: number;
18
+ };
19
+ type Token = {
20
+ release(): void;
21
+ };
22
+ type TryAcquireResult = {
23
+ ok: true;
24
+ token: Token;
25
+ } | {
26
+ ok: false;
27
+ reason: 'concurrency_limit' | 'queue_limit';
28
+ };
29
+ type AcquireResult = {
6
30
  ok: true;
7
- release: () => void;
31
+ token: Token;
8
32
  } | {
9
33
  ok: false;
10
- reason: "concurrency_limit" | "queue_limit";
34
+ reason: 'concurrency_limit' | 'queue_limit' | 'timeout' | 'aborted';
11
35
  };
36
+ type RejectReason = 'concurrency_limit' | 'queue_limit' | 'timeout' | 'aborted';
37
+ declare class BulkheadRejectedError extends Error {
38
+ readonly reason: RejectReason;
39
+ readonly code: "BULKHEAD_REJECTED";
40
+ constructor(reason: RejectReason);
41
+ }
12
42
  declare function createBulkhead(opts: BulkheadOptions): {
13
- admit: () => AdmitResult;
14
- stats: () => {
15
- inFlight: number;
16
- queued: number;
17
- maxConcurrent: number;
18
- maxQueue: number;
19
- };
43
+ tryAcquire: () => TryAcquireResult;
44
+ acquire: (ao?: AcquireOptions) => Promise<AcquireResult>;
45
+ run: <T>(fn: (signal?: AbortSignal) => Promise<T>, ao?: AcquireOptions) => Promise<T>;
46
+ stats: () => Stats;
20
47
  };
21
48
 
22
- export { type AdmitResult, type BulkheadOptions, createBulkhead };
49
+ export { type AcquireOptions, type AcquireResult, type BulkheadOptions, BulkheadRejectedError, type RejectReason, type Stats, type Token, type TryAcquireResult, createBulkhead };
package/dist/index.js CHANGED
@@ -1,53 +1,157 @@
1
1
  // src/index.ts
2
+ var BulkheadRejectedError = class extends Error {
3
+ constructor(reason) {
4
+ super(`Bulkhead rejected: ${reason}`);
5
+ this.reason = reason;
6
+ this.name = "BulkheadRejectedError";
7
+ }
8
+ code = "BULKHEAD_REJECTED";
9
+ };
2
10
  function createBulkhead(opts) {
3
11
  if (!Number.isInteger(opts.maxConcurrent) || opts.maxConcurrent <= 0) {
4
12
  throw new Error("maxConcurrent must be a positive integer");
5
13
  }
6
14
  const maxQueue = opts.maxQueue ?? 0;
15
+ if (!Number.isInteger(maxQueue) || maxQueue < 0) {
16
+ throw new Error("maxQueue must be an integer >= 0");
17
+ }
7
18
  let inFlight = 0;
8
- const queue = [];
9
- const tryStartNext = () => {
10
- while (inFlight < opts.maxConcurrent && queue.length > 0) {
11
- const start = queue.shift();
19
+ const q = [];
20
+ let qHead = 0;
21
+ let aborted = 0;
22
+ let timedOut = 0;
23
+ let rejected = 0;
24
+ let doubleRelease = 0;
25
+ const pendingCount = () => q.length - qHead;
26
+ const makeToken = () => {
27
+ let released = false;
28
+ return {
29
+ release() {
30
+ if (released) {
31
+ doubleRelease++;
32
+ return;
33
+ }
34
+ released = true;
35
+ inFlight--;
36
+ if (inFlight < 0) inFlight = 0;
37
+ drain();
38
+ }
39
+ };
40
+ };
41
+ const cleanupWaiter = (w) => {
42
+ if (w.abortListener) w.abortListener();
43
+ if (w.timeoutId) clearTimeout(w.timeoutId);
44
+ w.abortListener = void 0;
45
+ w.timeoutId = void 0;
46
+ };
47
+ const drain = () => {
48
+ while (inFlight < opts.maxConcurrent && pendingCount() > 0) {
49
+ const w = q[qHead++];
50
+ if (w.cancelled) {
51
+ cleanupWaiter(w);
52
+ continue;
53
+ }
12
54
  inFlight++;
13
- start();
55
+ cleanupWaiter(w);
56
+ w.resolve({ ok: true, token: makeToken() });
57
+ }
58
+ if (qHead > 1024 && qHead * 2 > q.length) {
59
+ q.splice(0, qHead);
60
+ qHead = 0;
14
61
  }
15
62
  };
16
- const release = () => {
17
- inFlight = Math.max(0, inFlight - 1);
18
- tryStartNext();
63
+ const tryAcquire = () => {
64
+ if (inFlight < opts.maxConcurrent) {
65
+ inFlight++;
66
+ return { ok: true, token: makeToken() };
67
+ }
68
+ if (maxQueue > 0 && pendingCount() >= maxQueue) {
69
+ rejected++;
70
+ return { ok: false, reason: "queue_limit" };
71
+ }
72
+ rejected++;
73
+ return { ok: false, reason: maxQueue > 0 ? "queue_limit" : "concurrency_limit" };
19
74
  };
20
- const admit = () => {
75
+ const acquire = (ao = {}) => {
21
76
  if (inFlight < opts.maxConcurrent) {
22
77
  inFlight++;
23
- return { ok: true, release };
78
+ return Promise.resolve({ ok: true, token: makeToken() });
79
+ }
80
+ if (maxQueue === 0) {
81
+ rejected++;
82
+ return Promise.resolve({ ok: false, reason: "concurrency_limit" });
83
+ }
84
+ if (pendingCount() >= maxQueue) {
85
+ rejected++;
86
+ return Promise.resolve({ ok: false, reason: "queue_limit" });
24
87
  }
25
- if (maxQueue > 0 && queue.length < maxQueue) {
26
- let started = false;
27
- const gate = () => {
28
- started = true;
88
+ return new Promise((resolve) => {
89
+ const w = {
90
+ resolve,
91
+ cancelled: false,
92
+ abortListener: void 0,
93
+ timeoutId: void 0
29
94
  };
30
- queue.push(gate);
31
- return {
32
- ok: true,
33
- release: () => {
34
- if (!started) {
35
- const idx = queue.indexOf(gate);
36
- if (idx >= 0) queue.splice(idx, 1);
37
- } else {
38
- release();
95
+ if (ao.signal) {
96
+ if (ao.signal.aborted) {
97
+ aborted++;
98
+ resolve({ ok: false, reason: "aborted" });
99
+ return;
100
+ }
101
+ const onAbort = () => {
102
+ if (!w.cancelled) {
103
+ w.cancelled = true;
104
+ aborted++;
105
+ cleanupWaiter(w);
106
+ resolve({ ok: false, reason: "aborted" });
39
107
  }
108
+ };
109
+ ao.signal.addEventListener("abort", onAbort, { once: true });
110
+ w.abortListener = () => ao.signal.removeEventListener("abort", onAbort);
111
+ }
112
+ if (ao.timeoutMs != null) {
113
+ if (!Number.isFinite(ao.timeoutMs) || ao.timeoutMs < 0) {
114
+ timedOut++;
115
+ resolve({ ok: false, reason: "timeout" });
116
+ return;
40
117
  }
41
- };
42
- }
43
- return { ok: false, reason: maxQueue > 0 ? "queue_limit" : "concurrency_limit" };
118
+ w.timeoutId = setTimeout(() => {
119
+ if (!w.cancelled) {
120
+ w.cancelled = true;
121
+ timedOut++;
122
+ cleanupWaiter(w);
123
+ resolve({ ok: false, reason: "timeout" });
124
+ }
125
+ }, ao.timeoutMs);
126
+ }
127
+ q.push(w);
128
+ });
44
129
  };
45
- return {
46
- admit,
47
- stats: () => ({ inFlight, queued: queue.length, maxConcurrent: opts.maxConcurrent, maxQueue })
130
+ const run = async (fn, ao = {}) => {
131
+ const r = await acquire(ao);
132
+ if (!r.ok) {
133
+ throw new BulkheadRejectedError(r.reason);
134
+ }
135
+ try {
136
+ return await fn(ao.signal);
137
+ } finally {
138
+ r.token.release();
139
+ }
48
140
  };
141
+ const stats = () => ({
142
+ inFlight,
143
+ pending: pendingCount(),
144
+ maxConcurrent: opts.maxConcurrent,
145
+ maxQueue,
146
+ aborted,
147
+ timedOut,
148
+ rejected,
149
+ doubleRelease
150
+ });
151
+ return { tryAcquire, acquire, run, stats };
49
152
  }
50
153
  export {
154
+ BulkheadRejectedError,
51
155
  createBulkhead
52
156
  };
53
157
  //# sourceMappingURL=index.js.map
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; // if undefined => no queue (fail fast)\n};\n\nexport type AdmitResult =\n | { ok: true; release: () => void }\n | { ok: false; reason: \"concurrency_limit\" | \"queue_limit\" };\n\nexport function createBulkhead(opts: BulkheadOptions) {\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\n let inFlight = 0;\n const queue: Array<() => void> = [];\n\n const tryStartNext = () => {\n while (inFlight < opts.maxConcurrent && queue.length > 0) {\n const start = queue.shift()!;\n inFlight++;\n start();\n }\n };\n\n const release = () => {\n inFlight = Math.max(0, inFlight - 1);\n tryStartNext();\n };\n\n const admit = (): AdmitResult => {\n if (inFlight < opts.maxConcurrent) {\n inFlight++;\n return { ok: true, release };\n }\n if (maxQueue > 0 && queue.length < maxQueue) {\n let started = false;\n const gate = () => {\n started = true;\n };\n queue.push(gate);\n\n return {\n ok: true,\n release: () => {\n // If not started yet, remove from queue and free slot reserved by queue entry.\n if (!started) {\n const idx = queue.indexOf(gate);\n if (idx >= 0) queue.splice(idx, 1);\n } else {\n release();\n }\n }\n };\n }\n return { ok: false, reason: maxQueue > 0 ? \"queue_limit\" : \"concurrency_limit\" };\n };\n\n return {\n admit,\n stats: () => ({ inFlight, queued: queue.length, maxConcurrent: opts.maxConcurrent, maxQueue })\n };\n}\n"],"mappings":";AASO,SAAS,eAAe,MAAuB;AACpD,MAAI,CAAC,OAAO,UAAU,KAAK,aAAa,KAAK,KAAK,iBAAiB,GAAG;AACpE,UAAM,IAAI,MAAM,0CAA0C;AAAA,EAC5D;AACA,QAAM,WAAW,KAAK,YAAY;AAElC,MAAI,WAAW;AACf,QAAM,QAA2B,CAAC;AAElC,QAAM,eAAe,MAAM;AACzB,WAAO,WAAW,KAAK,iBAAiB,MAAM,SAAS,GAAG;AACxD,YAAM,QAAQ,MAAM,MAAM;AAC1B;AACA,YAAM;AAAA,IACR;AAAA,EACF;AAEA,QAAM,UAAU,MAAM;AACpB,eAAW,KAAK,IAAI,GAAG,WAAW,CAAC;AACnC,iBAAa;AAAA,EACf;AAEA,QAAM,QAAQ,MAAmB;AAC/B,QAAI,WAAW,KAAK,eAAe;AACjC;AACA,aAAO,EAAE,IAAI,MAAM,QAAQ;AAAA,IAC7B;AACA,QAAI,WAAW,KAAK,MAAM,SAAS,UAAU;AAC3C,UAAI,UAAU;AACd,YAAM,OAAO,MAAM;AACjB,kBAAU;AAAA,MACZ;AACA,YAAM,KAAK,IAAI;AAEf,aAAO;AAAA,QACL,IAAI;AAAA,QACJ,SAAS,MAAM;AAEb,cAAI,CAAC,SAAS;AACZ,kBAAM,MAAM,MAAM,QAAQ,IAAI;AAC9B,gBAAI,OAAO,EAAG,OAAM,OAAO,KAAK,CAAC;AAAA,UACnC,OAAO;AACL,oBAAQ;AAAA,UACV;AAAA,QACF;AAAA,MACF;AAAA,IACF;AACA,WAAO,EAAE,IAAI,OAAO,QAAQ,WAAW,IAAI,gBAAgB,oBAAoB;AAAA,EACjF;AAEA,SAAO;AAAA,IACL;AAAA,IACA,OAAO,OAAO,EAAE,UAAU,QAAQ,MAAM,QAAQ,eAAe,KAAK,eAAe,SAAS;AAAA,EAC9F;AACF;","names":[]}
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":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "async-bulkhead-ts",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Fail-fast admission control + bulkheads for async workloads",
5
5
  "license": "MIT",
6
6
  "type": "module",