Architecture Overview
NodeDB splits work across three planes connected by lock-free ring buffers. Each plane does exactly what it is best at and nothing else. Mixing planes is a correctness bug.
┌───────────────────────────────────────────┐
│ Control Plane (Tokio) │
│ SQL parsing, query planning, connections │
│ Send + Sync, async │
└─────────────┬──────────────┬──────────────┘
│ SPSC Bridge │ Event subscriptions
│ │
┌─────────────▼──────────┐ ┌▼────────────────────────────────────┐
│ Data Plane (TPC) │ │ Event Plane (Tokio) │
│ Physical execution ├─►│ AFTER trigger dispatch │
│ Storage I/O, SIMD │ │ CDC change streams │
│ !Send, io_uring │ │ Cron scheduler │
│ Emits WriteEvents │ │ Durable pub/sub, webhook delivery │
└────────────────────────┘ └─────────────────────────────────────┘
Plane Boundaries
| Plane | Does | Does not do |
| Control Plane | SQL parsing, query planning, connection handling | Event processing, trigger execution, storage I/O |
| Data Plane | Physical I/O, SIMD math, WAL append, BEFORE triggers | Event delivery, AFTER triggers, cross-shard work |
| Event Plane | AFTER triggers, CDC, cron, webhooks, durable pub/sub | Query planning, storage I/O, TPC tasks |
If code needs to cross a plane boundary, it goes through the SPSC bridge (Control-Data) or the Event Bus (Data-Event).
Query Entry Paths
SQL path — All user-facing interfaces accept SQL. The Control Plane parses via sqlparser, plans via EngineRules, and dispatches a SqlPlan through the SPSC bridge to the Data Plane.
psql / ndb CLI / HTTP /v1/query
→ SQL parser (sqlparser-rs)
→ EngineRules::plan_*()
→ SqlPlan → SPSC Bridge → Data Plane
Native opcode path — The Rust SDK and FFI/WASM bindings dispatch typed opcode messages over the NDB protocol. The Control Plane converts them directly to a plan, skipping SQL parsing.
nodedb-client / FFI / WASM
→ Native opcode + typed fields
→ build_plan()
→ PhysicalPlan → SPSC Bridge → Data Plane
Both paths produce the same plan and execute identically on the Data Plane.
Cross-Engine Identity
Every row in every engine — document, KV, columnar, timeseries, spatial, vector, array, graph node, FTS posting — carries a stable global u32 surrogate allocated at insert from a WAL-durable, Raft-replicated monotonic counter. Every engine keys its internal indexes on the surrogate, so cross-engine prefilter and join reduce to roaring-bitmap intersections with zero per-query translation.
A query like "find product cells whose embedding is near $q, that have FTS hits for 'memory leak', that live within 5km of point P, in tenant 42" turns into:
vector_index.search($q, k) → roaring bitmap A
fts_index.match("memory leak") → roaring bitmap B
spatial_index.dwithin(P, 5km) → roaring bitmap C
metadata_index.tenant_id = 42 → roaring bitmap D
A ∩ B ∩ C ∩ D → final candidate surrogate set
No HashMap<surrogate, doc_id> translations between hops. Adding a new engine does not require new translation paths — it just allocates surrogates from the same counter. This is the mechanism behind every cross-engine query example you'll see in the SQL reference.