5.3 KiB
SQL Writes Through Raft (C3b) — Design
Date: 2026-07-30 Status: Approved direction (user: "ок ... продължавай"); implementation follows.
Problem
With C3a a real Raft cluster elects a leader over TCP, but SQL writes still
bypass Raft entirely: they go straight to the local LSM and (optionally) to
the legacy ReplicationManager (unconnected in production). Committed Raft
entries can be applied (applyCommand is wired and invoked on commit), yet
nothing ever calls appendLog from the write path.
Goal
When Raft is enabled, SQL writes are committed through the Raft log before the client sees success:
- Leader: execute locally (constraints validated as today), append the resulting KV pairs to the Raft log, wait for majority commit, then return.
- Followers: reject write statements with a clear "not leader" error naming
the known leader; apply committed entries via the existing
applyCommandloop (raft.nim:195-215). - Non-raft deployments: byte-identical behavior to today.
Non-goals (C3c+): leader forwarding/proxy, read consistency levels, schema/DDL replication (DDL produces no keyValuePairs today — out of scope), membership, snapshots.
Design
Write interception point
core/server.nim:206-227 — the server-level executeQuery parses the AST
(server.nim:213) and, after successful execution, already ships
res.keyValuePairs to the ReplicationManager (server.nim:219-227). The Raft
path slots into the same place with the same data source:
- Classify the statement right after parse: write =
stmt.kind in {nkInsert, nkUpdate, nkDelete, nkMerge}(DDL is out of scope; SELECT unaffected). New helperisWrite(stmt)inexec/params.nimnext toisDDL. - If Raft is active for this server (
server.raftNode != nil):- Not leader (
node.state != rsLeader): return errornot leader; leader is '<node.leaderId>'(orno leader elected). The statement is NOT executed. - Leader: execute as today. If
res.successandres.keyValuePairs.len > 0: for each(key, value)append one log entry —cmd = "put"with datakey \x00 value, orcmd = "delete"with datakeywhen value is empty (this is EXACTLY the format the existingapplyCommandin baradadb.nim:336-343 already consumes — no format changes, frozen). Then wait untilnode.commitIndexreaches the last appended index (see below) before returningres. - The legacy
replication.writeLsnhook is skipped when the Raft write path handled the statement (no double shipping).
- Not leader (
- Single-node raft (enabled, no peers): majority is self — appends commit immediately; behavior is correct with negligible overhead.
Wait-for-commit
No raft.nim changes (the state machine stays TLA-frozen). Poll from
server.nim: after appending, while node.commitIndex < lastIdx: sleep 10ms,
up to config.raftWriteTimeoutMs (new env BARADB_RAFT_WRITE_TIMEOUT_MS,
default 5000). Commit advances on the 50ms heartbeat cadence via the
existing handleAppendReply/applyCommitted path, so typical latency is
one heartbeat. On timeout: return an error (raft commit timeout) — the
entry may still commit later; documented limitation. Local execution already
happened (it validates constraints and produces kvPairs) — leader double-
applies via applyCommand on commit, which is idempotent for put/delete KV.
Server wiring
Server gains raftNode*: RaftNode (nil by default) in core/server.nim;
baradadb.nim assigns it when raft is enabled (the node already exists there).
server.nim imports core/raft (no cycle: raft.nim does not import server).
Known v1 limitations (documented in code)
- Leader losing leadership between local execution and appendLog (narrow
race): appendLog returns the empty entry (index 0) — surfaced as a
lost leadershiperror; the local write already applied (uncommitted). - Transactional writes: kvPairs are emitted at COMMIT (executor.nim:970-979) — the COMMIT statement is the raft-replicated unit (classify nkCommitTxn? — resolve in planning: COMMIT's kvPairs flow through the same hook since the existing replication hook already catches them).
- DDL is not replicated (no kvPairs today).
Testing
- Unit (
tests/bugfix_test.nim):isWriteclassification for INSERT/UPDATE/DELETE/MERGE vs SELECT/DDL. - Follower rejection (in-process or E2E): write on a follower returns the "not leader" error naming the leader; the statement is not executed.
- E2E (extend
tests/raft_e2e_test.nimor sibling): 3 real nodes; write a row on the leader via the Nim client (adaptors/nim/baradb_sqlite pattern from nimforum_smoke_test); poll a SELECT on a follower until the row appears (deadline ~5s — follower applies on commit via applyCommand); write on a follower → rejected; kill leader → re-election → writes work on the new leader. - Full
nimble testgreen (673+[OK]).
Risks
- Double application on the leader (executor + applyCommand): idempotent KV semantics — verify put/delete idempotency holds for the value encoding (same key → same value; delete of existing key).
node.log[idx-1]positional indexing in the leader commit loop (raft.nim:407) — pre-existing, out of scope unless the E2E trips it.- Apply lag on followers (heartbeat cadence): tests poll with deadlines, never fixed sleeps.