17 Commits

Author SHA1 Message Date
dimgigov a843f0a1a3 fix(raft): gate snapshot build/restore, repoint http ctx, pre-tag docs
CI / test (push) Has been cancelled
CI / raft-e2e (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
2026-07-31 04:46:05 +03:00
dimgigov dac92d1741 release: prepare v1.3.0 changelog and version bump 2026-07-31 04:16:11 +03:00
dimgigov d303cc5658 docs: v1.3.0 raft-supported docs, limitations, runbook updates 2026-07-31 04:16:11 +03:00
dimgigov ad90ebcd5e test(raft): cold-node rejoin and wiped-node join e2e
Includes two raft fixes the e2e exposed:
- heartbeatLoop drops cached peer sockets on leadership acquisition
  (half-dead sockets to a restarted peer never errored, so no redial
  ever happened and the cluster livelocked without heartbeats)
- restoreSnapshot repoints tcpServer ctx/db at the reopened database
  (queries kept reading the closed pre-restore LSM, serving 0 rows)
2026-07-31 03:53:48 +03:00
dimgigov 9ff9c2f6be feat(raft): compaction unpinned from stale peers (snapshot fallback) 2026-07-31 03:12:06 +03:00
dimgigov c94bac43e5 fix(raft): ignore intermediate InstallSnapshot chunk replies 2026-07-31 03:02:32 +03:00
dimgigov 862d62590e feat(raft): leader InstallSnapshot send on unrecoverable lag 2026-07-31 02:53:30 +03:00
dimgigov efa04e4b36 feat(raft): follower InstallSnapshot receive and restore 2026-07-31 02:33:32 +03:00
dimgigov cb9cd7415d feat(raft): InstallSnapshot wire protocol (backward-compatible) 2026-07-31 02:12:51 +03:00
dimgigov f416fe930e test(raft): wire-level TLS handshake assertion in tls e2e 2026-07-31 02:05:43 +03:00
dimgigov f2b7ed1ce2 test(raft): 3-node TLS cluster e2e with plaintext rejection 2026-07-31 01:55:43 +03:00
dimgigov 2f30a59216 feat(raft): TLS on follower→leader SQL forwarding 2026-07-31 01:45:59 +03:00
dimgigov ed89c88afa feat(raft): optional TLS on raft transport (server + dialer) 2026-07-31 01:31:20 +03:00
dimgigov 8d083f5fdc feat(raft): TLS config surface with fail-closed startup 2026-07-31 01:14:20 +03:00
dimgigov efa46b05c6 ci(raft): dedicated mandatory raft e2e job; no silent skips under CI 2026-07-31 01:07:11 +03:00
dimgigov 431334b70a fix(raft): distinguish put-with-empty-value from delete in write path 2026-07-31 00:58:51 +03:00
dimgigov 63cb05afe2 test(raft): failover under sustained write load e2e 2026-07-31 00:35:55 +03:00
32 changed files with 3611 additions and 91 deletions
+24
View File
@@ -61,6 +61,30 @@ jobs:
done
echo "--- Done ---"
raft-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Nim
uses: jiro4989/setup-nim-action@v1
with:
nim-version: '2.2.10'
- name: Install system dependencies
run: sudo apt-get update -qq && sudo apt-get install -y -qq libssl-dev libpcre3-dev openssl ca-certificates
- name: Install Nim dependencies
run: nimble install --depsOnly -y
- name: Build server
run: nim c -d:ssl -o:build/baradadb src/baradadb.nim
- name: Raft e2e suites
env:
CI: "true"
run: |
nim c -d:ssl --threads:on --path:src -r tests/raft_e2e_test.nim
nim c -d:ssl --threads:on --path:src -r tests/raft_writes_e2e_test.nim
nim c -d:ssl --threads:on --path:src -r tests/raft_failover_load_e2e_test.nim
nim c -d:ssl --threads:on --path:src -r tests/raft_tls_e2e_test.nim
nim c -d:ssl --threads:on --path:src -r tests/raft_coldnode_e2e_test.nim
verify:
runs-on: ubuntu-latest
steps:
+3
View File
@@ -16,6 +16,9 @@ tests/bugfix_test
tests/nimforum_smoke_test
tests/raft_e2e_test
tests/raft_writes_e2e_test
tests/raft_failover_load_e2e_test
tests/raft_tls_e2e_test
tests/raft_coldnode_e2e_test
benchmarks/bench_all
benchmarks/compare
clients/nim/tests/test_client
+25
View File
@@ -2,6 +2,31 @@
All notable changes to BaraDB are documented in this file.
## [1.3.0] — 2026-07-30
### Raft cluster — Supported (single `default` DB scope)
Raft 3-node moves from **Experimental** to **Supported** for the covered scope; single-node remains Production GA. Spec/plan: `docs/superpowers/specs/2026-07-30-raft-supported-design.md`, `docs/superpowers/plans/2026-07-30-v1.3.0-raft-supported.md`.
- **Failover under load proven** — `tests/raft_failover_load_e2e_test.nim`: sustained INSERT load, leader killed at ≥ 50 acked writes; every **acknowledged** write survives on both survivors. Client contract: in-flight writes during failover fail fast with an error — clients must retry ([distributed.md](docs/en/distributed.md))
- **Mandatory CI gate** — dedicated `raft-e2e` GitHub Actions job runs all five raft e2e suites; a missing server binary is a hard FAIL under CI (no silent skip)
- **Raft-port TLS** — `BARADB_RAFT_TLS_ENABLED` + `BARADB_RAFT_TLS_CERT_FILE` / `BARADB_RAFT_TLS_KEY_FILE` / `BARADB_RAFT_TLS_CA_FILE` / `BARADB_RAFT_TLS_VERIFY_PEER`; fail-closed startup when cert/key is missing; optional mutual auth; follower→leader SQL forwarding is TLS-wrapped when the client port is. E2E `tests/raft_tls_e2e_test.nim` (full-TLS cluster works; plaintext node excluded)
- **InstallSnapshot cold-node recovery** — backward-compatible wire protocol (`RaftProtoVersion` stays 1); the leader streams a `tar.gz` snapshot of the default DB in chunks of `BARADB_RAFT_SNAP_CHUNK_KB` KiB (default 256) to peers whose lag is unrecoverable; the follower restores via the backup/restore path and resumes from the snapshot base. Leader compaction unpins from peers stale beyond `BARADB_RAFT_PEER_STALE_MS` (default 30000). E2E `tests/raft_coldnode_e2e_test.nim` (returning node and wiped node converge automatically)
### Fixes
- **Raft put/delete encoding** — `ExecResult.keyValuePairs` carries an explicit `deleted` flag; an INSERT into a PK-only table (empty value) is no longer encoded as a `delete` and erased on apply; regression tests in `tests/bugfix_test.nim`
- **Rejoin livelock** — on leadership acquisition the leader drops cached peer sockets; half-dead sockets to a restarted peer previously never errored, so no redial ever happened and the cluster livelocked without heartbeats
- **Post-restore ctx repoint** — after an InstallSnapshot restore, the TCP serving ctx/db is repointed at the reopened database (queries previously read the closed pre-restore LSM and served 0 rows). Remaining limitation for startup-captured HTTP ctx: see [known-limitations](docs/en/known-limitations.md)
- **Intermediate InstallSnapshot chunk replies ignored** — the leader acts only on the final chunk reply
### Release
- `baradadb.nimble``1.3.0`; `/health` and startup version strings updated
- Docs: [distributed](docs/en/distributed.md) (failover contract, raft TLS setup, snapshot tunables), [known-limitations](docs/en/known-limitations.md) (raft supported scope + two newly documented limitations), [release-checklist](docs/en/release-checklist.md)
---
## [1.2.0] — 2026-07-30
### Production GA (single-node)
+3 -3
View File
@@ -1576,7 +1576,7 @@ features are still being refined:
| LSM-Tree SSTable reads | ✅ Implemented | Full disk I/O with compaction, WAL, and bloom filters. |
| HNSW vector search | ✅ Implemented | Hierarchical graph navigation with SIMD-optimized distance metrics. |
| TCP server execution | ✅ Implemented | Full binary wire protocol parsing and BaraQL query execution. |
| Raft consensus | ⚡ Experimental cluster | TCP election + SQL/DDL via log; single-node is **Production GA**. See `docs/en/known-limitations.md`. |
| Raft consensus | ✅ Supported (3-node, `default` DB) | TCP election + SQL/DDL via log; failover under load, raft TLS, InstallSnapshot recovery e2e-proven. See `docs/en/known-limitations.md`. |
| Graph / FTS / Columnar | ✅ Implemented | In-memory engines with serialization; FTS/vector/graph indexes persist across restarts. |
| Query codegen | ✅ Implemented | IR plans compile to storage engine operations with optimization passes. |
@@ -1585,10 +1585,10 @@ reflects 100% completion across all major phases.
## Changelog
See [CHANGELOG.md](CHANGELOG.md) for full release history. Package version is **v1.2.0**.
See [CHANGELOG.md](CHANGELOG.md) for full release history. Package version is **v1.3.0**.
- **Raft multi-node supported (v1.3.0):** failover under load, mandatory CI gate, raft TLS, InstallSnapshot cold-node recovery — [distributed.md](docs/en/distributed.md), [known-limitations](docs/en/known-limitations.md)
- **Production GA (single-node):** auth-on prod compose, backup/restore drill, runbook — [known-limitations](docs/en/known-limitations.md), [deployment](docs/en/deployment.md)
- **Raft multi-node:** experimental — [distributed.md](docs/en/distributed.md)
## License
+3 -1
View File
@@ -1,5 +1,5 @@
# Package
version = "1.2.0"
version = "1.3.0"
author = "BaraDB Team"
description = "BaraDB — Multimodal database written in Nim"
license = "BSD-3-Clause"
@@ -30,6 +30,8 @@ task test, "Run all tests":
for t in ["test_minimal", "test_all", "bugfix_test", "join_tests", "test_lock",
"test_schema_persist", "test_storage_hardening", "tla_faithfulness",
"nimforum_smoke_test", "raft_e2e_test", "raft_writes_e2e_test",
"raft_failover_load_e2e_test", "raft_tls_e2e_test",
"raft_coldnode_e2e_test",
"fuzz_test", "prop_test",
"test_wire_insert_stress", "stress_test"]:
exec "nim c -r tests/" & t & ".nim"
+13 -2
View File
@@ -5,7 +5,7 @@ BaraDB поддържа разпределено внедряване с Raft к
> ⚠️ **Ограничение при множество бази данни**
> Разпределените модули (Raft, шардиране и репликация) в момента работят само с **`default`** базата данни. Ако използвате множество бази (`CREATE DATABASE`, `USE DATABASE`), разпределените функции още не ги обхващат. Всяка база данни се нуждае от отделна кластър конфигурация.
> **Статус (2026-07-30):** Raft C3a (мрежова election), C3b (SQL записи), DDL репликация, leader forwarding, log compaction и metrics са **на `main`**. Преглед: `docs/superpowers/specs/2026-07-30-raft-cluster-status.md`.
> **Статус (2026-07-30, v1.3.0):** Raft е **supported** за обхвата single-`default`-DB: failover под товар, raft TLS и cold-node recovery чрез InstallSnapshot са e2e-доказани. Преглед: `docs/superpowers/specs/2026-07-30-raft-cluster-status.md`.
## Raft Консенсус
@@ -20,10 +20,21 @@ Leader election и log репликация през TCP; SQL DML/DDL за **def
| `BARADB_RAFT_WRITE_TIMEOUT_MS` | Макс. изчакване за majority commit при SQL записи (по подразбиране 5000) |
| `BARADB_RAFT_CLIENT_PEERS` | Опционален `id@host:clientPort` map за leader write forwarding |
| `BARADB_RAFT_LOG_MAX_ENTRIES` | Лимит на in-memory raft log (по подразбиране 256); safe prefix compact |
| `BARADB_RAFT_SNAP_CHUNK_KB` | Размер на InstallSnapshot chunk в KiB (по подразбиране 256) |
| `BARADB_RAFT_PEER_STALE_MS` | Peer е „stale“ след толкова ms без ack (по подразбиране 30000); stale peers не блокират compaction |
| `BARADB_RAFT_TLS_ENABLED` | TLS на raft порта (по подразбиране false; стартът спира при липсващ cert/key) |
| `BARADB_RAFT_TLS_CERT_FILE` / `BARADB_RAFT_TLS_KEY_FILE` | Сертификат и ключ за raft listener-а |
| `BARADB_RAFT_TLS_CA_FILE` / `BARADB_RAFT_TLS_VERIFY_PEER` | Опционален CA bundle и mutual auth (по подразбиране false) |
Когато Raft е активен, SQL DML и schema DDL се приемат само от лидера на **`default`**. DML отива като put/delete; DDL — като `ddl` запис. Followers **препращат** write/DDL към лидера, ако е зададен `BARADB_RAFT_CLIENT_PEERS`; иначе връщат `not leader; leader is '…'`. Записи към друга database name се отказват. `CREATE`/`DROP DATABASE` не се репликират. Приложен DML обновява и secondary индекси/графи.
**Log compaction (v1):** след apply node-ът може да изреже safe prefix, когато log-ът надхвърли `BARADB_RAFT_LOG_MAX_ENTRIES`. Leader не реже след matchIndex на peer (catch-up с AppendEntries). Snapshot metadata се пази в `raft_state.bin`.
**Log compaction:** след apply node-ът може да изреже safe prefix, когато log-ът надхвърли `BARADB_RAFT_LOG_MAX_ENTRIES`. На лидера safe prefix се смята само по peers с ack в рамките на `BARADB_RAFT_PEER_STALE_MS`; stale peers се възстановяват със snapshot при завръщане. Snapshot metadata се пази в `raft_state.bin`.
**Snapshot recovery (InstallSnapshot, v1.3.0):** когато изоставането на follower е невъзстановимо, лидерът изпраща `tar.gz` snapshot на default DB на chunk-ове от `BARADB_RAFT_SNAP_CHUNK_KB` KiB. Follower-ът го възстановява през backup/restore пътя и продължава catch-up. Върнат след дълъг прекъсване или **изтрит** (wiped data dir, същото node id) възел конвергира автоматично. E2E: `tests/raft_coldnode_e2e_test.nim`.
**Клиентски договор при failover:** запис, който е in-flight при смяна на лидера, **гърми бързо с грешка** — клиентът трябва да го повтори (retry). Всеки **потвърден** (acknowledged) запис оцелява failover-а и е наличен на новия лидер и на наваксалите followers. E2E: `tests/raft_failover_load_e2e_test.nim`.
**Raft TLS (v1.3.0):** `BARADB_RAFT_TLS_ENABLED=true` + cert/key на всеки възел; стартът спира при липсващ cert/key. Целият клъстер трябва да е в един и същ режим — plaintext възел не може да говори с TLS порт и е изключен от клъстера (`tests/raft_tls_e2e_test.nim`). Forwarding-ът follower→leader се обвива в TLS, когато клиентският wire порт е с TLS.
**Metrics:** при включен raft `GET /metrics` (HTTP = `BARADB_PORT + 440`) дава Prometheus редове: `baradb_raft_is_leader`, `baradb_raft_term`, `baradb_raft_log_entries`, `baradb_raft_apply_lag`, `baradb_raft_commit_wait_ms_total`, `baradb_raft_elections_total`, `baradb_raft_forwards_total`, `baradb_raft_compactions_total`. `GET /health` включва обект `raft` (`role`, `term`, `leader_id`, …).
+17 -6
View File
@@ -1,4 +1,4 @@
# Известни ограничения — v1.2.0 Production GA
# Известни ограничения — v1.3.0
| Ниво | Значение |
|------|----------|
@@ -8,25 +8,36 @@
## Матрица
| Област | GA (v1.2.0) | Experimental / по-късно |
|--------|-------------|-------------------------|
| Област | v1.3.0 | Experimental / по-късно |
|--------|--------|-------------------------|
| Single-node SQL + LSM | **Supported** | — |
| Schema / FTS / HNSW / graphs persist | **Supported** | — |
| Auth + JWT (когато е конфигуриран) | **Supported** | — |
| Backup / restore | **Supported** | — |
| Multi-DB (без Raft) | **Supported** | — |
| Raft 3-node + SQL/DDL | **Experimental** | InstallSnapshot, membership |
| Raft 3-node (само `default` DB) | **Supported** | failover под товар, TLS, InstallSnapshot — e2e |
| Raft multi-DB | **Not supported** | само `default` |
| `CREATE`/`DROP DATABASE` репликация | **Not supported** | per node |
| Raft membership промени (join/leave) | **Not supported** | фиксиран `BARADB_RAFT_PEERS` |
| Follower linearizable reads | **Not supported** | best-effort след apply |
| Rolling upgrades | **Not supported** | рестарт на всички възли заедно — смесени v1.2/v1.3 binaries не трябва да работят в един клъстер |
| ORC multi-thread shared LSM | **Not supported** | ARC по подразбиране |
## GA (single-node)
Crash recovery с WAL, schema/index persist, `/health` + `/metrics`, offline backup/restore.
## Raft
## Raft (supported)
Виж [distributed.md](distributed.md). Staging/ops, **не** v1.2.0 HA продукт.
Виж [distributed.md](distributed.md). Поддържан обхват: 3-node, DML/DDL само върху `default`, failover под товар (acked writes оцеляват; in-flight writes → грешка, retry), raft TLS, cold-node recovery чрез InstallSnapshot.
## Нови ограничения
- **Legacy REP replication (без raft)** — пътят още извежда delete от празна стойност; insert в PK-only таблица се прилага грешно по него (редът изчезва). Използвай raft.
- **Snapshot-restore ctx** — след InstallSnapshot restore HTTP endpoints със startup-captured ctx може да сервират стари данни до рестарт (`/query` е свеж per-request); съществуващите клиентски връзки виждат pre-restore състояние — reconnect след restore.
- **FK-cascade дивергенция под raft** — ефектите на `ON DELETE/UPDATE CASCADE``SET NULL`) не се реплицират през raft: followers прилагат само KV промяната на родителския ред, така че каскадираните дъщерни редове остават на followers. Избягвай FK actions върху raft-реплицирани таблици или приеми периодичен snapshot resync.
- **Непотвърдени записи в snapshots** — leader прилага записите локално преди raft majority commit; snapshot, направен в този прозорец, може да включи записи, които никога не се commit-ват (фантомни редове след restore + смяна на leadership). Тесен прозорец; поправката е планирана за следващ release.
- **Блокиране на event loop при snapshot build/restore** — snapshot build/restore изпълнява блокиращ tar/gzip на event loop на възела; големи data dirs могат да забавят heartbeats и да предизвикат election по средата на трансфер.
## Виж също
+18 -2
View File
@@ -5,7 +5,7 @@ BaraDB supports distributed deployment with Raft consensus, sharding, and replic
> ⚠️ **Multi-Database Limitation**
> The distributed modules (Raft, sharding, and replication) are currently wired to the **`default`** database only. If you use multiple databases (`CREATE DATABASE`, `USE DATABASE`), distributed features do not yet span across them. Each database would need its own cluster setup.
> **Status (2026-07-30):** Raft C3a/C3b + DDL/forward/compact/metrics are **shipped**. Multi-node Raft is **experimental** for v1.2.0 GA (single-node is the production tier). See [known-limitations](known-limitations.md) and `docs/superpowers/specs/2026-07-30-raft-cluster-status.md`.
> **Status (2026-07-30, v1.3.0):** Raft C3a/C3b + DDL/forward/compact/metrics are **shipped**, and multi-node Raft is **supported** for the single-`default`-DB scope (failover under load, raft TLS, InstallSnapshot cold-node recovery — all e2e-proven). See [known-limitations](known-limitations.md) and `docs/superpowers/specs/2026-07-30-raft-cluster-status.md`.
## Raft Consensus
@@ -20,10 +20,23 @@ Leader election and log replication over TCP; SQL DML/DDL on the default DB go t
| `BARADB_RAFT_WRITE_TIMEOUT_MS` | Max wait for majority commit on SQL writes (default 5000) |
| `BARADB_RAFT_CLIENT_PEERS` | Optional `id@host:clientPort` map for leader write forwarding |
| `BARADB_RAFT_LOG_MAX_ENTRIES` | Soft cap on in-memory raft log length (default 256); safe prefix compact |
| `BARADB_RAFT_SNAP_CHUNK_KB` | InstallSnapshot chunk size in KiB (default 256) |
| `BARADB_RAFT_PEER_STALE_MS` | Peer is stale after this many ms without an ack (default 30000); stale peers no longer pin log compaction |
| `BARADB_RAFT_TLS_ENABLED` | TLS on the raft TCP port (default false; fail-closed startup if cert/key missing) |
| `BARADB_RAFT_TLS_CERT_FILE` | Server certificate for the raft listener |
| `BARADB_RAFT_TLS_KEY_FILE` | Private key for the raft listener |
| `BARADB_RAFT_TLS_CA_FILE` | Optional CA bundle for peer verification |
| `BARADB_RAFT_TLS_VERIFY_PEER` | Mutual auth — verify client certificates (default false) |
When Raft is enabled, SQL DML (`INSERT`/`UPDATE`/`DELETE`/`MERGE` and transactional `COMMIT`) and schema DDL (`CREATE`/`DROP`/`ALTER` table, index, view, graph, …) are accepted only on the leader of the **`default`** database. DML ships as put/delete log entries; DDL ships as a `ddl` entry with the original SQL and is re-executed on every node at apply. Followers that receive a write/DDL **forward** it to the leader when `BARADB_RAFT_CLIENT_PEERS` maps the leader id to a SQL client address; otherwise they return `not leader; leader is '…'`. Writes against any other database name are rejected (`raft writes only supported on the 'default' database`). `CREATE`/`DROP DATABASE` are not raft-replicated (multi-DB is out of scope for v1). Committed DML also updates secondary B-tree/FTS/HNSW indexes and in-memory graphs.
**Log compaction (v1):** after apply, each node may drop a fully-safe log prefix once `log.len` exceeds `BARADB_RAFT_LOG_MAX_ENTRIES`. The leader never discards past any peer's `matchIndex` (so lagging followers still catch up via AppendEntries). Snapshot metadata (`lastSnapshotIndex`/`Term`) is persisted in `raft_state.bin`; full InstallSnapshot state-machine payloads are not required while this safe-prefix policy holds.
**Log compaction:** after apply, each node may drop a fully-safe log prefix once `log.len` exceeds `BARADB_RAFT_LOG_MAX_ENTRIES`. On the leader, the safe prefix is computed only over peers that acked within `BARADB_RAFT_PEER_STALE_MS` — stale peers no longer pin compaction and are recovered by snapshot on return. Compaction never goes past `lastApplied`. Snapshot metadata (`lastSnapshotIndex`/`Term`) is persisted in `raft_state.bin`.
**Snapshot recovery (InstallSnapshot, v1.3.0):** when a follower's lag is unrecoverable (the entries it needs were compacted away), the leader builds a `tar.gz` snapshot of the default DB and streams it as `BARADB_RAFT_SNAP_CHUNK_KB`-sized chunks. The follower restores it via the backup/restore path, adopts the snapshot base as its `commitIndex`/`lastApplied`, and resumes normal AppendEntries catch-up. A node that returns after a long outage — and a **wiped** node (data dir deleted, same node id) — both converge automatically through this path. Proven by `tests/raft_coldnode_e2e_test.nim`.
**Client failover contract:** a write that is in flight when the leader dies **fails fast with an error** — the client must retry it (against the new leader, or any follower if `BARADB_RAFT_CLIENT_PEERS` forwarding is configured). Every write the server **acknowledged** survives the failover and is present on the new leader and all caught-up followers. Proven by `tests/raft_failover_load_e2e_test.nim` (leader killed under sustained INSERT load; all acked writes found on both survivors).
**Raft TLS (v1.3.0):** set `BARADB_RAFT_TLS_ENABLED=true` plus `BARADB_RAFT_TLS_CERT_FILE`/`BARADB_RAFT_TLS_KEY_FILE` on every node; startup fails closed if the cert or key is missing. Add `BARADB_RAFT_TLS_CA_FILE` and `BARADB_RAFT_TLS_VERIFY_PEER=true` for mutual authentication. The whole cluster must run the same mode: a plaintext node cannot speak to a TLS port (its frames are undecryptable) and is excluded from the cluster — proven by `tests/raft_tls_e2e_test.nim`. Follower→leader SQL forwarding is TLS-wrapped automatically when the server's client wire port has TLS enabled.
**Metrics:** with raft enabled, `GET /metrics` (HTTP port = `BARADB_PORT + 440`) includes Prometheus lines such as `baradb_raft_is_leader`, `baradb_raft_term`, `baradb_raft_log_entries`, `baradb_raft_apply_lag`, `baradb_raft_commit_wait_ms_total`, `baradb_raft_elections_total`, `baradb_raft_forwards_total`, and `baradb_raft_compactions_total`. `GET /health` embeds a `raft` object (`role`, `term`, `leader_id`, `commit_index`, `apply_lag`, …).
@@ -67,6 +80,9 @@ let entry = n1.appendLog("SET key1 value1")
|------|----------------|
| `tests/raft_e2e_test.nim` | 3 real processes; election + kill-leader failover |
| `tests/raft_writes_e2e_test.nim` | DDL/DML via raft, follower forward, index SELECT, failover writes |
| `tests/raft_failover_load_e2e_test.nim` | Leader killed under sustained write load; every acked write survives |
| `tests/raft_tls_e2e_test.nim` | Full-TLS 3-node cluster works; plaintext node excluded |
| `tests/raft_coldnode_e2e_test.nim` | Returning node and wiped node converge via InstallSnapshot |
## Sharding
+23 -12
View File
@@ -1,6 +1,6 @@
# Known Limitations — v1.2.0 Production GA
# Known Limitations — v1.3.0
This page defines **what BaraDB promises** in the v1.2.0 production cut.
This page defines **what BaraDB promises** in the v1.3.0 production cut.
| Tier | Meaning |
|------|---------|
@@ -10,20 +10,22 @@ This page defines **what BaraDB promises** in the v1.2.0 production cut.
## Support matrix
| Area | GA (v1.2.0) | Experimental / later |
|------|-------------|----------------------|
| Area | v1.3.0 | Notes |
|------|--------|-------|
| Single-node SQL + LSM storage | **Supported** | — |
| Schema persistence (tables, indexes) | **Supported** | — |
| FTS / HNSW / graphs across restart | **Supported** | — |
| Auth + JWT (when configured) | **Supported** | — |
| Backup / restore (offline, all-databases) | **Supported** | — |
| Multi-database (non-Raft) | **Supported** | — |
| Raft 3-node election + SQL/DDL | **Experimental** | InstallSnapshot SM payload, membership |
| Raft 3-node (single `default` DB) | **Supported** | failover under load, raft TLS, InstallSnapshot recovery — e2e-proven |
| Leader write forwarding | **Supported** | needs `BARADB_RAFT_CLIENT_PEERS` |
| Raft multi-database | **Not supported** | only `default` |
| Leader write forwarding | **Experimental** | needs `BARADB_RAFT_CLIENT_PEERS` |
| `CREATE`/`DROP DATABASE` replication | **Not supported** | run per node |
| Raft membership changes (join/leave) | **Not supported** | fixed `BARADB_RAFT_PEERS` set |
| Follower linearizable reads | **Not supported** | best-effort after apply |
| Rolling upgrades | **Not supported** | restart all nodes together — mixed v1.2/v1.3 binaries must not run in one cluster |
| ORC multi-threaded shared LSM | **Not supported** | default is ARC (`nim.cfg`) |
| Zero-downtime rolling upgrade | **Not supported** | stop → backup → upgrade |
| Postgres wire protocol | **Not supported** | Bara wire + HTTP |
## Single-node GA (what you can rely on)
@@ -33,13 +35,22 @@ This page defines **what BaraDB promises** in the v1.2.0 production cut.
- HTTP `/health` and `/metrics` for process liveness
- Offline backup of `data/databases` and restore onto an empty data root
## Raft (experimental ops)
## Raft (supported, single-default-DB scope)
Documented in [distributed.md](distributed.md). Suitable for learning and careful staging; **not** the v1.2.0 HA product tier.
Documented in [distributed.md](distributed.md). Supported scope:
- SQL DML/DDL on **`default` only**
- Safe log prefix compact (not full InstallSnapshot)
- Failover proven in process e2e tests
- 3-node cluster, SQL DML/DDL on **`default` only**
- Failover under write load: every acknowledged write survives a leader kill; in-flight writes fail fast — clients must retry
- TLS on the raft port and on follower→leader forwarding (`BARADB_RAFT_TLS_*`)
- Cold-node recovery via InstallSnapshot (`BARADB_RAFT_SNAP_CHUNK_KB`, `BARADB_RAFT_PEER_STALE_MS`)
## Newly documented limitations
- **Legacy non-raft REP replication infers delete from empty value** — the non-raft replication path still treats an empty value as a delete, so inserts into a PK-only table are misapplied over that path (the row vanishes). Use raft replication instead.
- **Snapshot-restore ctx staleness** — after an InstallSnapshot restore, HTTP endpoints using the startup-captured ctx may serve stale data until the node is restarted; the `/query` path is fresh per-request. Pre-existing client connections likewise see pre-restore state — reconnect after a restore.
- **FK-cascade divergence under raft** — `ON DELETE/UPDATE CASCADE` (and `SET NULL`) effects are not raft-replicated: followers only apply the parent row's KV change, so cascaded child rows persist on followers. Avoid FK actions on raft-replicated tables, or accept periodic snapshot resync.
- **Uncommitted writes in snapshots** — the leader applies writes locally before raft majority commit; a snapshot taken in that window can include writes that never commit (phantom rows after restore + leadership change). Narrow window; fix tracked for a later release.
- **Event-loop stall during snapshot build/restore** — snapshot build/restore performs blocking tar/gzip on the node's event loop; large data dirs can stall heartbeats and trigger an election mid-transfer.
## Operational requirements
+10 -7
View File
@@ -1,4 +1,4 @@
# Release checklist — v1.2.0 Production GA
# Release checklist — v1.3.0 raft-supported
Use before tagging and publishing artifacts.
@@ -6,8 +6,8 @@ Use before tagging and publishing artifacts.
- [ ] Working tree clean on `main`
- [ ] [Known limitations](known-limitations.md) accurate
- [ ] `CHANGELOG.md` has dated `## [1.2.0]` (not Unreleased for shipped items)
- [ ] `baradadb.nimble` version `1.2.0`
- [ ] `CHANGELOG.md` has dated `## [1.3.0]` (not Unreleased for shipped items)
- [ ] `baradadb.nimble` version `1.3.0`
## Tests
@@ -23,9 +23,12 @@ nim c -d:ssl --threads:on --path:src -r tests/test_schema_persist.nim
./scripts/backup-restore-drill.sh
DRILL_PORT=19482 ./scripts/backup-restore-drill.sh
# Optional cluster e2e (experimental tier)
# Cluster e2e (raft supported tier — all five suites)
./tests/raft_e2e_test
./tests/raft_writes_e2e_test
./tests/raft_failover_load_e2e_test
./tests/raft_tls_e2e_test
./tests/raft_coldnode_e2e_test
```
## Production compose
@@ -41,17 +44,17 @@ docker compose -f docker-compose.prod.yml config >/dev/null
```bash
nimble build_release # or: nim c -d:release -o:build/baradadb src/baradadb.nim
docker build -t baradb:1.2.0 -t baradb:latest .
docker build -t baradb:1.3.0 -t baradb:latest .
```
## Tag
```bash
git tag -a v1.2.0 -m "BaraDB v1.2.0 Production GA (single-node)"
git tag -a v1.3.0 -m "BaraDB v1.3.0 raft-supported"
git push origin main --tags
```
## Post-release
- [ ] Smoke: start prod compose, `/health` → ok, auth required for `/query`
- [ ] Announce: single-node GA; Raft experimental (link known-limitations)
- [ ] Announce: raft-supported release (3-node, `default` DB); link known-limitations
@@ -0,0 +1,598 @@
# v1.3.0 Raft-Supported — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> Spec first: `docs/superpowers/specs/2026-07-30-raft-supported-design.md`.
**Goal:** Move raft from experimental to supported: proven failover under
load, mandatory CI e2e, cold-node recovery via InstallSnapshot, raft-port TLS.
**Architecture:** No changes to election/AppendEntries semantics. New
InstallSnapshot message pair rides the existing framed TCP transport
(backward-compatible trailing fields, `RaftProtoVersion` stays 1). TLS wraps
the existing transport via `protocol/ssl.nim`. Snapshot payload reuses
`core/backup.nim` tar.gz backup/restore.
**Tech stack:** Nim 2.2.x, std/asyncnet + std/net SSL, existing
`tests/raft_*_e2e_test.nim` process harness, GitHub Actions.
## Global Constraints
- Spec: `docs/superpowers/specs/2026-07-30-raft-supported-design.md`.
- Do **not** change election safety, commit rules, or the DDL/DML
classification from C3b/C3c.
- Raft remains default-DB-only; snapshot payload covers the default DB only.
- Compile all touched Nim with `-d:ssl --threads:on --path:src`.
- Test baseline per task: `tests/test_all.nim` + `tests/bugfix_test.nim` must
stay green; raft e2e suites green where the binary is built.
- Branch: `main` (short-lived feature branches merged same day are fine).
- Version bump to 1.3.0 happens only in the final task (T12).
---
## Phase map
| Phase | Tasks | Outcome |
|-------|-------|---------|
| P1 Proof | T1 | Failover-under-load e2e |
| P2 CI | T2 | Mandatory raft e2e CI gate |
| P3 TLS | T3T6 | Raft port TLS + mutual auth + TLS e2e |
| P4 Cold node | T7T11 | InstallSnapshot + compaction unpin + cold-node e2e |
| P5 Release | T12 | Docs, limitations, version 1.3.0 |
---
### Task 1: Failover-under-load E2E
**Files:**
- Create: `tests/raft_failover_load_e2e_test.nim`
- Modify: `baradadb.nimble` (task `test`, line ~30-34: add `raft_failover_load_e2e_test` after `raft_writes_e2e_test`)
**Interfaces:**
- Consumes: process harness conventions from `tests/raft_writes_e2e_test.nim`
(`NodeProc`, `drainOutput`, `portOpen`, `openClient`, `waitForRow` pattern);
client `adaptors/nim/baradb_sqlite`.
- Produces: suite `Raft failover under load E2E`.
**Scenario (spec D1):**
- Port base `cbase = 50000 + (tstamp mod 4000)`, `rbase = cbase + 100`
(distinct from 35000/41000/46000 bases already in use).
- Boot 3 nodes with `BARADB_RAFT_*` env exactly as
`raft_writes_e2e_test.nim:150-171`.
- Wait for stable leader (same `maxLeader` logic). Leader DDL:
`CREATE TABLE load_test (id INT PRIMARY KEY)`.
- Load phase: spawn a Nim `Thread` that loops `n = 1, 2, ...`:
`INSERT INTO load_test (id) VALUES (n)` against the current leader's
client port; every **acknowledged** `n` appended to a
`seq[int]` guarded by a `Lock`. On exception: reopen client against a
survivor, continue (this models the documented client retry contract).
- At ≥ 50 acked writes: `killNode(leader)`.
- Assert A (availability): some survivor accepts an INSERT within 10 s of
the kill.
- Assert B (durability): after new leader is stable and the remaining
follower caught up (poll `SELECT count(*)` equality or 10 s deadline),
`SELECT id FROM load_test` on **both** survivors contains every acked id.
- Stop the writer thread in `finally`; reuse the `dumpAll`-on-fail
convention.
- [x] **Step 1:** Write the suite skeleton: harness copied from
`raft_writes_e2e_test.nim` (drain/kill/leader-discovery helpers), writer
thread, kill at 50 acked, asserts A + B.
- [x] **Step 2:** Build binary and run:
`nim c -o:build/baradadb src/baradadb.nim && nim c -d:ssl --threads:on --path:src -r tests/raft_failover_load_e2e_test.nim`
Expected: PASS.
- [x] **Step 3:** Run 3 consecutive times (failover timing flakiness check).
- [x] **Step 4:** Add suite to `nimble test` list in `baradadb.nimble`.
- [x] **Step 5:** Commit
`test(raft): failover under sustained write load e2e`
---
### Task 1a: Fix raft put/delete encoding for empty values (bug found in T1)
**Bug:** `execInsert` (`src/barabadb/query/exec/dml.nim:60-90`) stores only
non-PK columns in the value, so a PK-only table row gets `valStr = ""` and
`kvPairs.add((fullKey, @[]))`. `appendWriteToRaft`
(`src/barabadb/core/server.nim:309-330`) encodes an empty value as a
`"delete"` log entry — but `execDelete` (`dml.nim:223-241`) uses the same
`(fullKey, @[])` shape for real deletes. Result: INSERT into a PK-only
table returns OK after majority commit, then every node (leader included,
on apply) **deletes the row**. Verified live in T1: 30 acked inserts → 0
rows on all nodes.
**Files:**
- Modify: `src/barabadb/query/exec/types.nim:139``ExecResult.keyValuePairs`
- Modify: `src/barabadb/query/exec/dml.nim` — 3 producer sites (insert ~90,
delete ~241, update ~316)
- Modify: `src/barabadb/core/server.nim``appendWriteToRaft` (~309) and
its call site (~441-464)
- Test: `tests/bugfix_test.nim` (new suite)
**Interfaces:**
- Change the pair type to carry the op explicitly:
```nim
# exec/types.nim
keyValuePairs*: seq[tuple[key: string, value: seq[byte], deleted: bool]]
```
- `execInsert`/`execUpdate` produce `deleted: false` (even when
`value.len == 0`); `execDelete` produces `deleted: true`.
- `appendWriteToRaft` encodes `deleted``"delete"`, else `"put"`
(empty value stays a put). Apply side (`baradadb.nim:358-368`) already
handles `put` with empty value correctly — no change needed there.
- Check other `keyValuePairs` consumers compile clean (replication path in
`server.nim`); keep `okResult(kvPairs=...)` call sites type-correct.
**Steps:**
- [x] **Step 1:** Write the failing test in `tests/bugfix_test.nim`:
build `ExecResult` via the insert path for a PK-only table (or call
`appendWriteToRaft` semantics directly): assert a PK-only insert yields
a pair with `deleted == false` and encodes as `"put"`, while a delete
yields `deleted == true` and encodes as `"delete"`.
- [x] **Step 2:** Run, expect fail/compile error.
- [x] **Step 3:** Implement the type + producer/consumer changes.
- [x] **Step 4:** `bugfix_test` + `test_all` green; rebuild
`build/baradadb` and re-run `tests/raft_failover_load_e2e_test.nim`
then **switch its table back** to the brief's original
`load_test (id INT PRIMARY KEY)` / `VALUES (n)` shape (remove the
two-column workaround and its header note) and re-run green.
- [x] **Step 5:** Commit
`fix(raft): distinguish put-with-empty-value from delete in write path`
---
### Task 2: Mandatory raft e2e CI gate
**Files:**
- Modify: `.github/workflows/ci.yml`
- Modify: `tests/raft_e2e_test.nim`, `tests/raft_writes_e2e_test.nim`,
`tests/raft_failover_load_e2e_test.nim` (skip→fail under CI)
**Steps:**
- [x] **Step 1:** In each suite's binary-missing branch, replace plain
`skip()` with:
```nim
if not fileExists(BinaryPath):
if getEnv("CI").len > 0:
echo "[FAIL] ", BinaryPath, " missing under CI — build step broken?"
fail()
else:
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
skip()
```
- [x] **Step 2:** Add a dedicated job to `.github/workflows/ci.yml` (after
the `test` job), modeled on its setup steps:
```yaml
raft-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Nim
uses: jiro4989/setup-nim-action@v1
with:
nim-version: '2.2.10'
- name: Install system dependencies
run: sudo apt-get update -qq && sudo apt-get install -y -qq libssl-dev libpcre3-dev openssl ca-certificates
- name: Install Nim dependencies
run: nimble install --depsOnly -y
- name: Build server
run: nim c -d:ssl -o:build/baradadb src/baradadb.nim
- name: Raft e2e suites
env:
CI: "true"
run: |
nim c -d:ssl --threads:on --path:src -r tests/raft_e2e_test.nim
nim c -d:ssl --threads:on --path:src -r tests/raft_writes_e2e_test.nim
nim c -d:ssl --threads:on --path:src -r tests/raft_failover_load_e2e_test.nim
```
- [x] **Step 3:** Push on a branch; confirm the `raft-e2e` job appears and
is green; confirm deleting `build/` would fail (local run with `CI=true`
and no binary → FAIL).
- [x] **Step 4:** Commit
`ci(raft): dedicated mandatory raft e2e job; no silent skips under CI`
---
### Task 3: Raft TLS config + fail-closed startup
**Files:**
- Modify: `src/barabadb/core/config.nim` (fields after `raftLogMaxEntries`
at lines ~46/88; env parsing after line ~212)
- Modify: `src/baradadb.nim` (raft wiring block, lines 337-377)
- Test: `tests/bugfix_test.nim` (new suite)
**Interfaces:**
- Produces config fields (used by T4/T6):
```nim
raftTlsEnabled*: bool # default false
raftTlsCertFile*: string # default ""
raftTlsKeyFile*: string # default ""
raftTlsCaFile*: string # default ""
raftTlsVerifyPeer*: bool # default false
```
- Env parsing (mirror lines 184-212 style):
```nim
cfg.raftTlsEnabled = parseEnvBool(getEnv("BARADB_RAFT_TLS_ENABLED", ""), cfg.raftTlsEnabled)
cfg.raftTlsCertFile = getEnv("BARADB_RAFT_TLS_CERT_FILE", cfg.raftTlsCertFile)
cfg.raftTlsKeyFile = getEnv("BARADB_RAFT_TLS_KEY_FILE", cfg.raftTlsKeyFile)
cfg.raftTlsCaFile = getEnv("BARADB_RAFT_TLS_CA_FILE", cfg.raftTlsCaFile)
cfg.raftTlsVerifyPeer = parseEnvBool(getEnv("BARADB_RAFT_TLS_VERIFY_PEER", ""), cfg.raftTlsVerifyPeer)
```
- Fail-closed in `baradadb.nim` before `newRaftNetwork`:
```nim
if config.raftTlsEnabled:
if config.raftTlsCertFile.len == 0 or config.raftTlsKeyFile.len == 0 or
not fileExists(config.raftTlsCertFile) or not fileExists(config.raftTlsKeyFile):
raise newException(ValueError,
"BARADB_RAFT_TLS_ENABLED=true but cert/key missing " &
"(BARADB_RAFT_TLS_CERT_FILE / BARADB_RAFT_TLS_KEY_FILE)")
```
- [x] **Step 1:** Write failing test in `tests/bugfix_test.nim`: default
config has `raftTlsEnabled == false`; env `BARADB_RAFT_TLS_ENABLED=true`
+ cert paths parse into config.
- [x] **Step 2:** Run test, expect compile/fail (fields don't exist).
- [x] **Step 3:** Implement fields + env parsing + startup check.
- [x] **Step 4:** Test green; `test_all` still green.
- [x] **Step 5:** Commit
`feat(raft): TLS config surface with fail-closed startup`
---
### Task 4: TLS in RaftNetwork transport
**Files:**
- Modify: `src/barabadb/core/raft.nim` (`RaftNetwork` type ~line 731,
`connectToPeer` ~748, `run` ~857)
- Modify: `src/baradadb.nim` (construct TLSContext, pass to network)
- Test: `tests/test_all.nim` (in-process TLS raft pair)
**Interfaces:**
- Consumes: `protocol/ssl.nim` — `newTLSConfig(certFile, keyFile, caFile,
verifyPeer)`, `newTLSContext`, `wrapClient`, `wrapServer`.
- Produces: `RaftNetwork.tls*: TLSContext` (nil = plaintext, unchanged
default); `newRaftNetwork(node, tls = nil)`.
**Implementation:**
```nim
# raft.nim — in connectToPeer, after successful connect:
if net.tls != nil:
try: net.tls.wrapClient(sock)
except CatchableError:
try: sock.close() except CatchableError: discard
return
# raft.nim — in run, after accept, before receiveLoop:
if net.tls != nil:
try: net.tls.wrapServer(client)
except CatchableError:
client.close()
continue
```
- `baradadb.nim`: build the context when enabled:
```nim
var raftTls: TLSContext = nil
if config.raftTlsEnabled:
raftTls = newTLSContext(newTLSConfig(
config.raftTlsCertFile, config.raftTlsKeyFile,
caFile = config.raftTlsCaFile, verifyPeer = config.raftTlsVerifyPeer))
...
raftNet = newRaftNetwork(raftNode, raftTls)
```
- [x] **Step 1:** Write failing in-process test (`test_all.nim`): two
`RaftNode`s over `RaftNetwork` with a self-signed cert from
`generateSelfSignedCert` (`protocol/ssl.nim:79`) — election completes
over TLS; plaintext dial to the TLS port produces no protocol effect
(no state change, connection dropped).
- [x] **Step 2:** Run, expect fail (no `tls` field).
- [x] **Step 3:** Implement transport changes + wiring.
- [x] **Step 4:** Test green; plaintext raft e2e suites still green
(regression: nil-TLS path untouched).
- [x] **Step 5:** Commit
`feat(raft): optional TLS on raft transport (server + dialer)`
---
### Task 5: TLS for leader SQL forwarding
**Files:**
- Modify: `src/barabadb/core/server.nim` (`forwardQueryToLeader`, lines
210-289)
**Interfaces:**
- Consumes: `protocol/ssl.nim` `wrapClient`; server config `tlsEnabled`,
`certFile`, `keyFile`.
- Produces: `forwardQueryToLeader(host, port, query, tls: TLSContext = nil,
...)`.
- [x] **Step 1:** When the server's client wire port has TLS on
(`server.tls != nil`), wrap the forwarding socket with a client-side
context before sending the wire header; on handshake failure return
`(false, QueryResult(), "leader forward TLS handshake failed")`.
- [x] **Step 2:** Manual check: TLS server + raft forwarding (follower
INSERT forwarded over TLS) works; non-TLS setup unchanged.
- [x] **Step 3:** Commit
`feat(raft): TLS on follower→leader SQL forwarding`
---
### Task 6: Raft TLS E2E
**Files:**
- Create: `tests/raft_tls_e2e_test.nim`
- Modify: `baradadb.nimble` (test list), `.github/workflows/ci.yml`
(raft-e2e job: add this suite)
**Interfaces:**
- Consumes: T3/T4 implementation; harness from T1; openssl CLI for cert
generation (already used by `protocol/ssl.nim`).
**Scenario:**
- Port base `54000 + (tstamp mod 4000)`.
- Generate one self-signed cert per node into the temp data dirs
(`openssl req -x509 ...`, or `generateSelfSignedCert`).
- Boot 3 nodes with `BARADB_RAFT_TLS_ENABLED=true` + per-node cert/key;
assert election + `CREATE TABLE` via raft DDL + one replicated INSERT
visible on a follower.
- Negative: start a 4th process with raft TLS **disabled** pointed at the
same peers; assert the TLS cluster still elects/operates among its 3
members and the plaintext node never becomes leader (its frames are
undecryptable).
- Same `CI`-fail semantics as T2.
- [x] **Step 1:** Write suite; Step 2: run green locally; Step 3: run 3×
(timing); Step 4: wire into `nimble test` + CI job; Step 5: Commit
`test(raft): 3-node TLS cluster e2e with plaintext rejection`
---
### Task 7: InstallSnapshot protocol
**Files:**
- Modify: `src/barabadb/core/raft.nim` (`RaftMessageKind` ~line 80,
`RaftMessage` ~86, `serialize` ~679, `deserializeRaftMessage` ~702)
- Test: `tests/test_all.nim` (serialize/deserialize round-trip)
**Interfaces:**
- Produces:
```nim
# RaftMessageKind += rmkInstallSnapshot, rmkInstallSnapshotReply
# RaftMessage new fields:
snapId*: uint64 # snapshot generation, matches leader's base at build time
snapOffset*: uint64 # byte offset of this chunk within the archive
snapData*: seq[byte] # chunk payload (<= snapChunkBytes)
snapDone*: bool # last chunk
# Reused for this kind: prevLogIndex = snapshot base index,
# prevLogTerm = snapshot base term. Reply uses success/matchIdx as usual.
```
- Serialization: append `snapId`, `snapOffset`, `snapData` (length-prefixed),
`snapDone` **after** `matchIdx`; deserialize each with `if not s.atEnd`
guards (pattern from `loadState`, raft.nim:163-167). Old binaries ignore
trailing bytes; new binaries default missing fields to zero/false.
`RaftProtoVersion` stays 1.
- [x] **Step 1:** Write failing round-trip test: all new fields survive
serialize→deserialize; a buffer serialized by the *old* layout (no
trailing fields) deserializes with zero defaults.
- [x] **Step 2:** Run, expect fail.
- [x] **Step 3:** Implement.
- [x] **Step 4:** Green; existing raft suites still green.
- [x] **Step 5:** Commit
`feat(raft): InstallSnapshot wire protocol (backward-compatible)`
---
### Task 8: Follower snapshot receive + restore
**Files:**
- Modify: `src/barabadb/core/raft.nim` (`RaftNode` — new callback +
incoming-snapshot buffer; `processMessage` ~786)
- Modify: `src/barabadb/core/config.nim` (`raftSnapChunkKb: int`, env
`BARADB_RAFT_SNAP_CHUNK_KB`, default 256; parsing next to the other
`BARADB_RAFT_*` env reads)
- Modify: `src/baradadb.nim` (wire `restoreSnapshot` callback using
`core/backup.nim` + `DatabaseRegistry`; pass chunk size to the node)
- Test: `tests/test_all.nim`
**Interfaces:**
- Produces on `RaftNode`:
```nim
snapChunkBytes*: int # from BARADB_RAFT_SNAP_CHUNK_KB, default 262144
restoreSnapshot*: proc(archivePath: string, baseIndex: uint64,
baseTerm: uint64): bool {.gcsafe.}
snapIncomingId*: uint64
snapIncomingFile*: string # temp path under dataDir/raft/snap_incoming/
```
- `processMessage` case `rmkInstallSnapshot`: append `snapData` at
`snapOffset` to the temp file (create/truncate when `snapId !=
snapIncomingId`); on `snapDone`: call `restoreSnapshot`; on success set
`lastSnapshotIndex/Term = prevLogIndex/prevLogTerm`,
`commitIndex = lastApplied = lastSnapshotIndex`, clear `log`,
`saveState()`, reply success with `matchIdx = lastSnapshotIndex`; on
failure reply `success = false` and delete the temp file.
- `baradadb.nim` `restoreSnapshot` implementation: close default DB via
registry, `restoreDataDir(archivePath, defaultDbDir)`
(`backup.nim:263`), reopen, swap `ctx`. Return false on any exception.
- [x] **Step 1:** Write failing test: feed a node two chunks + done with a
real tar.gz fixture; assert callback received the assembled file, state
fields updated, log cleared.
- [x] **Step 2:** Run, expect fail. **Step 3:** Implement.
- [x] **Step 4:** Green + regression suites. **Step 5:** Commit
`feat(raft): follower InstallSnapshot receive and restore`
---
### Task 9: Leader snapshot send
**Files:**
- Modify: `src/barabadb/core/raft.nim` (`handleAppendReply` floor branch
~526-531, new `sendSnapshot` proc, per-peer reject counter)
- Modify: `src/baradadb.nim` (wire `buildSnapshot` callback)
- Test: `tests/test_all.nim`
**Interfaces:**
- Consumes: T7 protocol, `backupDataDir` (`backup.nim:225`).
- Produces on `RaftNode`:
```nim
buildSnapshot*: proc(destPath: string): bool {.gcsafe.}
snapRejectStreak*: Table[string, int] # consecutive floor-level rejects per peer
```
- Logic: in `handleAppendReply`, when a reject arrives **and**
`nextIndex[peerId] == lastSnapshotIndex + 1` (floor reached): increment
streak; at streak ≥ 2 the leader knows the follower needs a snapshot →
`asyncCheck sendSnapshot(peerId)`. Reset streak on any successful reply.
- `sendSnapshot`: `buildSnapshot` into `dataDir/raft/snap_out_<snapId>.tar.gz`
(`snapId = lastSnapshotIndex`); stream chunks of `snapChunkBytes` as
`rmkInstallSnapshot`; on final success reply set
`matchIndex[peer] = lastSnapshotIndex`,
`nextIndex[peer] = lastSnapshotIndex + 1`; delete the temp archive.
- [x] **Step 1:** Write failing test: leader with compacted log
(`lastSnapshotIndex = 100`) + peer at floor rejecting twice → snapshot
messages emitted; success reply advances `matchIndex`/`nextIndex`.
- [x] **Step 2:** Run, expect fail. **Step 3:** Implement.
- [x] **Step 4:** Green + regression. **Step 5:** Commit
`feat(raft): leader InstallSnapshot send on unrecoverable lag`
---
### Task 10: Unpin compaction from dead peers
**Files:**
- Modify: `src/barabadb/core/raft.nim` (`compactLog` ~244, `becomeLeader`
~327, `handleAppendReply` ~487)
- Modify: `src/barabadb/core/config.nim` (`raftPeerStaleMs`, env
`BARADB_RAFT_PEER_STALE_MS`, default 30000)
- Test: `tests/test_all.nim`
**Interfaces:**
- Produces: `matchIndexSeenMs*: Table[string, int64]` on `RaftNode` —
monotonic ms timestamp of the last successful reply per peer, updated in
`handleAppendReply` success branch and initialized to "now" in
`becomeLeader`.
**Logic:** leader-side `compactLog` computes `minMatch` only over peers
with `now - matchIndexSeenMs[peer] <= raftPeerStaleMs`; peers stale longer
are excluded (they'll be snapshotted on return per T9). Follower path
unchanged. Guard: never compact past `lastApplied`.
- [x] **Step 1:** Write failing test: leader, one peer never replies,
log > maxEntries → with default stale window, log compacts through
lastApplied anyway; with the peer responsive, compaction still pins at
its matchIndex (existing safety preserved).
- [x] **Step 2:** Run, expect fail. **Step 3:** Implement.
- [x] **Step 4:** Green + regression. **Step 5:** Commit
`feat(raft): compaction unpinned from stale peers (snapshot fallback)`
---
### Task 11: Cold-node E2E
**Files:**
- Create: `tests/raft_coldnode_e2e_test.nim`
- Modify: `baradadb.nimble`, `.github/workflows/ci.yml` (raft-e2e job)
**Interfaces:** Consumes T7T10; harness from T1. Port base
`58000 + (tstamp mod 4000)`. Small `BARADB_RAFT_LOG_MAX_ENTRIES=16` and
`BARADB_RAFT_PEER_STALE_MS=3000` to force compaction quickly.
**Scenario A — node returns after compaction:**
1. 3-node cluster, create table, kill node n3.
2. Write 100 rows through the leader (forces compaction past n3's
matchIndex once n3 is stale).
3. Assert via `/metrics` on the leader HTTP port
(`baradb_raft_log_entries`) that the log stayed bounded.
4. Restart n3 with its intact data dir; assert it receives a snapshot
(leader log line / `baradb_raft_snapshot_index` advances on n3) and
within 15 s `SELECT count(*)` on n3 matches the leader.
**Scenario B — wiped node joins:**
1. Stop n3, **delete its data dir**, restart with the same node id.
2. Assert it converges (snapshot → catch-up) and serves the full row set
within 20 s.
- [x] **Step 1:** Write suite. **Step 2:** Green locally. **Step 3:** 3×
stability runs. **Step 4:** `nimble test` + CI wiring. **Step 5:** Commit
`test(raft): cold-node rejoin and wiped-node join e2e`
---
### Task 12: Docs, limitations, version 1.3.0
**Files:**
- Modify: `docs/en/distributed.md`, `docs/bg/distributed.md` — client
failover contract (in-flight writes fail fast, retry; acked writes
durable), TLS setup (`BARADB_RAFT_TLS_*`), snapshot behavior/tunables
(`BARADB_RAFT_SNAP_CHUNK_KB`, `BARADB_RAFT_PEER_STALE_MS`)
- Modify: `docs/en/known-limitations.md`, `docs/bg/known-limitations.md` —
raft 3-node moves from "Experimental" to "Supported" for the covered
scope; remaining non-goals (multi-DB raft, membership changes, read
consistency levels) stay listed
- Modify: `docs/superpowers/specs/2026-07-30-raft-cluster-status.md` —
status → v1.3.0 supported
- Modify: `CHANGELOG.md` — `## [1.3.0] — <ship date>`
- Modify: `baradadb.nimble` → `version = "1.3.0"`; README status lines
- Modify: `docs/en/release-checklist.md` — add raft TLS + cold-node suites
- [x] **Step 1:** Doc edits. **Step 2:** Full `nimble test` green.
- [x] **Step 3:** Commit
`release: v1.3.0 raft-supported (failover load, CI gate, snapshot, TLS)`
- [ ] **Step 4 (human/controller):** tag `v1.3.0` after review.
---
## Task dependency graph
```
T1 failover-load e2e ──→ T2 CI gate
T3 TLS config ──→ T4 transport TLS ──→ T5 forward TLS ──→ T6 TLS e2e
T7 snapshot protocol ──→ T8 follower restore ──→ T9 leader send ──→ T10 unpin ──→ T11 cold-node e2e
all ──→ T12 docs/version
```
## Explicit out-of-scope
- Membership change (join/leave) protocol
- Multi-database raft; `CREATE`/`DROP DATABASE` replication
- Linearizable follower reads
- Rolling-upgrade compat shims beyond the trailing-field guard
(upgrade = restart all nodes)
## Definition of Done
- [x] All P1P5 tasks complete, `nimble test` green
- [ ] `raft-e2e` CI job green and mandatory (no silent skip)
- [x] Failover-under-load e2e: every acked write survives leader kill
- [x] Cold-node e2e: returning node and wiped node converge automatically
- [x] Raft TLS e2e: full-TLS cluster works; plaintext node excluded
- [x] known-limitations updated: raft supported for the covered scope
## Estimated effort
| Phase | Effort |
|-------|--------|
| P1P2 | 0.51 day |
| P3 | 1 day |
| P4 | 23 days |
| P5 | 0.5 day |
| **Total** | **~45 focused days** |
@@ -1,9 +1,38 @@
# Raft Cluster Status — C3a / C3b / post-C3b
# Raft Cluster Status — C3a / C3b / post-C3b / v1.3.0
Date: 2026-07-30
Status: **Shipped on `main`** (tip includes metrics).
Status: **v1.3.0 — Supported** for the single-`default`-DB scope (see the v1.3.0 section below).
Branch: all work merged to `main` only (feature branch removed).
## v1.3.0 — raft-supported (2026-07-30)
Raft moves from Experimental to **Supported** for a 3-node cluster on the
`default` database. Landed on top of the C3a/C3b base:
- **Failover under load** — `tests/raft_failover_load_e2e_test.nim`: leader
killed under sustained writes; every acked write survives (client contract:
in-flight writes fail fast, retry).
- **Mandatory CI gate** — dedicated `raft-e2e` job runs all five raft e2e
suites; missing binary is a hard FAIL under CI.
- **Raft TLS** — `BARADB_RAFT_TLS_*` config, fail-closed startup, optional
mutual auth, TLS on follower→leader forwarding;
`tests/raft_tls_e2e_test.nim` (plaintext node excluded).
- **InstallSnapshot** — backward-compatible wire protocol, leader chunk send
(`BARADB_RAFT_SNAP_CHUNK_KB`, default 256), follower restore via
backup/restore, compaction unpinned from stale peers
(`BARADB_RAFT_PEER_STALE_MS`, default 30000);
`tests/raft_coldnode_e2e_test.nim` (returning + wiped node converge).
- **Fixes** — put/delete encoding (`deleted` flag), rejoin livelock (cached
peer sockets dropped on leadership), post-restore ctx repoint.
Resolved non-goals from the list below: raft-port TLS, InstallSnapshot with
full SM payload. Still open: multi-database raft, `CREATE`/`DROP DATABASE`
replication, membership changes, linearizable follower reads, rolling
upgrades (restart all nodes together).
Plan: `docs/superpowers/plans/2026-07-30-v1.3.0-raft-supported.md` ·
Design: `docs/superpowers/specs/2026-07-30-raft-supported-design.md`
## Phase map
| Phase | Spec / plan | Status | What landed |
@@ -0,0 +1,212 @@
# v1.3.0 Raft-Supported — Design Spec
Date: 2026-07-30
Status: Draft
Follows: `2026-07-30-raft-cluster-status.md` (C3a/C3b/C3c-lite shipped),
`2026-07-30-production-ga-design.md` ("After GA" section).
## Goal
Move the raft cluster from **experimental** to **supported** by closing the
four gaps named in the GA plan: failover under load (proven, not assumed),
CI e2e mandatory, a cold-node story, and raft-port TLS.
Non-goals (unchanged from C3 status doc): multi-database raft, membership
change protocol, read consistency levels, `CREATE`/`DROP DATABASE`
replication.
## Current state (verified 2026-07-30)
- `src/barabadb/core/raft.nim` (919 lines): election, AppendEntries,
safe-prefix compaction, metrics, plain-TCP `RaftNetwork` transport.
- Wiring: `src/baradadb.nim:337-377` (env `BARADB_RAFT_*`, state in
`dataDir/raft/raft_state.bin`).
- Leader forwarding: `src/barabadb/core/server.nim:210-289`
(`forwardQueryToLeader`, plain TCP).
- TLS infra exists for the client wire port only:
`src/barabadb/protocol/ssl.nim` (`TLSConfig`, `TLSContext`, `wrapClient`,
`wrapServer`); server accept loop wraps at `core/server.nim:876-889`.
- E2E: `tests/raft_e2e_test.nim` (election + failover),
`tests/raft_writes_e2e_test.nim` (DDL/DML replication, forwarding,
failover write probe). Both run under `nimble test`, which CI runs.
## Gap analysis
### G1. Failover under load — unproven
The existing failover test (`raft_writes_e2e_test.nim:349-405`) kills the
leader *while idle* and probes a single INSERT afterwards. Nothing tests a
write workload running *during* the leader crash, and nothing verifies that
every client-acknowledged write survives the failover (raft's core promise:
committed entries are never lost).
### G2. CI e2e — present but silently skippable
Both e2e suites `skip()` when `./build/baradadb` is missing
(`raft_writes_e2e_test.nim:413-417`). `nimble test` builds the binary first,
so CI runs them today — but a broken build step or a renamed binary turns a
raft regression into a silent green skip. There is also no dedicated CI job
that names raft e2e as a first-class gate.
### G3. Cold node — two real failure modes
1. **Log growth pinning.** Leader compaction
(`raft.nim:244-276`, `compactLog`) never discards past any peer's
`matchIndex`. A peer that is down keeps `matchIndex` stale, so the leader's
log grows without bound for as long as the node is down.
2. **Unrecoverable laggard.** Once the leader's log no longer contains a
follower's `nextIndex` (fresh/wiped node, or a node that was down through a
compaction), the follower rejects every AppendEntries
(`handleAppendEntries`, `raft.nim:380-394`) and the leader's
`nextIndex` decrement floor is `lastSnapshotIndex + 1`
(`handleAppendReply`, `raft.nim:526-531`). The pair is stuck forever: no
InstallSnapshot path exists.
### G4. Raft port is plaintext
`RaftNetwork` uses bare `newAsyncSocket()` (`raft.nim:748-765`, `857-871`).
Any host that can reach the raft port can inject RequestVote/AppendEntries
frames. The TLS machinery in `protocol/ssl.nim` is not used here; leader
forwarding (`forwardQueryToLeader`) is likewise plaintext.
### G5. Raft write encoding loses empty-value puts (found 2026-07-30)
`appendWriteToRaft` (`core/server.nim:309-330`) encodes an empty value as
`"delete"`, but PK-only-table inserts legitimately produce empty values
(`execInsert`, `query/exec/dml.nim:60-90`) — such inserts are acked after
majority commit and then deleted everywhere on apply. Fixed as plan Task 1a
(explicit `deleted` flag on `ExecResult.keyValuePairs`).
## Design
### D1. Failover-under-load E2E (test-only)
New suite `tests/raft_failover_load_e2e_test.nim`, same process-management
conventions as `raft_writes_e2e_test.nim` (port base `50000 + tstamp mod
4000` to avoid collisions):
1. Boot a 3-node cluster, elect a leader, create `load_test` table via raft
DDL.
2. Writer thread: sequential `INSERT INTO load_test (id) VALUES (n)`,
`n = 1, 2, ...`, recording every *acknowledged* id. On error ("not
leader", commit timeout, connection reset), probe both survivors and
resume on whichever accepts.
3. At ~50 acknowledged writes, kill the leader.
4. Assert: a survivor accepts a write within **10 s** of the kill
(availability bound).
5. Assert: after the new leader is stable and the remaining follower has
caught up, `SELECT id FROM load_test` on **both** survivors contains
**every acknowledged id** (committed writes never lost). Unacknowledged
writes may be present or absent — this is documented, not asserted.
Also document the client-visible contract in `docs/en/distributed.md`:
in-flight writes during failover fail fast with an error; clients must
retry; acknowledged writes are durable across failover.
### D2. CI e2e mandatory
- New `raft-e2e` job in `.github/workflows/ci.yml`: setup Nim, build
`build/baradadb`, run the three raft e2e suites explicitly with `CI=true`
in the environment.
- Change skip semantics in all raft e2e suites: when `CI` env var is
non-empty and `./build/baradadb` is missing, **fail** instead of `skip()`.
- Add `raft_failover_load_e2e_test` to the `nimble test` list in
`baradadb.nimble`.
### D3. Cold node — InstallSnapshot
Extend the raft wire protocol and apply path:
**Protocol.** New message kinds `rmkInstallSnapshot`,
`rmkInstallSnapshotReply`, and new fields on `RaftMessage`:
`snapId: uint64`, `snapOffset: uint64`, `snapData: seq[byte]`,
`snapDone: bool`. `lastSnapshotIndex`/`lastSnapshotTerm` ride on the
existing fields (`prevLogIndex`/`prevLogTerm` are reused as the snapshot
base for this kind). Serialization appends the new fields with `atEnd`
guards (same backward-compatible pattern as `loadState`,
`raft.nim:163-167`); `RaftProtoVersion` stays 1 — mixed-version clusters
simply never send the new kind (old leaders never trigger it).
**Leader side.** Track consecutive AppendEntries rejections per peer. When
`nextIndex[peer]` has hit the `lastSnapshotIndex + 1` floor and the peer
still rejects, the peer is unrecoverably behind:
1. Build a snapshot archive of the **default database** data dir with the
existing backup machinery (`backupDataDir` in
`src/barabadb/core/backup.nim:225`) into a temp file.
2. Stream it in chunks (`BARADB_RAFT_SNAP_CHUNK_KB`, default 256 KB) as
`rmkInstallSnapshot` messages over the existing peer socket.
3. On final ack, set `matchIndex[peer] = lastSnapshotIndex`,
`nextIndex[peer] = lastSnapshotIndex + 1`, resume normal AppendEntries.
**Follower side.** On `rmkInstallSnapshot`:
1. Buffer chunks to a temp file under `dataDir/raft/snap_incoming/`.
2. On `snapDone`, hand the archive to a new injected callback
`restoreSnapshot: proc(archivePath: string): bool {.gcsafe.}` (wired in
`baradadb.nim` where the `DatabaseRegistry` lives): close the default
DB, `restoreDataDir` (`backup.nim:263`) into the default DB dir, reopen,
and swap execution context.
3. Set `lastSnapshotIndex`/`lastSnapshotTerm`/`commitIndex`/`lastApplied`
from the message, clear the log, `saveState`.
**Unpinning compaction.** Once InstallSnapshot exists, `compactLog` on the
leader compacts through `lastApplied` for peers whose `matchIndex` was
updated within the last `BARADB_RAFT_PEER_STALE_MS` (default 30 000);
long-dead peers no longer pin the log — they get a snapshot when they
return. Follower compaction is unchanged.
**Fresh-node join** falls out for free: a wiped node rejects at the floor
and receives a snapshot.
### D4. Raft TLS
Config (env, mirroring existing `BARADB_TLS_*`):
| Env | Config field | Default |
|-----|--------------|---------|
| `BARADB_RAFT_TLS_ENABLED` | `raftTlsEnabled: bool` | false |
| `BARADB_RAFT_TLS_CERT_FILE` | `raftTlsCertFile: string` | "" |
| `BARADB_RAFT_TLS_KEY_FILE` | `raftTlsKeyFile: string` | "" |
| `BARADB_RAFT_TLS_CA_FILE` | `raftTlsCaFile: string` | "" |
| `BARADB_RAFT_TLS_VERIFY_PEER` | `raftTlsVerifyPeer: bool` | false |
- `RaftNetwork` gains `tls: TLSContext` (nil = plaintext, current
behavior). `connectToPeer` wraps with `wrapClient`; the accept loop in
`run` wraps with `wrapServer` before `receiveLoop` (same pattern as
`core/server.nim:876-889`). `verifyPeer` + CA file gives mutual auth.
- Fail closed: `raftEnabled and raftTlsEnabled` with missing cert/key →
refuse to start (raise at startup, like the JWT check in
`newServerWithRegistry`, `core/server.nim:58-63`).
- Leader SQL forwarding (`forwardQueryToLeader`) wraps its socket with the
**client** TLS context when `BARADB_TLS_ENABLED` is on (it dials the
client wire port, which is already TLS-capable).
- E2E: TLS variant cluster test — generate self-signed certs with
`generateSelfSignedCert` (`protocol/ssl.nim:79`), boot a 3-node cluster
with raft TLS on, assert election + one replicated write; assert a
plaintext peer cannot join (its frames are rejected and the cluster
elects among the TLS nodes).
## Rollout / phases
| Phase | Deliverable | Risk |
|-------|-------------|------|
| P1 | D1 failover-load e2e | none (test-only) |
| P2 | D2 CI e2e job | none |
| P3 | D4 raft TLS | medium (transport) |
| P4 | D3 InstallSnapshot + compaction unpin | high (protocol + apply) |
P3 before P4 so snapshot transfer ships already-encryptable. Each phase is
independently mergeable; P4 is the v1.3.0 gate for calling raft
"supported".
## Acceptance (v1.3.0)
- Failover-under-load e2e green locally and in CI, in the mandatory gate.
- Killed-node-returns and wiped-node-join scenarios converge without manual
intervention (covered by new e2e phases).
- Leader log length stays bounded while a peer is down > `PEER_STALE_MS`
(assert via `baradb_raft_log_entries` metric in e2e).
- Raft port TLS: cluster runs fully over TLS; plaintext injection fails.
- `docs/en/distributed.md` + `known-limitations.md` updated: raft no longer
"experimental" for the covered scope; remaining non-goals listed.
+21
View File
@@ -44,6 +44,13 @@ type
raftPeerClientAddrs*: Table[string, tuple[host: string, port: int]]
raftWriteTimeoutMs*: int
raftLogMaxEntries*: int
raftSnapChunkKb*: int
raftPeerStaleMs*: int
raftTlsEnabled*: bool
raftTlsCertFile*: string
raftTlsKeyFile*: string
raftTlsCaFile*: string
raftTlsVerifyPeer*: bool
CompactionStrategy* = enum
csSizeTiered = "size_tiered"
@@ -86,6 +93,13 @@ proc defaultConfig*(): BaraConfig =
raftPeerClientAddrs: initTable[string, tuple[host: string, port: int]](),
raftWriteTimeoutMs: 5_000,
raftLogMaxEntries: 256,
raftSnapChunkKb: 256,
raftPeerStaleMs: 30000,
raftTlsEnabled: false,
raftTlsCertFile: "",
raftTlsKeyFile: "",
raftTlsCaFile: "",
raftTlsVerifyPeer: false,
)
# ----------------------------------------------------------------------
@@ -213,6 +227,13 @@ proc loadConfigFromEnv*(cfg: var BaraConfig) =
cfg.raftNodeId = getEnv("BARADB_RAFT_NODE_ID", cfg.raftNodeId)
cfg.raftWriteTimeoutMs = parseEnvInt(getEnv("BARADB_RAFT_WRITE_TIMEOUT_MS", ""), cfg.raftWriteTimeoutMs)
cfg.raftLogMaxEntries = parseEnvInt(getEnv("BARADB_RAFT_LOG_MAX_ENTRIES", ""), cfg.raftLogMaxEntries)
cfg.raftSnapChunkKb = parseEnvInt(getEnv("BARADB_RAFT_SNAP_CHUNK_KB", ""), cfg.raftSnapChunkKb)
cfg.raftPeerStaleMs = parseEnvInt(getEnv("BARADB_RAFT_PEER_STALE_MS", ""), cfg.raftPeerStaleMs)
cfg.raftTlsEnabled = parseEnvBool(getEnv("BARADB_RAFT_TLS_ENABLED", ""), cfg.raftTlsEnabled)
cfg.raftTlsCertFile = getEnv("BARADB_RAFT_TLS_CERT_FILE", cfg.raftTlsCertFile)
cfg.raftTlsKeyFile = getEnv("BARADB_RAFT_TLS_KEY_FILE", cfg.raftTlsKeyFile)
cfg.raftTlsCaFile = getEnv("BARADB_RAFT_TLS_CA_FILE", cfg.raftTlsCaFile)
cfg.raftTlsVerifyPeer = parseEnvBool(getEnv("BARADB_RAFT_TLS_VERIFY_PEER", ""), cfg.raftTlsVerifyPeer)
# Optional: client (SQL) addresses for leader write forwarding.
# Same id@host:port shape as BARADB_RAFT_PEERS, but ports are BARADB_PORT values.
let clientPeersEnv = getEnv("BARADB_RAFT_CLIENT_PEERS", "")
+4 -4
View File
@@ -31,7 +31,7 @@ type
config: BaraConfig
running: bool
db*: LSMTree
ctx: ExecutionContext
ctx*: ExecutionContext # read/write only under the storage gate
registry*: DatabaseRegistry
metrics*: Metrics
secretKey*: string
@@ -262,7 +262,7 @@ proc healthHandler(server: HttpServer): RequestHandler =
let ctx = newContext(request)
var body = %*{
"status": "ok",
"version": "1.2.0"
"version": "1.3.0"
}
if server.raftNode != nil:
let n = server.raftNode
@@ -368,7 +368,7 @@ proc openApiHandler(): RequestHandler =
let ctx = newContext(request)
ctx.json(%*{
"openapi": "3.0.0",
"info": {"title": "BaraDB API", "version": "1.2.0"},
"info": {"title": "BaraDB API", "version": "1.3.0"},
"paths": {
"/query": {
"post": {
@@ -906,7 +906,7 @@ function showTab(idx){
}
setInterval(() => { if(document.querySelectorAll('.panel')[4].classList.contains('active')) loadMetrics() }, 5000)
</script>
<div class='status' style='text-align:center;padding:10px'>BaraDB v1.2.0 — Multimodal Database Engine</div>
<div class='status' style='text-align:center;padding:10px'>BaraDB v1.3.0 — Multimodal Database Engine</div>
</body></html>"""
request.respond(200, @[("Content-Type", "text/html; charset=utf-8")], html)
+317 -3
View File
@@ -12,6 +12,7 @@ import std/endians
import std/os
import logging
import ../protocol/wire
import ../protocol/ssl
type
RaftState* = enum
@@ -66,6 +67,15 @@ type
# Leader state
nextIndex*: Table[string, uint64]
matchIndex*: Table[string, uint64]
## Monotonic ms of the last successful AppendEntries reply per peer,
## initialized to "now" in becomeLeader (grace window) and bumped in
## handleAppendReply. Leader compaction excludes peers silent longer than
## raftPeerStaleMs from its minMatch — a stale peer no longer pins the log
## forever; it is caught up via InstallSnapshot on return (T9).
matchIndexSeenMs*: Table[string, int64]
## Stale window in ms (BARADB_RAFT_PEER_STALE_MS, default 30000;
## 0 = default).
raftPeerStaleMs*: int
# Cluster
peers*: seq[string]
leaderId*: string
@@ -76,12 +86,33 @@ type
peerAddrs*: Table[string, tuple[host: string, port: int]]
raftPort*: int
dataDir*: string
## InstallSnapshot follower receive. snapChunkBytes caps a single chunk
## (from BARADB_RAFT_SNAP_CHUNK_KB, default 262144); snapIncomingId /
## snapIncomingFile track the archive currently being assembled under
## dataDir/snap_incoming/.
snapChunkBytes*: int
restoreSnapshot*: proc(archivePath: string, baseIndex: uint64,
baseTerm: uint64): bool {.gcsafe.}
snapIncomingId*: uint64
snapIncomingFile*: string
## Leader InstallSnapshot send. buildSnapshot archives the current data
## dir into destPath (wired in baradadb.nim via backupDataDir).
## snapRejectStreak counts consecutive floor-level AppendEntries rejects
## per peer; at 2 the peer is queued in snapPending and the network layer
## (processMessage) kicks off sendSnapshot. snapSending is the
## single-flight guard: at most one snapshot transfer per peer.
buildSnapshot*: proc(destPath: string): bool {.gcsafe.}
snapRejectStreak*: Table[string, int]
snapPending*: HashSet[string]
snapSending*: HashSet[string]
RaftMessageKind* = enum
rmkRequestVote
rmkRequestVoteReply
rmkAppendEntries
rmkAppendEntriesReply
rmkInstallSnapshot
rmkInstallSnapshotReply
RaftMessage* = object
kind*: RaftMessageKind
@@ -98,6 +129,12 @@ type
# Reply
success*: bool
matchIdx*: uint64
# InstallSnapshot (prevLogIndex/prevLogTerm reuse: snapshot base index/term;
# reply uses success/matchIdx as usual)
snapId*: uint64 # snapshot generation, matches leader's base at build time
snapOffset*: uint64 # byte offset of this chunk within the archive
snapData*: seq[byte] # chunk payload (<= snapChunkBytes)
snapDone*: bool # last chunk
RaftCluster* = ref object
nodes*: Table[string, RaftNode]
@@ -191,6 +228,8 @@ proc newRaftNode*(id: string, peers: seq[string], raftPort: int = 0,
metrics: RaftMetrics(),
nextIndex: initTable[string, uint64](),
matchIndex: initTable[string, uint64](),
matchIndexSeenMs: initTable[string, int64](),
raftPeerStaleMs: 30000,
peers: peers,
leaderId: "",
electionTimeout: 150 + rand(150),
@@ -199,6 +238,12 @@ proc newRaftNode*(id: string, peers: seq[string], raftPort: int = 0,
peerAddrs: initTable[string, tuple[host: string, port: int]](),
raftPort: raftPort,
dataDir: dataDir,
snapChunkBytes: 262144,
snapIncomingId: 0,
snapIncomingFile: "",
snapRejectStreak: initTable[string, int](),
snapPending: initHashSet[string](),
snapSending: initHashSet[string](),
)
result.loadState()
@@ -243,15 +288,22 @@ proc termAtIndex(node: RaftNode, index: uint64): uint64 =
proc compactLog*(node: RaftNode) =
## Drop a fully-replicated / applied log prefix so the in-memory log stays
## bounded. Leader: never discard past any peer's matchIndex (catch-up via
## AppendEntries remains possible). Follower: discard through lastApplied.
## bounded. Leader: never discard past any responsive peer's matchIndex
## (catch-up via AppendEntries remains possible); peers silent longer than
## raftPeerStaleMs are excluded and catch up via InstallSnapshot instead.
## Follower: discard through lastApplied.
let maxEntries = if node.logMaxEntries > 0: node.logMaxEntries else: 256
if node.log.len <= maxEntries:
return
var through = node.lastApplied
if node.state == rsLeader and node.peers.len > 0:
let staleMs = if node.raftPeerStaleMs > 0: node.raftPeerStaleMs else: 30000
let nowMs = getMonoTime().ticks() div 1_000_000
var minMatch = through
for peer in node.peers:
let seenMs = node.matchIndexSeenMs.getOrDefault(peer, 0)
if seenMs <= 0 or nowMs - seenMs > staleMs.int64:
continue # stale peer — unpinned, snapshotted on return (T9)
let m = node.matchIndex.getOrDefault(peer, 0'u64)
if m < minMatch: minMatch = m
through = minMatch
@@ -312,6 +364,10 @@ proc becomeFollower*(node: RaftNode, term: uint64) =
node.votesReceived.clear()
node.nextIndex.clear()
node.matchIndex.clear()
node.matchIndexSeenMs.clear()
# Leader-only snapshot-send state is meaningless once we step down
node.snapRejectStreak.clear()
node.snapPending.clear()
node.saveState()
proc becomeCandidate*(node: RaftNode) =
@@ -330,9 +386,16 @@ proc becomeLeader*(node: RaftNode) =
if node.metrics != nil:
inc node.metrics.electionsTotal
info("Raft node " & node.id & " became leader for term " & $node.currentTerm)
let nowMs = getMonoTime().ticks() div 1_000_000
node.matchIndexSeenMs.clear()
for peer in node.peers:
node.nextIndex[peer] = node.lastLogIndex + 1
node.matchIndex[peer] = 0
# Grace window: an unreplied peer still pins compaction until it has been
# silent for raftPeerStaleMs since this leadership began.
node.matchIndexSeenMs[peer] = nowMs
node.snapRejectStreak.clear()
node.snapPending.clear()
proc handleRequestVote*(node: RaftNode, msg: RaftMessage): RaftMessage =
var reply = RaftMessage(
@@ -418,6 +481,87 @@ proc handleAppendEntries*(node: RaftNode, msg: RaftMessage): RaftMessage =
reply.matchIdx = node.lastLogIndex
return reply
proc handleInstallSnapshot*(node: RaftNode, msg: RaftMessage): RaftMessage =
## Follower side of InstallSnapshot: assemble the chunk stream into a temp
## archive under `dataDir/snap_incoming/`, then hand the completed archive
## to the restoreSnapshot callback. Chunks arrive in order from a single
## leader over one socket, so we append sequentially and only sanity-check
## that snapOffset equals the number of bytes assembled so far.
##
## NOTE: this runs on the async event loop and restoreSnapshot performs
## blocking disk I/O (archive extract + DB reopen). Implementations must be
## fast, or defer the heavy work; the baradadb.nim wiring decides.
var reply = RaftMessage(
kind: rmkInstallSnapshotReply,
term: node.currentTerm,
senderId: node.id,
success: false,
matchIdx: node.lastSnapshotIndex,
)
if msg.term < node.currentTerm:
return reply
if msg.term > node.currentTerm:
node.becomeFollower(msg.term)
node.leaderId = msg.senderId
# Chunk size cap (deferred from the wire-protocol task).
if msg.snapData.len > node.snapChunkBytes or node.dataDir.len == 0:
return reply
let snapDir = node.dataDir / "snap_incoming"
if msg.snapId != node.snapIncomingId:
# New snapshot generation: discard any partial assembly and restart.
if msg.snapOffset != 0:
return reply
createDir(snapDir)
node.snapIncomingId = msg.snapId
node.snapIncomingFile = snapDir / "snap_" & $msg.snapId & ".tar.gz"
let f = open(node.snapIncomingFile, fmWrite) # truncate any leftover
f.close()
if node.snapIncomingFile.len == 0:
return reply
let assembled = getFileSize(node.snapIncomingFile)
if msg.snapOffset != uint64(assembled):
# Gap or overlap: reset so the leader restarts the transfer.
removeFile(node.snapIncomingFile)
node.snapIncomingId = 0
node.snapIncomingFile = ""
return reply
if msg.snapData.len > 0:
let f = open(node.snapIncomingFile, fmAppend)
try:
discard f.writeBuffer(addr msg.snapData[0], msg.snapData.len)
finally:
f.close()
if not msg.snapDone:
reply.success = true
return reply
# Transfer complete: restore the data dir and adopt the snapshot base.
if node.restoreSnapshot == nil or
not node.restoreSnapshot(node.snapIncomingFile,
msg.prevLogIndex, msg.prevLogTerm):
removeFile(node.snapIncomingFile)
node.snapIncomingId = 0
node.snapIncomingFile = ""
return reply
node.lastSnapshotIndex = msg.prevLogIndex
node.lastSnapshotTerm = msg.prevLogTerm
node.commitIndex = node.lastSnapshotIndex
node.lastApplied = node.lastSnapshotIndex
node.log = @[]
node.snapIncomingId = 0
node.snapIncomingFile = ""
node.saveState()
reply.success = true
reply.matchIdx = node.lastSnapshotIndex
return reply
proc requestVote*(node: RaftNode): seq[RaftMessage] =
result = @[]
for peer in node.peers:
@@ -498,6 +642,9 @@ proc handleAppendReply*(node: RaftNode, peerId: string, reply: RaftMessage) =
if reply.success:
node.matchIndex[peerId] = reply.matchIdx
node.nextIndex[peerId] = reply.matchIdx + 1
node.matchIndexSeenMs[peerId] = getMonoTime().ticks() div 1_000_000
node.snapRejectStreak.del(peerId)
node.snapPending.excl(peerId)
# Update commit index using true majority calculation
let majority = (node.peers.len + 1 + 1) div 2 # majority of cluster (peers + leader)
@@ -527,8 +674,47 @@ proc handleAppendReply*(node: RaftNode, peerId: string, reply: RaftMessage) =
let floor = node.lastSnapshotIndex + 1
if node.nextIndex.getOrDefault(peerId, 1) > floor:
dec node.nextIndex[peerId]
# Not a floor-level reject, so it breaks any floor-reject streak.
node.snapRejectStreak.del(peerId)
else:
node.nextIndex[peerId] = floor
# Stuck at the compaction floor: the entries the follower needs have
# been compacted away, so AppendEntries can never catch it up. Count
# consecutive floor rejects; at 2, queue an InstallSnapshot transfer
# (the network layer picks this up after handleAppendReply returns).
node.snapRejectStreak[peerId] =
node.snapRejectStreak.getOrDefault(peerId, 0) + 1
if node.snapRejectStreak[peerId] >= 2:
node.snapPending.incl(peerId)
proc handleInstallSnapshotReply*(node: RaftNode, peerId: string,
reply: RaftMessage) =
## Leader side: follower's answer to a completed InstallSnapshot transfer.
## success=true adopts the snapshot base (reply.matchIdx) as the peer's
## match point; success=false leaves all state alone — the normal
## AppendEntries reject path re-triggers another snapshot if the peer is
## still stuck at the floor.
if reply.term > node.currentTerm:
node.becomeFollower(reply.term)
return
if reply.term < node.currentTerm:
return
if node.state != rsLeader:
return
if reply.success and reply.matchIdx >= node.lastSnapshotIndex:
# The follower has actually adopted the snapshot base. Intermediate chunk
# replies (the T8 follower acks every non-done chunk with success=true and
# matchIdx = its OLD lastSnapshotIndex, below ours) fall through here and
# must be ignored: applying them would regress matchIndex/nextIndex and
# clear the reject streak mid-transfer, causing state flapping until the
# final reply lands.
node.matchIndex[peerId] = reply.matchIdx
node.nextIndex[peerId] = reply.matchIdx + 1
node.snapRejectStreak.del(peerId)
node.snapPending.excl(peerId)
proc state*(node: RaftNode): RaftState = node.state
proc isLeader*(node: RaftNode): bool = node.state == rsLeader
@@ -693,6 +879,14 @@ proc serialize*(msg: RaftMessage): seq[byte] =
stream.write(msg.leaderCommit)
stream.write(char(if msg.success: 1 else: 0))
stream.write(msg.matchIdx)
# InstallSnapshot trailing fields (appended for wire backward compatibility;
# pre-v1.3 peers stop reading at matchIdx and ignore these bytes)
stream.write(msg.snapId)
stream.write(msg.snapOffset)
stream.write(uint32(msg.snapData.len))
if msg.snapData.len > 0:
stream.writeData(addr msg.snapData[0], msg.snapData.len)
stream.write(char(if msg.snapDone: 1 else: 0))
let strData = stream.data
result = newSeq[byte](strData.len)
for i in 0 ..< strData.len:
@@ -721,6 +915,19 @@ proc deserializeRaftMessage*(data: seq[byte]): RaftMessage =
result.leaderCommit = stream.readUint64()
result.success = stream.readChar() != '\0'
result.matchIdx = stream.readUint64()
# Optional trailing InstallSnapshot fields (absent in pre-v1.3 buffers)
if not stream.atEnd:
result.snapId = stream.readUint64()
if not stream.atEnd:
result.snapOffset = stream.readUint64()
if not stream.atEnd:
let dataLen = int(stream.readUint32())
result.snapData = newSeq[byte](dataLen)
if dataLen > 0:
if stream.readData(addr result.snapData[0], dataLen) != dataLen:
raise newException(IOError, "Incomplete snapshot data read from stream")
if not stream.atEnd:
result.snapDone = stream.readChar() != '\0'
stream.close()
# ---------------------------------------------------------------------------
@@ -734,13 +941,16 @@ type
running*: bool
peerSockets*: Table[string, AsyncSocket]
timer*: ElectionTimer
## Optional TLS context; nil = plaintext (default, pre-TLS behavior).
tls*: TLSContext
proc newRaftNetwork*(node: RaftNode): RaftNetwork =
proc newRaftNetwork*(node: RaftNode, tls: TLSContext = nil): RaftNetwork =
RaftNetwork(
node: node,
running: false,
peerSockets: initTable[string, AsyncSocket](),
timer: newElectionTimer(node, node.electionTimeout),
tls: tls,
)
const RaftConnectTimeoutMs = 200
@@ -759,6 +969,12 @@ proc connectToPeer(net: RaftNetwork, peerId: string) {.async.} =
if not ok:
sock.close()
return
if net.tls != nil:
try:
net.tls.wrapClient(sock)
except CatchableError:
try: sock.close() except CatchableError: discard
return
net.peerSockets[peerId] = sock
except CatchableError:
if sock != nil:
@@ -783,6 +999,69 @@ proc broadcast*(net: RaftNetwork, msgs: seq[RaftMessage]) {.async.} =
if i < msgs.len:
await net.send(peer, msgs[i])
proc sendSnapshot*(net: RaftNetwork, peerId: string) {.async.} =
## Leader side of InstallSnapshot: build an archive of the current data dir
## via the buildSnapshot callback and stream it to a lagging peer in
## snapChunkBytes chunks. Triggered (via asyncCheck from processMessage)
## when handleAppendReply queues the peer in snapPending after consecutive
## floor-level rejects. Single-flight per peer via node.snapSending.
##
## Runs on the raft event loop; buildSnapshot performs blocking disk I/O
## (tar+gzip). Snapshot sends are rare, so we accept the stall rather than
## adding a worker round-trip (same trade-off as restoreSnapshot).
let node = net.node
if peerId in node.snapSending:
return
if node.state != rsLeader or node.buildSnapshot == nil or
node.dataDir.len == 0:
return
let snapId = node.lastSnapshotIndex
if snapId == 0:
# snapId 0 can never be accepted (a follower's initial snapIncomingId is
# 0), and sends only trigger after compaction anyway — guard regardless.
warn("sendSnapshot: lastSnapshotIndex is 0; skipping snapshot send to " & peerId)
return
node.snapSending.incl(peerId)
defer: node.snapSending.excl(peerId)
let baseIndex = node.lastSnapshotIndex
let baseTerm = node.lastSnapshotTerm
let destPath = node.dataDir / ("snap_out_" & $snapId & ".tar.gz")
defer:
if fileExists(destPath):
removeFile(destPath)
if not node.buildSnapshot(destPath):
warn("sendSnapshot: buildSnapshot failed; aborting snapshot send to " & peerId)
return
var f: File
if not open(f, destPath, fmRead):
warn("sendSnapshot: cannot open built archive " & destPath)
return
defer: f.close()
let total = uint64(getFileSize(destPath))
var offset = 0'u64
while true:
var chunk = newSeq[byte](node.snapChunkBytes)
let n = f.readBytes(chunk, 0, chunk.len)
let done = offset + uint64(n) >= total
await net.send(peerId, RaftMessage(
kind: rmkInstallSnapshot,
term: node.currentTerm,
senderId: node.id,
prevLogIndex: baseIndex, # snapshot base index/term (T7 wire layout)
prevLogTerm: baseTerm,
snapId: snapId,
snapOffset: offset,
snapData: chunk[0 ..< n],
snapDone: done,
))
if done:
break
offset += uint64(n)
proc processMessage*(net: RaftNetwork, msg: RaftMessage) {.async.} =
case msg.kind
of rmkRequestVote:
@@ -800,6 +1079,19 @@ proc processMessage*(net: RaftNetwork, msg: RaftMessage) {.async.} =
await net.send(msg.senderId, reply)
of rmkAppendEntriesReply:
net.node.handleAppendReply(msg.senderId, msg)
# Floor-reject streak reached the threshold: this peer needs a snapshot.
if msg.senderId in net.node.snapPending:
net.node.snapPending.excl(msg.senderId)
asyncCheck net.sendSnapshot(msg.senderId)
of rmkInstallSnapshot:
# Same election-timer rule as AppendEntries: only a plausible current
# leader resets it.
if msg.term >= net.node.currentTerm:
net.timer.resetTimeout()
let reply = net.node.handleInstallSnapshot(msg)
await net.send(msg.senderId, reply)
of rmkInstallSnapshotReply:
net.node.handleInstallSnapshotReply(msg.senderId, msg)
proc recvExact*(client: AsyncSocket, size: int): Future[string] {.async.} =
## Reads exactly `size` bytes from `client`. A short return means the peer
@@ -839,8 +1131,21 @@ proc receiveLoop(net: RaftNetwork, client: AsyncSocket) {.async.} =
proc heartbeatLoop(net: RaftNetwork) {.async.} =
## Fan out heartbeats in parallel so a slow/dead peer cannot delay
## AppendEntries to the rest of the cluster.
var wasLeader = false
while net.running:
if net.node.state == rsLeader:
if not wasLeader:
# Fresh term, fresh connections. A peer that restarted while we were
# partitioned leaves a half-dead cached socket whose writes can keep
# "succeeding" into the void (the TCP error surfaces late or never,
# so the error-triggered redial in send() may never fire) — the
# restarted peer then never sees AppendEntries, keeps candidating,
# and the cluster livelocks. Drop all cached peer connections on
# leadership acquisition so the heartbeat fan-out redials.
for peerId, sock in net.peerSockets:
try: sock.close() except CatchableError: discard
net.peerSockets.clear()
wasLeader = true
var futs: seq[Future[void]] = @[]
for peer in net.node.peers:
let msg = net.node.appendEntries(peer)
@@ -850,6 +1155,8 @@ proc heartbeatLoop(net: RaftNetwork) {.async.} =
await f
except CatchableError:
discard
else:
wasLeader = false
await sleepAsync(net.node.heartbeatTimeout)
proc timerLoop*(net: RaftNetwork) {.async.}
@@ -866,6 +1173,13 @@ proc run*(net: RaftNetwork) {.async.} =
while net.running:
try:
let client = await net.socket.accept()
if net.tls != nil:
try:
net.tls.wrapServer(client)
except CatchableError:
# Handshake failed (e.g. plaintext dial) — drop, no protocol effect.
client.close()
continue
asyncCheck net.receiveLoop(client)
except CatchableError:
break
+26
View File
@@ -203,6 +203,32 @@ proc getDatabaseInfo*(reg: DatabaseRegistry, name: string): DatabaseInfo =
return reg.databases[name]
return nil
proc reopenDatabase*(reg: DatabaseRegistry, name: string): bool =
## Reopen a database from its on-disk directory, swapping the new LSMTree
## and ctx into the EXISTING DatabaseInfo slot so captured references (e.g.
## the raft applyCommand closure) see the new state.
## Minimal API added for raft InstallSnapshot restore: the caller must have
## closed info.db first (snapshot restore closes it before swapping the data
## directory); this proc does not close.
## Returns false if the database is unknown or the reopen fails.
acquire(reg.lock)
let info = if name in reg.databases: reg.databases[name] else: nil
release(reg.lock)
if info == nil:
return false
try:
let dbDir = reg.dataRoot / name
let db = openLsmForRegistry(reg, dbDir)
let ctx = reg.ctxFactory(db, reg)
info.db = db
info.ctx = ctx
return true
except CatchableError as e:
# echo instead of logging: callers include gcsafe raft callbacks, and
# core/logging's info/warn are not gcsafe.
echo "[registry] Error reopening database '", name, "': ", e.msg
return false
proc closeAll*(reg: DatabaseRegistry) =
acquire(reg.lock)
defer: release(reg.lock)
+36 -16
View File
@@ -216,16 +216,28 @@ proc forwardRecvExact(sock: AsyncSocket, size: int): Future[string] {.async.} =
return buf
proc forwardQueryToLeader*(host: string, port: int, query: string,
tls: TLSContext = nil,
params: seq[WireValue] = @[],
timeoutMs: int = 5000): Future[(bool, QueryResult, string)] {.async.} =
## Proxy a write/DDL to the known leader's SQL port. Used by followers when
## BARADB_RAFT_CLIENT_PEERS maps leader id → host:clientPort.
## `tls` is the local server's client-port TLS context: when the wire port
## serves TLS, the leader's does too, so the forwarding dial must complete a
## client handshake. The context is reused as-is (verifyMode stays
## CVerifyNone — do NOT enable verifyPeer on the reused context); OpenSSL
## contexts are role-agnostic in Nim's stdlib, wrapConnectedSocket with
## handshakeAsClient sets the role.
var sock: AsyncSocket = nil
try:
sock = newAsyncSocket()
let okConn = await withTimeout(sock.connect(host, Port(port)), min(timeoutMs, 2000))
if not okConn:
return (false, QueryResult(), "leader forward connect timeout")
if tls != nil:
try:
tls.wrapClient(sock)
except CatchableError:
return (false, QueryResult(), "leader forward TLS handshake failed")
let reqId = 1'u32
let msg = if params.len > 0:
makeQueryParamsMessage(reqId, query, params)
@@ -306,10 +318,12 @@ proc waitRaftCommit(node: RaftNode, lastIdx: uint64, timeoutMs: int): Future[(bo
node.metrics.commitWaitMsTotal += waitedMs
return (true, "")
proc appendWriteToRaft*(node: RaftNode, kvPairs: seq[(string, seq[byte])],
proc appendWriteToRaft*(node: RaftNode,
kvPairs: seq[tuple[key: string, value: seq[byte], deleted: bool]],
timeoutMs: int): Future[(bool, string)] {.async.} =
## C3b leader write path: append each written KV pair to the Raft log and
## wait for majority commit. An empty value encodes a delete; the entry
## wait for majority commit. The `deleted` flag encodes a delete — an empty
## value alone is a put (PK-only tables store an empty LSM value); the entry
## format matches applyCommand ("put": key \x00 value, "delete": key).
##
## MUST be called from the async event-loop thread that owns `node` and
@@ -317,11 +331,11 @@ proc appendWriteToRaft*(node: RaftNode, kvPairs: seq[(string, seq[byte])],
## handleAppendReply on the same loop, and applyCommand re-enters the
## (non-reentrant) gate — waiting under the gate would deadlock the loop.
var lastIdx = 0'u64
for (key, value) in kvPairs:
let entry = if value.len > 0:
node.appendLog("put", cast[seq[byte]](key & "\x00" & cast[string](value)))
for pair in kvPairs:
let entry = if pair.deleted:
node.appendLog("delete", cast[seq[byte]](pair.key))
else:
node.appendLog("delete", cast[seq[byte]](key))
node.appendLog("put", cast[seq[byte]](pair.key & "\x00" & cast[string](pair.value)))
if entry.index == 0:
if node.metrics != nil:
inc node.metrics.lostLeadershipTotal
@@ -346,14 +360,15 @@ proc executeQuery(db: LSMTree, ctx: ExecutionContext, query: string, params: seq
raftNode: RaftNode = nil,
raftWriteTimeoutMs: int = 5000,
raftPeerClientAddrs: Table[string, tuple[host: string, port: int]] =
initTable[string, tuple[host: string, port: int]]()): Future[(bool, QueryResult, string)] {.async.} =
initTable[string, tuple[host: string, port: int]](),
forwardTls: TLSContext = nil): Future[(bool, QueryResult, string)] {.async.} =
## All storage access is under the global StorageGate so HTTP worker threads
## and the TCP event loop never touch ORC-managed LSM/executor state concurrently.
## The gate is released BEFORE the Raft commit wait — see appendWriteToRaft.
var ok = false
var qr = QueryResult()
var msg = ""
var kvPairs: seq[(string, seq[byte])] = @[]
var kvPairs: seq[tuple[key: string, value: seq[byte], deleted: bool]] = @[]
var needsRaftDdl = false
var needsForward = false
var forwardHost = ""
@@ -397,11 +412,14 @@ proc executeQuery(db: LSMTree, ctx: ExecutionContext, query: string, params: seq
# Ship written key-value pairs to replicas (legacy path; skipped when
# the raft path below handles the statement).
if raftNode == nil and replication != nil and res.keyValuePairs.len > 0:
for (key, value) in res.keyValuePairs:
var data = newSeq[byte](key.len + 1 + value.len)
for i, c in key: data[i] = byte(c)
data[key.len] = byte(0)
for i, c in value: data[key.len + 1 + i] = c
for pair in res.keyValuePairs:
# Legacy REP wire format: key \x00 value, empty value = delete
# on the receiver. Deletes ship an empty value as before.
let value = if pair.deleted: @[] else: pair.value
var data = newSeq[byte](pair.key.len + 1 + value.len)
for i, c in pair.key: data[i] = byte(c)
data[pair.key.len] = byte(0)
for i, c in value: data[pair.key.len + 1 + i] = c
discard replication.writeLsn(data)
qr = QueryResult(affectedRows: res.affectedRows, rowCount: res.rows.len)
qr.columns = res.columns
@@ -446,7 +464,7 @@ proc executeQuery(db: LSMTree, ctx: ExecutionContext, query: string, params: seq
# Follower write/DDL: proxy to leader SQL port (outside the storage gate).
if needsForward:
let (okF, qrF, errF) = await forwardQueryToLeader(forwardHost, forwardPort,
query, params, raftWriteTimeoutMs)
query, forwardTls, params, raftWriteTimeoutMs)
if raftNode != nil and raftNode.metrics != nil:
if okF: inc raftNode.metrics.forwardsTotal
else: inc raftNode.metrics.forwardErrorsTotal
@@ -769,7 +787,8 @@ proc handleClient(server: Server, client: AsyncSocket, clientId: int) {.async.}
let (success, result, errorMsg) = await executeQuery(connCtx.db, connCtx, queryStr,
replication=server.replicationManager, raftNode=server.raftNode,
raftWriteTimeoutMs=server.config.raftWriteTimeoutMs,
raftPeerClientAddrs=server.config.raftPeerClientAddrs)
raftPeerClientAddrs=server.config.raftPeerClientAddrs,
forwardTls=server.tls)
let durationMs = int((getMonoTime().ticks() - startTicks) div 1_000_000)
if durationMs >= slowThreshold:
@@ -792,7 +811,8 @@ proc handleClient(server: Server, client: AsyncSocket, clientId: int) {.async.}
let (success, result, errorMsg) = await executeQuery(connCtx.db, connCtx, queryStr, params,
replication=server.replicationManager, raftNode=server.raftNode,
raftWriteTimeoutMs=server.config.raftWriteTimeoutMs,
raftPeerClientAddrs=server.config.raftPeerClientAddrs)
raftPeerClientAddrs=server.config.raftPeerClientAddrs,
forwardTls=server.tls)
let durationMs = int((getMonoTime().ticks() - startTicks) div 1_000_000)
if durationMs >= slowThreshold:
+9 -1
View File
@@ -29,10 +29,14 @@ proc newTLSConfig*(certFile: string, keyFile: string, caFile: string = "",
proc newTLSContext*(config: TLSConfig): TLSContext =
result = TLSContext(config: config)
if fileExists(config.certFile) and fileExists(config.keyFile):
# caFile is only honored by newContext when verifyPeer is true
# (verifyMode != CVerifyNone); a missing CA file then raises IOError,
# which is the desired fail-closed behavior.
result.sslCtx = newContext(
certFile = config.certFile,
keyFile = config.keyFile,
verifyMode = if config.verifyPeer: CVerifyPeer else: CVerifyNone,
caFile = config.caFile,
)
else:
raise newException(IOError, "TLS certificate or key file not found: " &
@@ -40,7 +44,11 @@ proc newTLSContext*(config: TLSConfig): TLSContext =
proc wrapClient*(tls: TLSContext, socket: AsyncSocket) {.inline.} =
if tls.sslCtx != nil:
tls.sslCtx.wrapSocket(socket)
# wrapConnectedSocket (asyncnet overload) sets connect state; the
# handshake itself is driven lazily by the first send/recv. Plain
# wrapSocket leaves the SSL handle in SSL_ST_BEFORE and the first
# SSL_write fails with "uninitialized".
tls.sslCtx.wrapConnectedSocket(socket, handshakeAsClient)
proc wrapServer*(tls: TLSContext, socket: AsyncSocket) {.inline.} =
if tls.sslCtx != nil:
+6 -6
View File
@@ -45,7 +45,7 @@ proc violatesUniqueIndex*(ctx: ExecutionContext, table: string, fields: seq[stri
return ""
proc execInsert*(ctx: ExecutionContext, table: string, fields: seq[string], values: seq[seq[string]],
kvPairs: var seq[(string, seq[byte])]): int =
kvPairs: var seq[tuple[key: string, value: seq[byte], deleted: bool]]): int =
if not hasPrivilege(ctx, table, "INSERT"):
return 0
let tblDef = if table in ctx.tables: ctx.tables[table] else: TableDef()
@@ -87,7 +87,7 @@ proc execInsert*(ctx: ExecutionContext, table: string, fields: seq[string], valu
discard ctx.txnManager.write(ctx.pendingTxn, fullKey, cast[seq[byte]](valStr))
else:
ctx.db.put(fullKey, cast[seq[byte]](valStr))
kvPairs.add((fullKey, cast[seq[byte]](valStr)))
kvPairs.add((fullKey, cast[seq[byte]](valStr), false))
for colName in ctx.btrees.keys.toSeq():
if colName.startsWith(table & "."):
@@ -221,7 +221,7 @@ proc execInsert*(ctx: ExecutionContext, table: string, fields: seq[string], valu
return count
proc execDelete*(ctx: ExecutionContext, table: string, key: string,
kvPairs: var seq[(string, seq[byte])]): int =
kvPairs: var seq[tuple[key: string, value: seq[byte], deleted: bool]]): int =
if not hasPrivilege(ctx, table, "DELETE"):
return 0
let fullKey = table & "." & key
@@ -238,7 +238,7 @@ proc execDelete*(ctx: ExecutionContext, table: string, key: string,
discard ctx.txnManager.delete(ctx.pendingTxn, fullKey)
else:
ctx.db.delete(fullKey)
kvPairs.add((fullKey, @[]))
kvPairs.add((fullKey, @[], true))
# Update BTree indexes
for colName in ctx.btrees.keys.toSeq():
if colName.startsWith(table & "."):
@@ -264,7 +264,7 @@ proc execDelete*(ctx: ExecutionContext, table: string, key: string,
return 0
proc execUpdateRow*(ctx: ExecutionContext, table: string, key: string, sets: Table[string, string],
kvPairs: var seq[(string, seq[byte])]): int =
kvPairs: var seq[tuple[key: string, value: seq[byte], deleted: bool]]): int =
if not hasPrivilege(ctx, table, "UPDATE"):
return 0
let fullKey = table & "." & key
@@ -313,7 +313,7 @@ proc execUpdateRow*(ctx: ExecutionContext, table: string, key: string, sets: Tab
discard ctx.txnManager.write(ctx.pendingTxn, fullKey, cast[seq[byte]](newVal))
else:
ctx.db.put(fullKey, cast[seq[byte]](newVal))
kvPairs.add((fullKey, cast[seq[byte]](newVal)))
kvPairs.add((fullKey, cast[seq[byte]](newVal), false))
# Update FTS indexes: remove old doc, add new
for ftsKey, ftsIdx in ctx.ftsIndexes:
if ftsKey.startsWith(table & "."):
-1
View File
@@ -923,7 +923,6 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
ddl.add("\n")
# Sample data
var kvPairs: seq[(string, seq[byte])] = @[]
let rows = requireExecScanHook()(ctx, table)
let sampleLimit = min(5, rows.len)
if sampleLimit > 0:
+4 -4
View File
@@ -30,14 +30,14 @@ proc enforceFkOnDelete*(ctx: ExecutionContext, parentTable: string, parentCol: s
of "CASCADE":
for refRow in refs:
if "$key" in refRow:
var dummy: seq[(string, seq[byte])] = @[]
var dummy: seq[tuple[key: string, value: seq[byte], deleted: bool]] = @[]
discard execDelete(ctx, childTblName, valueToString(refRow["$key"]), dummy)
of "SET NULL":
for refRow in refs:
if "$key" in refRow:
var sets = initTable[string, string]()
sets[col.name] = "\\N"
var dummy: seq[(string, seq[byte])] = @[]
var dummy: seq[tuple[key: string, value: seq[byte], deleted: bool]] = @[]
discard execUpdateRow(ctx, childTblName, valueToString(refRow["$key"]), sets, dummy)
of "RESTRICT", "NO ACTION":
return (false, "FOREIGN KEY violation: row is referenced by " & childTblName & "." & col.name)
@@ -56,14 +56,14 @@ proc enforceFkOnUpdate*(ctx: ExecutionContext, parentTable: string, parentCol: s
if "$key" in refRow:
var sets = initTable[string, string]()
sets[col.name] = newVal
var dummy: seq[(string, seq[byte])] = @[]
var dummy: seq[tuple[key: string, value: seq[byte], deleted: bool]] = @[]
discard execUpdateRow(ctx, childTblName, valueToString(refRow["$key"]), sets, dummy)
of "SET NULL":
for refRow in refs:
if "$key" in refRow:
var sets = initTable[string, string]()
sets[col.name] = "\\N"
var dummy: seq[(string, seq[byte])] = @[]
var dummy: seq[tuple[key: string, value: seq[byte], deleted: bool]] = @[]
discard execUpdateRow(ctx, childTblName, valueToString(refRow["$key"]), sets, dummy)
of "RESTRICT", "NO ACTION":
return (false, "FOREIGN KEY violation: row is referenced by " & childTblName & "." & col.name)
+2 -2
View File
@@ -136,13 +136,13 @@ type
rows*: seq[Row]
affectedRows*: int
message*: string
keyValuePairs*: seq[(string, seq[byte])]
keyValuePairs*: seq[tuple[key: string, value: seq[byte], deleted: bool]]
proc `==`*(a, b: IndexEntry): bool =
a.lsmKey == b.lsmKey and a.rowValue == b.rowValue
proc okResult*(rows: seq[Row] = @[], cols: seq[string] = @[], affected: int = 0, msg: string = "",
kvPairs: seq[(string, seq[byte])] = @[]): ExecResult =
kvPairs: seq[tuple[key: string, value: seq[byte], deleted: bool]] = @[]): ExecResult =
ExecResult(success: true, columns: cols, rows: rows, affectedRows: affected, message: msg,
keyValuePairs: kvPairs)
+11 -10
View File
@@ -574,7 +574,7 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
row[f] = mutableValues[0][i]
fireTriggers(ctx, stmt.insTarget, "before", "insert", row)
var kvPairs: seq[(string, seq[byte])]
var kvPairs: seq[tuple[key: string, value: seq[byte], deleted: bool]]
let count = execInsert(ctx, stmt.insTarget, mutableFields, mutableValues, kvPairs)
# Fire AFTER INSERT triggers
@@ -626,7 +626,7 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
# Scan and apply
let rows = execScan(ctx, stmt.updTarget)
var count = 0
var kvPairs: seq[(string, seq[byte])]
var kvPairs: seq[tuple[key: string, value: seq[byte], deleted: bool]]
for row in rows:
# Compute sets for this row (expressions may reference columns)
var sets = initTable[string, string]()
@@ -701,7 +701,7 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
# Delete all rows matching WHERE
let rows = execScan(ctx, stmt.delTarget)
var count = 0
var kvPairs: seq[(string, seq[byte])]
var kvPairs: seq[tuple[key: string, value: seq[byte], deleted: bool]]
for row in rows:
if stmt.delWhere != nil and stmt.delWhere.whereExpr != nil:
let whereExpr = lowerExpr(stmt.delWhere.whereExpr)
@@ -744,7 +744,7 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
let targetRows = execScan(ctx, stmt.mergeTarget)
var count = 0
var kvPairs: seq[(string, seq[byte])]
var kvPairs: seq[tuple[key: string, value: seq[byte], deleted: bool]]
for srcRow in sourceRows:
var matched = false
@@ -793,7 +793,7 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
for i, f in fields:
if i < values.len: row[f] = Value(kind: vkString, strVal: values[i])
fireTriggers(ctx, stmt.mergeTarget, "before", "insert", row)
var insKvPairs: seq[(string, seq[byte])]
var insKvPairs: seq[tuple[key: string, value: seq[byte], deleted: bool]]
count += execInsert(ctx, stmt.mergeTarget, fields, @[values], insKvPairs)
for kv in insKvPairs: kvPairs.add(kv)
fireTriggers(ctx, stmt.mergeTarget, "after", "insert", row)
@@ -982,16 +982,17 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
of nkCommitTxn:
if ctx.pendingTxn != nil and ctx.pendingTxn.state == tsActive:
var kvPairs: seq[(string, seq[byte])]
var kvPairs: seq[tuple[key: string, value: seq[byte], deleted: bool]]
for key, version in ctx.pendingTxn.writeSet:
if version.isDelete:
ctx.db.delete(key)
# Empty value is the raft/replication "delete" convention — never
# ship a non-empty body for isDelete or followers will resurrect.
kvPairs.add((key, @[]))
# Empty value + deleted=true is the raft/replication "delete"
# convention — never ship a non-empty body for isDelete or
# followers will resurrect.
kvPairs.add((key, @[], true))
else:
ctx.db.put(key, version.value)
kvPairs.add((key, version.value))
kvPairs.add((key, version.value, false))
discard ctx.txnManager.commit(ctx.pendingTxn)
ctx.pendingTxn = nil
return okResult(msg="Transaction committed", kvPairs=kvPairs)
+91 -2
View File
@@ -23,6 +23,7 @@ import barabadb/core/gossip
import barabadb/core/replication
import barabadb/core/disttxn
import barabadb/core/registry
import barabadb/core/backup
import barabadb/tools/repair
import barabadb/tools/migrate
@@ -288,7 +289,7 @@ proc main() =
# Init structured logger from config
let logLvl = parseEnum[LogLevel]("ll" & capitalizeAscii(config.logLevel))
defaultLogger = newLogger(logLvl, config.logFile)
info("BaraDB v1.2.0 — Multimodal Database Engine")
info("BaraDB v1.3.0 — Multimodal Database Engine")
info("Storage gate initialized (serializes HTTP/TCP/compaction access)")
# Security check: warn if JWT secret is not configured (non-production only)
@@ -338,6 +339,21 @@ proc main() =
var raftNet: RaftNetwork = nil
if config.raftEnabled:
info("Starting Raft node " & config.raftNodeId & " on port " & $config.raftPort)
var raftTls: TLSContext = nil
if config.raftTlsEnabled:
if config.raftTlsCertFile.len == 0 or config.raftTlsKeyFile.len == 0 or
not fileExists(config.raftTlsCertFile) or not fileExists(config.raftTlsKeyFile):
raise newException(ValueError,
"BARADB_RAFT_TLS_ENABLED=true but cert/key missing " &
"(BARADB_RAFT_TLS_CERT_FILE / BARADB_RAFT_TLS_KEY_FILE)")
if config.raftTlsVerifyPeer and config.raftTlsCaFile.len > 0 and
not fileExists(config.raftTlsCaFile):
raise newException(ValueError,
"BARADB_RAFT_TLS_VERIFY_PEER=true but CA file missing: " &
config.raftTlsCaFile & " (BARADB_RAFT_TLS_CA_FILE)")
raftTls = newTLSContext(newTLSConfig(
config.raftTlsCertFile, config.raftTlsKeyFile,
caFile = config.raftTlsCaFile, verifyPeer = config.raftTlsVerifyPeer))
let raftDataDir = config.dataDir / "raft"
createDir(raftDataDir) # idempotent; loadState reads from it, saveState writes
# Raft convention: `peers` excludes the node itself (majority math and
@@ -350,6 +366,10 @@ proc main() =
raftNode.peerAddrs = config.raftPeerAddrs
if config.raftLogMaxEntries > 0:
raftNode.logMaxEntries = config.raftLogMaxEntries
if config.raftSnapChunkKb > 0:
raftNode.snapChunkBytes = config.raftSnapChunkKb * 1024
if config.raftPeerStaleMs > 0:
raftNode.raftPeerStaleMs = config.raftPeerStaleMs
tcpServer.raftNode = raftNode # C3b: executeQuery rejects writes on followers
httpServer.raftNode = raftNode # /metrics + /health raft gauges
# Wire state machine: committed entries update LSM + secondary indexes
@@ -367,13 +387,82 @@ proc main() =
elif cmd == "ddl":
applyReplicatedDdl(ctx, cast[string](data))
# Follower InstallSnapshot restore: swap the default DB's data directory
# with the received archive, then reopen it into the same DatabaseInfo
# slot (the applyCommand closure above keeps working through the swap).
# Runs on the raft async event loop and performs blocking disk I/O
# (tar extract + LSM close/reopen); snapshot installs are rare, so we
# accept the stall rather than adding a worker round-trip.
let defaultDbDir = config.dataDir / "databases" / "default"
raftNode.restoreSnapshot = proc(archivePath: string, baseIndex: uint64,
baseTerm: uint64): bool {.gcsafe.} =
# NOTE: core/logging's info/warn are not gcsafe (global logger), so
# this callback stays silent; restoreDataDir echoes progress itself.
echo "[raft] Installing snapshot (base index ", baseIndex,
", base term ", baseTerm, ")"
# gcsafe cast: this runs on the raft event-loop thread (same thread as
# the rest of the server); the registry ctxFactory type is not marked
# gcsafe, which would otherwise reject the call.
{.cast(gcsafe).}:
# Hold the storage gate for the whole close/extract/reopen/repoint
# sequence: HTTP workers run queries under the same gate, so this
# cannot close the LSM out from under an in-flight /query.
withStorageGate:
try:
defaultDbInfo.db.close()
# restoreDataDir moves the old dir aside and extracts the archive; on
# extraction failure it rolls back automatically. Reopen whatever is
# on disk either way so the node is not left with a closed DB.
let restored = restoreDataDir(archivePath, defaultDbDir)
let reopened = registry.reopenDatabase("default")
if reopened:
# Serve the (re)opened data. Client connections clone tcpServer.ctx
# on accept (cloneForConnection), and reopenDatabase installs a NEW
# ctx object in the registry slot — without repointing, queries
# keep reading the closed pre-restore LSM (empty results, no
# error). The websocket change hook was installed on the previous
# ctx object; carry it over.
let oldCtx = tcpServer.ctx
let newCtx = cast[ExecutionContext](cast[pointer](defaultDbInfo.ctx))
newCtx.onChange = oldCtx.onChange
tcpServer.db = defaultDbInfo.db
tcpServer.ctx = newCtx
tcpServer.txnManager = newCtx.txnManager
# The HTTP thread reads server.db/ctx only inside
# withStorageGate (getRequestDatabaseContext in the /query and
# /tables handlers), so repointing them here — while this thread
# holds the gate — is race-free against request handlers.
httpServer.db = defaultDbInfo.db
httpServer.ctx = newCtx
result = reopened and restored
except CatchableError as e:
echo "[raft] Snapshot restore failed: ", e.msg
result = false
# Leader InstallSnapshot send: archive the default DB's data directory
# into the path raft picks (dataDir/raft/snap_out_<snapId>.tar.gz). Like
# restoreSnapshot this runs on the raft event loop and blocks on disk I/O
# (tar+gzip); snapshot sends are rare, so we accept the stall.
raftNode.buildSnapshot = proc(destPath: string): bool {.gcsafe.} =
echo "[raft] Building snapshot archive ", destPath
{.cast(gcsafe).}:
# Hold the storage gate while tarring the data dir so a concurrent
# memtable flush (HTTP /query path) cannot write an SSTable
# mid-archive.
withStorageGate:
try:
result = backupDataDir(defaultDbDir, destPath)
except CatchableError as e:
echo "[raft] Snapshot build failed: ", e.msg
result = false
# Wire RAFT ↔ DistTxn
wireRaftDistTxn(raftNode, tcpServer)
# Wire replication ↔ DistTxn
wireReplicationDistTxn(tcpServer.replicationManager, tcpServer.distTxnManager)
raftNet = newRaftNetwork(raftNode)
raftNet = newRaftNetwork(raftNode, raftTls)
asyncCheck raftNet.run()
# HTTP (hunos) after raft wiring so /metrics can see raftNode
+104
View File
@@ -4,6 +4,7 @@ import std/os
import std/tables
import ../src/barabadb/query/[parser, executor, lexer, ast]
import ../src/barabadb/query/exec/params
import ../src/barabadb/query/exec/dml
import ../src/barabadb/core/types
import ../src/barabadb/core/config
import ../src/barabadb/storage/lsm
@@ -417,6 +418,78 @@ suite "Raft peer address parsing":
check msg.len > 0
check bad in msg
suite "Raft put/delete encoding — empty value is not a delete":
test "PK-only INSERT yields a put pair (deleted == false, empty value)":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("CREATE TABLE pkonly (id INTEGER PRIMARY KEY)"))
let r = executeQuery(ctx, parse("INSERT INTO pkonly (id) VALUES (1)"))
check r.success
check r.keyValuePairs.len == 1
check r.keyValuePairs[0].value.len == 0
check r.keyValuePairs[0].deleted == false
test "DELETE yields a delete pair (deleted == true)":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'alice')"))
let r = executeQuery(ctx, parse("DELETE FROM users WHERE id = 1"))
check r.success
check r.keyValuePairs.len == 1
check r.keyValuePairs[0].deleted == true
check r.keyValuePairs[0].value.len == 0
test "UPDATE yields a put pair (deleted == false)":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'alice')"))
let r = executeQuery(ctx, parse("UPDATE users SET name = 'bob' WHERE id = 1"))
check r.success
check r.keyValuePairs.len == 1
check r.keyValuePairs[0].deleted == false
check r.keyValuePairs[0].value.len > 0
test "txn COMMIT pairs carry deleted flag for buffered writes":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("CREATE TABLE pkonly (id INTEGER PRIMARY KEY)"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'alice')"))
discard executeQuery(ctx, parse("BEGIN"))
discard executeQuery(ctx, parse("INSERT INTO pkonly (id) VALUES (7)"))
discard executeQuery(ctx, parse("DELETE FROM users WHERE id = 1"))
let r = executeQuery(ctx, parse("COMMIT"))
check r.success
check r.keyValuePairs.len == 2
var sawPut = false
var sawDelete = false
for pair in r.keyValuePairs:
if pair.deleted:
sawDelete = true
check pair.value.len == 0
else:
sawPut = true
check pair.key == "pkonly.id=7"
check pair.value.len == 0 # empty value must still be a put
check sawPut and sawDelete
test "apply of a put with empty value keeps the PK-only row":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("CREATE TABLE pkonly (id INTEGER PRIMARY KEY)"))
let r = executeQuery(ctx, parse("INSERT INTO pkonly (id) VALUES (3)"))
check r.success
check r.keyValuePairs.len == 1
let pair = r.keyValuePairs[0]
# Same decode as applyCommand in src/baradadb.nim for a "put" entry.
let encoded = pair.key & "\x00" & cast[string](pair.value)
let parts = encoded.split("\x00")
check parts.len >= 2
applyReplicatedPut(ctx, parts[0], cast[seq[byte]](parts[1]))
let sel = executeQuery(ctx, parse("SELECT * FROM pkonly WHERE id = 3"))
check sel.success
check sel.rows.len == 1
suite "Raft write classification":
test "isWrite classifies DML and COMMIT":
@@ -448,3 +521,34 @@ suite "Raft write classification":
check not isRaftDdl(parse("CREATE DATABASE other").stmts[0])
check not isRaftDdl(parse("INSERT INTO t (id) VALUES (1)").stmts[0])
check not isRaftDdl(parse("SELECT 1").stmts[0])
suite "Raft TLS config":
test "default config has raft TLS disabled with empty paths":
let cfg = defaultConfig()
check cfg.raftTlsEnabled == false
check cfg.raftTlsCertFile == ""
check cfg.raftTlsKeyFile == ""
check cfg.raftTlsCaFile == ""
check cfg.raftTlsVerifyPeer == false
test "env vars parse into raft TLS config":
putEnv("BARADB_RAFT_TLS_ENABLED", "true")
putEnv("BARADB_RAFT_TLS_CERT_FILE", "/tmp/raft.crt")
putEnv("BARADB_RAFT_TLS_KEY_FILE", "/tmp/raft.key")
putEnv("BARADB_RAFT_TLS_CA_FILE", "/tmp/raft-ca.crt")
putEnv("BARADB_RAFT_TLS_VERIFY_PEER", "1")
defer:
delEnv("BARADB_RAFT_TLS_ENABLED")
delEnv("BARADB_RAFT_TLS_CERT_FILE")
delEnv("BARADB_RAFT_TLS_KEY_FILE")
delEnv("BARADB_RAFT_TLS_CA_FILE")
delEnv("BARADB_RAFT_TLS_VERIFY_PEER")
var cfg = defaultConfig()
loadConfigFromEnv(cfg)
check cfg.raftTlsEnabled == true
check cfg.raftTlsCertFile == "/tmp/raft.crt"
check cfg.raftTlsKeyFile == "/tmp/raft.key"
check cfg.raftTlsCaFile == "/tmp/raft-ca.crt"
check cfg.raftTlsVerifyPeer == true
+480
View File
@@ -0,0 +1,480 @@
## Raft cold-node E2E — real 3-node cluster; end-to-end proof of the
## InstallSnapshot work (compaction, snapshot build/send, follower restore).
## Starts three actual build/baradadb processes with a tiny raft log
## (BARADB_RAFT_LOG_MAX_ENTRIES=16) and a short stale window
## (BARADB_RAFT_PEER_STALE_MS=3000) so the leader compacts quickly past a
## downed peer's matchIndex.
##
## Scenario A: a node is killed, 100 rows are written through the leader
## (forcing compaction past its matchIndex), the node restarts with its
## intact data dir and must catch up via InstallSnapshot within 15 s.
## Scenario B: the node is stopped, its data dir is WIPED, it rejoins with
## the same node id and must serve the full row set within 20 s.
##
## Process-management conventions follow tests/raft_writes_e2e_test.nim
## (copying is the repo's e2e convention — do not factor out).
import std/unittest
import std/osproc
import std/os
import std/strtabs
import std/strutils
import std/times
import std/net
import std/posix
import std/httpclient
import ../adaptors/nim/baradb_sqlite as sqlite
const
BinaryPath = "./build/baradadb"
LeaderMarker = "became leader"
SnapshotMarker = "Installing snapshot"
TableName = "cold_test"
RowCount = 100
type
NodeProc = object
id: string
clientPort: int
raftPort: int
peers: string
clientPeers: string
p: Process
dataDir: string
output: string
alive: bool
proc drainOutput(n: var NodeProc) =
## Reads whatever the child has written so far. The pipe was set O_NONBLOCK
## at start, so this never blocks — a hung read is impossible here.
var tmp: array[8192, char]
while true:
let count = posix.read(n.p.outputHandle.cint, tmp[0].addr, tmp.len)
if count <= 0: break
for i in 0 ..< count: n.output.add tmp[i]
proc drainAll(nodes: var seq[NodeProc]) =
for n in nodes.mitems:
if n.p != nil: n.drainOutput()
proc dumpAll(nodes: var seq[NodeProc]) =
## Debuggability: on failure, everything the nodes said.
nodes.drainAll()
for n in nodes:
echo "===== output of ", n.id, " (port ", n.clientPort, ") ====="
echo n.output
proc portOpen(port: int): bool =
var s: Socket
try:
s = newSocket()
s.connect("127.0.0.1", Port(port), timeout = 250)
s.close()
result = true
except CatchableError:
if s != nil: s.close()
result = false
proc killNode(n: var NodeProc) =
if n.p != nil and n.alive:
try:
n.p.terminate()
discard n.p.waitForExit()
except CatchableError:
discard
n.alive = false
proc leaderTerms(output: string): seq[int] =
## All terms this node logged leadership for ("became leader for term T").
var pos = 0
while true:
let idx = output.find(LeaderMarker, pos)
if idx < 0: break
let tIdx = output.find("term ", idx)
if tIdx < 0: break
let numStart = tIdx + 5
var numEnd = numStart
while numEnd < output.len and output[numEnd] in Digits: inc numEnd
if numEnd > numStart:
result.add(parseInt(output[numStart ..< numEnd]))
pos = numEnd
proc maxLeader(nodes: seq[NodeProc]): tuple[idx, term: int] =
## Node that logged leadership for the highest term seen so far.
result = (-1, 0)
for i in 0 ..< nodes.len:
for t in leaderTerms(nodes[i].output):
if t > result.term: result = (i, t)
proc drainFor(nodes: var seq[NodeProc], ms: int) =
let start = getTime()
while getTime() - start < initDuration(milliseconds = ms):
nodes.drainAll()
sleep(50)
proc openClient(port: int): DbConn =
## Connect with retries — the port may accept TCP before the DB is usable.
for i in 0 ..< 50:
try:
return open("127.0.0.1:" & $port, "", "", "default")
except CatchableError:
sleep(100)
raise newException(IOError, "cannot connect to port " & $port)
proc rowCountOn(port: int): int =
## SELECT COUNT(*) — raises while the table is not there yet (e.g. before
## the snapshot restore has landed); callers poll and tolerate that.
let db = openClient(port)
defer: db.close()
parseInt(db.getValue(sql("SELECT COUNT(*) FROM " & TableName)))
proc fetchMetric(port: int, name: string): int =
## GET /metrics on the node's HTTP port (clientPort + 440) and parse
## `name{...} <value>`. Returns -1 on any error or when absent.
let client = newHttpClient(timeout = 1500)
defer: client.close()
try:
let body = client.getContent("http://127.0.0.1:" & $(port + 440) & "/metrics")
for line in body.splitLines():
if line.startsWith(name & "{"):
let parts = line.splitWhitespace()
if parts.len >= 2:
return parseInt(parts[^1])
except CatchableError:
discard
return -1
proc startNode(id, dataDir: string, clientPort, raftPort: int,
peers, clientPeers: string): NodeProc =
## Boots one build/baradadb process. Takes the data dir explicitly so a
## node can be restarted with the same dir (scenario A) or a wiped dir
## (scenario B). Compaction/stale-window env goes on EVERY node.
createDir(dataDir)
var env = newStringTable()
for key, val in envPairs():
env[key] = val
env["BARADB_PORT"] = $clientPort
env["BARADB_RAFT_ENABLED"] = "true"
env["BARADB_RAFT_PORT"] = $raftPort
env["BARADB_RAFT_NODE_ID"] = id
env["BARADB_RAFT_PEERS"] = peers
env["BARADB_RAFT_CLIENT_PEERS"] = clientPeers
env["BARADB_RAFT_LOG_MAX_ENTRIES"] = "16"
env["BARADB_RAFT_PEER_STALE_MS"] = "3000"
env["BARADB_DATA_DIR"] = dataDir
env["BARADB_LOG_LEVEL"] = "info"
let p = startProcess(BinaryPath, env = env,
options = {poStdErrToStdOut, poDaemon})
discard fcntl(p.outputHandle.cint, F_SETFL,
fcntl(p.outputHandle.cint, F_GETFL) or O_NONBLOCK)
NodeProc(id: id, clientPort: clientPort, raftPort: raftPort,
peers: peers, clientPeers: clientPeers, p: p,
dataDir: dataDir, alive: true)
proc restartNode(n: var NodeProc, wipe: bool) =
## Kill, then boot the same node id again. wipe=false keeps the data dir
## (cold-node return); wipe=true deletes it first (fresh node rejoin).
n.killNode()
if n.p != nil: n.p.close()
if wipe:
removeDir(n.dataDir)
let fresh = startNode(n.id, n.dataDir, n.clientPort, n.raftPort,
n.peers, n.clientPeers)
n.p = fresh.p
n.alive = true
proc waitReady(n: NodeProc, deadlineSec: int): bool =
let start = getTime()
while getTime() - start < initDuration(seconds = deadlineSec):
if portOpen(n.clientPort):
return true
sleep(100)
return false
proc runColdNodeScenario() =
## Fatal phase failures dump all captured node output, record a test
## failure, and return; cleanup happens in the finally below either way.
let tstamp = getTime().toUnix.int
# Port base per the T11 brief: distinct from raft_writes_e2e_test
# (46000+mod4000) and raft_tls_e2e_test (54000+mod4000). Client ports are
# spaced by 10 because the server derives HTTP (port+440), WS (port+441)
# and gossip (raftPort+100) ports — consecutive client ports collide.
let cbase = 58000 + (tstamp mod 4000)
let rbase = cbase + 100
let peers = "n1@127.0.0.1:" & $(rbase + 1) &
",n2@127.0.0.1:" & $(rbase + 2) &
",n3@127.0.0.1:" & $(rbase + 3)
# SQL client ports for transparent leader write forwarding.
let clientPeers = "n1@127.0.0.1:" & $(cbase + 10) &
",n2@127.0.0.1:" & $(cbase + 20) &
",n3@127.0.0.1:" & $(cbase + 30)
var nodes: seq[NodeProc]
try:
# Node starts live inside the try so a raise from start #2/#3 still
# reaches the cleanup in the finally below.
for i in 1 .. 3:
let id = "n" & $i
let dataDir = getTempDir() / "baradb_raft_coldnode_e2e_" & $tstamp & "_" & id
nodes.add startNode(id, dataDir, cbase + i * 10, rbase + i,
peers, clientPeers)
# Readiness: all three client ports accept TCP connections (10s each).
for i in 0 ..< nodes.len:
if not waitReady(nodes[i], 10):
echo "node ", nodes[i].id, " never became ready"
dumpAll(nodes)
fail()
return
# Election: timeouts are 150-300ms, heartbeat 50ms — a leader should
# emerge within ~2s; 10s deadline for margin.
var elected = false
let electStart = getTime()
while getTime() - electStart < initDuration(seconds = 10):
nodes.drainAll()
if maxLeader(nodes).idx >= 0:
elected = true
break
sleep(50)
if not elected:
echo "no leader elected within 10s"
dumpAll(nodes)
fail()
return
# Settle and require stability, same as raft_writes_e2e_test.
nodes.drainFor(2000)
let (leaderIdx, leaderTerm) = maxLeader(nodes)
nodes.drainFor(1000)
let (stableIdx, stableTerm) = maxLeader(nodes)
if stableIdx != leaderIdx or stableTerm != leaderTerm:
echo "cluster unstable: leadership moved from ", nodes[leaderIdx].id,
" (term ", leaderTerm, ") to ", nodes[stableIdx].id,
" (term ", stableTerm, ")"
dumpAll(nodes)
fail()
return
echo "leader elected: ", nodes[leaderIdx].id, " (term ", leaderTerm, ")"
# The cold node: n3 unless n3 is the leader (then n1) — it must be a
# follower so killing it never forces a re-election.
let coldIdx = (if leaderIdx == 2: 0 else: 2)
let otherIdx = 3 - leaderIdx - coldIdx
echo "cold node: ", nodes[coldIdx].id, "; surviving follower: ",
nodes[otherIdx].id
# Schema: CREATE TABLE goes through the raft "ddl" log (C3c). Create on
# the leader; wait until the cold node has applied it before killing it.
block:
let db = openClient(nodes[leaderIdx].clientPort)
try:
db.exec(sql("CREATE TABLE " & TableName & " (id INT PRIMARY KEY, name STRING)"))
except CatchableError as e:
echo "leader CREATE TABLE failed: ", e.msg
dumpAll(nodes)
fail()
return
db.close()
block:
let start = getTime()
var ready = false
while getTime() - start < initDuration(seconds = 5):
try:
discard rowCountOn(nodes[coldIdx].clientPort)
ready = true
break
except CatchableError:
sleep(100)
if not ready:
echo "cold node ", nodes[coldIdx].id,
" never applied CREATE TABLE within 5s"
dumpAll(nodes)
fail()
return
echo "schema replicated to cold node ", nodes[coldIdx].id
# ---- Kill the cold node; write 100 rows through the leader. ----
# With logMaxEntries=16 and peerStaleMs=3000 the leader compacts past the
# dead node's matchIndex partway through the writes, so the node can only
# catch up via InstallSnapshot on return.
killNode(nodes[coldIdx])
echo "cold node ", nodes[coldIdx].id, " killed; writing ", RowCount, " rows"
block:
let db = openClient(nodes[leaderIdx].clientPort)
var atRow = 0
try:
for i in 1 .. RowCount:
atRow = i
db.exec(sql("INSERT INTO " & TableName & " (id, name) VALUES (" &
$i & ", 'row-" & $i & "')"))
except CatchableError as e:
echo "leader INSERT failed at row ", atRow, ": ", e.msg
dumpAll(nodes)
fail()
return
db.close()
echo RowCount, " rows committed with ", nodes[coldIdx].id, " down"
# Compaction evidence on the leader: the in-memory log stayed bounded
# (<= 64, far below the 100 entries written) and the snapshot base moved.
nodes.drainAll()
let leaderLogLen = fetchMetric(nodes[leaderIdx].clientPort,
"baradb_raft_log_entries")
let leaderSnapIdx = fetchMetric(nodes[leaderIdx].clientPort,
"baradb_raft_snapshot_index")
echo "leader after writes: log_entries=", leaderLogLen,
" snapshot_index=", leaderSnapIdx
if leaderLogLen < 0 or leaderLogLen > 64:
echo "leader raft log not bounded: baradb_raft_log_entries=",
leaderLogLen, " (want <= 64)"
dumpAll(nodes)
fail()
return
if leaderSnapIdx <= 0:
echo "leader never compacted: baradb_raft_snapshot_index=",
leaderSnapIdx, " (want > 0)"
dumpAll(nodes)
fail()
return
echo "leader log stayed bounded and compaction advanced"
# Sanity: leader and surviving follower agree on the row count.
let wantCount = rowCountOn(nodes[leaderIdx].clientPort)
if wantCount != RowCount:
echo "leader count=", wantCount, " want ", RowCount
dumpAll(nodes)
fail()
return
# ---- Scenario A: cold node returns with its intact data dir. ----
nodes[coldIdx].output.setLen(0) # fresh log for the snapshot marker scan
restartNode(nodes[coldIdx], wipe = false)
if not waitReady(nodes[coldIdx], 10):
echo "cold node ", nodes[coldIdx].id, " never became ready after restart"
dumpAll(nodes)
fail()
return
echo "scenario A: ", nodes[coldIdx].id, " restarted with intact data dir"
# Within 15s: snapshot evidence (metric on the cold node, or its restore
# log line) AND data convergence with the leader.
block:
let start = getTime()
var snapSeen = false
var converged = false
while getTime() - start < initDuration(seconds = 15):
nodes.drainAll()
if not snapSeen:
snapSeen = fetchMetric(nodes[coldIdx].clientPort,
"baradb_raft_snapshot_index") > 0 or
nodes[coldIdx].output.contains(SnapshotMarker)
if not converged:
try:
converged = rowCountOn(nodes[coldIdx].clientPort) == wantCount
except CatchableError:
discard
if snapSeen and converged: break
sleep(200)
if not snapSeen:
echo "scenario A: no snapshot evidence on ", nodes[coldIdx].id,
" (baradb_raft_snapshot_index stayed 0, no restore log line)"
dumpAll(nodes)
fail()
return
if not converged:
echo "scenario A: ", nodes[coldIdx].id,
" count did not converge to ", wantCount, " within 15s"
dumpAll(nodes)
fail()
return
echo "scenario A: snapshot installed, count converged to ", wantCount
# Spot-check a few ids survived the snapshot round-trip.
block:
let db = openClient(nodes[coldIdx].clientPort)
defer: db.close()
for i in [1, 42, RowCount]:
let v = db.getValue(sql("SELECT name FROM " & TableName &
" WHERE id = " & $i))
if v != "row-" & $i:
echo "scenario A: spot-check id=", i, " got '", v,
"' want 'row-", i, "'"
dumpAll(nodes)
fail()
return
echo "scenario A: spot-checks passed"
# ---- Scenario B: wiped node rejoins with the same node id. ----
nodes[coldIdx].output.setLen(0)
restartNode(nodes[coldIdx], wipe = true)
if not waitReady(nodes[coldIdx], 10):
echo "wiped node ", nodes[coldIdx].id, " never became ready after restart"
dumpAll(nodes)
fail()
return
echo "scenario B: ", nodes[coldIdx].id, " restarted with a wiped data dir"
# Within 20s: snapshot → catch-up, then the full row set.
block:
let start = getTime()
var snapSeen = false
var converged = false
while getTime() - start < initDuration(seconds = 20):
nodes.drainAll()
if not snapSeen:
snapSeen = fetchMetric(nodes[coldIdx].clientPort,
"baradb_raft_snapshot_index") > 0 or
nodes[coldIdx].output.contains(SnapshotMarker)
if not converged:
try:
converged = rowCountOn(nodes[coldIdx].clientPort) == wantCount
except CatchableError:
discard
if snapSeen and converged: break
sleep(200)
if not snapSeen:
echo "scenario B: no snapshot evidence on wiped ", nodes[coldIdx].id
dumpAll(nodes)
fail()
return
if not converged:
echo "scenario B: wiped ", nodes[coldIdx].id,
" count did not converge to ", wantCount, " within 20s"
dumpAll(nodes)
fail()
return
echo "scenario B: wiped node converged to ", wantCount, " rows"
# Spot-check again on the wiped node.
block:
let db = openClient(nodes[coldIdx].clientPort)
defer: db.close()
for i in [1, 42, RowCount]:
let v = db.getValue(sql("SELECT name FROM " & TableName &
" WHERE id = " & $i))
if v != "row-" & $i:
echo "scenario B: spot-check id=", i, " got '", v,
"' want 'row-", i, "'"
dumpAll(nodes)
fail()
return
echo "scenario B: spot-checks passed"
finally:
for n in nodes.mitems:
n.killNode()
if n.p != nil: n.p.close()
removeDir(n.dataDir)
suite "Raft cold-node E2E":
test "compacted-away node and wiped node rejoin and converge":
if not fileExists(BinaryPath):
if getEnv("CI").len > 0:
echo "[FAIL] ", BinaryPath, " missing under CI — build step broken?"
fail()
else:
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
skip()
else:
runColdNodeScenario()
+6 -2
View File
@@ -208,7 +208,11 @@ proc runClusterScenario() =
suite "Raft E2E cluster":
test "3-node election and failover":
if not fileExists(BinaryPath):
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
skip()
if getEnv("CI").len > 0:
echo "[FAIL] ", BinaryPath, " missing under CI — build step broken?"
fail()
else:
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
skip()
else:
runClusterScenario()
+435
View File
@@ -0,0 +1,435 @@
## Raft failover-under-load E2E — real 3-node cluster over the TCP transport.
## A writer thread hammers the current leader with INSERTs; once 50 writes
## have been acknowledged the leader is killed mid-load. Asserts:
## A (availability): a survivor accepts an INSERT within 10s of the kill.
## B (durability): every acknowledged id is present on BOTH survivors
## once the new leader is stable and the remaining
## follower has caught up.
## Process-management conventions follow tests/raft_writes_e2e_test.nim;
## client access follows tests/nimforum_smoke_test.nim.
import std/unittest
import std/osproc
import std/os
import std/strtabs
import std/strutils
import std/sequtils
import std/times
import std/net
import std/posix
import std/sets
import std/locks
import std/typedthreads
import ../adaptors/nim/baradb_sqlite as sqlite
const
BinaryPath = "./build/baradadb"
LeaderMarker = "became leader"
AckTarget = 50 # kill the leader once this many writes are acked
ProbeId = 1000001 # availability-probe row id (clear of writer's 1..N)
type
NodeProc = object
id: string
clientPort: int
p: Process
dataDir: string
output: string
alive: bool
# Shared writer-thread state, passed by pointer into the thread (same
# convention as tests/test_storage_hardening.nim); all access goes through
# the lock.
type
WriterArgs = object
lock: ptr Lock
acked: ptr seq[int] # ids whose INSERT was acknowledged by the cluster
stop: ptr bool # main thread sets this to end the writer loop
ports: array[3, int] # candidate client ports (leader first, then survivors)
proc drainOutput(n: var NodeProc) =
## Reads whatever the child has written so far. The pipe was set O_NONBLOCK
## at start, so this never blocks — a hung read is impossible here.
var tmp: array[8192, char]
while true:
let count = posix.read(n.p.outputHandle.cint, tmp[0].addr, tmp.len)
if count <= 0: break
for i in 0 ..< count: n.output.add tmp[i]
proc drainAll(nodes: var seq[NodeProc]) =
for n in nodes.mitems:
if n.p != nil: n.drainOutput()
proc dumpAll(nodes: var seq[NodeProc]) =
## Debuggability: on failure, everything the nodes said.
nodes.drainAll()
for n in nodes:
echo "===== output of ", n.id, " (port ", n.clientPort, ") ====="
echo n.output
proc portOpen(port: int): bool =
var s: Socket
try:
s = newSocket()
s.connect("127.0.0.1", Port(port), timeout = 250)
s.close()
result = true
except CatchableError:
if s != nil: s.close()
result = false
proc killNode(n: var NodeProc) =
if n.p != nil and n.alive:
try:
n.p.terminate()
discard n.p.waitForExit()
except CatchableError:
discard
n.alive = false
proc leaderTerms(output: string): seq[int] =
## All terms this node logged leadership for ("became leader for term T").
var pos = 0
while true:
let idx = output.find(LeaderMarker, pos)
if idx < 0: break
let tIdx = output.find("term ", idx)
if tIdx < 0: break
let numStart = tIdx + 5
var numEnd = numStart
while numEnd < output.len and output[numEnd] in Digits: inc numEnd
if numEnd > numStart:
result.add(parseInt(output[numStart ..< numEnd]))
pos = numEnd
proc maxLeader(nodes: seq[NodeProc]): tuple[idx, term: int] =
## Node that logged leadership for the highest term seen so far.
result = (-1, 0)
for i in 0 ..< nodes.len:
for t in leaderTerms(nodes[i].output):
if t > result.term: result = (i, t)
proc drainFor(nodes: var seq[NodeProc], ms: int) =
let start = getTime()
while getTime() - start < initDuration(milliseconds = ms):
nodes.drainAll()
sleep(50)
proc openClient(port: int): DbConn =
## Connect with retries — the port may accept TCP before the DB is usable.
for i in 0 ..< 50:
try:
return open("127.0.0.1:" & $port, "", "", "default")
except CatchableError:
sleep(100)
raise newException(IOError, "cannot connect to port " & $port)
proc countRows(port: int): int =
## Row count of load_test on `port`, or -1 on any error (not ready yet).
result = -1
try:
let db = openClient(port)
try:
result = parseInt(db.getValue(sql"SELECT count(*) FROM load_test"))
finally:
db.close()
except CatchableError:
discard
proc allIds(port: int): HashSet[int] =
## Every id in load_test on `port`. Raises on error.
let db = openClient(port)
try:
for row in db.getAllRows(sql"SELECT id FROM load_test"):
if row.len >= 1 and row[0].len > 0:
result.incl(parseInt(row[0]))
finally:
db.close()
proc writerLoop(args: WriterArgs) {.thread.} =
## INSERTs n = 1, 2, ... against the cluster. Every acknowledged n is
## appended to args.acked. On any error the client is dropped and reopened
## against the next candidate port — the documented client retry contract.
## Writes against followers are forwarded to the leader by the server.
var n = 1
var portIdx = 0
var db: DbConn
while true:
withLock args.lock[]:
if args.stop[]: break
if cast[pointer](db) == nil:
let port = args.ports[portIdx]
try:
db = open("127.0.0.1:" & $port, "", "", "default")
except CatchableError:
portIdx = (portIdx + 1) mod args.ports.len
sleep(50)
continue
try:
db.exec(sql("INSERT INTO load_test (id) VALUES (" & $n & ")"))
withLock args.lock[]:
args.acked[].add n
inc n
except CatchableError:
if cast[pointer](db) != nil:
try: db.close()
except CatchableError: discard
db = default(DbConn)
portIdx = (portIdx + 1) mod args.ports.len
sleep(50)
if cast[pointer](db) != nil:
try: db.close()
except CatchableError: discard
proc runFailoverLoadScenario() =
## Fatal phase failures dump all captured node output, record a test
## failure, and return; cleanup happens in the finally below either way.
let tstamp = getTime().toUnix.int
# Port bases: distinct from nimforum_smoke_test (35000+mod10000),
# raft_e2e_test (41000+mod5000) and raft_writes_e2e_test (46000+mod4000).
# Client ports are spaced by 10 because the server derives HTTP (port+440),
# WS (port+441) and gossip (raftPort+100) ports — consecutive client ports
# collide.
let cbase = 50000 + (tstamp mod 4000)
let rbase = cbase + 100
let peers = "n1@127.0.0.1:" & $(rbase + 1) &
",n2@127.0.0.1:" & $(rbase + 2) &
",n3@127.0.0.1:" & $(rbase + 3)
# SQL client ports for transparent leader write forwarding.
let clientPeers = "n1@127.0.0.1:" & $(cbase + 10) &
",n2@127.0.0.1:" & $(cbase + 20) &
",n3@127.0.0.1:" & $(cbase + 30)
var nodes: seq[NodeProc]
for i in 1 .. 3:
let id = "n" & $i
let dataDir = getTempDir() / "baradb_raft_failover_load_e2e_" & $tstamp & "_" & id
createDir(dataDir)
var env = newStringTable()
for key, val in envPairs():
env[key] = val
env["BARADB_PORT"] = $(cbase + i * 10)
env["BARADB_RAFT_ENABLED"] = "true"
env["BARADB_RAFT_PORT"] = $(rbase + i)
env["BARADB_RAFT_NODE_ID"] = id
env["BARADB_RAFT_PEERS"] = peers
env["BARADB_RAFT_CLIENT_PEERS"] = clientPeers
env["BARADB_DATA_DIR"] = dataDir
env["BARADB_LOG_LEVEL"] = "info"
let p = startProcess(BinaryPath, env = env,
options = {poStdErrToStdOut, poDaemon})
discard fcntl(p.outputHandle.cint, F_SETFL,
fcntl(p.outputHandle.cint, F_GETFL) or O_NONBLOCK)
nodes.add NodeProc(id: id, clientPort: cbase + i * 10, p: p,
dataDir: dataDir, alive: true)
var
writer: Thread[WriterArgs]
writerStarted = false
wLock: Lock
wAcked: seq[int]
wStop = false
initLock(wLock)
try:
# Readiness: all three client ports accept TCP connections (10s each).
for i in 0 ..< nodes.len:
let readyStart = getTime()
var ok = false
while getTime() - readyStart < initDuration(seconds = 10):
if portOpen(nodes[i].clientPort):
ok = true
break
sleep(100)
if not ok:
echo "node ", nodes[i].id, " never became ready"
dumpAll(nodes)
fail()
return
# Election: timeouts are 150-300ms, heartbeat 50ms — a leader should
# emerge within ~2s; 10s deadline for margin.
var elected = false
let electStart = getTime()
while getTime() - electStart < initDuration(seconds = 10):
nodes.drainAll()
if maxLeader(nodes).idx >= 0:
elected = true
break
sleep(50)
if not elected:
echo "no leader elected within 10s"
dumpAll(nodes)
fail()
return
# Settle and require stability, same as raft_writes_e2e_test.
nodes.drainFor(2000)
let (leaderIdx, leaderTerm) = maxLeader(nodes)
nodes.drainFor(1000)
let (stableIdx, stableTerm) = maxLeader(nodes)
if stableIdx != leaderIdx or stableTerm != leaderTerm:
echo "cluster unstable: leadership moved from ", nodes[leaderIdx].id,
" (term ", leaderTerm, ") to ", nodes[stableIdx].id,
" (term ", stableTerm, ")"
dumpAll(nodes)
fail()
return
echo "leader elected: ", nodes[leaderIdx].id, " (term ", leaderTerm, ")"
# Schema goes through the raft ddl log on the leader (C3c).
block:
let db = openClient(nodes[leaderIdx].clientPort)
try:
db.exec(sql"CREATE TABLE load_test (id INT PRIMARY KEY)")
except CatchableError as e:
echo "leader CREATE TABLE failed: ", e.msg
dumpAll(nodes)
fail()
return
db.close()
echo "leader schema committed via raft ddl"
# Load phase: sustained INSERTs from a background writer thread.
let wArgs = WriterArgs(
lock: addr wLock, acked: addr wAcked, stop: addr wStop,
ports: [nodes[leaderIdx].clientPort,
nodes[(leaderIdx + 1) mod 3].clientPort,
nodes[(leaderIdx + 2) mod 3].clientPort])
createThread(writer, writerLoop, wArgs)
writerStarted = true
# Wait until AckTarget writes are acknowledged (30s deadline).
var ackedAtKill = 0
let loadStart = getTime()
while getTime() - loadStart < initDuration(seconds = 30):
withLock wLock:
ackedAtKill = wAcked.len
if ackedAtKill >= AckTarget: break
sleep(20)
if ackedAtKill < AckTarget:
echo "only ", ackedAtKill, " writes acked within 30s (need ", AckTarget, ")"
dumpAll(nodes)
fail()
return
echo "load phase: ", ackedAtKill, " writes acknowledged, killing leader ",
nodes[leaderIdx].id
# Kill the leader mid-load.
killNode(nodes[leaderIdx])
let killTime = getTime()
# Assert A (availability): a survivor accepts an INSERT within 10s of
# the kill. Probe both survivors; keep the one that answers. Each attempt
# uses a fresh id: an attempt may commit but lose its response during the
# failover, and retrying the same id would then loop on UNIQUE violations.
var writerSurvivor = -1
var writeErr = ""
var probeAttempt = 0
while getTime() - killTime < initDuration(seconds = 10):
for i in 0 ..< nodes.len:
if i == leaderIdx: continue
inc probeAttempt
let probeId = ProbeId + probeAttempt
try:
let db = openClient(nodes[i].clientPort)
try:
db.exec(sql("INSERT INTO load_test (id) VALUES (" & $probeId & ")"))
writerSurvivor = i
finally:
db.close()
if writerSurvivor >= 0: break
except CatchableError as e:
writeErr = e.msg
# "not leader" / commit timeout / connection blips — keep probing.
if writerSurvivor >= 0: break
sleep(100)
if writerSurvivor < 0:
echo "ASSERT A FAILED: no survivor accepted a write within 10s of the kill",
(if writeErr.len > 0: " (last error: " & writeErr & ")" else: "")
dumpAll(nodes)
fail()
return
let availMs = inMilliseconds(getTime() - killTime)
echo "availability: ", nodes[writerSurvivor].id,
" accepted a write ", availMs, "ms after the kill"
# Stop the writer thread and snapshot what was acknowledged.
withLock wLock:
wStop = true
joinThreads(writer)
writerStarted = false
withLock wLock:
ackedAtKill = wAcked.len
echo "writer stopped; ", ackedAtKill, " total acknowledged writes"
# Let the new leader stabilize and the remaining follower catch up:
# poll until both survivors agree on a row count that covers every
# acknowledged write (10s deadline). The >= ackedAtKill guard prevents
# a trivial 0 == 0 pass before any raft entries have been applied.
let survivorIdx = [0, 1, 2].filterIt(it != leaderIdx)
var caughtUp = false
let cuStart = getTime()
while getTime() - cuStart < initDuration(seconds = 10):
let c0 = countRows(nodes[survivorIdx[0]].clientPort)
let c1 = countRows(nodes[survivorIdx[1]].clientPort)
if c0 >= ackedAtKill and c0 == c1:
caughtUp = true
break
sleep(100)
if not caughtUp:
echo "survivors never reached equal row counts within 10s (",
countRows(nodes[survivorIdx[0]].clientPort), " vs ",
countRows(nodes[survivorIdx[1]].clientPort), ")"
dumpAll(nodes)
fail()
return
echo "survivors caught up: equal row counts"
# Assert B (durability): every acknowledged id must be present on BOTH
# survivors.
var acked: HashSet[int]
withLock wLock:
acked = toHashSet(wAcked)
for i in survivorIdx:
var ids: HashSet[int]
try:
ids = allIds(nodes[i].clientPort)
except CatchableError as e:
echo "ASSERT B FAILED: could not read ids from ", nodes[i].id,
": ", e.msg
dumpAll(nodes)
fail()
return
let missing = acked - ids
if missing.len > 0:
echo "ASSERT B FAILED: ", nodes[i].id, " is missing ",
missing.len, " acknowledged ids"
dumpAll(nodes)
fail()
return
echo "durability: ", nodes[i].id, " contains all ",
acked.len, " acknowledged ids"
check acked.len >= AckTarget
finally:
if writerStarted:
withLock wLock:
wStop = true
joinThreads(writer)
deinitLock(wLock)
for n in nodes.mitems:
n.killNode()
if n.p != nil: n.p.close()
removeDir(n.dataDir)
suite "Raft failover under load E2E":
test "committed writes survive a leader kill under sustained write load":
if not fileExists(BinaryPath):
if getEnv("CI").len > 0:
echo "[FAIL] ", BinaryPath, " missing under CI — build step broken?"
fail()
else:
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
skip()
else:
runFailoverLoadScenario()
+445
View File
@@ -0,0 +1,445 @@
## Raft TLS E2E — real 3-node cluster with TLS on the raft transport.
## Starts three actual build/baradadb processes with per-node self-signed
## certs (BARADB_RAFT_TLS_ENABLED + CERT/KEY_FILE), asserts election and
## replicated writes work over the encrypted transport, then starts a 4th
## plaintext node pointed at the same peers and asserts it never becomes
## leader (its frames are undecryptable) while the TLS cluster keeps
## operating among its 3 members.
## Process-management conventions follow tests/raft_writes_e2e_test.nim
## (copying is the repo's e2e convention — do not factor out).
##
## NOTE: only the raft port is TLS here. The SQL client port stays
## plaintext (BARADB_TLS_ENABLED is the client wire port — a different
## feature), so the baradb_sqlite adaptor connects as usual.
import std/unittest
import std/osproc
import std/os
import std/strtabs
import std/strutils
import std/times
import std/net
import std/posix
import ../adaptors/nim/baradb_sqlite as sqlite
import barabadb/protocol/ssl
const
BinaryPath = "./build/baradadb"
LeaderMarker = "became leader"
type
NodeProc = object
id: string
clientPort: int
raftPort: int
p: Process
dataDir: string
output: string
alive: bool
proc drainOutput(n: var NodeProc) =
## Reads whatever the child has written so far. The pipe was set O_NONBLOCK
## at start, so this never blocks — a hung read is impossible here.
var tmp: array[8192, char]
while true:
let count = posix.read(n.p.outputHandle.cint, tmp[0].addr, tmp.len)
if count <= 0: break
for i in 0 ..< count: n.output.add tmp[i]
proc drainAll(nodes: var seq[NodeProc]) =
for n in nodes.mitems:
if n.p != nil: n.drainOutput()
proc dumpAll(nodes: var seq[NodeProc]) =
## Debuggability: on failure, everything the nodes said.
nodes.drainAll()
for n in nodes:
echo "===== output of ", n.id, " (port ", n.clientPort, ") ====="
echo n.output
proc portOpen(port: int): bool =
var s: Socket
try:
s = newSocket()
s.connect("127.0.0.1", Port(port), timeout = 250)
s.close()
result = true
except CatchableError:
if s != nil: s.close()
result = false
proc tlsHandshake(port: int): bool =
## True when a real TLS client handshake completes against `port`.
## Wire-level discriminator: against a plaintext peer it fails — either
## immediately (SSL error on the garbage reply) or, when the peer swallows
## our ClientHello and waits for more bytes, via the 3s recv timeout.
var ctx: SslContext
var s: Socket
try:
ctx = newContext(verifyMode = CVerifyNone)
s = newSocket()
var tv = Timeval(tvSec: posix.Time(3), tvUsec: Suseconds(0))
discard setsockopt(s.getFd(), SOL_SOCKET, SO_RCVTIMEO,
addr tv, SockLen(sizeof(tv)))
s.connect("127.0.0.1", Port(port), timeout = 3000)
ctx.wrapConnectedSocket(s, handshakeAsClient)
result = true
except CatchableError:
result = false
finally:
if s != nil: s.close()
if ctx != nil: ctx.destroyContext()
proc killNode(n: var NodeProc) =
if n.p != nil and n.alive:
try:
n.p.terminate()
discard n.p.waitForExit()
except CatchableError:
discard
n.alive = false
proc leaderTerms(output: string): seq[int] =
## All terms this node logged leadership for ("became leader for term T").
var pos = 0
while true:
let idx = output.find(LeaderMarker, pos)
if idx < 0: break
let tIdx = output.find("term ", idx)
if tIdx < 0: break
let numStart = tIdx + 5
var numEnd = numStart
while numEnd < output.len and output[numEnd] in Digits: inc numEnd
if numEnd > numStart:
result.add(parseInt(output[numStart ..< numEnd]))
pos = numEnd
proc maxLeader(nodes: seq[NodeProc]): tuple[idx, term: int] =
## Node that logged leadership for the highest term seen so far.
result = (-1, 0)
for i in 0 ..< nodes.len:
for t in leaderTerms(nodes[i].output):
if t > result.term: result = (i, t)
proc drainFor(nodes: var seq[NodeProc], ms: int) =
let start = getTime()
while getTime() - start < initDuration(milliseconds = ms):
nodes.drainAll()
sleep(50)
proc openClient(port: int): DbConn =
## Connect with retries — the port may accept TCP before the DB is usable.
for i in 0 ..< 50:
try:
return open("127.0.0.1:" & $port, "", "", "default")
except CatchableError:
sleep(100)
raise newException(IOError, "cannot connect to port " & $port)
proc waitForRow(port: int, table, name: string, deadlineSec: int): bool =
## Poll SELECT on `port` until a row with `name` appears. Tolerates errors
## (e.g. "unknown table" while schema has not been created yet) by retrying.
let db = openClient(port)
defer: db.close()
let start = getTime()
while getTime() - start < initDuration(seconds = deadlineSec):
try:
let rows = db.getAllRows(sql("SELECT * FROM " & table))
for row in rows:
if row.len >= 2 and row[1] == name:
return true
except CatchableError:
discard
sleep(100)
return false
proc startNode(id: string, clientPort, raftPort: int, peers, clientPeers: string,
tlsEnabled: bool): NodeProc =
## Boots one build/baradadb process. When tlsEnabled, a self-signed cert
## (CN = node id) is generated into the node's data dir first.
let tstamp = getTime().toUnix.int
let dataDir = getTempDir() / "baradb_raft_tls_e2e_" & $tstamp & "_" & id
createDir(dataDir)
var env = newStringTable()
for key, val in envPairs():
env[key] = val
env["BARADB_PORT"] = $clientPort
env["BARADB_RAFT_ENABLED"] = "true"
env["BARADB_RAFT_PORT"] = $raftPort
env["BARADB_RAFT_NODE_ID"] = id
env["BARADB_RAFT_PEERS"] = peers
env["BARADB_RAFT_CLIENT_PEERS"] = clientPeers
env["BARADB_DATA_DIR"] = dataDir
env["BARADB_LOG_LEVEL"] = "info"
if tlsEnabled:
let (certFile, keyFile) = generateSelfSignedCert(dataDir, id)
doAssert certFile.len > 0 and keyFile.len > 0,
"openssl cert generation failed for " & id
env["BARADB_RAFT_TLS_ENABLED"] = "true"
env["BARADB_RAFT_TLS_CERT_FILE"] = certFile
env["BARADB_RAFT_TLS_KEY_FILE"] = keyFile
let p = startProcess(BinaryPath, env = env,
options = {poStdErrToStdOut, poDaemon})
discard fcntl(p.outputHandle.cint, F_SETFL,
fcntl(p.outputHandle.cint, F_GETFL) or O_NONBLOCK)
NodeProc(id: id, clientPort: clientPort, raftPort: raftPort, p: p,
dataDir: dataDir, alive: true)
proc runTlsScenario() =
## Fatal phase failures dump all captured node output, record a test
## failure, and return; cleanup happens in the finally below either way.
let tstamp = getTime().toUnix.int
# Port base per the T6 brief: distinct from raft_writes_e2e_test
# (46000+mod4000). Client ports are spaced by 10 because the server
# derives HTTP (port+440), WS (port+441) and gossip (raftPort+100)
# ports — consecutive client ports collide.
let cbase = 54000 + (tstamp mod 4000)
let rbase = cbase + 100
let peers = "n1@127.0.0.1:" & $(rbase + 1) &
",n2@127.0.0.1:" & $(rbase + 2) &
",n3@127.0.0.1:" & $(rbase + 3)
# SQL client ports for transparent leader write forwarding.
let clientPeers = "n1@127.0.0.1:" & $(cbase + 10) &
",n2@127.0.0.1:" & $(cbase + 20) &
",n3@127.0.0.1:" & $(cbase + 30)
var nodes: seq[NodeProc]
# Negative-case node: same peers, raft TLS DISABLED, own dir and ports
# (4th port in each base range). Started later, after the TLS cluster is
# up, so the positive assertions are not polluted by its noise.
var rogue: NodeProc
try:
# Node starts live inside the try so a raise from start #2/#3 still
# reaches the cleanup in the finally below.
for i in 1 .. 3:
nodes.add startNode("n" & $i, cbase + i * 10, rbase + i,
peers, clientPeers, tlsEnabled = true)
# Readiness: all three client ports accept TCP connections (10s each).
for i in 0 ..< nodes.len:
let readyStart = getTime()
var ok = false
while getTime() - readyStart < initDuration(seconds = 10):
if portOpen(nodes[i].clientPort):
ok = true
break
sleep(100)
if not ok:
echo "node ", nodes[i].id, " never became ready"
dumpAll(nodes)
fail()
return
# Election over TLS: timeouts are 150-300ms, heartbeat 50ms — a leader
# should emerge within ~2s; 10s deadline for margin.
var elected = false
let electStart = getTime()
while getTime() - electStart < initDuration(seconds = 10):
nodes.drainAll()
if maxLeader(nodes).idx >= 0:
elected = true
break
sleep(50)
if not elected:
echo "no leader elected within 10s over TLS"
dumpAll(nodes)
fail()
return
# Settle and require stability, same as raft_writes_e2e_test.
nodes.drainFor(2000)
let (leaderIdx, leaderTerm) = maxLeader(nodes)
nodes.drainFor(1000)
let (stableIdx, stableTerm) = maxLeader(nodes)
if stableIdx != leaderIdx or stableTerm != leaderTerm:
echo "TLS cluster unstable: leadership moved from ", nodes[leaderIdx].id,
" (term ", leaderTerm, ") to ", nodes[stableIdx].id,
" (term ", stableTerm, ")"
dumpAll(nodes)
fail()
return
echo "TLS leader elected: ", nodes[leaderIdx].id, " (term ", leaderTerm, ")"
# Wire-level TLS assertion: a real client handshake must complete
# against every cluster node's raft port. Without this, the negative
# "plaintext node never becomes leader" check alone cannot distinguish
# TLS rejection from "can't win an election anyway" — if TLS wrapping
# were silently dropped from the transport, these handshakes raise and
# the suite fails.
for n in nodes.items:
if not tlsHandshake(n.raftPort):
echo "TLS handshake failed against raft port of ", n.id,
" (port ", n.raftPort, ") — raft transport not encrypted?"
dumpAll(nodes)
fail()
return
echo "TLS handshake verified against all 3 raft ports"
let followerIdx = (if leaderIdx == 0: 1 else: 0)
# Schema: CREATE TABLE goes through the raft "ddl" log (C3c) — this
# proves raft DDL replication works over the TLS transport.
block:
let db = openClient(nodes[leaderIdx].clientPort)
try:
db.exec(sql"CREATE TABLE tls_test (id INT PRIMARY KEY, name STRING)")
except CatchableError as e:
echo "leader CREATE TABLE failed: ", e.msg
dumpAll(nodes)
fail()
return
db.close()
echo "leader schema committed via raft ddl over TLS"
# Wait until the follower has applied CREATE TABLE (SELECT no longer
# errors with unknown table). Deadline 5s.
block:
let db = openClient(nodes[followerIdx].clientPort)
defer: db.close()
let start = getTime()
var ready = false
while getTime() - start < initDuration(seconds = 5):
try:
discard db.getAllRows(sql"SELECT * FROM tls_test")
ready = true
break
except CatchableError:
sleep(100)
if not ready:
echo "follower ", nodes[followerIdx].id,
" never applied CREATE TABLE within 5s"
dumpAll(nodes)
fail()
return
echo "schema replicated to follower ", nodes[followerIdx].id, " over TLS"
# Leader write: INSERT goes through the raft log and waits for majority
# commit before responding — expect success over TLS.
block:
let db = openClient(nodes[leaderIdx].clientPort)
try:
db.exec(sql"INSERT INTO tls_test (id, name) VALUES (1, 'tls-row')")
except CatchableError as e:
echo "leader INSERT failed: ", e.msg
dumpAll(nodes)
fail()
return
db.close()
echo "leader INSERT committed over TLS"
# Follower visibility: poll until the row shows up (5s deadline).
if not waitForRow(nodes[followerIdx].clientPort, "tls_test", "tls-row", 5):
echo "follower ", nodes[followerIdx].id,
" never saw the replicated row within 5s"
dumpAll(nodes)
fail()
return
echo "row replicated to follower ", nodes[followerIdx].id, " over TLS"
# Negative scenario: start the plaintext node pointed at the same peers.
# Its TLS-less frames are undecryptable to the cluster, so it can never
# collect votes and must never become leader.
rogue = startNode("n4", cbase + 40, rbase + 4, peers, clientPeers,
tlsEnabled = false)
block:
let readyStart = getTime()
var ok = false
while getTime() - readyStart < initDuration(seconds = 10):
if portOpen(rogue.clientPort):
ok = true
break
sleep(100)
if not ok:
echo "plaintext node never became ready"
rogue.drainOutput()
echo "===== output of ", rogue.id, " (port ", rogue.clientPort, ") ====="
echo rogue.output
dumpAll(nodes)
fail()
return
echo "plaintext node n4 started against the TLS peers"
# Complementary wire-level negative: the rogue node's raft port speaks
# plaintext, so a TLS handshake against it must FAIL.
if tlsHandshake(rogue.raftPort):
echo "TLS handshake unexpectedly succeeded against plaintext n4 ",
"raft port ", rogue.raftPort
dumpAll(nodes)
fail()
return
echo "TLS handshake correctly fails against plaintext n4 raft port"
# Give n4 many election cycles (timeouts 150-300ms) to try its luck.
# The TLS cluster must keep operating among its 3 members meanwhile.
# Drain n4's pipe alongside the others — its connection-refused spam
# must not fill the pipe and block the child.
block:
let negStart = getTime()
while getTime() - negStart < initDuration(seconds = 6):
nodes.drainAll()
rogue.drainOutput()
sleep(50)
# Cluster still elects/operates: leader among the 3 TLS nodes accepts a
# write and it replicates to a follower, with n4 running.
nodes.drainAll()
let (leaderIdx2, leaderTerm2) = maxLeader(nodes)
if leaderIdx2 < 0:
echo "TLS cluster lost its leader after plaintext node joined"
dumpAll(nodes)
fail()
return
let followerIdx2 = (if leaderIdx2 == 0: 1 else: 0)
block:
let db = openClient(nodes[leaderIdx2].clientPort)
try:
db.exec(sql"INSERT INTO tls_test (id, name) VALUES (2, 'still-tls')")
except CatchableError as e:
echo "post-plaintext INSERT failed on ", nodes[leaderIdx2].id,
" (term ", leaderTerm2, "): ", e.msg
rogue.drainOutput()
echo "===== output of ", rogue.id, " (port ", rogue.clientPort, ") ====="
echo rogue.output
dumpAll(nodes)
fail()
return
db.close()
if not waitForRow(nodes[followerIdx2].clientPort, "tls_test", "still-tls", 5):
echo "post-plaintext row never replicated to ", nodes[followerIdx2].id
dumpAll(nodes)
fail()
return
echo "TLS cluster kept operating with plaintext node present"
# Final negative assertion: n4 never won an election.
rogue.drainOutput()
if rogue.output.contains(LeaderMarker):
echo "plaintext node became leader — TLS isolation broken"
echo "===== output of ", rogue.id, " (port ", rogue.clientPort, ") ====="
echo rogue.output
dumpAll(nodes)
fail()
return
echo "plaintext node never became leader (TLS rejection confirmed)"
finally:
for n in nodes.mitems:
n.killNode()
if n.p != nil: n.p.close()
removeDir(n.dataDir)
if rogue.p != nil:
rogue.killNode()
rogue.p.close()
removeDir(rogue.dataDir)
suite "Raft TLS E2E":
test "3-node TLS cluster elects and replicates; plaintext node rejected":
if not fileExists(BinaryPath):
if getEnv("CI").len > 0:
echo "[FAIL] ", BinaryPath, " missing under CI — build step broken?"
fail()
else:
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
skip()
else:
runTlsScenario()
+6 -2
View File
@@ -412,7 +412,11 @@ proc runWritesScenario() =
suite "Raft replicated writes E2E":
test "writes replicate, followers reject, failover resumes writes":
if not fileExists(BinaryPath):
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
skip()
if getEnv("CI").len > 0:
echo "[FAIL] ", BinaryPath, " missing under CI — build step broken?"
fail()
else:
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
skip()
else:
runWritesScenario()
+628 -3
View File
@@ -1,6 +1,7 @@
## BaraDB — Test Suite
import std/unittest
import std/tables
import std/sets
import std/strutils
import std/os
import std/asyncdispatch
@@ -8,11 +9,13 @@ import std/asyncnet
import std/monotimes
import std/base64
import std/json
import std/streams
import barabadb/core/types
import barabadb/core/mvcc
import barabadb/core/deadlock
import barabadb/core/config
import barabadb/core/backup
import barabadb/core/server
import barabadb/core/columnar
import barabadb/core/raft
@@ -2607,6 +2610,581 @@ suite "Raft Network Transport":
check replyMsg.kind == rmkRequestVoteReply
check replyMsg.success
suite "Raft InstallSnapshot Protocol":
test "InstallSnapshot fields survive serialize/deserialize round-trip":
let msg = RaftMessage(
kind: rmkInstallSnapshot,
term: 9,
senderId: "leader-1",
prevLogIndex: 42, # snapshot base index
prevLogTerm: 7, # snapshot base term
snapId: 3,
snapOffset: 4096,
snapData: @[byte 1, 2, 3, 250, 0, 17],
snapDone: true)
let decoded = deserializeRaftMessage(serialize(msg))
check decoded.kind == rmkInstallSnapshot
check decoded.term == 9
check decoded.senderId == "leader-1"
check decoded.prevLogIndex == 42
check decoded.prevLogTerm == 7
check decoded.snapId == 3
check decoded.snapOffset == 4096
check decoded.snapData == @[byte 1, 2, 3, 250, 0, 17]
check decoded.snapDone
test "InstallSnapshotReply fields survive serialize/deserialize round-trip":
let msg = RaftMessage(
kind: rmkInstallSnapshotReply,
term: 9,
senderId: "follower-2",
success: true,
matchIdx: 42,
snapId: 3,
snapOffset: 8192,
snapData: @[],
snapDone: false)
let decoded = deserializeRaftMessage(serialize(msg))
check decoded.kind == rmkInstallSnapshotReply
check decoded.term == 9
check decoded.senderId == "follower-2"
check decoded.success
check decoded.matchIdx == 42
check decoded.snapId == 3
check decoded.snapOffset == 8192
check decoded.snapData.len == 0
check not decoded.snapDone
test "old wire layout (no snapshot fields) deserializes with zero defaults":
# Manually serialize a message in the pre-InstallSnapshot layout:
# magic, version, kind, term, senderId, lastLogIndex, lastLogTerm,
# prevLogIndex, prevLogTerm, entries, leaderCommit, success, matchIdx.
let s = newStringStream()
s.write("RAFT")
s.write(1'u32) # RaftProtoVersion
s.write(uint32(ord(rmkAppendEntries)))
s.write(5'u64) # term
let sender = "old-leader"
s.write(uint32(sender.len))
s.writeData(sender[0].unsafeAddr, sender.len)
s.write(11'u64) # lastLogIndex
s.write(4'u64) # lastLogTerm
s.write(10'u64) # prevLogIndex
s.write(4'u64) # prevLogTerm
s.write(0'u32) # entries count
s.write(10'u64) # leaderCommit
s.write(char(1)) # success
s.write(10'u64) # matchIdx
let strData = s.data
var buf = newSeq[byte](strData.len)
for i in 0 ..< strData.len:
buf[i] = byte(strData[i])
s.close()
let decoded = deserializeRaftMessage(buf)
check decoded.kind == rmkAppendEntries
check decoded.term == 5
check decoded.senderId == "old-leader"
check decoded.matchIdx == 10
check decoded.snapId == 0
check decoded.snapOffset == 0
check decoded.snapData.len == 0
check not decoded.snapDone
test "old message kinds still round-trip unchanged":
let msg = RaftMessage(kind: rmkRequestVote, term: 2, senderId: "cand",
lastLogIndex: 5, lastLogTerm: 1)
let decoded = deserializeRaftMessage(serialize(msg))
check decoded.kind == rmkRequestVote
check decoded.term == 2
check decoded.senderId == "cand"
check decoded.lastLogIndex == 5
check decoded.lastLogTerm == 1
check decoded.snapId == 0
check decoded.snapOffset == 0
check decoded.snapData.len == 0
check not decoded.snapDone
suite "Raft InstallSnapshot Receive":
test "follower assembles chunks, restores snapshot, resets state":
proc scenario() =
let tmp = getTempDir() / "baradb_snaprx_ok_" & $getCurrentProcessId()
removeDir(tmp)
createDir(tmp)
defer: removeDir(tmp)
# Real tar.gz fixture with a marker file
let srcDb = tmp / "srcdb"
createDir(srcDb)
writeFile(srcDb / "marker.txt", "snapshot-payload")
let archivePath = tmp / "snap.tar.gz"
check backupDataDir(srcDb, archivePath)
let archiveBytes = readFile(archivePath)
check archiveBytes.len > 0
let raftDir = tmp / "raft"
var node = newRaftNode("follower-1", @[], dataDir = raftDir)
node.currentTerm = 5
node.log.add(LogEntry(term: 3, index: 10, command: "put", data: @[byte 1]))
node.commitIndex = 10
var gotPath = ""
var gotBaseIndex = 0'u64
var gotBaseTerm = 0'u64
node.restoreSnapshot = proc(p: string, bi: uint64, bt: uint64): bool {.gcsafe.} =
gotPath = p
gotBaseIndex = bi
gotBaseTerm = bt
# Assembled archive must match the original byte-for-byte
result = readFile(p) == archiveBytes
let half = archiveBytes.len div 2
let reply1 = node.handleInstallSnapshot(RaftMessage(
kind: rmkInstallSnapshot, term: 5, senderId: "leader-1",
prevLogIndex: 40, prevLogTerm: 4,
snapId: 7, snapOffset: 0,
snapData: cast[seq[byte]](archiveBytes[0 ..< half]), snapDone: false))
check reply1.kind == rmkInstallSnapshotReply
check reply1.success
let reply2 = node.handleInstallSnapshot(RaftMessage(
kind: rmkInstallSnapshot, term: 5, senderId: "leader-1",
prevLogIndex: 40, prevLogTerm: 4,
snapId: 7, snapOffset: uint64(half),
snapData: cast[seq[byte]](archiveBytes[half .. ^1]), snapDone: true))
check reply2.success
check reply2.matchIdx == 40
check gotPath.len > 0
check "snap_incoming" in gotPath
check gotBaseIndex == 40
check gotBaseTerm == 4
check node.lastSnapshotIndex == 40
check node.lastSnapshotTerm == 4
check node.commitIndex == 40
check node.lastApplied == 40
check node.log.len == 0
# State was persisted: a fresh node on the same dir sees the snapshot base
let reloaded = newRaftNode("follower-1", @[], dataDir = raftDir)
check reloaded.lastSnapshotIndex == 40
check reloaded.lastSnapshotTerm == 4
check reloaded.log.len == 0
scenario()
test "failed restore leaves state untouched and removes temp file":
proc scenario() =
let tmp = getTempDir() / "baradb_snaprx_fail_" & $getCurrentProcessId()
removeDir(tmp)
createDir(tmp)
defer: removeDir(tmp)
let raftDir = tmp / "raft"
var node = newRaftNode("follower-1", @[], dataDir = raftDir)
node.currentTerm = 5
node.log.add(LogEntry(term: 2, index: 3, command: "put", data: @[byte 9]))
node.restoreSnapshot = proc(p: string, bi: uint64, bt: uint64): bool {.gcsafe.} =
false
let reply = node.handleInstallSnapshot(RaftMessage(
kind: rmkInstallSnapshot, term: 5, senderId: "leader-1",
prevLogIndex: 8, prevLogTerm: 2,
snapId: 1, snapOffset: 0,
snapData: @[byte 1, 2, 3], snapDone: true))
check not reply.success
check node.lastSnapshotIndex == 0
check node.lastSnapshotTerm == 0
check node.commitIndex == 0
check node.log.len == 1
check not fileExists(raftDir / "snap_incoming" / "snap_1.tar.gz")
scenario()
test "oversized chunk is rejected":
let tmp = getTempDir() / "baradb_snaprx_cap_" & $getCurrentProcessId()
removeDir(tmp)
createDir(tmp)
defer: removeDir(tmp)
var node = newRaftNode("follower-1", @[], dataDir = tmp / "raft")
node.currentTerm = 1
node.snapChunkBytes = 4
let reply = node.handleInstallSnapshot(RaftMessage(
kind: rmkInstallSnapshot, term: 1, senderId: "leader-1",
prevLogIndex: 1, prevLogTerm: 1,
snapId: 1, snapOffset: 0,
snapData: @[byte 1, 2, 3, 4, 5], snapDone: false))
check not reply.success
check node.snapIncomingId == 0
test "out-of-order offset is rejected and assembly restarts on new snapId":
let tmp = getTempDir() / "baradb_snaprx_off_" & $getCurrentProcessId()
removeDir(tmp)
createDir(tmp)
defer: removeDir(tmp)
let raftDir = tmp / "raft"
var node = newRaftNode("follower-1", @[], dataDir = raftDir)
node.currentTerm = 1
let ok1 = node.handleInstallSnapshot(RaftMessage(
kind: rmkInstallSnapshot, term: 1, senderId: "leader-1",
prevLogIndex: 2, prevLogTerm: 1,
snapId: 3, snapOffset: 0,
snapData: @[byte 65, 66], snapDone: false))
check ok1.success
# Gap: offset 5 while only 2 bytes assembled
let bad = node.handleInstallSnapshot(RaftMessage(
kind: rmkInstallSnapshot, term: 1, senderId: "leader-1",
prevLogIndex: 2, prevLogTerm: 1,
snapId: 3, snapOffset: 5,
snapData: @[byte 67], snapDone: false))
check not bad.success
check node.snapIncomingId == 0
suite "Raft InstallSnapshot Send":
test "two consecutive floor rejects queue a snapshot send":
var node = newRaftNode("leader", @["peer-1"])
node.currentTerm = 5
node.state = rsLeader
node.lastSnapshotIndex = 100
node.lastSnapshotTerm = 4
node.nextIndex["peer-1"] = 101
node.matchIndex["peer-1"] = 0
let reject = RaftMessage(kind: rmkAppendEntriesReply, term: 5,
senderId: "peer-1", success: false)
node.handleAppendReply("peer-1", reject)
check node.snapRejectStreak["peer-1"] == 1
check "peer-1" notin node.snapPending
check node.nextIndex["peer-1"] == 101 # pinned at the compaction floor
node.handleAppendReply("peer-1", reject)
check node.snapRejectStreak["peer-1"] == 2
check "peer-1" in node.snapPending
check node.nextIndex["peer-1"] == 101
test "non-floor reject decrements nextIndex without touching the streak":
var node = newRaftNode("leader", @["peer-1"])
node.currentTerm = 5
node.state = rsLeader
node.lastSnapshotIndex = 100
node.lastSnapshotTerm = 4
node.nextIndex["peer-1"] = 105
node.handleAppendReply("peer-1", RaftMessage(
kind: rmkAppendEntriesReply, term: 5, senderId: "peer-1", success: false))
check node.nextIndex["peer-1"] == 104
check "peer-1" notin node.snapRejectStreak
check "peer-1" notin node.snapPending
test "successful AppendEntries reply resets the streak and cancels a pending snapshot":
var node = newRaftNode("leader", @["peer-1"])
node.currentTerm = 5
node.state = rsLeader
node.lastSnapshotIndex = 100
node.lastSnapshotTerm = 4
node.nextIndex["peer-1"] = 101
node.matchIndex["peer-1"] = 0
node.snapRejectStreak["peer-1"] = 1
node.snapPending.incl("peer-1")
node.handleAppendReply("peer-1", RaftMessage(
kind: rmkAppendEntriesReply, term: 5, senderId: "peer-1",
success: true, matchIdx: 101))
check "peer-1" notin node.snapRejectStreak
check "peer-1" notin node.snapPending
check node.matchIndex["peer-1"] == 101
check node.nextIndex["peer-1"] == 102
test "InstallSnapshotReply success advances match/next index and clears streak":
var node = newRaftNode("leader", @["peer-1"])
node.currentTerm = 5
node.state = rsLeader
node.lastSnapshotIndex = 100
node.lastSnapshotTerm = 4
node.nextIndex["peer-1"] = 101
node.matchIndex["peer-1"] = 0
node.snapRejectStreak["peer-1"] = 2
node.snapPending.incl("peer-1")
node.handleInstallSnapshotReply("peer-1", RaftMessage(
kind: rmkInstallSnapshotReply, term: 5, senderId: "peer-1",
success: true, matchIdx: 100))
check node.matchIndex["peer-1"] == 100
check node.nextIndex["peer-1"] == 101
check "peer-1" notin node.snapRejectStreak
check "peer-1" notin node.snapPending
test "InstallSnapshotReply failure leaves leader state untouched":
var node = newRaftNode("leader", @["peer-1"])
node.currentTerm = 5
node.state = rsLeader
node.lastSnapshotIndex = 100
node.lastSnapshotTerm = 4
node.nextIndex["peer-1"] = 101
node.matchIndex["peer-1"] = 0
node.snapRejectStreak["peer-1"] = 2
node.handleInstallSnapshotReply("peer-1", RaftMessage(
kind: rmkInstallSnapshotReply, term: 5, senderId: "peer-1",
success: false, matchIdx: 0))
check node.matchIndex["peer-1"] == 0
check node.nextIndex["peer-1"] == 101
check node.snapRejectStreak["peer-1"] == 2
test "intermediate chunk replies are ignored; final reply advances state":
var node = newRaftNode("leader", @["peer-1"])
node.currentTerm = 5
node.state = rsLeader
node.lastSnapshotIndex = 100
node.lastSnapshotTerm = 4
node.nextIndex["peer-1"] = 101
node.matchIndex["peer-1"] = 0
node.snapRejectStreak["peer-1"] = 2
node.snapPending.incl("peer-1")
# Intermediate chunk ack: follower replies success=true with its OLD
# lastSnapshotIndex (40 < our 100). Leader state must not move.
node.handleInstallSnapshotReply("peer-1", RaftMessage(
kind: rmkInstallSnapshotReply, term: 5, senderId: "peer-1",
success: true, matchIdx: 40))
check node.matchIndex["peer-1"] == 0
check node.nextIndex["peer-1"] == 101
check node.snapRejectStreak["peer-1"] == 2
check "peer-1" in node.snapPending
# Final reply: follower adopted the snapshot base (matchIdx == 100).
node.handleInstallSnapshotReply("peer-1", RaftMessage(
kind: rmkInstallSnapshotReply, term: 5, senderId: "peer-1",
success: true, matchIdx: 100))
check node.matchIndex["peer-1"] == 100
check node.nextIndex["peer-1"] == 101
check "peer-1" notin node.snapRejectStreak
check "peer-1" notin node.snapPending
test "InstallSnapshotReply term handling matches AppendEntriesReply":
var node = newRaftNode("leader", @["peer-1"])
node.currentTerm = 5
node.state = rsLeader
node.lastSnapshotIndex = 100
node.nextIndex["peer-1"] = 101
node.matchIndex["peer-1"] = 0
# Stale term: ignored entirely
node.handleInstallSnapshotReply("peer-1", RaftMessage(
kind: rmkInstallSnapshotReply, term: 4, senderId: "peer-1",
success: true, matchIdx: 100))
check node.matchIndex["peer-1"] == 0
check node.state == rsLeader
# Higher term: step down
node.handleInstallSnapshotReply("peer-1", RaftMessage(
kind: rmkInstallSnapshotReply, term: 7, senderId: "peer-1",
success: true, matchIdx: 100))
check node.state == rsFollower
check node.currentTerm == 7
test "floor rejects trigger sendSnapshot end-to-end via processMessage":
proc scenario() =
let tmp = getTempDir() / "baradb_snaptx_e2e_" & $getCurrentProcessId()
removeDir(tmp)
createDir(tmp)
defer: removeDir(tmp)
var payload = ""
for i in 0 ..< 200:
payload.add(char(32 + (i mod 90)))
var leader = newRaftNode("leader", @["peer-1"], raftPort = 29331,
dataDir = tmp / "raft-l")
createDir(tmp / "raft-l") # newRaftNode only reads; sendSnapshot writes here
leader.currentTerm = 5
leader.state = rsLeader
leader.lastSnapshotIndex = 100
leader.lastSnapshotTerm = 4
leader.nextIndex["peer-1"] = 101
leader.matchIndex["peer-1"] = 0
leader.snapChunkBytes = 64 # 200 bytes -> 4 chunks
leader.peerAddrs["peer-1"] = ("127.0.0.1", 29332)
var buildCalls = 0
leader.buildSnapshot = proc(destPath: string): bool {.gcsafe.} =
inc buildCalls
check "snap_out_100" in destPath
writeFile(destPath, payload)
true
var follower = newRaftNode("peer-1", @["leader"], raftPort = 29332,
dataDir = tmp / "raft-f")
follower.currentTerm = 1
var gotBaseIndex = 0'u64
var gotBaseTerm = 0'u64
follower.restoreSnapshot = proc(p: string, bi: uint64,
bt: uint64): bool {.gcsafe.} =
gotBaseIndex = bi
gotBaseTerm = bt
result = readFile(p) == payload
let netL = newRaftNetwork(leader)
let netF = newRaftNetwork(follower)
asyncCheck netF.run()
waitFor sleepAsync(50)
# Two floor-level rejects through the real message path; the second one
# must trigger an async snapshot send (leader itself never listens).
let reject = RaftMessage(kind: rmkAppendEntriesReply, term: 5,
senderId: "peer-1", success: false)
waitFor netL.processMessage(reject)
check "peer-1" notin leader.snapPending
waitFor netL.processMessage(reject)
var waited = 0
while follower.lastSnapshotIndex != 100 and waited < 3000:
waitFor sleepAsync(50)
waited += 50
netF.stop()
waitFor sleepAsync(50)
check buildCalls == 1
check follower.lastSnapshotIndex == 100
check follower.lastSnapshotTerm == 4
check gotBaseIndex == 100
check gotBaseTerm == 4
# Temp archive cleaned up after the transfer
check not fileExists(tmp / "raft-l" / "snap_out_100.tar.gz")
scenario()
test "sendSnapshot single-flight guard skips a concurrent send":
let tmp = getTempDir() / "baradb_snaptx_guard_" & $getCurrentProcessId()
removeDir(tmp)
createDir(tmp)
defer: removeDir(tmp)
var node = newRaftNode("leader", @["peer-1"], dataDir = tmp / "raft")
node.currentTerm = 5
node.state = rsLeader
node.lastSnapshotIndex = 100
node.lastSnapshotTerm = 4
var buildCalls = 0
node.buildSnapshot = proc(destPath: string): bool {.gcsafe.} =
inc buildCalls
writeFile(destPath, "x")
true
let net = newRaftNetwork(node)
node.snapSending.incl("peer-1") # a send is already in flight
waitFor net.sendSnapshot("peer-1")
check buildCalls == 0
test "sendSnapshot skips when there is no compacted snapshot":
let tmp = getTempDir() / "baradb_snaptx_zero_" & $getCurrentProcessId()
removeDir(tmp)
createDir(tmp)
defer: removeDir(tmp)
var node = newRaftNode("leader", @["peer-1"], dataDir = tmp / "raft")
node.currentTerm = 5
node.state = rsLeader
# lastSnapshotIndex == 0: snapId 0 can never be received by a follower
var buildCalls = 0
node.buildSnapshot = proc(destPath: string): bool {.gcsafe.} =
inc buildCalls
true
let net = newRaftNetwork(node)
waitFor net.sendSnapshot("peer-1")
check buildCalls == 0
check "peer-1" notin node.snapSending
suite "Raft TLS Transport":
test "2-node election over TLS":
let certDir = getTempDir() / "baradb_test_raft_tls"
let (certPath, keyPath) = generateSelfSignedCert(certDir, "raft-tls.local")
if certPath.len == 0:
skip() # openssl unavailable
else:
let tls = newTLSContext(newTLSConfig(certPath, keyPath))
var n1 = newRaftNode("n1", @["n2"], raftPort = 29301)
var n2 = newRaftNode("n2", @["n1"], raftPort = 29302)
n1.electionTimeout = 150
n2.electionTimeout = 350
n1.peerAddrs["n2"] = ("127.0.0.1", 29302)
n2.peerAddrs["n1"] = ("127.0.0.1", 29301)
let net1 = newRaftNetwork(n1, tls)
let net2 = newRaftNetwork(n2, tls)
asyncCheck net1.run()
asyncCheck net2.run()
waitFor sleepAsync(50)
# No manual ticks — timerLoop drives the election over TLS.
var leaderCount = 0
var waited = 0
while waited < 3000:
leaderCount = 0
if n1.isLeader: inc leaderCount
if n2.isLeader: inc leaderCount
if leaderCount == 1: break
waitFor sleepAsync(100)
waited += 100
net1.stop()
net2.stop()
waitFor sleepAsync(50)
check leaderCount == 1
test "plaintext dial to a TLS raft port has no protocol effect":
let certDir = getTempDir() / "baradb_test_raft_tls"
let (certPath, keyPath) = generateSelfSignedCert(certDir, "raft-tls.local")
if certPath.len == 0:
skip() # openssl unavailable
else:
let tls = newTLSContext(newTLSConfig(certPath, keyPath))
var n = newRaftNode("srv", @["cli"], raftPort = 29311)
n.electionTimeout = 60000 # keep the server passive during the test
n.peerAddrs["cli"] = ("127.0.0.1", 29312)
let net = newRaftNetwork(n, tls)
asyncCheck net.run()
waitFor sleepAsync(50)
let termBefore = n.currentTerm
# A plaintext client sends a perfectly valid serialized raft frame; the
# bytes fail the TLS handshake, so nothing reaches the state machine.
let voteReq = RaftMessage(kind: rmkRequestVote, term: 42, senderId: "cli")
let data = serialize(voteReq)
var frame = newSeq[byte](4 + data.len)
frame[0] = byte(data.len shr 24)
frame[1] = byte(data.len shr 16)
frame[2] = byte(data.len shr 8)
frame[3] = byte(data.len)
for i in 0 ..< data.len:
frame[4 + i] = data[i]
let client = newAsyncSocket()
waitFor client.connect("127.0.0.1", Port(29311))
try:
waitFor client.send(cast[string](frame))
except CatchableError:
discard
waitFor sleepAsync(300)
# The server must have dropped the connection after the failed handshake.
var connectionDropped = false
try:
connectionDropped = (waitFor client.recv(1)).len == 0
except CatchableError:
connectionDropped = true
client.close()
net.stop()
waitFor sleepAsync(50)
check n.state == rsFollower
check n.currentTerm == termBefore
check n.votedFor == ""
check connectionDropped
suite "Raft SQL Write Path":
test "leader append+commit wait round-trips through applyCommand":
proc scenario() =
@@ -2670,7 +3248,7 @@ suite "Raft SQL Write Path":
# Server-side leader write path: append + wait for majority commit
let (ok, errMsg) = waitFor appendWriteToRaft(leader,
@[("users.1", cast[seq[byte]]("alice"))], timeoutMs = 3000)
@[("users.1", cast[seq[byte]]("alice"), false)], timeoutMs = 3000)
check ok
if not ok: echo "appendWriteToRaft failed: ", errMsg
@@ -2760,6 +3338,53 @@ suite "Raft SQL Write Path":
check n.lastSnapshotIndex == 0
check n.log.len == 15
test "leader compactLog unpins from a stale peer (never replied, stale window exceeded)":
var n = newRaftNode("n1", @["n2"], raftPort = 29123)
n.logMaxEntries = 5
n.raftPeerStaleMs = 1000
n.becomeLeader()
n.matchIndex["n2"] = 0 # peer never caught up
# Last successful reply is long past the stale window.
n.matchIndexSeenMs["n2"] = getMonoTime().ticks() div 1_000_000 - 60_000
for i in 1 .. 15:
discard n.appendLog("put", cast[seq[byte]]("x"))
n.commitIndex = uint64(i)
n.lastApplied = uint64(i)
n.compactLog()
# Stale peer excluded from minMatch — compaction runs through lastApplied.
check n.lastSnapshotIndex == 15
check n.log.len == 0
test "leader compactLog still pins at a recently-responsive peer matchIndex":
var n = newRaftNode("n1", @["n2"], raftPort = 29124)
n.logMaxEntries = 5
n.raftPeerStaleMs = 1000
n.becomeLeader()
n.matchIndex["n2"] = 3
n.matchIndexSeenMs["n2"] = getMonoTime().ticks() div 1_000_000 # just replied
for i in 1 .. 15:
discard n.appendLog("put", cast[seq[byte]]("x"))
n.commitIndex = uint64(i)
n.lastApplied = uint64(i)
n.compactLog()
check n.lastSnapshotIndex == 3
check n.log.len == 12
test "leader compactLog respects grace window for an unreplied peer":
var n = newRaftNode("n1", @["n2"], raftPort = 29125)
n.logMaxEntries = 5
n.raftPeerStaleMs = 30000
n.becomeLeader() # matchIndexSeenMs initialized to now — within grace
n.matchIndex["n2"] = 0 # peer has not replied yet
for i in 1 .. 15:
discard n.appendLog("put", cast[seq[byte]]("x"))
n.commitIndex = uint64(i)
n.lastApplied = uint64(i)
n.compactLog()
# Grace window still active — peer pins compaction at matchIndex 0.
check n.lastSnapshotIndex == 0
check n.log.len == 15
test "appendDdlToRaft fails when node is not leader":
var n = newRaftNode("n1", @["n2"], raftPort = 29113)
let (ok, err) = waitFor appendDdlToRaft(n,
@@ -2782,7 +3407,7 @@ suite "Raft SQL Write Path":
var n = newRaftNode("n1", @["n2"], raftPort = 29111)
# Still a follower — appendLog returns index 0.
let (ok, err) = waitFor appendWriteToRaft(n,
@[("k", cast[seq[byte]]("v"))], timeoutMs = 200)
@[("k", cast[seq[byte]]("v"), false)], timeoutMs = 200)
check not ok
check "lost leadership" in err
@@ -2791,7 +3416,7 @@ suite "Raft SQL Write Path":
var n = newRaftNode("n1", @["n2", "n3"], raftPort = 29112)
n.becomeLeader()
let (ok, err) = waitFor appendWriteToRaft(n,
@[("k", cast[seq[byte]]("v"))], timeoutMs = 300)
@[("k", cast[seq[byte]]("v"), false)], timeoutMs = 300)
check not ok
check "raft commit timeout" in err