Declare the interface once - use it anywhere — the tag decides the reach
Use ← → for sections · ↑ ↓ for slides within a section · ~60 min
01
The pain inventory — and the one idea that replaces it
Callback tables wired at init — producer imports its consumers, ordering & removal are your problem.
{.base.} method vtables — interface + subtype + factory + mock plumbing, per component.
Channel[T] wiring — request/response channel pairs, wake-ups, deep copies, per thread boundary.
FFI trampolines — hand-written C ABI, marshaling, callback shims, per language.
nim-brokers: one declared, compile-time-checked, fully typed contract — the library generates the plumbing.
RequestBroker: # in-proc: direct dispatch
RequestBroker(mt): # cross-thread: lock-free ring
RequestBroker(API): # cross-language: CBOR shared lib
Same declaration, same call sites. You choose the reach by adding a tag — and pay only for the axis you compile in.
Built on chronos + results.
| Broker | Shape | One-liner | Lanes |
|---|---|---|---|
| EventBroker | pub/sub, many→many | fire-and-forget events; listen / emit | ST · mt · API |
| RequestBroker | req/resp, many→1 provider | typed request() to a swappable provider; async or sync | ST · mt · API |
| MultiRequestBroker | req/resp, many→N providers | fan out, aggregate results | ST only |
| SignalBroker | one-way, many→1 handler | fire-and-forget with accept/backpressure result | ST · mt · API |
The invariant to remember: the call shape is identical in every lane — emit / signal are always sync, request is async (or declared sync), drop* is always async. The same source compiles with or without the tag.
02
pub/sub · many emitters → many listeners · fire-and-forget
Running example — a telemetry pipeline: a sensor driver produces readings; a dashboard, a logger, later a network relay all react.
driver.nim
emit
→ SensorReading →
dashboard.nim · logger.nim · relay.nim
listen
# driver.nim owns its consumers' hooks
type SensorDriver = ref object
onReading: seq[proc(id: string,
c: float) {.gcsafe.}]
# main.nim — wiring, ordering, removal:
# all manual, all coupled
driver.onReading.add(dashboard.update)
driver.onReading.add(logger.record)
New consumer ⇒ edit driver and main. Exceptions in one hook kill the loop for the rest.
# telemetry_events.nim — the contract
EventBroker:
type SensorReading = object
sensorId*: string
valueC*: float
# dashboard.nim — subscribes itself
discard SensorReading.listenIt:
updateGauge(it.sensorId, it.valueC)
# driver.nim — imports nobody
SensorReading.emit(sensorId = "t1",
valueC = 21.5)
listenIt: the block is the listener body, event injected as it. Emit by value or by fields.
emit is sync void: it snapshots the listener set and asyncSpawns each — never await it.proc(evt: T): Future[void] {.async: (raises: []).} — handle your own errors.listen / listenIt return Result[ListenerHandle, string] — keep the handle to drop later.drop* is async in every lane — await it; dropping cancels the listener's in-flight work.
let h = SensorReading.listenIt:
await store(it) # bodies may await
SensorReading.emit(sensorId = "t1", valueC = 21.5)
await sleepAsync(0) # test idiom: yield once so handlers run
await SensorReading.dropListener(h.get())
await SensorReading.dropAllListeners()
Full reference: USAGEGUIDE.md § EventBroker · COOKBOOK.md § EventBroker
03
request/response · many callers → one swappable provider
# contract — visible to callers and provider alike
RequestBroker:
type Calibration = object
offset*: float
proc signature*(sensorId: string): Future[Result[Calibration, string]] {.async.}
# storage.nim — the provider (exactly one per signature)
discard Calibration.provideIt: # declared arg names injected
ok(Calibration(offset: lookupOffset(sensorId)))
# driver.nim — the consumer; no import of storage
let cal = await Calibration.request("t1")
# swap in place — tests, hot reconfig — no clearProvider dance
discard Calibration.reprovideIt:
ok(Calibration(offset: 0.0))
A second setProvider returns err — replacement is explicit (reprovideIt / replaceProvider). Prefer explicit procs? setProvider takes a hand-written closure; the *It sugar generates the same thing.
RequestBroker(sync):
proc plusOne(x: int): Result[int, string]
# proc-sugar: broker = PlusOne,
# request() returns the RAW payload
discard PlusOne.setProvider(
proc(x: int): Result[int, string] =
ok(x + 1))
echo PlusOne.request(41).get() # 42, blocks
No Future, no {.async.} — usable from plain sync code.
RequestBroker:
type FetchUser = object
name*: string
proc signature*(id: int):
Future[Result[FetchUser, string]] {.async.}
# a body that could fall through would
# silently answer err("") — so it's a
# COMPILE error instead:
FetchUser.provideIt:
echo "side effect only" # void trailing call
FetchUser.provideIt:
if id > 0: # if without else
return ok(FetchUser(name: "u" & $id))
return / result= / trailing expression all work — but every path must produce a value.
04
request/response fanned out to N providers · results aggregated
MultiRequestBroker:
type Health = object
subsystem*: string
healthy*: bool
proc signature*(deep: bool): Future[Result[Health, string]] {.async.}
# every provideIt ADDS a provider (no replace verb here)
let h1 = Health.provideIt: ok(Health(subsystem: "net", healthy: netUp()))
let h2 = Health.provideIt: ok(Health(subsystem: "disk", healthy: diskOk(deep)))
let all = await Health.request(true) # Result[seq[Health], string] — len == 2
Health.removeProvider(h1.get()) # drop one by handle
Any provider failing fails the whole request. Model per-provider failure inside the payload if partial results must survive.
Single-thread lane only — no (mt)/(API) variant.
05
one-way notification · single handler · backpressure made visible
SignalBroker:
type FlushHint = object
reason*: string
discard FlushHint.onSignalIt: # ONE handler; a second onSignal => err
await persister.flush() # exceptions swallowed (warn) — no reply path
let r = FlushHint.signal(reason = "buffer 90%") # plain proc — NEVER await it
if r.isErr:
metrics.inc("flush_dropped") # backpressure is a visible branch
ok() = accepted — a handler exists and the queue had room. It does not mean handled.
err() = "no signal handler installed" | "queue full". Payload-less pulse: type Wakeup = void · Wakeup.signal().
| You need… | Reach for | Caller gets back |
|---|---|---|
| N reactions, fire-and-forget | EventBroker | nothing (emit is void) |
| an answer from the one responsible component | RequestBroker | Result[T, string] |
| answers from every registered component | MultiRequestBroker | Result[seq[T], string] |
| "did it get through?" without a reply | SignalBroker | accept / backpressure Result[void, string] |
Rule of thumb: start with EventBroker for facts that happened, RequestBroker for questions. Signal & MultiRequest are the specialists.
06
isolation without declaring new broker types
distinct uint32 that multiplexes broker instances
let ctxA = NewBrokerContext() # globally unique (atomic counter, thread-safe)
let ctxB = NewBrokerContext()
discard SensorReading.listenIt(ctxA): recordTenantA(it)
discard SensorReading.listenIt(ctxB): recordTenantB(it)
SensorReading.emit(ctxA, SensorReading(sensorId: "t1", valueC: 20.0))
# only ctxA's listener fires — ctxB is fully isolated
await SensorReading.dropAllListeners(ctxA)
DefaultBrokerContext.07
the OOP/DI layer — a contract of brokers, per-instance isolation
type Greeter = ref object of RootObj
method greet(g: Greeter, name: string):
Future[Result[string, string]] {.base.} =
doAssert false, "override me"
type GreeterImpl = ref object of Greeter
# ...plus: factory, registry lookup,
# and mock plumbing rebuilt per test
Runtime "override me" traps · consumers bound to the concrete type at wiring sites · mocking needs a DI framework or a service locator.
BrokerInterface(IGreeter):
RequestBroker:
proc greet(name: string):
Future[Result[string, string]] {.async.}
BrokerContext — full isolation (section 06 pays off).
type GreeterImpl = ref object of IGreeter
prefix: string
BrokerImplement GreeterImpl of IGreeter:
proc new(T: typedesc[GreeterImpl], prefix: string): GreeterImpl =
GreeterImpl(prefix: prefix)
method greet(self: GreeterImpl, name: string): Future[Result[string, string]] {.async.} =
ok(self.prefix & name)
let g = GreeterImpl.create(prefix = "hello ")
assert (waitFor g.greet("alice")).value == "hello alice"
# the call tunnels through the broker — so a scoped mock is honored
# even for the DIRECT method call:
Greet.withMockProvider(g.brokerCtx,
proc(name: string): Future[Result[string, string]] {.async.} =
ok("MOCK<" & name & ">")):
assert (waitFor g.greet("alice")).value == "MOCK<alice>"
Consumers depend only on IGreeter. Implementation registered at runtime, swappable per instance — DI without a container. Every broker also works standalone; the interface layer is opt-in.
08
the same interface across thread boundaries — one tag away
RequestBroker: # single-thread: direct dispatch on one chronos loop
RequestBroker(mt): # cross-thread: lock-free ring — CALL SITES UNCHANGED
--threads:on and --mm:orc or --mm:refc.BrokerContexts served by different threads.emit/signal sync, drop* async — the same source compiles in both lanes.Migration story: start single-thread; when a component earns its own thread, add (mt) and move it — consumers don't notice.
caller thread
encode into slab cell
Vyukov MPSC ring
carries the cell index
provider thread
woken by shared ThreadSignalPtr, decodes in place
Channel[T] on the hot path — pre-allocated payload slab + (for requests) a response-slot pool; zero per-call shared-heap allocation.ThreadSignalPtr per thread — all broker types on a thread share it; zero OS fds per broker type.vs. the v1.x Channel[T] design: up to 7.4× throughput / 270× lower latency (refc); EventBroker fan-out ≈ 788 K evt/s refc / 511 K evt/s orc (5×500×512 B bench). Details: doc/MT_BROKER_REFACTOR_RETROSPECTIVE.md · nimble perftest.
RequestBroker(mt):
type Weather = object
city*: string
tempC*: float
proc signature*(city: string): Future[Result[Weather, string]] {.async.}
proc worker() {.thread.} =
let res = waitFor Weather.request("Berlin") # ANY thread may ask
doAssert res.isOk()
proc main() {.async.} =
doAssert Weather.setProvider( # provider runs on THIS thread
proc(city: string): Future[Result[Weather, string]] {.async.} =
ok(Weather(city: city, tempC: 21.5))
).isOk()
var t: Thread[void]
t.createThread(worker)
# ... join, then: Weather.clearProvider()
Exactly the section-03 shape — the request line did not change.
setProvider and serves requests sequentially on its event loop — throughput is bounded by handler time.err, not a hang:
Weather.setRequestTimeout(chronos.seconds(2))
echo Weather.requestTimeout() # 2 seconds — same-thread calls unaffected
New visible failure mode: the bounded ring / response pool can return err(...) on overflow — unlike an unbounded Channel[T] that grows silently. Handle it, and size for bursts (next slides).
asyncSpawn).N_listeners, then one ring enqueue + one signal per listener thread — no per-listener payload copy.emit() stays sync void — marshal + enqueue happen before it returns.dropListener must be called from the registering thread (enforced at runtime).
dropAllListeners works from any thread — sends shutdown to all listener threads and drains in-flight tasks.
# Wide ring + slab for bursty broadcast — built-in preset:
EventBroker(mt, preset = fastBurst, maxPayloadBytes = 1024):
type WireEvent = object
payload*: seq[byte]
# Memory-constrained, fully manual:
RequestBroker(mt, queueDepth = 16, responseSlots = 8, maxResponseBytes = 512):
type LedState = object
proc query*(id: uint8): Future[Result[LedState, string]] {.async.}
default / kwarg / preset:<n> / auto:<reason>) and an idle-RAM estimate — you see at build time what the broker reserves.Full knob/preset/failure-mode reference: doc/MT_BROKER_CONFIG.md
--threads:on + --mm:orc|refc; prefer -d:release + orc for throughput.emit/signal stay sync — never await; drop* stays async — always await (or waitFor from {.thread.} procs).err — branch on it; don't discard blindly.dropListener from the registering thread; dropAllListeners from anywhere.preset/queueDepth/maxPayloadBytes — read the compile-time hint line.09
how (API) turns the same declaration into a multi-language library — connection facility only
RequestBroker(API): # on the Nim side: just a normal multi-thread broker
type Weather = object ...
registerBrokerLibrary generates the lifecycle exports, context registry, startup threads — and the wrapper artifacts.libmylib + C header over a fixed 12-function CBOR C ABI + idiomatic C++ / Python / Rust / Go wrappers — typed Result/error surfaces, no hand-written FFI plumbing.Author the API once, in Nim. Foreign callers see a native-feeling, memory-safe library.
BrokerInterface(API, IWeather)
type Forecast* = object # plain type — auto-registered on reference
day*: string
tempC*: float64
outlook*: string
BrokerInterface(API, IWeather): # (API) propagates to every inner broker
EventBroker:
type StormWarning = object
region*: string
severity*: int32
RequestBroker:
proc getDayForecast(day: string):
Future[Result[Forecast, string]] {.async.}
RequestBroker:
proc getCurrentTempC(city: string):
Future[Result[float64, string]] {.async.}
# + WeatherImpl = ref object of IWeather & BrokerImplement (section 07),
# then — last in the module:
registerBrokerLibrary:
name: "weather"
version: "1.0.0"
...
BrokerImplement provides the methods; createUnderContext adopts the FFI-allocated context.Weather) in every generated wrapper.The OOP structure is an authoring concern — foreign consumers see the same typed surface whether the Nim side uses flat brokers or interfaces.
class Weather {
Result<Forecast> getDayForecast(std::string_view day);
Result<double> getCurrentTempC(std::string_view city);
// + getDayForecastAsync(day, cb) / getDayForecastFuture(day)
EventHandle onStormWarning(std::function<void(
Weather& owner, std::string_view region, int32_t severity)> cb);
void offStormWarning(EventHandle h = 0); // 0 = remove all
};
class Weather:
def get_day_forecast(self, day) -> Result
def get_current_temp_c(self, city) -> Result
# + get_day_forecast_async(day, cb)
def on_storm_warning(self, cb) -> int # cb(owner, region, severity)
def off_storm_warning(self, handle=0) # 0 = remove all
impl Weather {
pub fn get_day_forecast(&self, day: &str) -> Result<Forecast>;
pub fn get_current_temp_c(&self, city: &str) -> Result<f64>;
// + get_day_forecast_async(day, timeout).await
pub fn on_storm_warning(&self,
cb: impl Fn(String, i32) + Send + 'static) -> EventHandle;
pub fn off_storm_warning(&self, h: EventHandle); // 0 = remove all
}
func (w *Weather) GetDayForecast(day string) (Forecast, error)
func (w *Weather) GetCurrentTempC(city string) (float64, error)
// + GetDayForecastContext(ctx, day)
func (w *Weather) OnStormWarning(
cb func(region string, severity int32)) EventHandle
func (w *Weather) OffStormWarning(h EventHandle) // 0 = remove all
Naming, error style, callbacks — all idiomatic per language (extract; see the shipped wrappers in examples/ffiapi). Callbacks are delivered on the library's delivery thread; C++/Python callbacks receive the owner as first argument.
mylib_createContext() — creates one broker-backed context, does one-time runtime init internally.InitializeRequest / ShutdownRequest — post-create configuration and orderly teardown, as ordinary broker requests._call — synchronous round-trip; blocks until the provider answers with the CBOR response._callAsync — enqueue and return; result arrives via the async response callback. Caller thread never blocks.SignalBroker(API) rides _call enqueue-only and rejects _callAsync.Event callbacks carry ctx + opaque userData; the C++ wrapper adds an owner-aware dispatcher, swallows callback exceptions at the boundary, and is non-copyable so callback identity stays stable.
examples/ffiapi — minimal end-to-end library with C++ / Python / Rust / Go clients.examples/torpedo — a full game engine as a Nim library, four language frontends.examples/persistence — a persistence service across the boundary.doc/FFI_API.md · type support matrix: doc/TYPESUPPORT.md.Bindings authoring, schema evolution and wrapper internals are deliberately out of scope today — that's its own session.
| Reach for… | when… | tag |
|---|---|---|
| EventBroker | facts happened, N modules react, producer stays decoupled | (none) → (mt) → (API) |
| RequestBroker | a question with one responsible, swappable answerer (async or sync) | |
| MultiRequestBroker | ask everyone, aggregate | single-thread |
| SignalBroker | one-way with visible backpressure | (none) → (mt) → (API) |
| BrokerContext | isolation: tenants, components, tests | all lanes |
| Interface / Implement | a contract of brokers per component instance — DI without a container | — |
Everything else is documented — go deep on demand:
USAGEGUIDE.md ·
doc/COOKBOOK.md ·
doc/MT_BROKER_CONFIG.md ·
doc/FFI_API.md ·
DeepWiki ·
debug dumps: -d:brokerDebug (see USAGEGUIDE § Debug)
λ Thank you — questions?