bubus 1.7.3

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 ADDED
@@ -0,0 +1,362 @@
1
+ # bubus-ts: Python vs JS Differences (and the tricky parts)
2
+
3
+ This README only covers the differences between the Python implementation and this TypeScript port, plus the
4
+ gotchas we uncovered while matching behavior. It intentionally does **not** re-document the full TS API surface.
5
+
6
+ ## Key Differences vs Python
7
+
8
+ ### 1) Awaiting events: `event.done()` instead of `await event`
9
+
10
+ - Python: `await event` waits for handlers and can jump the queue when awaited inside a handler.
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.
14
+
15
+ ### 2) Cross-bus queue jump (forwarding)
16
+
17
+ - Python uses a global re-entrant lock to let awaited events process immediately on every bus where they appear.
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.
20
+
21
+ ### 3) `event.bus` is a BusScopedEvent view
22
+
23
+ - In Python, `event.event_bus` is dynamic (contextvars).
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.
26
+
27
+ ### 4) Monotonic timestamps
28
+
29
+ - JS `Date.now()` is not strictly monotonic at millisecond granularity.
30
+ - To keep FIFO tests stable, we generate strictly increasing timestamps via `BaseEvent.nextTimestamp()` (returns `{ date, isostring, ts }`).
31
+
32
+ ### 5) No middleware, no WAL, no SQLite mirrors
33
+
34
+ - Those Python features were intentionally dropped for the JS version.
35
+
36
+ ### 6) Default timeouts come from the EventBus
37
+
38
+ - `BaseEvent.event_timeout` defaults to `null`.
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`).
42
+
43
+ ## EventBus Options
44
+
45
+ All options are passed to `new EventBus(name, options)`.
46
+
47
+ - `max_history_size?: number | null` (default: `100`)
48
+ - Max number of events kept in history. Set to `null` for unlimited history.
49
+ - `event_concurrency?: "global-serial" | "bus-serial" | "parallel" | "auto"` (default: `"bus-serial"`)
50
+ - Controls how many **events** can be processed at a time.
51
+ - `"global-serial"` enforces FIFO across all buses.
52
+ - `"bus-serial"` enforces FIFO per bus, allows cross-bus overlap.
53
+ - `"parallel"` allows events to process concurrently.
54
+ - `"auto"` uses the bus default (mostly useful for overrides).
55
+ - `event_handler_concurrency?: "global-serial" | "bus-serial" | "parallel" | "auto"` (default: `"bus-serial"`)
56
+ - Controls how many **handlers** run at once for each event.
57
+ - Same semantics as `event_concurrency`, but applied to handler execution.
58
+ - `event_timeout?: number | null` (default: `60`)
59
+ - Default handler timeout in seconds, applied when `event.event_timeout` is `null`.
60
+ - Set to `null` to disable timeouts globally for the bus.
61
+ - `event_handler_slow_timeout?: number | null` (default: `30`)
62
+ - Warn after this many seconds for slow handlers.
63
+ - Only warns when the handler's timeout is `null` or greater than this value.
64
+ - Set to `null` to disable slow handler warnings.
65
+ - `event_slow_timeout?: number | null` (default: `300`)
66
+ - Warn after this many seconds for slow event processing.
67
+ - Set to `null` to disable slow event warnings.
68
+
69
+ ## Concurrency Overrides and Precedence
70
+
71
+ You can override concurrency per event and per handler:
72
+
73
+ ```ts
74
+ const FastEvent = BaseEvent.extend('FastEvent', {
75
+ payload: z.string(),
76
+ })
77
+
78
+ // Per-event override (highest precedence)
79
+ const event = FastEvent({
80
+ payload: 'x',
81
+ event_concurrency: 'parallel',
82
+ event_handler_concurrency: 'parallel',
83
+ })
84
+
85
+ // Per-handler override (lower precedence)
86
+ bus.on(FastEvent, handler, { event_handler_concurrency: 'parallel' })
87
+ ```
88
+
89
+ Precedence order (highest → lowest):
90
+
91
+ 1. Event instance overrides (`event_concurrency`, `event_handler_concurrency`)
92
+ 2. Handler options (`event_handler_concurrency`)
93
+ 3. Bus defaults (`event_concurrency`, `event_handler_concurrency`)
94
+
95
+ `"auto"` resolves to the bus default.
96
+
97
+ ## Handler Options
98
+
99
+ Handlers can be configured at registration time:
100
+
101
+ ```ts
102
+ bus.on(SomeEvent, handler, {
103
+ event_handler_concurrency: 'parallel',
104
+ handler_timeout: 10, // per-handler timeout in seconds
105
+ })
106
+ ```
107
+
108
+ - `event_handler_concurrency` allows per-handler concurrency overrides.
109
+ - `handler_timeout` sets a per-handler timeout in seconds (overrides the bus default when lower).
110
+
111
+ ## TypeScript Return Type Enforcement (Edge Cases)
112
+
113
+ TypeScript can only enforce handler return types when the event type is inferable at compile time.
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.
184
+
185
+ ## Semaphores (how concurrency is enforced)
186
+
187
+ We use four semaphores:
188
+
189
+ - `LockManager.global_event_semaphore`
190
+ - `LockManager.global_handler_semaphore`
191
+ - `bus.locks.bus_event_semaphore`
192
+ - `bus.locks.bus_handler_semaphore`
193
+
194
+ They are applied centrally when scheduling events and handlers, so concurrency is controlled without scattering
195
+ mutex checks throughout the code.
196
+
197
+ ## Full lifecycle across concurrency modes
198
+
199
+ Below is the complete execution flow for nested events, including forwarding across buses, and how it behaves
200
+ under different `event_concurrency` / `event_handler_concurrency` configurations.
201
+
202
+ ### 1) Base execution flow (applies to all modes)
203
+
204
+ **Dispatch (non-awaited):**
205
+
206
+ 1. `dispatch()` normalizes to `original_event`, sets `bus` if missing.
207
+ 2. Captures `_dispatch_context` (AsyncLocalStorage if available).
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()`.
214
+
215
+ **Runloop + processing:**
216
+
217
+ 1. `runloop()` drains `pending_event_queue`.
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)
227
+
228
+ ### 2) Event concurrency modes (`event_concurrency`)
229
+
230
+ - **`global-serial`**: events are serialized across _all_ buses using `LockManager.global_event_semaphore`.
231
+ - **`bus-serial`**: events are serialized per bus; different buses can overlap.
232
+ - **`parallel`**: no event semaphore; events can run concurrently on the same bus.
233
+ - **`auto`**: resolves to the bus default.
234
+
235
+ **Mixed buses:** each bus enforces its own event mode. Forwarding to another bus does not inherit the source bus’s mode.
236
+
237
+ ### 3) Handler concurrency modes (`event_handler_concurrency`)
238
+
239
+ `event_handler_concurrency` controls how handlers run **for a single event**:
240
+
241
+ - **`global-serial`**: only one handler at a time across all buses using `LockManager.global_handler_semaphore`.
242
+ - **`bus-serial`**: handlers serialize per bus.
243
+ - **`parallel`**: handlers run concurrently for the event.
244
+ - **`auto`**: resolves to the bus default.
245
+
246
+ **Interaction with event concurrency:**
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.
249
+
250
+ ### 4) Forwarding across buses (non-awaited)
251
+
252
+ When a handler on Bus A calls `bus_b.dispatch(event)` without awaiting:
253
+
254
+ - Bus A continues running its handler.
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.
257
+
258
+ ### 5) Queue-jump (`await event.done()` inside handlers)
259
+
260
+ When `event.done()` is awaited inside a handler, **queue-jump** happens:
261
+
262
+ 1. `BaseEvent.done()` delegates to `bus.processEventImmediately()`, which detects whether we're inside a handler
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.
271
+
272
+ **Important:** queue-jump bypasses event semaphores but **respects** handler semaphores via yield-and-reacquire.
273
+ This means queue-jumped handlers run serially on a `bus-serial` bus, not in parallel.
274
+
275
+ ### 6) Precedence recap
276
+
277
+ Highest → lowest:
278
+
279
+ 1. Event instance fields (`event_concurrency`, `event_handler_concurrency`)
280
+ 2. Handler options (`event_handler_concurrency`)
281
+ 3. Bus defaults
282
+
283
+ `"auto"` always resolves to the bus default.
284
+
285
+ ## Gotchas and Design Choices (What surprised us)
286
+
287
+ ### A) Handler attribution without AsyncLocalStorage
288
+
289
+ We need to know **which handler emitted a child** to correctly assign:
290
+
291
+ - `event_parent_id`
292
+ - `event_emitted_by_handler_id`
293
+ - and to attach child events under the correct handler in the tree.
294
+
295
+ In TS we do this by injecting a **BusScopedEvent** into handlers, which captures the active handler id and
296
+ propagates it via `event_emitted_by_handler_id`. This keeps parentage deterministic even with nested awaits.
297
+
298
+ ### B) Why runloop pausing exists
299
+
300
+ When an event is awaited inside a handler, the event must **jump the queue**. If the runloop continues normally,
301
+ it could process unrelated events ("overshoot"), breaking FIFO guarantees.
302
+
303
+ The `LockManager` pause mechanism (`requestPause`/`waitUntilRunloopResumed`) pauses the runloop while we run the awaited
304
+ event immediately. Once the queue-jump completes, the runloop resumes in FIFO order. This matches the Python behavior.
305
+
306
+ ### C) BusScopedEvent: why it exists and how it works
307
+
308
+ Forwarding exposes a subtle bug: if you pass the **same event object** to another bus, a naive implementation
309
+ can mutate `event.bus` mid-handler and break parent-child tracking.
310
+
311
+ To prevent that:
312
+
313
+ - Handlers always receive a **BusScopedEvent** (Proxy of the original event).
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.
317
+
318
+ ### D) Cross-bus immediate processing (forwarding + awaiting)
319
+
320
+ When you `await event.done()` inside a handler:
321
+
322
+ - the system finds all buses that have this event queued (using `EventBus._all_instances` + `event_path`)
323
+ - pauses their runloops
324
+ - processes the event immediately on each bus
325
+ - then resumes the runloops
326
+
327
+ This gives the same "awaited events jump the queue" semantics as Python, but without a global lock.
328
+
329
+ ### E) Why `event.bus` is required for `done()`
330
+
331
+ `done()` is the signal to run an event immediately when called inside a handler. Without a bus, we can't
332
+ perform the queue jump, so `done()` throws if no bus is attached.
333
+
334
+ ## Summary
335
+
336
+ The core contract is preserved:
337
+
338
+ - FIFO order
339
+ - child event tracking
340
+ - forwarding
341
+ - await-inside-handler queue jump
342
+
343
+ But the **implementation details are different** because JS needs browser compatibility and lacks Python's
344
+ contextvars + asyncio primitives. The `LockManager` (runloop pause + semaphore coordination), `HandlerLock`
345
+ (yield-and-reacquire), and `BusScopedEvent` proxy are the key differences that make the behavior match in practice.
346
+
347
+ ## Publishing to npm (pnpm)
348
+
349
+ Manual publish from your machine:
350
+
351
+ 1. `cd bubus-ts`
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`
357
+
358
+ Auth setup once per machine:
359
+
360
+ - `pnpm login --registry https://registry.npmjs.org`
361
+
362
+ CI publish is also configured in `.github/workflows/publish-npm.yml` and expects `NPM_TOKEN` in repo secrets.