Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a843f0a1a3 | |||
| dac92d1741 | |||
| d303cc5658 | |||
| ad90ebcd5e | |||
| 9ff9c2f6be | |||
| c94bac43e5 | |||
| 862d62590e | |||
| efa04e4b36 | |||
| cb9cd7415d | |||
| f416fe930e | |||
| f2b7ed1ce2 | |||
| 2f30a59216 | |||
| ed89c88afa | |||
| 8d083f5fdc | |||
| efa46b05c6 | |||
| 431334b70a | |||
| 63cb05afe2 |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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`, …).
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 | T3–T6 | Raft port TLS + mutual auth + TLS e2e |
|
||||
| P4 Cold node | T7–T11 | 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 T7–T10; 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 P1–P5 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 |
|
||||
|-------|--------|
|
||||
| P1–P2 | 0.5–1 day |
|
||||
| P3 | 1 day |
|
||||
| P4 | 2–3 days |
|
||||
| P5 | 0.5 day |
|
||||
| **Total** | **~4–5 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.
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 & "."):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user