Cross-Module Communication

When messages need to reach components in a different module, they leave the pod over the cross-module wire. TinySystems supports two substrates behind the same contract:

  • NATS — the primary wire, used whenever TINY_NATS_URL is set.
  • gRPC — the fallback, used when TINY_NATS_URL is unset (see gRPC Fundamentals).

Both satisfy the same runner.Handler shape, so the scheduler — and your components — never know which wire carries an edge. The blocking I/O model is preserved on all of them: the sender blocks until the receiver's handler (and everything downstream of it) returns.

Transport Selection

Selection happens once at module startup (cli/run.go):

EnvironmentWire
TINY_NATS_URL unsetgRPC via AddressPool
TINY_NATS_URL setNATS core request/reply (default)
TINY_NATS_URL set + TINY_NATS_TRANSPORT=jetstreamJetStream-backed durable wire

If TINY_NATS_URL is set but the connection fails, the module logs a warning and falls back to gRPC.

Overview (NATS core)

+-----------------------------------------------------------------------+
|                 CROSS-MODULE COMMUNICATION OVER NATS                  |
+-----------------------------------------------------------------------+

  common-module Pod                             http-module Pod
  +----------------------------+             +----------------------------+
  |  +----------------------+  |             |  +----------------------+  |
  |  |   Router Component   |  |             |  |    HTTP Server       |  |
  |  |  handler() ----------|--|--- NATS ----|->|-> Handle()           |  |
  |  +----------------------+  |   req/reply |  +----------------------+  |
  |           |                |             |           ^                |
  |           v                |             |           |                |
  |  +----------------------+  |             |  +----------------------+  |
  |  |      Scheduler       |  |             |  |      Scheduler       |  |
  |  |  not local ->        |  |             |  |  Handle(ctx, msg)    |  |
  |  |  transport.Handler   |  |             |  |  (any, error)        |  |
  |  +----------------------+  |             |  +----------------------+  |
  |           |                |             |           ^                |
  |           v                |             |           |                |
  |   publish to subject       |             |   QueueSubscribe on        |
  |   tinymodule.http-module   |             |   tinymodule.http-module   |
  |   .msg, block on reply     |             |   .msg (queue group =      |
  |                            |             |   module name)             |
  +----------------------------+             +----------------------------+

Subject Layout

SubjectPurpose
tinymodule.<module-name>.msgBusiness-port messages. Each module pod subscribes with a queue group equal to its module name, so N replicas load-balance incoming messages
tinymodule.<module-name>.sysmsgSystem-port writes (_control, _settings, _reconcile, _identity). Fan-out: every pod of the module receives every message; each component's in-handler IsLeader check decides whether to act

The target module name is parsed from the message's To full name; the transport publishes to that module's subject.

Wire Metadata

Metadata travels in NATS headers:

HeaderMeaning
x-fromSource full port name
x-toTarget full port name
x-edge-idEdge ID that carried the message
x-message-depthHop count, for cycle detection (MaxMessageDepth = 256)
x-errorOn the reply only — non-empty means the handler failed
x-error-codeSet when the component marked the failure with module.NonRetryable(code, err); lets the edge-retry loop short-circuit
x-emptyOn the reply — the handler succeeded with no data
traceparentW3C trace context for OpenTelemetry propagation

Blocking Semantics

Core NATS uses request/reply: the sender blocks on RequestMsgWithContext until the responder writes back on the reply inbox, the caller's context is cancelled, or — when the context carries no deadline — the transport's default timeout (5 minutes) fires. Flows with longer-running components must pass an explicit deadline.

On the receiving side, every incoming message dispatches into the scheduler:

Handle(ctx context.Context, msg *runner.Msg) (any, error)

The any return is the synchronous response payload that flows back over the reply — this is how blocking I/O components (like http-server) receive responses computed several modules away. An error return travels back in the x-error header (plus x-error-code for non-retryable failures) and surfaces to the sender as a Go error.

common-module                  NATS                    http-module
     |                           |                          |
     | handler("http-server:req")|                          |
     | ==========================+========================= |
     |     publish + block       |                          |
     |                           | ------- deliver -------> |
     |                           |            Handle()      |
     |                           |               |          |
     |                           |          (processing)    |
     |                           |               |          |
     |                           | <---- reply on inbox --- |
     | return (response data)    |                          |
     | <=========================+                          |
     v                           v                          v

JetStream: the Durable Wire

With TINY_NATS_TRANSPORT=jetstream, requests publish to a durable work-queue stream (module-edges) instead of a plain subject:

  • Pod-death recovery: receivers run as durable consumers. If a pod dies mid-handler, the broker redelivers the message to another replica after AckWait (30s) expires. Live handlers extend AckWait with InProgress ticks every 10s, so long-running edges (e.g. slow LLM calls) stay in flight on the pod that owns them.
  • Bounded redelivery: MaxDeliver = 3 caps broker-driven redelivery; a message that keeps killing pods is dropped rather than looping.
  • Single-shot on handler error: when a handler returns an error and a caller is waiting on the reply, the error is sent back and the message is Term-ed — never redelivered. The runtime does not silently retry logical failures.
  • Fire-and-forget durable hops (durable execution mode, RunID set): the sender returns as soon as the broker acks the publish — no reply wait — and the hop carries an idempotency key so a redelivered handler's re-emit dedupes. On these hops only, a failure the component marked transient (module.ShouldRetry(err) true) is Nak-ed for broker redelivery instead of Term-ed, bounded by MaxDeliver.
  • Replies still travel on a core-NATS inbox — the caller is the only reader, so no durability is needed there.

Retries Are Per-Edge and Explicit

The runtime never retries edge dispatch implicitly. Authors opt in per edge via retryPolicy on the edge (see Internal Routing for the EdgeRetryPolicy shape). Components signal permanently-failing errors with module.NonRetryable(code, err); the transport stamps the code on x-error-code and the retry loop short-circuits when the code matches the policy's nonRetryableErrorCodes.

Error Handling

// Sender side — both wires surface remote failures as Go errors
res, err := s.msgHandler(ctx, outMsg)
if err != nil {
    // Route to an error port, or let it propagate to the caller
}

Component-level best practice is unchanged: catch downstream failures and emit them on a dedicated error port rather than swallowing them:

func (c *Client) Handle(ctx context.Context, handler module.Handler, port string, msg any) module.Result {
    res, err := c.callRemoteService(ctx, msg)
    if err != nil {
        return handler(ctx, "error", ErrorOutput{Error: err.Error()})
    }
    return handler(ctx, "success", res)
}

Best Practices

1. Minimize Cross-Module Hops

Group tightly-coupled components in the same module when possible — an in-process hop is a direct function call; a cross-module hop is a network round trip:

Good:
+-------------------------------------+
|  http-module                        |
|  +---------+   +---------+          |
|  | Server  |-->| Parser  |          |
|  +---------+   +---------+          |
|       |                             |
|       v                             |
|  +---------+                        |
|  | Router  |--> (to other modules)  |
|  +---------+                        |
+-------------------------------------+

2. Set Deadlines on Long Chains

Without a context deadline, a blocked cross-module call is capped by the transport default (5 minutes). Pass an explicit deadline when the caller needs a tighter or looser bound; per-edge timeoutMs in the retry policy caps a single dispatch attempt.

3. Use Durable Mode for Runs That Must Survive Pod Death

Flows marked with the durable execution mode ride the JetStream wire fire-and-forget, so a mid-run pod reschedule migrates the run instead of failing it.

4. Watch Depth on Cyclic Graphs

Every hop increments the message depth; exceeding MaxMessageDepth (256) returns a permanent error. If you hit it, look for an unintended cycle in the flow graph.

Next Steps