Tutorial
λogos · nim-brokers

nim-brokers - Tutorial

NagyZoltanPeter/nim-brokers

Declare the interface once - use it anywhere — the tag decides the reach

EventBroker RequestBroker MultiRequestBroker SignalBroker BrokerContext
Interface / Implement Multi-Thread FFI Connection

Use ← → for sections  ·  ↑ ↓ for slides within a section  ·  ~60 min

01

Why Brokers

The pain inventory — and the one idea that replaces it

Every project hand-rolls the same plumbing

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.

Mental model — 4 shapes × 3 lanes

BrokerShapeOne-linerLanes
EventBrokerpub/sub, many→manyfire-and-forget events; listen / emitST · mt · API
RequestBrokerreq/resp, many→1 providertyped request() to a swappable provider; async or syncST · mt · API
MultiRequestBrokerreq/resp, many→N providersfan out, aggregate resultsST only
SignalBrokerone-way, many→1 handlerfire-and-forget with accept/backpressure resultST · 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

EventBroker

pub/sub · many emitters → many listeners · fire-and-forget

Use case: the producer must not import its consumers

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

  • Adding a consumer touches only the consumer's module.
  • No init-time wiring, no shared registry object, no ownership question.
  • Everything runs on the one chronos loop you already have.

From this code… to that code

before — callback table, wired at init

# 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.

after — declared event, self-registered listeners

# 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.

EventBroker — the rules

  • emit is sync void: it snapshots the listener set and asyncSpawns each — never await it.
  • Listener signature: 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 laneawait 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

RequestBroker

request/response · many callers → one swappable provider

One provider, typed, swappable — the DI seed


# 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.

Sync mode & fool-proof provider bodies

RequestBroker(sync) — no event loop needed

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.

these do NOT compile — by design

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

MultiRequestBroker

request/response fanned out to N providers · results aggregated

Fan-out: health probes, plugin queries, capability discovery


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

SignalBroker

one-way notification · single handler · backpressure made visible

"Was it accepted?" — not "was it handled?"


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().

Which shape do I reach for?

You need…Reach forCaller gets back
N reactions, fire-and-forgetEventBrokernothing (emit is void)
an answer from the one responsible componentRequestBrokerResult[T, string]
answers from every registered componentMultiRequestBrokerResult[seq[T], string]
"did it get through?" without a replySignalBrokeraccept / backpressure Result[void, string]

Rule of thumb: start with EventBroker for facts that happened, RequestBroker for questions. Signal & MultiRequest are the specialists.

06

BrokerContext

isolation without declaring new broker types

A 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)
  • Every broker API takes an optional first context arg; omitted → thread-global DefaultBrokerContext.
  • Per-tenant, per-component, per-test isolation — same broker type, zero extra declarations.
  • Foundation for what comes later: BrokerInterface gives every instance its own context.

07

BrokerInterface / BrokerImplement

the OOP/DI layer — a contract of brokers, per-instance isolation

From hand-rolled vtable… to a declared contract

before — the usual Nim answer

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.

after — brokers grouped behind an interface

BrokerInterface(IGreeter):
  RequestBroker:
    proc greet(name: string):
      Future[Result[string, string]] {.async.}
  • The contract is compile-time checked — an implementation missing a method doesn't build.
  • Each instance gets its own BrokerContext — full isolation (section 06 pays off).
  • Calls tunnel through the broker — everything from section 03 applies.

Implement, use — and mock without a framework


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

Multi-Thread Brokers

the same interface across thread boundaries — one tag away

The transition is one token


RequestBroker:          # single-thread: direct dispatch on one chronos loop
RequestBroker(mt):      # cross-thread: lock-free ring — CALL SITES UNCHANGED
  • Compile with --threads:on and --mm:orc or --mm:refc.
  • When you want it: provider lives on a dedicated thread (main/UI loop) and workers query it · typed decoupled interface across threads without channel plumbing · sandboxed BrokerContexts served by different threads.
  • The invariant holds: 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.

Under the hood (v2): ring + slab + pool

caller thread
encode into slab cell

Vyukov MPSC ring
carries the cell index

provider thread
woken by shared ThreadSignalPtr, decodes in place

  • No Channel[T] on the hot path — pre-allocated payload slab + (for requests) a response-slot pool; zero per-call shared-heap allocation.
  • One shared ThreadSignalPtr per thread — all broker types on a thread share it; zero OS fds per broker type.
  • Same-thread calls bypass the ring entirely — near-zero overhead.
  • Everything is sized at compile time (type-driven defaults, overridable).

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) — provider on main, workers ask


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.

RequestBroker(mt) — operational rules

  • The provider runs on the thread that called setProvider and serves requests sequentially on its event loop — throughput is bounded by handler time.
  • Cross-thread timeout — default 5 s; unresponsive provider ⇒ err, not a hang:

Weather.setRequestTimeout(chronos.seconds(2))
echo Weather.requestTimeout()          # 2 seconds — same-thread calls unaffected
  • Same-thread path = direct provider call through threadvar state — zero ring/slab traffic.

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).

EventBroker(mt) — broadcast without copies

  • Listeners register on any thread; emitters emit from any thread. Same-thread delivery skips the ring (asyncSpawn).
  • Cross-thread fan-out: the event is encoded once into a slab cell, refcounted to N_listeners, then one ring enqueue + one signal per listener threadno 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.

Tuning: size it at the declaration


# 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.}
  • Defaults are type-driven — sensible sizes derived from the payload shape.
  • Every MT callsite emits a compile-time hint: resolved capacities, their origin (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

MT checklist — the six things people get wrong

  • --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).
  • Overflow of a bounded ring/pool is an errbranch on it; don't discard blindly.
  • Keep the provider thread's loop responsive — it serves sequentially.
  • dropListener from the registering thread; dropAllListeners from anywhere.
  • Bursty or large payloads? Set preset/queueDepth/maxPayloadBytes — read the compile-time hint line.

09

The FFI Connection

how (API) turns the same declaration into a multi-language library — connection facility only

(API) = the MT broker + a generated language boundary


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.
  • You get: 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.
  • Requests and events tunnel over extensible CBOR — the same contract you declared in Nim.
  • MultiRequestBroker has no API lane; SignalBroker(API) is one-way (see call modes).

Author the API once, in Nim. Foreign callers see a native-feeling, memory-safe library.

Author once — 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"
  ...
  • Exactly the section-07 OOP model — BrokerImplement provides the methods; createUnderContext adopts the FFI-allocated context.
  • The main interface becomes the library class (Weather) in every generated wrapper.
  • One event + two requests here ⇒ per language: one on/off registration pair + two typed request methods.

The OOP structure is an authoring concern — foreign consumers see the same typed surface whether the Nim side uses flat brokers or interfaces.

…generated four times — same contract, native dialects

C++ — owner-aware, non-copyable

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
};
Python — Result with .is_ok() / .value / .error

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
Rust — Result<T>, closures, optional async

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
}
Go — idiomatic (T, error)

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.

Runtime model & call modes

Per-context runtime

  • mylib_createContext() — creates one broker-backed context, does one-time runtime init internally.
  • Two threads per context: processing (request providers) + delivery (listener registration, foreign-language callbacks).
  • InitializeRequest / ShutdownRequest — post-create configuration and orderly teardown, as ordinary broker requests.

Two ways to call in

  • _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.
  • Exception: 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.

Where to go from here

  • 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.
  • Reference: 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.

Take-away — declare once, use anywhere, tag the reach

Reach for…when…tag
EventBrokerfacts happened, N modules react, producer stays decoupled(none)(mt)(API)
RequestBrokera question with one responsible, swappable answerer (async or sync)
MultiRequestBrokerask everyone, aggregatesingle-thread
SignalBrokerone-way with visible backpressure(none)(mt)(API)
BrokerContextisolation: tenants, components, testsall lanes
Interface / Implementa 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?