Event Flow
Gordon's IPC spine is a single NATS JetStream stream (gordon-bus) covering all hot paths between services. Producers write event rows to a Postgres outbox in the same transaction as their domain state change; a leader-elected publisher loop drains the outbox to NATS. Consumers attach to durable JetStream consumers keyed by stable per-service names. Postgres remains the durable state store; the bus is the delta channel.
Stream configuration
| Parameter | Value |
|---|---|
| Stream name | gordon-bus |
| Subject filter | market.klines.>, intents.>, risk.>, trading.> |
| Retention | limits (age + size) |
| Max age | 168 h (7 days) |
| Max bytes | 768 MiB (local) / 1 GiB (production) |
| Storage | file |
| Discard | old (drop oldest on cap hit; never block producers) |
A single stream covers all subject hierarchies. Sharding by domain is a future operator decision — not the current topology.
Subject table
| Subject pattern | Producer | Consumer(s) | Durable name |
|---|---|---|---|
market.klines.binance.{spot|perp}.{symbol}.{tf} | gordon-data | gordon-bot | bot-{bot_id}-{symbol}-{tf}-klines |
intents.executor | gordon-bot | gordon-executor | executor-default |
risk.commands | gordon-risk | gordon-executor, gordon-bot | executor-risk, bot-{bot_id}-risk |
risk.events.{breaker} | gordon-risk | gordon-manager | manager-risk |
trading.fills.{bot_id} | gordon-executor | (consumers TBD) | — |
Subject tokens are lowercase. Symbols stay uppercase inside the payload ("symbol": "BTCUSDT"); the subject token lower-cases the symbol (market.klines.binance.spot.btcusdt.1m).
Wire types
All bus payloads live in gordon_protocol::bus::* (crate gordon-protocol).
| Type | Subject | Schema version |
|---|---|---|
KlineEvent | market.klines.> | 1 |
OrderIntentEvent | intents.executor | 1 |
RiskCommandEvent | risk.commands | 1 |
BreakerEvent | risk.events.{breaker} | 1 |
Schema rules (load-bearing):
- First field is
schema_version: u16— numeric, neverString. - Every other field carries
#[serde(default)]— av_nconsumer decodes av_n+1payload with the new field defaulted. - Additive only — never rename, never remove. Renames go through one full major-release deprecation window.
- Adding a field bumps
gordon-protocolminor; renaming or removing bumps major.
Event flow diagram
End-to-end trace: kline to console
- Binance pushes a 1m candle on the spot WebSocket to gordon-data.
- In a single transaction:
INSERT market_data.spot_klines+NatsPublisher::publish_within(&mut tx, "market.klines.binance.spot.btcusdt.1m", ...)appends abus.outboxrow. - The outbox publisher loop (leader via
pg_try_advisory_lock) drains the row, publishes to JetStream with broker ack, then flipspublished_to_nats = TRUE. - The bot's
bot-{bot_id}-BTCUSDT-1m-klinesconsumer delivers to the strategy loop. - Strategy emits an
OrderIntent. The bot writes it in one transaction:INSERT trading.order_intents+publish_within("intents.executor", ...). - gordon-executor's
executor-defaultconsumer reads, validates the lease fence, submits to Binance. - Binance fills. Executor
INSERT trading.trades(idempotent ontrade_fingerprint).pg_notify('bot_events', id)fires. - gordon-manager's LISTEN multiplexer hydrates the row, pushes a
LiveEventto the broadcaster, fans out to gordon-console over/ws.
Steps 1–6 traverse the bus. Step 7 stays on pg LISTEN/NOTIFY (envelope channels are row-hydration; they are intentionally not on the bus — same-process producer/consumer).
Outbox pattern
Producers never publish to NATS directly. Every emission is mediated by bus.outbox.
The pure NATS publish path has an unsolvable failure mode: the domain row commits, the publish fails, the world sees a state change with no event. The outbox writes the event row in the same transaction as the domain change — either both commit or both roll back. The publisher loop drains pg to NATS asynchronously and only flips published_to_nats = TRUE after the broker's ack. At-least-once end-to-end; consumer-side idempotency closes the loop.
Entry point: NatsPublisher::publish_within(&mut tx, ...). Using Publisher::publish for a domain dual-write is a correctness bug (ghost-event class).
Leader election
The outbox publisher loop uses pg_try_advisory_lock(0x0B05_0010_2026_0508):
- Any service may run the loop. gordon-data is the production leader today.
- Losers sleep 30 s and retry.
- Drain batch: 100 rows. Active poll: 100 ms. Idle poll: 1 s.
LISTEN bus_outbox_appendedshort-circuits the idle wait. - NATS publish failures back off exponentially (1 s to 30 s); after 5 minutes of sustained failure the leader releases the lock so a peer can take over.
- The advisory lock is session-scoped — the loop pins a
PoolConnectionfor the lock's lifetime.
Consumer contract
Every durable consumer follows the same contract:
ack_policy = explicit,ack_wait = 30s.deliver_policy = allon first attach (replays the retention window); resumes from the saved cursor on restart.max_deliver = -1— at-least-once, with consumer-side idempotency (intent_idPK, breaker hydration idempotent, kline(open_time, symbol, tf)PK).- Outer reconnect loop with capped exponential backoff (500 ms to 30 s) wraps the inner pull loop.
What is NOT on the bus
Three categories are intentionally kept off the bus:
Manager-internal channels (10). Channels where both producer and consumer are gordon-manager (runs, equity_points, stack_health, source_freshness, etc.) stay on pg LISTEN/NOTIFY. Moving them to the bus adds infrastructure mass for zero architectural benefit — the producer and consumer are in the same process.
Browser WebSocket. gordon-manager fans out over a single multiplexed /ws to gordon-console. Browsers do not speak NATS. Manager is also the auth boundary: cookies, tokens, and rate limits live there.
gordon-data REST endpoints. /warmup, /klines, /healthz, /sources/health are operator and orchestration surfaces, not event flow.
Invariants
- Every producer dual-writes: domain row +
bus.outboxrow in the same transaction. schema_version: u16is always the first field of every bus payload.- Schema evolution is additive-only — no renames, no removals.
- Consumer names are stable and lowercase-hyphenated. Renaming abandons the JetStream cursor.
- The
pgbackend in gordon-bus 2.x is a compiled-in rollback path. It is not a parity layer. Rollback is a full redeploy, not a hot swap.
Related
- Architecture — full topology diagram.
- BFF Boundary — which traffic is stateful (bus / manager) vs stateless (NATS-WS direct).
- Execution — intent-to-fill flow in detail.