Files
kenjreno 128ddcb4df v0.1.0: thread-safe pub/sub event bus
Verbatim port of Fastway-Server's TFWEventBus from fw_plugin_host.pas
per feedback_copy_dont_reinterpret.md.  Adjustments limited to:

  - Type renames (TFW* -> T*).
  - uses clause: drop fw_log; add log.types from fpc-log so the
    optional Logger property uses the canonical ecosystem-wide
    TLogProc shape, matching every other fpc-* library.
  - Per-handler exception logging now calls Logger with
    Level=llError, Category='events', and includes the source
    plugin (ASourcePlugin parameter) in the message text so the
    canonical signature stays meaningful.

Behaviours preserved verbatim: APluginName bulk-Unsubscribe key,
wildcard '*' subscriber, OnBroadcast external-listener tap,
snapshot-iterate-outside-lock pattern, per-handler exception
isolation, TCriticalSection.

docs/DEVELOPER_GUIDE.md added covering threading, payload
ownership, recursive Fire, OnBroadcast, logger plumbing, and
the relationship between fpc-events (ecosystem-wide pub/sub)
and per-library typed observer callbacks (bp.events / cm.events
pattern).

Tests: 44 assertions across 14 scenarios pass on x86_64-linux.
Pre-tag -vh audit on src/ev.bus.pas reports zero hints/warnings.
2026-05-05 18:13:10 -07:00

147 lines
6.3 KiB
Markdown

# fpc-events — architecture
## Layers
```
┌──────────────────────────────────────────────────┐
│ Caller (BBS, plugin, daemon, web-admin, …) │
└──────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ events.bus (TEventBus class) │
├──────────────────────────────────────────────────┤
│ events.version (semver constants) │
├──────────────────────────────────────────────────┤
│ log.types (logger callback type only, │
│ from sibling fpc-log) │
├──────────────────────────────────────────────────┤
│ RTL: Classes (TCriticalSection), fpjson │
└──────────────────────────────────────────────────┘
```
Two source units, no platform-specific code. No backends, no
formats, no I/O — `TEventBus` is a pure in-memory pub/sub
registry.
## Origin
Verbatim port of `TFWEventBus` from Fastway-Server's
`fw_plugin_host.pas` (lines 188-203 + 1626-1753). Preserved
behaviours:
- `APluginName` parameter on `Subscribe` + bulk-Unsubscribe key.
- Wildcard `'*'` event type.
- `OnBroadcast` external-listener tap.
- Snapshot-iterate-outside-lock (safe re/un-subscribe + recursive
`Fire` from inside callbacks).
- Per-handler exception isolation via `try/except` *inside* the
iteration.
- `TCriticalSection` synchronisation (not `TMonitor` / RW lock).
The only structural change: `fw_log.Log.Error(...)` calls in the
canonical handler-error path were replaced with the optional
`Logger: log.types.TLogProc` callback to drop the Fastway-only
`fw_log` dependency.
## Where it fits in the fpc-* family
```
┌────────────────────────┐ ┌────────────────────────┐
│ fpc-events │ │ fpc-binkp / fpc-comet │
│ ecosystem-wide │ │ / fpc-emsi │
│ pub/sub │ │ per-library typed │
│ (one bus, N pubs, │ │ observer callbacks │
│ M subs) │ │ (one publisher, N │
│ │ │ consumers) │
└────────────────────────┘ └────────────────────────┘
▲ ▲
│ │
└────────── pick one ────────┘
(or use both — they're orthogonal)
```
These are different patterns for different problems:
- **Per-library typed callbacks** — `Session.OnFileBegin :=
@MyHandler` on a fpc-binkp session. Type-safe, no JSON
marshalling, the publisher knows its consumers. Used by
fpc-binkp's `bp.events`, fpc-comet's `cm.events`, fpc-cron's
`cron.events`.
- **fpc-events** — a single `TEventBus` instance receives
`Fire(...)` calls from many publishers; many `Subscribe(...)`d
consumers (web-admin WebSocket, audit log, monitoring) listen
without each publisher knowing they exist. Cost: `AData` is
JSON-bagged (no static typing); event names are strings.
A common hybrid: a library exposes typed callbacks AND a small
bridge class (consumer-written) that forwards selected events to
a `TEventBus`. fpc-events doesn't impose either pattern — it's
the substrate when the second pattern is what you need.
## Threading model
Single `TCriticalSection` (`FLock`) protects `FSubscriptions`.
- `Subscribe`, `Unsubscribe`, `UnsubscribeCallback`,
`GetSubscriptionCount` — acquire lock, mutate or read, release.
- `Fire` — acquire lock, copy `FSubscriptions` to a local array,
release lock, then iterate the copy *outside the lock*.
The snapshot-iterate pattern is load-bearing: a callback that
calls `Subscribe` / `Unsubscribe` / `Fire` recursively does so
without contending for the lock, and its modifications don't
affect the in-flight delivery (the snapshot was taken before the
mutation).
The bus does no thread marshalling. Subscribers run on whichever
thread called `Fire`. Consumers wanting cross-thread dispatch
(UI updates, message-queue posting) do that themselves inside
the handler.
## Memory ownership
The `AData: TJSONObject` parameter on `Fire` is **owned by the
caller**. Subscribers may read fields and copy values out, but
must not call `AData.Free`.
Standard publisher pattern:
```pascal
Data := TJSONObject.Create;
try
Data.Add('user_id', 42);
Bus.Fire('auth', 'user.login', Data);
finally
Data.Free;
end;
```
If a subscriber needs to retain the data beyond the `Fire` call,
it must clone:
```pascal
FRetained := TJSONObject(AData.Clone);
```
## Logger plumbing
`Bus.Logger: TLogProc` (from `log.types`) handles the
exception-isolation log path. When a subscriber callback or the
`OnBroadcast` tap raises:
- The exception is caught by `Fire`'s inner `try/except`.
- If `Logger` is assigned, it's called with
`(llError, 'events', formatted-message)`.
- The exception is then swallowed; delivery continues to the
next subscriber.
If `Logger` is `nil` (the default), exceptions are silently
swallowed. This matches canonical Fastway behaviour for callers
without an `fw_log.Log` configured.
`TLogProc` is defined in fpc-log's `log.types` — the same shape
fpc-binkp, fpc-comet, fpc-emsi, fpc-cron use. One log sink wires
to all of them.