bubus 1.7.3 β 1.8.1
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 +342 -268
- package/dist/esm/index.js +1239 -881
- package/dist/esm/index.js.map +4 -4
- package/dist/types/async_context.d.ts +3 -1
- package/dist/types/base_event.d.ts +50 -19
- package/dist/types/event_bus.d.ts +24 -40
- package/dist/types/event_handler.d.ts +34 -9
- package/dist/types/event_result.d.ts +47 -29
- package/dist/types/index.d.ts +3 -1
- package/dist/types/lock_manager.d.ts +9 -16
- package/dist/types/logging.d.ts +1 -0
- package/dist/types/retry.d.ts +52 -0
- package/package.json +11 -4
package/README.md
CHANGED
|
@@ -1,362 +1,436 @@
|
|
|
1
|
-
# bubus
|
|
1
|
+
# `bubus`: π’ Production-ready multi-language event bus
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
gotchas we uncovered while matching behavior. It intentionally does **not** re-document the full TS API surface.
|
|
3
|
+
<img width="200" alt="image" src="https://github.com/user-attachments/assets/b3525c24-51ba-496c-b327-ccdfe46a7362" align="right" />
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
[](https://deepwiki.com/pirate/bbus)   
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
[](https://deepwiki.com/pirate/bbus/3-typescript-implementation) 
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
- TS: use `await event.done()` for the same behavior.
|
|
12
|
-
- Outside a handler, `done()` just waits for completion (it does not jump the queue).
|
|
13
|
-
- Inside a handler, `done()` triggers immediate processing (queue jump) on **all buses** where the event is queued.
|
|
9
|
+
Bubus is an in-memory event bus library for async Python and TS (node/browser).
|
|
14
10
|
|
|
15
|
-
|
|
11
|
+
It's designed for quickly building resilient, predictable, complex event-driven apps.
|
|
16
12
|
|
|
17
|
-
|
|
18
|
-
- TS optionally uses `AsyncLocalStorage` on Node.js (auto-detected) to capture dispatch context, but falls back gracefully in browsers.
|
|
19
|
-
- `EventBus._all_instances` + the `LockManager` pause mechanism pauses each runloop and processes the same event immediately across buses.
|
|
13
|
+
It "just works" with an intuitive, but powerful event JSON format + dispatch API that's consistent across both languages and scales consistently from one event up to millions:
|
|
20
14
|
|
|
21
|
-
|
|
15
|
+
```python
|
|
16
|
+
bus.on(SomeEvent, some_function)
|
|
17
|
+
bus.emit(SomeEvent({some_data: 132}))
|
|
18
|
+
```
|
|
22
19
|
|
|
23
|
-
|
|
24
|
-
- In TS, `event.bus` is provided by a **BusScopedEvent** (a Proxy over the original event).
|
|
25
|
-
- That proxy injects a bus-bound `emit/dispatch` to ensure correct parent/child tracking.
|
|
20
|
+
It's async native, has proper automatic nested event tracking, and powerful concurrency control options. The API is inspired by `EventEmitter` or [`emittery`](https://github.com/sindresorhus/emittery) in JS, but it takes it a step further:
|
|
26
21
|
|
|
27
|
-
|
|
22
|
+
- nice Zod / Pydantic schemas for events that can be exchanged between both languages
|
|
23
|
+
- automatic UUIDv7s and monotonic nanosecond timestamps for ordering events globally
|
|
24
|
+
- built in locking options to force strict global FIFO procesing or fully parallel processing
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
- To keep FIFO tests stable, we generate strictly increasing timestamps via `BaseEvent.nextTimestamp()` (returns `{ date, isostring, ts }`).
|
|
26
|
+
---
|
|
31
27
|
|
|
32
|
-
|
|
28
|
+
βΎοΈ It's inspired by the simplicity of async and events in `JS` but with baked-in features that allow to eliminate most of the tedious repetitive complexity in event-driven codebases:
|
|
33
29
|
|
|
34
|
-
-
|
|
30
|
+
- correct timeout enforcement across multiple levels of events, if a parent times out it correctly aborts all child event processing
|
|
31
|
+
- ability to strongly type hint and enforce the return type of event handlers at compile-time
|
|
32
|
+
- ability to queue events on the bus, or inline await them for immediate execution like a normal function call
|
|
33
|
+
- handles ~5,000 events/sec/core in both languages, with ~2kb/event RAM consumed per event during active processing
|
|
35
34
|
|
|
36
|
-
|
|
35
|
+
<br/>
|
|
37
36
|
|
|
38
|
-
|
|
39
|
-
- When dispatched, `EventBus` applies its default `event_timeout` (60s unless configured).
|
|
40
|
-
- You can set `{ event_timeout: null }` on the bus to disable timeouts entirely.
|
|
41
|
-
- Slow handler warnings fire after `event_handler_slow_timeout` (default: `30s`). Slow event warnings fire after `event_slow_timeout` (default: `300s`).
|
|
37
|
+
## π’ Quickstart
|
|
42
38
|
|
|
43
|
-
|
|
39
|
+
```bash
|
|
40
|
+
pnpm add bubus
|
|
41
|
+
```
|
|
44
42
|
|
|
45
|
-
|
|
43
|
+
```ts
|
|
44
|
+
import { BaseEvent, EventBus } from 'bubus'
|
|
45
|
+
import { z } from 'zod'
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
47
|
+
const CreateUserEvent = BaseEvent.extend('CreateUserEvent', {
|
|
48
|
+
email: z.string(),
|
|
49
|
+
event_result_schema: z.object({ user_id: z.string() }),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const bus = new EventBus('MyAuthEventBus')
|
|
53
|
+
|
|
54
|
+
bus.on(CreateUserEvent, async (event) => {
|
|
55
|
+
const user = await yourCreateUserLogic(event.email)
|
|
56
|
+
return { user_id: user.id }
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const event = bus.emit(CreateUserEvent({ email: 'someuser@example.com' }))
|
|
60
|
+
await event.done()
|
|
61
|
+
console.log(event.first_result) // { user_id: 'some-user-uuid' }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
<br/>
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
<br/>
|
|
69
|
+
|
|
70
|
+
## β¨ Features
|
|
68
71
|
|
|
69
|
-
|
|
72
|
+
The features offered in TS are broadly similar to the ones offered in the python library.
|
|
70
73
|
|
|
71
|
-
|
|
74
|
+
- Typed events with Zod schemas (cross-compatible with Pydantic events from python library)
|
|
75
|
+
- FIFO event queueing with configurable concurrency
|
|
76
|
+
- Nested event support with automatic parent/child tracking
|
|
77
|
+
- Cross-bus forwarding with loop prevention
|
|
78
|
+
- Handler result tracking + validation + timeout enforcement
|
|
79
|
+
- History retention controls (`max_history_size`) for memory bounds
|
|
80
|
+
- Optional `@retry` decorator for easy management of per-handler retries, timeouts, and semaphore-limited execution
|
|
81
|
+
|
|
82
|
+
See the [Python README](../README.md) for more details.
|
|
83
|
+
|
|
84
|
+
<br/>
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
<br/>
|
|
89
|
+
|
|
90
|
+
## π API Documentation
|
|
91
|
+
|
|
92
|
+
### `EventBus`
|
|
93
|
+
|
|
94
|
+
Create a bus:
|
|
72
95
|
|
|
73
96
|
```ts
|
|
74
|
-
const
|
|
75
|
-
|
|
97
|
+
const bus = new EventBus('MyBus', {
|
|
98
|
+
max_history_size: 100, // keep small, copy events to external store manually if you want to persist/query long-term logs
|
|
99
|
+
event_concurrency: 'bus-serial', // 'global-serial' | 'bus-serial' (default) | 'parallel'
|
|
100
|
+
event_handler_concurrency: 'serial', // 'serial' (default) | 'parallel'
|
|
101
|
+
event_handler_completion: 'all', // 'all' (default) | 'first' (stop handlers after the first non-undefined result from any handler)
|
|
102
|
+
event_timeout: 60, // default hard timeout for event handlers before they are marked result.status = 'error' w/ result.error = HandlerTimeoutError(...)
|
|
103
|
+
event_handler_slow_timeout: 30, // default timeout before a console.warn("Slow event handler bus.on(SomeEvent, someHandler()) has taken more than 30s"
|
|
104
|
+
event_slow_timeout: 300, // default timeout before a console.warn("Slow event processing: bus.on(SomeEvent, ...4 handlers) have taken more than 300s"
|
|
76
105
|
})
|
|
106
|
+
```
|
|
77
107
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
108
|
+
Core methods:
|
|
109
|
+
|
|
110
|
+
- `bus.emit(event)` aka `bus.dispatch(event)`
|
|
111
|
+
- `bus.on(eventKey, handler, options?)`
|
|
112
|
+
- `bus.off(eventKey, handler)`
|
|
113
|
+
- `bus.find(eventKey, options?)`
|
|
114
|
+
- `bus.waitUntilIdle()`
|
|
115
|
+
- `bus.destroy()`
|
|
116
|
+
|
|
117
|
+
Notes:
|
|
118
|
+
|
|
119
|
+
- String matching of event types using `bus.on('SomeEvent', ...)` and `bus.on('*', ...)` wildcard matching is supported
|
|
120
|
+
- Prefer passing event class to (`bus.on(MyEvent, handler)`) over string-based maching for strictest type inference
|
|
121
|
+
|
|
122
|
+
### `BaseEvent`
|
|
123
|
+
|
|
124
|
+
Define typed events:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
const MyEvent = BaseEvent.extend('MyEvent', {
|
|
128
|
+
some_key: z.string(),
|
|
129
|
+
some_other_key: z.number(),
|
|
130
|
+
// ...
|
|
131
|
+
// any other payload fields you want to include can go here
|
|
132
|
+
|
|
133
|
+
// fields that start with event_* are reserved for metadata used by the library
|
|
134
|
+
event_result_schema: z.string().optional(),
|
|
135
|
+
event_timeout: 60,
|
|
136
|
+
// ...
|
|
83
137
|
})
|
|
84
138
|
|
|
85
|
-
|
|
86
|
-
|
|
139
|
+
const pending_event: MyEvent = MyEvent({ some_key: 'abc', some_other_key: 234 })
|
|
140
|
+
const queued_event: MyEvent = bus.emit(pending_event)
|
|
141
|
+
const completed_event: MyEvent = queued_event.done()
|
|
87
142
|
```
|
|
88
143
|
|
|
89
|
-
|
|
144
|
+
Special fields that change how the event is processed:
|
|
145
|
+
|
|
146
|
+
- `event_result_schema` defines the type to enforce for handler return values
|
|
147
|
+
- `event_concurrency`, `event_handler_concurrency`, `event_handler_completion`
|
|
148
|
+
- `event_timeout`, `event_handler_timeout`, `event_handler_slow_timeout`
|
|
149
|
+
|
|
150
|
+
Common methods:
|
|
151
|
+
|
|
152
|
+
- `await event.done()`
|
|
153
|
+
- `await event.first()`
|
|
154
|
+
- `event.toJSON()` (serialization format is compatible with python library)
|
|
155
|
+
- `event.fromJSON()`
|
|
156
|
+
|
|
157
|
+
#### `done()`
|
|
158
|
+
|
|
159
|
+
- Runs the event with completion mode `'all'` and waits for all handlers/buses to finish.
|
|
160
|
+
- Returns the same event instance in completed state so you can inspect `event_results`, `event_errors`, etc.
|
|
161
|
+
- Want to dispatch and await an event like a function call? simply `await event.done()` and it will process immediately, skipping queued events.
|
|
162
|
+
- Want to wait for normal processing in the order it was originally queued? use `await event.waitForCompletion()`
|
|
163
|
+
|
|
164
|
+
#### `first()`
|
|
165
|
+
|
|
166
|
+
- Runs the event with completion mode `'first'`.
|
|
167
|
+
- Returns the temporally first non-`undefined` handler result (not registration order).
|
|
168
|
+
- If all handlers return `undefined` (or only error), it resolves to `undefined`.
|
|
169
|
+
- Remaining handlers are cancelled after the winning result is found.
|
|
170
|
+
|
|
171
|
+
### `EventResult`
|
|
172
|
+
|
|
173
|
+
Each handler run produces an `EventResult` stored in `event.event_results` with:
|
|
174
|
+
|
|
175
|
+
- `status`: `pending | started | completed | error`
|
|
176
|
+
- `result: EventType.event_result_schema` or `error: Error | undefined`
|
|
177
|
+
- handler metadata (`handler_id`, `handler_name`, bus metadata)
|
|
178
|
+
- `event_children` list of any sub-events that were emitted during handling
|
|
179
|
+
|
|
180
|
+
The event aggregates these via `event.event_results` and exposes the values from them via getters like `event.first_result`, `event.event_errors`, and others.
|
|
181
|
+
|
|
182
|
+
<br/>
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
<br/>
|
|
187
|
+
|
|
188
|
+
## π§΅ Advanced Concurrency Control
|
|
90
189
|
|
|
91
|
-
|
|
92
|
-
2. Handler options (`event_handler_concurrency`)
|
|
93
|
-
3. Bus defaults (`event_concurrency`, `event_handler_concurrency`)
|
|
190
|
+
### Concurrency Config Options
|
|
94
191
|
|
|
95
|
-
|
|
192
|
+
#### Bus-level config options (`new EventBus(name, {...options...})`)
|
|
96
193
|
|
|
97
|
-
|
|
194
|
+
- `max_history_size?: number | null` (default: `100`)
|
|
195
|
+
- Max completed events kept in history. `null` = unlimited. `bus.find(...)` uses this log to query recently completed events
|
|
196
|
+
- `event_concurrency?: 'global-serial' | 'bus-serial' | 'parallel' | null` (default: `'bus-serial'`)
|
|
197
|
+
- Event-level scheduling policy (`global-serial`: FIFO across all buses, `bus-serial`: FIFO per bus, `parallel`: concurrent events per bus).
|
|
198
|
+
- `event_handler_concurrency?: 'serial' | 'parallel' | null` (default: `'serial'`)
|
|
199
|
+
- Handler-level scheduling policy for each event (`serial`: one handler at a time per event, `parallel`: all handlers for the event can run concurrently).
|
|
200
|
+
- `event_handler_completion?: 'all' | 'first'` (default: `'all'`)
|
|
201
|
+
- Completion strategy (`all`: wait for all handlers, `first`: stop after first non-`undefined` result).
|
|
202
|
+
- `event_timeout?: number | null` (default: `60`)
|
|
203
|
+
- Default handler timeout budget in seconds.
|
|
204
|
+
- `event_handler_slow_timeout?: number | null` (default: `30`)
|
|
205
|
+
- Slow-handler warning threshold in seconds.
|
|
206
|
+
- `event_slow_timeout?: number | null` (default: `300`)
|
|
207
|
+
- Slow-event warning threshold in seconds.
|
|
98
208
|
|
|
99
|
-
|
|
209
|
+
#### Event-level config options
|
|
210
|
+
|
|
211
|
+
Override the bus defaults on a per-event basis by using these special fields in the event:
|
|
100
212
|
|
|
101
213
|
```ts
|
|
102
|
-
|
|
214
|
+
const event = MyEvent({
|
|
215
|
+
event_concurrency: 'parallel',
|
|
103
216
|
event_handler_concurrency: 'parallel',
|
|
104
|
-
|
|
217
|
+
event_handler_completion: 'first',
|
|
218
|
+
event_timeout: 10,
|
|
219
|
+
event_handler_timeout: 3,
|
|
105
220
|
})
|
|
106
221
|
```
|
|
107
222
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
- `bus.on(EventFactoryOrClass, handler)`:
|
|
116
|
-
- Return values are type-checked against the event's `event_result_schema` (if defined).
|
|
117
|
-
- `undefined` (or no return) is always allowed.
|
|
118
|
-
- `bus.on('SomeEventName', handler)`:
|
|
119
|
-
- Return type checking is best-effort only (treated as unknown in typing).
|
|
120
|
-
- Use class/factory keys when you want compile-time return-shape enforcement.
|
|
121
|
-
- `bus.on('*', handler)`:
|
|
122
|
-
- Return type checking is intentionally loose (best-effort only), because wildcard handlers may receive many event types, including forwarded events from other buses.
|
|
123
|
-
- In practice, wildcard handlers are expected to be side-effect/forwarding handlers and usually return `undefined`.
|
|
124
|
-
|
|
125
|
-
Runtime behavior is still consistent across all key styles:
|
|
126
|
-
|
|
127
|
-
- If an event has `event_result_schema` and a handler returns a non-`undefined` value, that value is validated at runtime.
|
|
128
|
-
- If the handler returns `undefined`, schema validation is skipped and the result is accepted.
|
|
129
|
-
|
|
130
|
-
## Throughput + Memory Behavior (Current)
|
|
131
|
-
|
|
132
|
-
This section documents the current runtime profile and the important edge cases. It is intentionally conservative:
|
|
133
|
-
we describe what is enforced today, not theoretical best-case behavior.
|
|
134
|
-
|
|
135
|
-
### Throughput model
|
|
136
|
-
|
|
137
|
-
- Baseline throughput in tests is gated at `<30s` for:
|
|
138
|
-
- `50k events within reasonable time`
|
|
139
|
-
- `50k events with ephemeral on/off handler registration across 2 buses`
|
|
140
|
-
- `500 ephemeral buses with 100 events each`
|
|
141
|
-
- The major hot-path operations are linear in collection sizes:
|
|
142
|
-
- Per event, handler matching is `O(total handlers on bus)` (`exact` scan + `*` scan).
|
|
143
|
-
- `.off()` is `O(total handlers on bus)` for matching/removal.
|
|
144
|
-
- Queue-jump (`await event.done()` inside handlers) does cross-bus discovery by walking `event_path` and iterating `EventBus._all_instances`, so cost grows with buses and forwarding depth.
|
|
145
|
-
- `waitUntilIdle()` is best used at batch boundaries, not per event:
|
|
146
|
-
- Idle checks call `isIdle()`, which scans `event_history` and handler results.
|
|
147
|
-
- There is a fast-path that skips idle scans when no idle waiters exist, which keeps normal dispatch/complete flows fast even with large history.
|
|
148
|
-
- Concurrency settings are a direct throughput limiter:
|
|
149
|
-
- `global-serial` and `bus-serial` intentionally serialize work.
|
|
150
|
-
- `parallel` increases throughput but can increase transient memory if producers outpace consumers.
|
|
151
|
-
|
|
152
|
-
### Memory model
|
|
153
|
-
|
|
154
|
-
- Per bus, strong references are held for:
|
|
155
|
-
- `handlers`
|
|
156
|
-
- `pending_event_queue`
|
|
157
|
-
- `in_flight_event_ids`
|
|
158
|
-
- `event_history` (bounded by `max_history_size`, or unbounded if `null`)
|
|
159
|
-
- active `find()` waiters until match/timeout
|
|
160
|
-
- Per event, retained state includes:
|
|
161
|
-
- `event_results` (per-handler result objects)
|
|
162
|
-
- descendant links in `event_results[].event_children`
|
|
163
|
-
- History trimming behavior:
|
|
164
|
-
- Completed events are evicted first (oldest first).
|
|
165
|
-
- If still over limit, oldest remaining events are dropped even if pending, and a warning is logged.
|
|
166
|
-
- Eviction calls `event._gc()` to clear internal references (`event_results`, child arrays, bus/context pointers).
|
|
167
|
-
- Memory is not strictly bounded by only `pending_queue_size + max_history_size`:
|
|
168
|
-
- A retained parent event can hold references to many children/grandchildren via `event_children`.
|
|
169
|
-
- So effective retained memory can exceed a simple `event_count * avg_event_size` bound in high fan-out trees.
|
|
170
|
-
- `destroy()` is recommended for deterministic cleanup, but not required for GC safety:
|
|
171
|
-
- `_all_instances` is WeakRef-based, so unreferenced buses can be collected without calling `.destroy()`.
|
|
172
|
-
- There is a GC regression test for this (`unreferenced buses with event history are garbage collected without destroy()`).
|
|
173
|
-
- `heapUsed` vs `rss`:
|
|
174
|
-
- `heapUsed` returning near baseline after GC is the primary leak signal in tests.
|
|
175
|
-
- `rss` can stay elevated due to V8 allocator high-water behavior and is not, by itself, a proof of leak.
|
|
176
|
-
|
|
177
|
-
### Practical guidance for high-load deployments
|
|
178
|
-
|
|
179
|
-
- Keep `max_history_size` finite in production.
|
|
180
|
-
- Avoid very large wildcard handler sets on hot event types.
|
|
181
|
-
- Avoid calling `waitUntilIdle()` for every single event in large streams; prefer periodic/batch waits.
|
|
182
|
-
- Be aware that very deep/high-fan-out parent-child graphs increase retained memory until parent events are evicted.
|
|
183
|
-
- Use `.destroy()` for explicit lifecycle control in request-scoped or short-lived bus patterns.
|
|
223
|
+
Notes:
|
|
224
|
+
|
|
225
|
+
- `null` means "inherit/fall back to bus default" for event-level concurrency and timeout fields.
|
|
226
|
+
- Forwarded events are processed under the target bus's config; source bus config is not inherited.
|
|
227
|
+
- `event_handler_completion` is independent from handler scheduling mode (`serial` vs `parallel`).
|
|
228
|
+
|
|
229
|
+
#### Handler-level config options
|
|
184
230
|
|
|
185
|
-
|
|
231
|
+
Set at registration:
|
|
186
232
|
|
|
187
|
-
|
|
233
|
+
```ts
|
|
234
|
+
bus.on(MyEvent, handler, { handler_timeout: 2 }) // max time in seconds this handler is allowed to run before it's aborted
|
|
235
|
+
```
|
|
188
236
|
|
|
189
|
-
|
|
190
|
-
- `LockManager.global_handler_semaphore`
|
|
191
|
-
- `bus.locks.bus_event_semaphore`
|
|
192
|
-
- `bus.locks.bus_handler_semaphore`
|
|
237
|
+
#### Precedence and interaction
|
|
193
238
|
|
|
194
|
-
|
|
195
|
-
mutex checks throughout the code.
|
|
239
|
+
Event and handler concurrency precedence:
|
|
196
240
|
|
|
197
|
-
|
|
241
|
+
1. Event instance override (`event.event_concurrency`, `event.event_handler_concurrency`)
|
|
242
|
+
2. Bus defaults (`EventBus` options)
|
|
243
|
+
3. Built-in defaults (`bus-serial`, `serial`)
|
|
198
244
|
|
|
199
|
-
|
|
200
|
-
under different `event_concurrency` / `event_handler_concurrency` configurations.
|
|
245
|
+
Timeout resolution for each handler run:
|
|
201
246
|
|
|
202
|
-
|
|
247
|
+
1. Resolve handler timeout source:
|
|
248
|
+
- `bus.on(..., { handler_timeout })`
|
|
249
|
+
- else `event.event_handler_timeout`
|
|
250
|
+
- else bus `event_timeout`
|
|
251
|
+
2. Apply event cap:
|
|
252
|
+
- effective timeout is `min(resolved_handler_timeout, event.event_timeout)` when both are non-null
|
|
253
|
+
- if either is `null`, the non-null value wins; both null means no timeout
|
|
203
254
|
|
|
204
|
-
|
|
255
|
+
Additional timeout nuance:
|
|
205
256
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
3. Applies `event_timeout_default` if `event.event_timeout === null`.
|
|
209
|
-
4. If this bus is already in `event_path` (or `bus.hasProcessedEvent()`), return a BusScopedEvent without queueing.
|
|
210
|
-
5. Append bus name to `event_path`, record child relationship (if `event_parent_id` is set).
|
|
211
|
-
6. Add to `event_history` (a `Map<string, BaseEvent>` keyed by event id).
|
|
212
|
-
7. Increment `event_pending_bus_count`.
|
|
213
|
-
8. Push to `pending_event_queue` and `startRunloop()`.
|
|
257
|
+
- `BaseEvent.event_timeout` starts as `null` unless set; dispatch applies bus default timeout when still unset.
|
|
258
|
+
- Bus/event timeouts are outer budgets for handler execution; use `@retry({ timeout })` for per-attempt timeouts.
|
|
214
259
|
|
|
215
|
-
|
|
260
|
+
Use `@retry` for per-handler execution timeout/retry/backoff/semaphore control. Keep bus/event timeouts as outer execution budgets.
|
|
216
261
|
|
|
217
|
-
|
|
218
|
-
2. Adds event id to `in_flight_event_ids`.
|
|
219
|
-
3. Calls `scheduleEventProcessing()` (async).
|
|
220
|
-
4. `scheduleEventProcessing()` selects the event semaphore and runs `processEvent()`.
|
|
221
|
-
5. `processEvent()`:
|
|
222
|
-
- `event.markStarted()`
|
|
223
|
-
- `notifyFindListeners(event)`
|
|
224
|
-
- creates handler results (`event_results`)
|
|
225
|
-
- runs handlers (respecting handler semaphore)
|
|
226
|
-
- decrements `event_pending_bus_count` and calls `event.markCompleted(false)` (completes only if all buses and children are done)
|
|
262
|
+
### Runtime lifecycle (bus -> event -> handler)
|
|
227
263
|
|
|
228
|
-
|
|
264
|
+
Dispatch flow:
|
|
229
265
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
266
|
+
1. `dispatch()` normalizes to original event and captures async context when available.
|
|
267
|
+
2. Bus applies defaults and appends itself to `event_path`.
|
|
268
|
+
3. Event enters `event_history`, `pending_event_queue`, and runloop starts.
|
|
269
|
+
4. Runloop dequeues and calls `processEvent()`.
|
|
270
|
+
5. Event-level semaphore (`event_concurrency`) is applied.
|
|
271
|
+
6. Handler results are created and executed under handler-level semaphore (`event_handler_concurrency`).
|
|
272
|
+
7. Event completion and child completion propagate through `event_pending_bus_count` and result states.
|
|
273
|
+
8. History trimming evicts completed events first; if still over limit, oldest pending events can be dropped (with warning), then cleanup runs.
|
|
234
274
|
|
|
235
|
-
|
|
275
|
+
Locking model:
|
|
236
276
|
|
|
237
|
-
|
|
277
|
+
- Global event semaphore: `global-serial`
|
|
278
|
+
- Bus event semaphore: `bus-serial`
|
|
279
|
+
- Per-event handler semaphore: `serial` handler mode
|
|
238
280
|
|
|
239
|
-
`
|
|
281
|
+
### Queue-jumping (`await event.done()` inside handlers)
|
|
240
282
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
- **`parallel`**: handlers run concurrently for the event.
|
|
244
|
-
- **`auto`**: resolves to the bus default.
|
|
283
|
+
Want to dispatch and await an event like a function call? simply `await event.done()`.
|
|
284
|
+
When called inside a handler, the awaited event is processed immediately (queue-jump behavior) before normal queued work continues.
|
|
245
285
|
|
|
246
|
-
|
|
247
|
-
Even if events are parallel, handlers can still be serialized:
|
|
248
|
-
`event_concurrency: "parallel"` + `event_handler_concurrency: "bus-serial"` means events start concurrently but handler execution on a bus is serialized.
|
|
286
|
+
### `@retry` Decorator
|
|
249
287
|
|
|
250
|
-
|
|
288
|
+
`retry()` adds retry logic and optional semaphore-based concurrency limiting to async functions/handlers.
|
|
251
289
|
|
|
252
|
-
|
|
290
|
+
#### Why retry is handler-level
|
|
253
291
|
|
|
254
|
-
|
|
255
|
-
- Bus B queues and processes the event according to **Bus Bβs** concurrency settings.
|
|
256
|
-
- No coupling unless both buses use the global semaphores.
|
|
292
|
+
Retry and timeout belong on handlers, not emit sites:
|
|
257
293
|
|
|
258
|
-
|
|
294
|
+
- Handlers fail; events are messages.
|
|
295
|
+
- Handler-level retries preserve replay semantics (one event dispatch, internal retry attempts).
|
|
296
|
+
- Bus concurrency and retry concerns are orthogonal and compose cleanly.
|
|
259
297
|
|
|
260
|
-
|
|
298
|
+
#### Recommended pattern: `@retry()` on class methods
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
import { retry, EventBus } from 'bubus'
|
|
302
|
+
|
|
303
|
+
class ScreenshotService {
|
|
304
|
+
constructor(private bus: InstanceType<typeof EventBus>) {
|
|
305
|
+
bus.on(ScreenshotRequestEvent, this.onScreenshot.bind(this))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
@retry({
|
|
309
|
+
max_attempts: 4,
|
|
310
|
+
retry_on_errors: [/timeout/i],
|
|
311
|
+
timeout: 5,
|
|
312
|
+
semaphore_scope: 'global',
|
|
313
|
+
semaphore_name: 'Screenshots',
|
|
314
|
+
semaphore_limit: 2,
|
|
315
|
+
})
|
|
316
|
+
async onScreenshot(event: InstanceType<typeof ScreenshotRequestEvent>): Promise<Buffer> {
|
|
317
|
+
return await takeScreenshot(event.data.url)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const ev = bus.emit(ScreenshotRequestEvent({ url: 'https://example.com' }))
|
|
322
|
+
await ev.done()
|
|
323
|
+
```
|
|
261
324
|
|
|
262
|
-
|
|
263
|
-
(via `getActiveHandlerResult()` / `getParentEventResultAcrossAllBusses()`). If not inside a handler, it falls back to `waitForCompletion()`.
|
|
264
|
-
2. `processEventImmediately()` **yields** the parent handler's concurrency semaphore (if held) so child handlers can acquire it.
|
|
265
|
-
3. `processEventImmediately()` removes the event from the pending queue (if present).
|
|
266
|
-
4. `runImmediatelyAcrossBuses()` processes the event immediately on all buses where it is queued.
|
|
267
|
-
5. While immediate processing is active, each affected bus's runloop is paused to prevent unrelated events from running.
|
|
268
|
-
6. Once immediate processing completes, `processEventImmediately()` **re-acquires** the parent handler's semaphore
|
|
269
|
-
(unless the parent timed out while the child was processing).
|
|
270
|
-
7. Paused runloops resume.
|
|
325
|
+
#### Also works: inline HOF
|
|
271
326
|
|
|
272
|
-
|
|
273
|
-
|
|
327
|
+
```ts
|
|
328
|
+
bus.on(
|
|
329
|
+
MyEvent,
|
|
330
|
+
retry({ max_attempts: 3, timeout: 10 })(async (event) => {
|
|
331
|
+
await riskyOperation(event.data)
|
|
332
|
+
})
|
|
333
|
+
)
|
|
334
|
+
```
|
|
274
335
|
|
|
275
|
-
|
|
336
|
+
#### Options
|
|
276
337
|
|
|
277
|
-
|
|
338
|
+
| Option | Type | Default | Description |
|
|
339
|
+
| ---------------------- | ----------------------------------------- | ----------- | ----------------------------------------------- |
|
|
340
|
+
| `max_attempts` | `number` | `1` | Total attempts including first call. |
|
|
341
|
+
| `retry_after` | `number` | `0` | Seconds between retries. |
|
|
342
|
+
| `retry_backoff_factor` | `number` | `1.0` | Multiplier for retry delay. |
|
|
343
|
+
| `retry_on_errors` | `(ErrorClass \| string \| RegExp)[]` | `undefined` | Retry filter. `undefined` retries on any error. |
|
|
344
|
+
| `timeout` | `number \| null` | `undefined` | Per-attempt timeout in seconds. |
|
|
345
|
+
| `semaphore_limit` | `number \| null` | `undefined` | Max concurrent executions sharing semaphore. |
|
|
346
|
+
| `semaphore_name` | `string \| ((...args) => string) \| null` | fn name | Semaphore key. |
|
|
347
|
+
| `semaphore_lax` | `boolean` | `true` | Continue if semaphore acquisition times out. |
|
|
348
|
+
| `semaphore_scope` | `'global' \| 'class' \| 'instance'` | `'global'` | Scope for semaphore identity. |
|
|
349
|
+
| `semaphore_timeout` | `number \| null` | `undefined` | Max seconds waiting for semaphore. |
|
|
278
350
|
|
|
279
|
-
|
|
280
|
-
2. Handler options (`event_handler_concurrency`)
|
|
281
|
-
3. Bus defaults
|
|
351
|
+
#### Error types
|
|
282
352
|
|
|
283
|
-
`
|
|
353
|
+
- `RetryTimeoutError`: per-attempt timeout exceeded.
|
|
354
|
+
- `SemaphoreTimeoutError`: semaphore acquisition timeout (`semaphore_lax=false`).
|
|
284
355
|
|
|
285
|
-
|
|
356
|
+
#### Re-entrancy
|
|
286
357
|
|
|
287
|
-
|
|
358
|
+
On Node.js/Bun, `AsyncLocalStorage` tracks held semaphores and avoids deadlocks for nested calls using the same semaphore.
|
|
359
|
+
In browsers, this tracking is unavailable, avoid recursive/nested same-semaphore patterns there.
|
|
288
360
|
|
|
289
|
-
|
|
361
|
+
#### Interaction with bus concurrency
|
|
290
362
|
|
|
291
|
-
|
|
292
|
-
- `event_emitted_by_handler_id`
|
|
293
|
-
- and to attach child events under the correct handler in the tree.
|
|
363
|
+
Execution order when used on bus handlers:
|
|
294
364
|
|
|
295
|
-
|
|
296
|
-
|
|
365
|
+
1. Bus acquires handler semaphore (`event_handler_concurrency`)
|
|
366
|
+
2. `retry()` acquires retry semaphore (if configured)
|
|
367
|
+
3. Handler executes (with retries)
|
|
368
|
+
4. `retry()` releases retry semaphore
|
|
369
|
+
5. Bus releases handler semaphore
|
|
297
370
|
|
|
298
|
-
|
|
371
|
+
Use bus/event timeouts for outer deadlines and `retry({ timeout })` for per-handler-attempt deadlines.
|
|
299
372
|
|
|
300
|
-
|
|
301
|
-
it could process unrelated events ("overshoot"), breaking FIFO guarantees.
|
|
373
|
+
#### Discouraged: retrying emit sites
|
|
302
374
|
|
|
303
|
-
|
|
304
|
-
|
|
375
|
+
Avoid wrapping `emit()/done()` in `retry()` unless you intentionally want multiple event dispatches (a new event for every retry).
|
|
376
|
+
Keep retries on handlers so that your logs represent the original high-level intent, with a single event per call even if handling it took multiple tries.
|
|
377
|
+
Emitting a new event for each retry is only recommended if you are using the logs for debugging more than for replayability / time-travel.
|
|
305
378
|
|
|
306
|
-
|
|
379
|
+
<br/>
|
|
307
380
|
|
|
308
|
-
|
|
309
|
-
can mutate `event.bus` mid-handler and break parent-child tracking.
|
|
381
|
+
---
|
|
310
382
|
|
|
311
|
-
|
|
383
|
+
<br/>
|
|
312
384
|
|
|
313
|
-
|
|
314
|
-
- Its `bus` property is a proxy over the real `EventBus`.
|
|
315
|
-
- That proxy intercepts `emit/dispatch` to set `event_parent_id` and attach children to the correct handler.
|
|
316
|
-
- The original event object is still the canonical one stored in history.
|
|
385
|
+
## π Runtimes
|
|
317
386
|
|
|
318
|
-
|
|
387
|
+
`bubus-ts` supports all major JS runtimes.
|
|
319
388
|
|
|
320
|
-
|
|
389
|
+
- Node.js (default development and test runtime)
|
|
390
|
+
- Browsers (ESM)
|
|
391
|
+
- Bun
|
|
392
|
+
- Deno
|
|
321
393
|
|
|
322
|
-
|
|
323
|
-
- pauses their runloops
|
|
324
|
-
- processes the event immediately on each bus
|
|
325
|
-
- then resumes the runloops
|
|
394
|
+
### Browser support notes
|
|
326
395
|
|
|
327
|
-
|
|
396
|
+
- The package output is ESM (`./dist/esm`) which is supported by all browsers [released after 2018](https://caniuse.com/?search=ESM)
|
|
397
|
+
- `AsyncLocalStorage` is preserved at dispatch and used during handling when availabe (Node/Bun), otel/tracing context will work normally in those environments
|
|
328
398
|
|
|
329
|
-
###
|
|
399
|
+
### Performance comparison (local run, per-event)
|
|
330
400
|
|
|
331
|
-
|
|
332
|
-
perform the queue jump, so `done()` throws if no bus is attached.
|
|
401
|
+
Measured locally on an `Apple M4 Pro` with:
|
|
333
402
|
|
|
334
|
-
|
|
403
|
+
- `pnpm run perf:node` (`node v22.21.1`)
|
|
404
|
+
- `pnpm run perf:bun` (`bun v1.3.9`)
|
|
405
|
+
- `pnpm run perf:deno` (`deno v2.6.8`)
|
|
406
|
+
- `pnpm run perf:browser` (`chrome v145.0.7632.6`)
|
|
335
407
|
|
|
336
|
-
|
|
408
|
+
| Runtime | 1 bus x 50k events x 1 handler | 500 busses x 100 events x 1 handler | 1 bus x 1 event x 50k parallel handlers | 1 bus x 50k events x 50k one-off handlers | Worst case (N busses x N events x N handlers) |
|
|
409
|
+
| ------------------ | ------------------------------ | ----------------------------------- | --------------------------------------- | ----------------------------------------- | --------------------------------------------- |
|
|
410
|
+
| Node | `0.015ms/event`, `0.6kb/event` | `0.058ms/event`, `0.1kb/event` | `0.021ms/handler`, `189792.0kb/event` | `0.028ms/event`, `0.6kb/event` | `0.442ms/event`, `0.9kb/event` |
|
|
411
|
+
| Bun | `0.011ms/event`, `2.5kb/event` | `0.054ms/event`, `1.0kb/event` | `0.006ms/handler`, `223296.0kb/event` | `0.019ms/event`, `2.8kb/event` | `0.441ms/event`, `3.1kb/event` |
|
|
412
|
+
| Deno | `0.018ms/event`, `1.2kb/event` | `0.063ms/event`, `0.4kb/event` | `0.024ms/handler`, `156752.0kb/event` | `0.064ms/event`, `2.6kb/event` | `0.461ms/event`, `7.9kb/event` |
|
|
413
|
+
| Browser (Chromium) | `0.030ms/event` | `0.197ms/event` | `0.022ms/handler` | `0.022ms/event` | `1.566ms/event` |
|
|
337
414
|
|
|
338
|
-
|
|
339
|
-
- child event tracking
|
|
340
|
-
- forwarding
|
|
341
|
-
- await-inside-handler queue jump
|
|
415
|
+
Notes:
|
|
342
416
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
417
|
+
- `kb/event` is peak RSS delta per event during active processing (most representative of OS-visible RAM in Activity Monitor / Task Manager, with `EventBus.max_history_size=1`)
|
|
418
|
+
- In `1 bus x 1 event x 50k parallel handlers` stats are shown per-handler for clarity, `0.02ms/handler * 50k handlers ~= 1000ms` for the entire event
|
|
419
|
+
- Browser runtime does not expose memory usage easily, in practice memory performance in-browser is comparable to Node (they both use V8)
|
|
346
420
|
|
|
347
|
-
|
|
421
|
+
<br/>
|
|
348
422
|
|
|
349
|
-
|
|
423
|
+
---
|
|
350
424
|
|
|
351
|
-
|
|
352
|
-
2. `pnpm install --frozen-lockfile`
|
|
353
|
-
3. `pnpm login --registry https://registry.npmjs.org`
|
|
354
|
-
4. `pnpm run release:check`
|
|
355
|
-
5. `pnpm run release:dry-run`
|
|
356
|
-
6. `pnpm publish --access public`
|
|
425
|
+
<br/>
|
|
357
426
|
|
|
358
|
-
|
|
427
|
+
## πΎ Development
|
|
359
428
|
|
|
360
|
-
|
|
429
|
+
```bash
|
|
430
|
+
git clone https://github.com/pirate/bbus bubus && cd bubus
|
|
361
431
|
|
|
362
|
-
|
|
432
|
+
cd ./bubus-ts
|
|
433
|
+
pnpm install
|
|
434
|
+
pnpm lint
|
|
435
|
+
pnpm test
|
|
436
|
+
```
|