20 Commits

Author SHA1 Message Date
dimgigov 1ed97fb075 fix: audit batches 3–4 — TLS verify, WS, OFFSET, B-tree, NULL equality
CI / test (push) Has been cancelled
CI / raft-e2e (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
Close the remaining 2026-08 findings: peer TLS on leader forward, disttxn
SO_ERROR, compaction catalog order, OFFSET without LIMIT, window aggregates,
WebSocket mask/size/auth, SCRAM timing and cbind, B-tree leaf left-max
separators, and SQL three-valued NULL comparisons.
2026-08-28 13:53:02 +03:00
dimgigov e44341e47c fix: audit batch 2 — semi-sync, DISTINCT, set ops, MERGE, storage hardening
CI / test (push) Has been cancelled
CI / raft-e2e (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
Address 12 deep-audit findings: semi-sync fail-closed on partial ack,
COUNT/SUM/AVG(DISTINCT), UNION/INTERSECT/EXCEPT dedup, MERGE THEN DELETE,
WAL torn-record recovery, MVCC/checkpoint/flush/compaction/WAL rewrite
safety, mmap overflow bounds; remove stray protocol/scram ELF.
2026-08-02 23:12:47 +03:00
dimgigov ccc54e8f18 fix: stabilization session — auth bypass, raft quorum, wire DoS, query operators
CI / test (push) Has been cancelled
CI / raft-e2e (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
Security:
- MIGRATE handler now requires auth (unauthenticated arbitrary writes)
- parseHeader rejects oversized messages before allocation (pre-auth DoS)

Correctness:
- raft commit uses strict majority (N div 2 + 1), fixing even-N minority commit
- power (**) and concat (++) no longer lowered to equality
- != is now the exact complement of = for numerically-equal values
- legacy REP payload carries explicit put/delete tag (PK-only rows survive)
- REP receiver maintains secondary indexes via applyReplicatedPut/Delete
- snapshot send runs gzip off the event loop (heartbeat stall mitigation)

Docs: PLAN.md (session 13), BUG_AUDIT_2026-08.md (~28 findings, 23 tracked),
known-limitations.md, CHANGELOG.md.

Verified: baradadb build clean; test_all + bugfix_test pass.
2026-08-02 22:49:30 +03:00
dimgigov a843f0a1a3 fix(raft): gate snapshot build/restore, repoint http ctx, pre-tag docs
CI / test (push) Has been cancelled
CI / raft-e2e (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
2026-07-31 04:46:05 +03:00
dimgigov dac92d1741 release: prepare v1.3.0 changelog and version bump 2026-07-31 04:16:11 +03:00
dimgigov d303cc5658 docs: v1.3.0 raft-supported docs, limitations, runbook updates 2026-07-31 04:16:11 +03:00
dimgigov ad90ebcd5e test(raft): cold-node rejoin and wiped-node join e2e
Includes two raft fixes the e2e exposed:
- heartbeatLoop drops cached peer sockets on leadership acquisition
  (half-dead sockets to a restarted peer never errored, so no redial
  ever happened and the cluster livelocked without heartbeats)
- restoreSnapshot repoints tcpServer ctx/db at the reopened database
  (queries kept reading the closed pre-restore LSM, serving 0 rows)
2026-07-31 03:53:48 +03:00
dimgigov 9ff9c2f6be feat(raft): compaction unpinned from stale peers (snapshot fallback) 2026-07-31 03:12:06 +03:00
dimgigov c94bac43e5 fix(raft): ignore intermediate InstallSnapshot chunk replies 2026-07-31 03:02:32 +03:00
dimgigov 862d62590e feat(raft): leader InstallSnapshot send on unrecoverable lag 2026-07-31 02:53:30 +03:00
dimgigov efa04e4b36 feat(raft): follower InstallSnapshot receive and restore 2026-07-31 02:33:32 +03:00
dimgigov cb9cd7415d feat(raft): InstallSnapshot wire protocol (backward-compatible) 2026-07-31 02:12:51 +03:00
dimgigov f416fe930e test(raft): wire-level TLS handshake assertion in tls e2e 2026-07-31 02:05:43 +03:00
dimgigov f2b7ed1ce2 test(raft): 3-node TLS cluster e2e with plaintext rejection 2026-07-31 01:55:43 +03:00
dimgigov 2f30a59216 feat(raft): TLS on follower→leader SQL forwarding 2026-07-31 01:45:59 +03:00
dimgigov ed89c88afa feat(raft): optional TLS on raft transport (server + dialer) 2026-07-31 01:31:20 +03:00
dimgigov 8d083f5fdc feat(raft): TLS config surface with fail-closed startup 2026-07-31 01:14:20 +03:00
dimgigov efa46b05c6 ci(raft): dedicated mandatory raft e2e job; no silent skips under CI 2026-07-31 01:07:11 +03:00
dimgigov 431334b70a fix(raft): distinguish put-with-empty-value from delete in write path 2026-07-31 00:58:51 +03:00
dimgigov 63cb05afe2 test(raft): failover under sustained write load e2e 2026-07-31 00:35:55 +03:00
53 changed files with 5080 additions and 281 deletions
+24
View File
@@ -61,6 +61,30 @@ jobs:
done
echo "--- Done ---"
raft-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Nim
uses: jiro4989/setup-nim-action@v1
with:
nim-version: '2.2.10'
- name: Install system dependencies
run: sudo apt-get update -qq && sudo apt-get install -y -qq libssl-dev libpcre3-dev openssl ca-certificates
- name: Install Nim dependencies
run: nimble install --depsOnly -y
- name: Build server
run: nim c -d:ssl -o:build/baradadb src/baradadb.nim
- name: Raft e2e suites
env:
CI: "true"
run: |
nim c -d:ssl --threads:on --path:src -r tests/raft_e2e_test.nim
nim c -d:ssl --threads:on --path:src -r tests/raft_writes_e2e_test.nim
nim c -d:ssl --threads:on --path:src -r tests/raft_failover_load_e2e_test.nim
nim c -d:ssl --threads:on --path:src -r tests/raft_tls_e2e_test.nim
nim c -d:ssl --threads:on --path:src -r tests/raft_coldnode_e2e_test.nim
verify:
runs-on: ubuntu-latest
steps:
+4
View File
@@ -16,6 +16,9 @@ tests/bugfix_test
tests/nimforum_smoke_test
tests/raft_e2e_test
tests/raft_writes_e2e_test
tests/raft_failover_load_e2e_test
tests/raft_tls_e2e_test
tests/raft_coldnode_e2e_test
benchmarks/bench_all
benchmarks/compare
clients/nim/tests/test_client
@@ -68,5 +71,6 @@ src/barabadb/storage/lsm
src/barabadb/storage/wal
src/barabadb/storage/btree
src/barabadb/storage/gate
src/barabadb/protocol/scram
clients/nim/tests/test_pool
clients/nim/tests/test_wire
+80
View File
@@ -0,0 +1,80 @@
# BaraDB — Deep Audit (август 2026)
> Дата: 2026-08-02
> Метод: 4 паралелни одит-агента по слоеве (Storage / Query / Core / Protocol), всеки чете всички файлове в обхвата си и проверява находките срещу реалния код.
> Обхват: **само нови дефекти** — 80-те вече оправени в `BUGS.md` / `BUG_AUDIT.md` / `BARADB_CLIENT_BUGS.md` са изключени.
> **Общо: ~28 находки | Поправени: 28 (батч 1: 5 + батч 2: 12 + батч 3: 10 + батч 4: 2) | Остават: 0**
---
## Поправени — батч 1 (5)
| # | Severity | Проблем | Файл | Fix |
|---|----------|---------|------|-----|
| C1 | 🔴 CRITICAL | **MIGRATE handler без auth gate** — неавтентикиран клиент пишеше произволни key/value в базата (`handleMigrationMessage → applyMigrationBatch → storeKeys → db.put`). Открито независимо от 2 агента. | `core/server.nim:682` | Добавен `if not authenticated: ... continue` (като REP/DISTTXN блоковете) |
| C2 | 🔴 CRITICAL | **Raft commit quorum off-by-one за even-N**`(N+1) div 2` commit-ваше с малцинство при четен брой възли (N=4 → 2/4). Election-ът ползваше коректното strict majority. *GA обхватът е 3-node (нечетно), където формулите съвпадат.* | `core/raft.nim:653` | `let majority = (node.peers.len + 1) div 2 + 1` (съвпада с election); регресионен тест за 4-node |
| H1 | 🟠 HIGH | **Pre-auth memory-exhaustion DoS**`parseHeader` не ограничаваше `length` (uint32 до ~4 GiB); `recvExactWithTimeout` пре-алокира преди auth check. | `core/server.nim:168` | Reject `length > uint32(MaxWireStringLen)` (64 MB) преди алокация |
| H4 | 🟠 HIGH | **`**` и `++` се lower-ваха към equality** — `bkPow`/`bkConcat` липсваха в op-mapping case-а и попадаха в `else: irEq` (`2 ** 3``false`, `'a' ++ 'b'``false`). | `query/exec/lower.nim:79` | `of bkPow: irOp = irPow`, `of bkConcat: irOp = irAdd`; 2 регресионни теста |
| H5 | 🟠 HIGH | **`!=` не е отрицание на `=`** — `irNeq` short-circuit-ваше на string inequality, така че `5 != 5.0` → true, но `5 = 5.0` → true. | `query/exec/eval.nim:438` | `irNeq` numeric-first (точно допълнение на `irEq`); регресионен тест |
## Поправени — батч 2 (12)
| # | Severity | Проблем | Файл | Fix |
|---|----------|---------|------|-----|
| H3 | 🟠 HIGH | **Semi-sync partial/zero ack** — връщаше LSN дори при 0 acks | `core/replication.nim` | `return 0` когато connected replicas < `syncReplicaCount` acks; 0 connected → local-only (като sync) |
| H6 | 🟠 HIGH | **`COUNT/SUM/AVG(DISTINCT)` игнорира DISTINCT** | `query/exec/lower.nim`, `plan_exec.nim` | `aggDistinct = node.funcDistinct`; dedup с `HashSet` в agg пътищата |
| H7 | 🟠 HIGH | **`UNION/INTERSECT/EXCEPT` KeyError** | `query/executor.nim` | Dedup fingerprint от projected cols, не `row["$value"]` |
| H8 | 🟠 HIGH | **`MERGE … THEN DELETE` / matched condition no-op** | `query/executor.nim` | Honor `mergeMatchedDelete` + `mergeMatchedCondition` |
| H9 | 🟠 HIGH | **WAL recovery crash на torn record** | `storage/lsm.nim`, `wal.nim`, `recovery.nim` | Bound key/val ≤ 64 MB; validate kind преди enum cast |
| M1 | 🟡 MEDIUM | **MVCC `write` delete-during-iteration** | `core/mvcc.nim` | Collect-then-delete stale txn ids |
| M3 | 🟡 MEDIUM | **`checkpoint` lock leak** | `storage/lsm.nim` | try/finally около write lock + walLock |
| M4 | 🟡 MEDIUM | **`flushUnsafe` clear-before-write** | `storage/lsm.nim` | Clear memtable едва след успешен `writeSSTable` |
| M5 | 🟡 MEDIUM | **Compaction empty-key skip** | `storage/compaction.nim` | `haveLast` флаг вместо `lastKey = ""` sentinel |
| M6 | 🟡 MEDIUM | **`rewriteLive` remove-before-move** | `storage/wal.nim` | Само атомен `moveFile` (rename replace) |
| L3 | 🟢 LOW | **mmap `offset+size` overflow** | `storage/mmap.nim` | Overflow-safe: `offset > size - length` |
| — | hygiene | **Stray ELF `protocol/scram`** | `.gitignore` | Премахнат binary + ignore entry |
**Верификация (батч 2):** `baradadb` build чист; `tests/bugfix_test.nim` (вкл. batch-2 suite) и `tests/test_all.nim` (501 OK) минават без `[FAILED]`. `tests/prop_test.nim` B-Tree suite OK (H10 *не* е в този батч — naive left-max fix чупи interleaved remove).
## Поправени — батч 3 (10)
| # | Severity | Проблем | Файл | Fix |
|---|----------|---------|------|-----|
| H2 | 🟠 HIGH | **TLS client връзките не верифицираха сертификата** | `core/server.nim`, `core/config.nim` | Отделен `tlsClient` контекст; CA auto-enable verify; production fail-closed |
| M2 | 🟡 MEDIUM | **disttxn refused-connect wedge** | `core/disttxn.nim` | `getsockopt(SO_ERROR)` + try/except около RPC |
| M7 | 🟡 MEDIUM | **Compaction unlink преди catalog load** | `storage/compaction.nim`, `baradadb.nim` | Unlink след load + MANIFEST |
| M8 | 🟡 MEDIUM | **`OFFSET` без `LIMIT` → 0 реда** | `query/exec/lower.nim`, `plan_exec.nim` | `limitCount = -1` unlimited; clamp negative |
| M9 | 🟡 MEDIUM | **Window SUM/AVG/COUNT/MIN/MAX → NULL** | `query/exec/window.nim` | Frame aggregates |
| M10 | 🟡 MEDIUM | **WebSocket unmasked client frames** | `core/websocket.nim` | Protocol error / close |
| M11 | 🟡 MEDIUM | **WebSocket unbounded buffer** | `core/websocket.nim` | 1 MiB frame / 4 MiB message / 125-byte control |
| M12 | 🟡 MEDIUM | **SUBSCRIBE без table auth** | `core/websocket.nim`, `httpserver.nim` | `canSubscribe` + `hasPrivilegeFor` SELECT |
| L1 | 🟢 LOW | **SCRAM timing user enumeration** | `protocol/auth.nim` | Dummy nonce+encode work за unknown users |
| L2 | 🟢 LOW | **SCRAM `c=` не се верифицира** | `protocol/auth.nim`, `scram.nim` | `c=` must match gs2 header (`biws` за `n,,`) |
## Поправени — батч 4 (2)
| # | Severity | Проблем | Файл | Fix |
|---|----------|---------|------|-----|
| H10 | 🟠 HIGH | **B-tree `remove` separator convention**`splitChild`/search са left-max (`key > sep → right`); `removeRec` копираше first key на дясното дете (right-min). Naive left-max върху *internal* ключове чупи `prop_test`. | `storage/btree.nim` | Leaf borrow/remove пишат left-max; merge underflow се качва нагоре; invariant `max(left) <= sep` (boundary duplicates са позволени, `next` ги събира) |
| L4 | 🟢 LOW | **NULL equality**`NULL = NULL` / `col = NULL` бяха true през string sentinel | `query/exec/eval.nim` | Сравнения, LIKE, IN, NOT, AND/OR: NULL operand → unknown (`\N`); `IS NULL` непроменен |
---
## Остават (0)
Няма отворени находки от този одит.
---
## Проверени и чисти (не са бъгове)
- JWT `exp`/alg-confusion: pinned `jwt-nim-baraba#fbe084b` `verify()` enforce-ва alg-match, reject-ва `NONE`, проверява `exp/nbf/iat`, constant-time compare.
- `auth.nim` `constantTimeCompare` и SCRAM `verifyClientProof` са constant-time; празен JWT secret fail-closed (`server.nim:59`).
- `wire.nim` deserialize bounds/depth caps са sound.
- CRC byte ranges / `headerSize = 40` са консистентни между write/verify/load (format *коментарът* още казва "36" — само коментар).
- Lock ordering (`walLock` в `db.lock`; gate преди `db.lock`), mmap negative offset / `close()` recursion / fd handling (BUG-036/046) — непокътнати.
- `COUNT(col)` изключва NULLs (`v.kind != vkNull`); `LIMIT 0` → празно е коректно; IN-list се lower-ва към OR/AND вериги.
---
*Виж също: `PLAN.md` (Сесия 13), `docs/en/known-limitations.md`.*
+72
View File
@@ -2,6 +2,78 @@
All notable changes to BaraDB are documented in this file.
## [Unreleased]
### Security
- **MIGRATE auth bypass (CRITICAL)** — the internal `MIGRATE` text-protocol handler now requires authentication (matching the `REP`/`DISTTXN` handlers); previously an unauthenticated client could inject arbitrary key/value rows (`core/server.nim`)
- **Pre-auth wire-length DoS (HIGH)** — `parseHeader` rejects messages larger than the 64 MB wire cap before allocating the receive buffer (`core/server.nim`)
- **TLS peer verify on cluster forwarding (HIGH)** — follower→leader SQL forwarding uses a dedicated client TLS context; `BARADB_TLS_CA_FILE` auto-enables `BARADB_TLS_VERIFY_PEER`; production fails closed if TLS is on without CA+verify (`core/config.nim`, `core/server.nim`)
- **WebSocket unmasked frames / unbounded buffers (MEDIUM)** — unmasked client frames are rejected (RFC 6455 §5.1); frame/message size and control-frame caps bound memory (`core/websocket.nim`)
- **WebSocket SUBSCRIBE table auth (MEDIUM)** — with auth enabled, SUBSCRIBE requires table SELECT privilege (`core/websocket.nim`, `core/httpserver.nim`)
- **SCRAM timing + channel-binding (LOW)** — unknown users do equivalent dummy work; `c=` must match the gs2 header (`protocol/auth.nim`, `protocol/scram.nim`)
### Fixed
- **Raft commit quorum (CRITICAL)** — commit now requires a strict majority (`N div 2 + 1`), matching the election check; the previous `(N+1) div 2` formula committed at a minority for even-sized clusters (`core/raft.nim`)
- **`**` / `++` operators (HIGH)** — power and concat are no longer lowered to equality: `2 ** 3` → 8, `'a' ++ 'b'``'ab'` (`query/exec/lower.nim`)
- **`!=` semantics (HIGH)** — `!=` is now the exact complement of `=` for numerically-equal values (`5 != 5.0` is false) (`query/exec/eval.nim`)
- **Semi-sync partial ack (HIGH)** — `writeLsn` in `rmSemiSync` returns `0` when connected replicas fail to meet `syncReplicaCount`; zero connected peers still succeed local-only (like sync) (`core/replication.nim`)
- **`COUNT/SUM/AVG(DISTINCT …)` (HIGH)** — `funcDistinct` is copied to `aggDistinct` and applied via `HashSet` dedup in aggregate paths (`query/exec/lower.nim`, `plan_exec.nim`)
- **`UNION` / `INTERSECT` / `EXCEPT` (HIGH)** — set-op dedup fingerprints projected columns instead of missing `row["$value"]` (KeyError crash) (`query/executor.nim`)
- **`MERGE … WHEN MATCHED THEN DELETE` (HIGH)** — executor honors `mergeMatchedDelete` and optional `mergeMatchedCondition` (`query/executor.nim`)
- **WAL recovery torn records (HIGH)** — recovery bounds key/value to 64 MB and rejects out-of-range entry kinds before enum cast (avoids multi-GiB alloc / `CaseStmtError` Defect) (`storage/lsm.nim`, `wal.nim`, `recovery.nim`)
- **MVCC `write` timeout cleanup** — stale active transactions are collected then deleted (no mutation during `activeTxns` iteration) (`core/mvcc.nim`)
- **`checkpoint` lock leak** — write lock and `walLock` released in `try/finally` (`storage/lsm.nim`)
- **`flushUnsafe` data-loss window** — memtable is cleared only after a successful SSTable write (`storage/lsm.nim`)
- **Compaction empty-string key** — dedup uses a `haveLast` flag so key `""` is not skipped (`storage/compaction.nim`)
- **`rewriteLive` crash window** — atomic `moveFile` replace only (no `removeFile` before rename) (`storage/wal.nim`)
- **mmap OOB on overflow** — length checks use `offset > size - length` instead of wrapping `offset + size` (`storage/mmap.nim`)
- **REP replication put/delete encoding** — the legacy REP payload carries an explicit op tag so PK-only inserts (empty value) replicate as puts instead of vanishing as deletes (`core/replication.nim`, `core/server.nim`)
- **REP receiver secondary indexes** — the legacy REP receiver applies via `applyReplicatedPut/Delete` under the storage gate, keeping B-tree/FTS/HNSW/graph indexes consistent on the replica (`core/server.nim`)
- **Snapshot send stall (partial)** — the leader's snapshot send runs gzip off the event loop on a worker thread (`gzipFileAsync`), so heartbeats keep firing during compression; tar (send) and the restore path still run on the loop (`core/backup.nim`, `core/raft.nim`)
- **disttxn refused-connect wedge (MEDIUM)** — `connectWithTimeout` checks `SO_ERROR`; `sendDistTxnRpc` catches `CatchableError` so a refused peer cannot leave 2PC stuck (`core/disttxn.nim`)
- **Compaction catalog order (MEDIUM)** — inputs stay on disk until the output is loaded and MANIFEST is written; then they are unlinked (`storage/compaction.nim`, `baradadb.nim`)
- **`OFFSET n` without `LIMIT` (MEDIUM)** — no longer treated as `LIMIT 0`; negative LIMIT/OFFSET are clamped (`query/exec/lower.nim`, `plan_exec.nim`)
- **Window `SUM`/`AVG`/`COUNT`/`MIN`/`MAX` (MEDIUM)** — aggregate window functions compute over the frame instead of returning NULL (`query/exec/window.nim`)
- **B-tree leaf separators (HIGH)** — leaf borrow/remove now keep the left-max convention used by `splitChild` and search (`key > sep → right`); merge underflow rebalances up the tree; `checkInvariants` guards `max(left) <= sep` (`storage/btree.nim`)
- **NULL comparison three-valued logic (LOW)** — `NULL = NULL` / `col = NULL` / `col != x` yield unknown (`\N`) so `WHERE` excludes them; `IS NULL` is unchanged (`query/exec/eval.nim`)
### Removed
- Stray compiled ELF `src/barabadb/protocol/scram` from the source tree (added to `.gitignore`)
### Added
- Deep audit report `BUG_AUDIT_2026-08.md` (~28 findings; all fixed across batches 14)
---
## [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)
+30 -1
View File
@@ -145,6 +145,34 @@
---
## Сесия 13: Stabilization & Deep Audit (август 2026)
> **Цел**: Подобряване на надеждността и коректността върху вече завършеното ядро — целеви поправки + системен паралелен одит по слоеве (Storage / Query / Core / Protocol).
### Целеви поправки (завършени)
| # | Поправка | Файлове | Статус |
|---|----------|---------|--------|
| 1 | **REP delete-from-empty** — legacy REP payload носи явен put/delete таг (`encodeRepPayload`/`decodeRepPayload`); PK-only редове вече не изчезват при репликация | `core/replication.nim`, `core/server.nim` | ✅ + тестове |
| 2 | **Snapshot stall (частична mitigation)** — gzip при leader snapshot send се изнася извън event loop-а през worker thread (`gzipFileAsync`); heartbeats текат по време на компресия | `core/backup.nim`, `core/raft.nim`, `baradadb.nim` | ✅ + e2e |
| 3 | **REP receiver индекси** — receiver-ът минава през `applyReplicatedPut/Delete` под storage gate; вторичните индекси (B-tree/FTS/HNSW/graph) се поддържат на репликата | `core/server.nim` | ✅ + тест |
### Deep Audit (2026-08)
4 паралелни одит-агента по слоеве; ~28 нови находки (без дублиране на 80-те вече оправени в `BUGS.md`/`BUG_AUDIT.md`). Пълен отчет: [`BUG_AUDIT_2026-08.md`](BUG_AUDIT_2026-08.md).
**Батч 1 — поправени (5):** MIGRATE auth bypass (CRITICAL), raft commit strict-majority за even-N (CRITICAL), pre-auth wire-length DoS (HIGH), `**`/`++` lowering към equality (HIGH), `!=` не е отрицание на `=` (HIGH).
**Батч 2 — поправени (12):** semi-sync partial-ack (H3), COUNT/SUM/AVG(DISTINCT) (H6), UNION/INTERSECT/EXCEPT (H7), MERGE THEN DELETE (H8), WAL torn-record recovery (H9), MVCC write iteration (M1), checkpoint lock leak (M3), flushUnsafe order (M4), compaction empty-key (M5), rewriteLive atomic replace (M6), mmap overflow (L3), stray `protocol/scram` ELF.
**Батч 3 — поправени (10):** TLS peer verify (H2), disttxn SO_ERROR (M2), compaction catalog order (M7), OFFSET-без-LIMIT (M8), window агрегати (M9), WebSocket (M10M12), SCRAM (L1L2).
**Батч 4 — поправени (2):** B-tree leaf left-max separators (H10), NULL three-valued comparisons (L4).
**Остават (0):** вж. `BUG_AUDIT_2026-08.md`.
---
## Какво остава от старите планове
| Стар план | Статус |
@@ -157,6 +185,7 @@
| **Този план** — Сесии 10, 11, 12 | ✅ Завършен |
| Raft C3a/C3b + DDL/forward/compact/metrics (2026-07-30) | ✅ Завършен на `main``docs/superpowers/specs/2026-07-30-raft-cluster-status.md` |
| **Production GA v1.2.0** (single-node) | ✅ `docs/superpowers/plans/2026-07-30-production-ga.md` |
| **Сесия 13** — Stabilization & Deep Audit (2026-08) | ✅ Батч 14 (28 поправки); `BUG_AUDIT_2026-08.md` |
---
@@ -170,4 +199,4 @@
---
*План версия: 2026-05-17*
*План версия: 2026-08-02*
+3 -3
View File
@@ -1576,7 +1576,7 @@ features are still being refined:
| LSM-Tree SSTable reads | ✅ Implemented | Full disk I/O with compaction, WAL, and bloom filters. |
| HNSW vector search | ✅ Implemented | Hierarchical graph navigation with SIMD-optimized distance metrics. |
| TCP server execution | ✅ Implemented | Full binary wire protocol parsing and BaraQL query execution. |
| Raft consensus | ⚡ Experimental cluster | TCP election + SQL/DDL via log; single-node is **Production GA**. See `docs/en/known-limitations.md`. |
| Raft consensus | ✅ Supported (3-node, `default` DB) | TCP election + SQL/DDL via log; failover under load, raft TLS, InstallSnapshot recovery e2e-proven. See `docs/en/known-limitations.md`. |
| Graph / FTS / Columnar | ✅ Implemented | In-memory engines with serialization; FTS/vector/graph indexes persist across restarts. |
| Query codegen | ✅ Implemented | IR plans compile to storage engine operations with optimization passes. |
@@ -1585,10 +1585,10 @@ reflects 100% completion across all major phases.
## Changelog
See [CHANGELOG.md](CHANGELOG.md) for full release history. Package version is **v1.2.0**.
See [CHANGELOG.md](CHANGELOG.md) for full release history. Package version is **v1.3.0**.
- **Raft multi-node supported (v1.3.0):** failover under load, mandatory CI gate, raft TLS, InstallSnapshot cold-node recovery — [distributed.md](docs/en/distributed.md), [known-limitations](docs/en/known-limitations.md)
- **Production GA (single-node):** auth-on prod compose, backup/restore drill, runbook — [known-limitations](docs/en/known-limitations.md), [deployment](docs/en/deployment.md)
- **Raft multi-node:** experimental — [distributed.md](docs/en/distributed.md)
## License
+3 -1
View File
@@ -1,5 +1,5 @@
# Package
version = "1.2.0"
version = "1.3.0"
author = "BaraDB Team"
description = "BaraDB — Multimodal database written in Nim"
license = "BSD-3-Clause"
@@ -30,6 +30,8 @@ task test, "Run all tests":
for t in ["test_minimal", "test_all", "bugfix_test", "join_tests", "test_lock",
"test_schema_persist", "test_storage_hardening", "tla_faithfulness",
"nimforum_smoke_test", "raft_e2e_test", "raft_writes_e2e_test",
"raft_failover_load_e2e_test", "raft_tls_e2e_test",
"raft_coldnode_e2e_test",
"fuzz_test", "prop_test",
"test_wire_insert_stress", "stress_test"]:
exec "nim c -r tests/" & t & ".nim"
+13 -2
View File
@@ -5,7 +5,7 @@ BaraDB поддържа разпределено внедряване с Raft к
> ⚠️ **Ограничение при множество бази данни**
> Разпределените модули (Raft, шардиране и репликация) в момента работят само с **`default`** базата данни. Ако използвате множество бази (`CREATE DATABASE`, `USE DATABASE`), разпределените функции още не ги обхващат. Всяка база данни се нуждае от отделна кластър конфигурация.
> **Статус (2026-07-30):** Raft C3a (мрежова election), C3b (SQL записи), DDL репликация, leader forwarding, log compaction и metrics са **на `main`**. Преглед: `docs/superpowers/specs/2026-07-30-raft-cluster-status.md`.
> **Статус (2026-07-30, v1.3.0):** Raft е **supported** за обхвата single-`default`-DB: failover под товар, raft TLS и cold-node recovery чрез InstallSnapshot са e2e-доказани. Преглед: `docs/superpowers/specs/2026-07-30-raft-cluster-status.md`.
## Raft Консенсус
@@ -20,10 +20,21 @@ Leader election и log репликация през TCP; SQL DML/DDL за **def
| `BARADB_RAFT_WRITE_TIMEOUT_MS` | Макс. изчакване за majority commit при SQL записи (по подразбиране 5000) |
| `BARADB_RAFT_CLIENT_PEERS` | Опционален `id@host:clientPort` map за leader write forwarding |
| `BARADB_RAFT_LOG_MAX_ENTRIES` | Лимит на in-memory raft log (по подразбиране 256); safe prefix compact |
| `BARADB_RAFT_SNAP_CHUNK_KB` | Размер на InstallSnapshot chunk в KiB (по подразбиране 256) |
| `BARADB_RAFT_PEER_STALE_MS` | Peer е „stale“ след толкова ms без ack (по подразбиране 30000); stale peers не блокират compaction |
| `BARADB_RAFT_TLS_ENABLED` | TLS на raft порта (по подразбиране false; стартът спира при липсващ cert/key) |
| `BARADB_RAFT_TLS_CERT_FILE` / `BARADB_RAFT_TLS_KEY_FILE` | Сертификат и ключ за raft listener-а |
| `BARADB_RAFT_TLS_CA_FILE` / `BARADB_RAFT_TLS_VERIFY_PEER` | Опционален CA bundle и mutual auth (по подразбиране false) |
Когато Raft е активен, SQL DML и schema DDL се приемат само от лидера на **`default`**. DML отива като put/delete; DDL — като `ddl` запис. Followers **препращат** write/DDL към лидера, ако е зададен `BARADB_RAFT_CLIENT_PEERS`; иначе връщат `not leader; leader is '…'`. Записи към друга database name се отказват. `CREATE`/`DROP DATABASE` не се репликират. Приложен DML обновява и secondary индекси/графи.
**Log compaction (v1):** след apply node-ът може да изреже safe prefix, когато log-ът надхвърли `BARADB_RAFT_LOG_MAX_ENTRIES`. Leader не реже след matchIndex на peer (catch-up с AppendEntries). Snapshot metadata се пази в `raft_state.bin`.
**Log compaction:** след apply node-ът може да изреже safe prefix, когато log-ът надхвърли `BARADB_RAFT_LOG_MAX_ENTRIES`. На лидера safe prefix се смята само по peers с ack в рамките на `BARADB_RAFT_PEER_STALE_MS`; stale peers се възстановяват със snapshot при завръщане. Snapshot metadata се пази в `raft_state.bin`.
**Snapshot recovery (InstallSnapshot, v1.3.0):** когато изоставането на follower е невъзстановимо, лидерът изпраща `tar.gz` snapshot на default DB на chunk-ове от `BARADB_RAFT_SNAP_CHUNK_KB` KiB. Follower-ът го възстановява през backup/restore пътя и продължава catch-up. Върнат след дълъг прекъсване или **изтрит** (wiped data dir, същото node id) възел конвергира автоматично. E2E: `tests/raft_coldnode_e2e_test.nim`.
**Клиентски договор при failover:** запис, който е in-flight при смяна на лидера, **гърми бързо с грешка** — клиентът трябва да го повтори (retry). Всеки **потвърден** (acknowledged) запис оцелява failover-а и е наличен на новия лидер и на наваксалите followers. E2E: `tests/raft_failover_load_e2e_test.nim`.
**Raft TLS (v1.3.0):** `BARADB_RAFT_TLS_ENABLED=true` + cert/key на всеки възел; стартът спира при липсващ cert/key. Целият клъстер трябва да е в един и същ режим — plaintext възел не може да говори с TLS порт и е изключен от клъстера (`tests/raft_tls_e2e_test.nim`). Forwarding-ът follower→leader се обвива в TLS, когато клиентският wire порт е с TLS.
**Metrics:** при включен raft `GET /metrics` (HTTP = `BARADB_PORT + 440`) дава Prometheus редове: `baradb_raft_is_leader`, `baradb_raft_term`, `baradb_raft_log_entries`, `baradb_raft_apply_lag`, `baradb_raft_commit_wait_ms_total`, `baradb_raft_elections_total`, `baradb_raft_forwards_total`, `baradb_raft_compactions_total`. `GET /health` включва обект `raft` (`role`, `term`, `leader_id`, …).
+17 -6
View File
@@ -1,4 +1,4 @@
# Известни ограничения — v1.2.0 Production GA
# Известни ограничения — v1.3.0
| Ниво | Значение |
|------|----------|
@@ -8,25 +8,36 @@
## Матрица
| Област | GA (v1.2.0) | Experimental / по-късно |
|--------|-------------|-------------------------|
| Област | v1.3.0 | Experimental / по-късно |
|--------|--------|-------------------------|
| Single-node SQL + LSM | **Supported** | — |
| Schema / FTS / HNSW / graphs persist | **Supported** | — |
| Auth + JWT (когато е конфигуриран) | **Supported** | — |
| Backup / restore | **Supported** | — |
| Multi-DB (без Raft) | **Supported** | — |
| Raft 3-node + SQL/DDL | **Experimental** | InstallSnapshot, membership |
| Raft 3-node (само `default` DB) | **Supported** | failover под товар, TLS, InstallSnapshot — e2e |
| Raft multi-DB | **Not supported** | само `default` |
| `CREATE`/`DROP DATABASE` репликация | **Not supported** | per node |
| Raft membership промени (join/leave) | **Not supported** | фиксиран `BARADB_RAFT_PEERS` |
| Follower linearizable reads | **Not supported** | best-effort след apply |
| Rolling upgrades | **Not supported** | рестарт на всички възли заедно — смесени v1.2/v1.3 binaries не трябва да работят в един клъстер |
| ORC multi-thread shared LSM | **Not supported** | ARC по подразбиране |
## GA (single-node)
Crash recovery с WAL, schema/index persist, `/health` + `/metrics`, offline backup/restore.
## Raft
## Raft (supported)
Виж [distributed.md](distributed.md). Staging/ops, **не** v1.2.0 HA продукт.
Виж [distributed.md](distributed.md). Поддържан обхват: 3-node, DML/DDL само върху `default`, failover под товар (acked writes оцеляват; in-flight writes → грешка, retry), raft TLS, cold-node recovery чрез InstallSnapshot.
## Нови ограничения
- **Legacy REP replication (без raft)** — пътят още извежда delete от празна стойност; insert в PK-only таблица се прилага грешно по него (редът изчезва). Използвай raft.
- **Snapshot-restore ctx** — след InstallSnapshot restore HTTP endpoints със startup-captured ctx може да сервират стари данни до рестарт (`/query` е свеж per-request); съществуващите клиентски връзки виждат pre-restore състояние — reconnect след restore.
- **FK-cascade дивергенция под raft** — ефектите на `ON DELETE/UPDATE CASCADE``SET NULL`) не се реплицират през raft: followers прилагат само KV промяната на родителския ред, така че каскадираните дъщерни редове остават на followers. Избягвай FK actions върху raft-реплицирани таблици или приеми периодичен snapshot resync.
- **Непотвърдени записи в snapshots** — leader прилага записите локално преди raft majority commit; snapshot, направен в този прозорец, може да включи записи, които никога не се commit-ват (фантомни редове след restore + смяна на leadership). Тесен прозорец; поправката е планирана за следващ release.
- **Блокиране на event loop при snapshot build/restore** — snapshot build/restore изпълнява блокиращ tar/gzip на event loop на възела; големи data dirs могат да забавят heartbeats и да предизвикат election по средата на трансфер.
## Виж също
+18 -2
View File
@@ -5,7 +5,7 @@ BaraDB supports distributed deployment with Raft consensus, sharding, and replic
> ⚠️ **Multi-Database Limitation**
> The distributed modules (Raft, sharding, and replication) are currently wired to the **`default`** database only. If you use multiple databases (`CREATE DATABASE`, `USE DATABASE`), distributed features do not yet span across them. Each database would need its own cluster setup.
> **Status (2026-07-30):** Raft C3a/C3b + DDL/forward/compact/metrics are **shipped**. Multi-node Raft is **experimental** for v1.2.0 GA (single-node is the production tier). See [known-limitations](known-limitations.md) and `docs/superpowers/specs/2026-07-30-raft-cluster-status.md`.
> **Status (2026-07-30, v1.3.0):** Raft C3a/C3b + DDL/forward/compact/metrics are **shipped**, and multi-node Raft is **supported** for the single-`default`-DB scope (failover under load, raft TLS, InstallSnapshot cold-node recovery — all e2e-proven). See [known-limitations](known-limitations.md) and `docs/superpowers/specs/2026-07-30-raft-cluster-status.md`.
## Raft Consensus
@@ -20,10 +20,23 @@ Leader election and log replication over TCP; SQL DML/DDL on the default DB go t
| `BARADB_RAFT_WRITE_TIMEOUT_MS` | Max wait for majority commit on SQL writes (default 5000) |
| `BARADB_RAFT_CLIENT_PEERS` | Optional `id@host:clientPort` map for leader write forwarding |
| `BARADB_RAFT_LOG_MAX_ENTRIES` | Soft cap on in-memory raft log length (default 256); safe prefix compact |
| `BARADB_RAFT_SNAP_CHUNK_KB` | InstallSnapshot chunk size in KiB (default 256) |
| `BARADB_RAFT_PEER_STALE_MS` | Peer is stale after this many ms without an ack (default 30000); stale peers no longer pin log compaction |
| `BARADB_RAFT_TLS_ENABLED` | TLS on the raft TCP port (default false; fail-closed startup if cert/key missing) |
| `BARADB_RAFT_TLS_CERT_FILE` | Server certificate for the raft listener |
| `BARADB_RAFT_TLS_KEY_FILE` | Private key for the raft listener |
| `BARADB_RAFT_TLS_CA_FILE` | Optional CA bundle for peer verification |
| `BARADB_RAFT_TLS_VERIFY_PEER` | Mutual auth — verify client certificates (default false) |
When Raft is enabled, SQL DML (`INSERT`/`UPDATE`/`DELETE`/`MERGE` and transactional `COMMIT`) and schema DDL (`CREATE`/`DROP`/`ALTER` table, index, view, graph, …) are accepted only on the leader of the **`default`** database. DML ships as put/delete log entries; DDL ships as a `ddl` entry with the original SQL and is re-executed on every node at apply. Followers that receive a write/DDL **forward** it to the leader when `BARADB_RAFT_CLIENT_PEERS` maps the leader id to a SQL client address; otherwise they return `not leader; leader is '…'`. Writes against any other database name are rejected (`raft writes only supported on the 'default' database`). `CREATE`/`DROP DATABASE` are not raft-replicated (multi-DB is out of scope for v1). Committed DML also updates secondary B-tree/FTS/HNSW indexes and in-memory graphs.
**Log compaction (v1):** after apply, each node may drop a fully-safe log prefix once `log.len` exceeds `BARADB_RAFT_LOG_MAX_ENTRIES`. The leader never discards past any peer's `matchIndex` (so lagging followers still catch up via AppendEntries). Snapshot metadata (`lastSnapshotIndex`/`Term`) is persisted in `raft_state.bin`; full InstallSnapshot state-machine payloads are not required while this safe-prefix policy holds.
**Log compaction:** after apply, each node may drop a fully-safe log prefix once `log.len` exceeds `BARADB_RAFT_LOG_MAX_ENTRIES`. On the leader, the safe prefix is computed only over peers that acked within `BARADB_RAFT_PEER_STALE_MS` — stale peers no longer pin compaction and are recovered by snapshot on return. Compaction never goes past `lastApplied`. Snapshot metadata (`lastSnapshotIndex`/`Term`) is persisted in `raft_state.bin`.
**Snapshot recovery (InstallSnapshot, v1.3.0):** when a follower's lag is unrecoverable (the entries it needs were compacted away), the leader builds a `tar.gz` snapshot of the default DB and streams it as `BARADB_RAFT_SNAP_CHUNK_KB`-sized chunks. The follower restores it via the backup/restore path, adopts the snapshot base as its `commitIndex`/`lastApplied`, and resumes normal AppendEntries catch-up. A node that returns after a long outage — and a **wiped** node (data dir deleted, same node id) — both converge automatically through this path. Proven by `tests/raft_coldnode_e2e_test.nim`.
**Client failover contract:** a write that is in flight when the leader dies **fails fast with an error** — the client must retry it (against the new leader, or any follower if `BARADB_RAFT_CLIENT_PEERS` forwarding is configured). Every write the server **acknowledged** survives the failover and is present on the new leader and all caught-up followers. Proven by `tests/raft_failover_load_e2e_test.nim` (leader killed under sustained INSERT load; all acked writes found on both survivors).
**Raft TLS (v1.3.0):** set `BARADB_RAFT_TLS_ENABLED=true` plus `BARADB_RAFT_TLS_CERT_FILE`/`BARADB_RAFT_TLS_KEY_FILE` on every node; startup fails closed if the cert or key is missing. Add `BARADB_RAFT_TLS_CA_FILE` and `BARADB_RAFT_TLS_VERIFY_PEER=true` for mutual authentication. The whole cluster must run the same mode: a plaintext node cannot speak to a TLS port (its frames are undecryptable) and is excluded from the cluster — proven by `tests/raft_tls_e2e_test.nim`. Follower→leader SQL forwarding is TLS-wrapped automatically when the server's client wire port has TLS enabled.
**Metrics:** with raft enabled, `GET /metrics` (HTTP port = `BARADB_PORT + 440`) includes Prometheus lines such as `baradb_raft_is_leader`, `baradb_raft_term`, `baradb_raft_log_entries`, `baradb_raft_apply_lag`, `baradb_raft_commit_wait_ms_total`, `baradb_raft_elections_total`, `baradb_raft_forwards_total`, and `baradb_raft_compactions_total`. `GET /health` embeds a `raft` object (`role`, `term`, `leader_id`, `commit_index`, `apply_lag`, …).
@@ -67,6 +80,9 @@ let entry = n1.appendLog("SET key1 value1")
|------|----------------|
| `tests/raft_e2e_test.nim` | 3 real processes; election + kill-leader failover |
| `tests/raft_writes_e2e_test.nim` | DDL/DML via raft, follower forward, index SELECT, failover writes |
| `tests/raft_failover_load_e2e_test.nim` | Leader killed under sustained write load; every acked write survives |
| `tests/raft_tls_e2e_test.nim` | Full-TLS 3-node cluster works; plaintext node excluded |
| `tests/raft_coldnode_e2e_test.nim` | Returning node and wiped node converge via InstallSnapshot |
## Sharding
+23 -12
View File
@@ -1,6 +1,6 @@
# Known Limitations — v1.2.0 Production GA
# Known Limitations — v1.3.0
This page defines **what BaraDB promises** in the v1.2.0 production cut.
This page defines **what BaraDB promises** in the v1.3.0 production cut.
| Tier | Meaning |
|------|---------|
@@ -10,20 +10,22 @@ This page defines **what BaraDB promises** in the v1.2.0 production cut.
## Support matrix
| Area | GA (v1.2.0) | Experimental / later |
|------|-------------|----------------------|
| Area | v1.3.0 | Notes |
|------|--------|-------|
| Single-node SQL + LSM storage | **Supported** | — |
| Schema persistence (tables, indexes) | **Supported** | — |
| FTS / HNSW / graphs across restart | **Supported** | — |
| Auth + JWT (when configured) | **Supported** | — |
| Backup / restore (offline, all-databases) | **Supported** | — |
| Multi-database (non-Raft) | **Supported** | — |
| Raft 3-node election + SQL/DDL | **Experimental** | InstallSnapshot SM payload, membership |
| Raft 3-node (single `default` DB) | **Supported** | failover under load, raft TLS, InstallSnapshot recovery — e2e-proven |
| Leader write forwarding | **Supported** | needs `BARADB_RAFT_CLIENT_PEERS` |
| Raft multi-database | **Not supported** | only `default` |
| Leader write forwarding | **Experimental** | needs `BARADB_RAFT_CLIENT_PEERS` |
| `CREATE`/`DROP DATABASE` replication | **Not supported** | run per node |
| Raft membership changes (join/leave) | **Not supported** | fixed `BARADB_RAFT_PEERS` set |
| Follower linearizable reads | **Not supported** | best-effort after apply |
| Rolling upgrades | **Not supported** | restart all nodes together — mixed v1.2/v1.3 binaries must not run in one cluster |
| ORC multi-threaded shared LSM | **Not supported** | default is ARC (`nim.cfg`) |
| Zero-downtime rolling upgrade | **Not supported** | stop → backup → upgrade |
| Postgres wire protocol | **Not supported** | Bara wire + HTTP |
## Single-node GA (what you can rely on)
@@ -33,13 +35,22 @@ This page defines **what BaraDB promises** in the v1.2.0 production cut.
- HTTP `/health` and `/metrics` for process liveness
- Offline backup of `data/databases` and restore onto an empty data root
## Raft (experimental ops)
## Raft (supported, single-default-DB scope)
Documented in [distributed.md](distributed.md). Suitable for learning and careful staging; **not** the v1.2.0 HA product tier.
Documented in [distributed.md](distributed.md). Supported scope:
- SQL DML/DDL on **`default` only**
- Safe log prefix compact (not full InstallSnapshot)
- Failover proven in process e2e tests
- 3-node cluster, SQL DML/DDL on **`default` only**
- Failover under write load: every acknowledged write survives a leader kill; in-flight writes fail fast — clients must retry
- TLS on the raft port and on follower→leader forwarding (`BARADB_RAFT_TLS_*`)
- Cold-node recovery via InstallSnapshot (`BARADB_RAFT_SNAP_CHUNK_KB`, `BARADB_RAFT_PEER_STALE_MS`)
## Newly documented limitations
- **Legacy non-raft REP replication delete inference** — resolved: the non-raft REP payload now carries an explicit put/delete op tag (`encodeRepPayload`/`decodeRepPayload` in `core/replication.nim`), so PK-only inserts (empty LSM value) replicate as puts instead of being misapplied as deletes.
- **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** — partially mitigated: the leader's snapshot *send* now tars under the storage gate but runs the CPU-heavy gzip off the event loop on a worker thread (`gzipFileAsync` in `core/backup.nim`), so heartbeats keep firing during compression. The tar itself (send path) and the whole *restore* path (tar extract + DB reopen) still run on the event loop, so very large data dirs can still stall heartbeats during those phases; a full fix (an async/try-lock storage gate so the loop never blocks) is tracked for a later release.
## Operational requirements
+10 -7
View File
@@ -1,4 +1,4 @@
# Release checklist — v1.2.0 Production GA
# Release checklist — v1.3.0 raft-supported
Use before tagging and publishing artifacts.
@@ -6,8 +6,8 @@ Use before tagging and publishing artifacts.
- [ ] Working tree clean on `main`
- [ ] [Known limitations](known-limitations.md) accurate
- [ ] `CHANGELOG.md` has dated `## [1.2.0]` (not Unreleased for shipped items)
- [ ] `baradadb.nimble` version `1.2.0`
- [ ] `CHANGELOG.md` has dated `## [1.3.0]` (not Unreleased for shipped items)
- [ ] `baradadb.nimble` version `1.3.0`
## Tests
@@ -23,9 +23,12 @@ nim c -d:ssl --threads:on --path:src -r tests/test_schema_persist.nim
./scripts/backup-restore-drill.sh
DRILL_PORT=19482 ./scripts/backup-restore-drill.sh
# Optional cluster e2e (experimental tier)
# Cluster e2e (raft supported tier — all five suites)
./tests/raft_e2e_test
./tests/raft_writes_e2e_test
./tests/raft_failover_load_e2e_test
./tests/raft_tls_e2e_test
./tests/raft_coldnode_e2e_test
```
## Production compose
@@ -41,17 +44,17 @@ docker compose -f docker-compose.prod.yml config >/dev/null
```bash
nimble build_release # or: nim c -d:release -o:build/baradadb src/baradadb.nim
docker build -t baradb:1.2.0 -t baradb:latest .
docker build -t baradb:1.3.0 -t baradb:latest .
```
## Tag
```bash
git tag -a v1.2.0 -m "BaraDB v1.2.0 Production GA (single-node)"
git tag -a v1.3.0 -m "BaraDB v1.3.0 raft-supported"
git push origin main --tags
```
## Post-release
- [ ] Smoke: start prod compose, `/health` → ok, auth required for `/query`
- [ ] Announce: single-node GA; Raft experimental (link known-limitations)
- [ ] Announce: raft-supported release (3-node, `default` DB); link known-limitations
@@ -0,0 +1,598 @@
# v1.3.0 Raft-Supported — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> Spec first: `docs/superpowers/specs/2026-07-30-raft-supported-design.md`.
**Goal:** Move raft from experimental to supported: proven failover under
load, mandatory CI e2e, cold-node recovery via InstallSnapshot, raft-port TLS.
**Architecture:** No changes to election/AppendEntries semantics. New
InstallSnapshot message pair rides the existing framed TCP transport
(backward-compatible trailing fields, `RaftProtoVersion` stays 1). TLS wraps
the existing transport via `protocol/ssl.nim`. Snapshot payload reuses
`core/backup.nim` tar.gz backup/restore.
**Tech stack:** Nim 2.2.x, std/asyncnet + std/net SSL, existing
`tests/raft_*_e2e_test.nim` process harness, GitHub Actions.
## Global Constraints
- Spec: `docs/superpowers/specs/2026-07-30-raft-supported-design.md`.
- Do **not** change election safety, commit rules, or the DDL/DML
classification from C3b/C3c.
- Raft remains default-DB-only; snapshot payload covers the default DB only.
- Compile all touched Nim with `-d:ssl --threads:on --path:src`.
- Test baseline per task: `tests/test_all.nim` + `tests/bugfix_test.nim` must
stay green; raft e2e suites green where the binary is built.
- Branch: `main` (short-lived feature branches merged same day are fine).
- Version bump to 1.3.0 happens only in the final task (T12).
---
## Phase map
| Phase | Tasks | Outcome |
|-------|-------|---------|
| P1 Proof | T1 | Failover-under-load e2e |
| P2 CI | T2 | Mandatory raft e2e CI gate |
| P3 TLS | T3T6 | Raft port TLS + mutual auth + TLS e2e |
| P4 Cold node | T7T11 | InstallSnapshot + compaction unpin + cold-node e2e |
| P5 Release | T12 | Docs, limitations, version 1.3.0 |
---
### Task 1: Failover-under-load E2E
**Files:**
- Create: `tests/raft_failover_load_e2e_test.nim`
- Modify: `baradadb.nimble` (task `test`, line ~30-34: add `raft_failover_load_e2e_test` after `raft_writes_e2e_test`)
**Interfaces:**
- Consumes: process harness conventions from `tests/raft_writes_e2e_test.nim`
(`NodeProc`, `drainOutput`, `portOpen`, `openClient`, `waitForRow` pattern);
client `adaptors/nim/baradb_sqlite`.
- Produces: suite `Raft failover under load E2E`.
**Scenario (spec D1):**
- Port base `cbase = 50000 + (tstamp mod 4000)`, `rbase = cbase + 100`
(distinct from 35000/41000/46000 bases already in use).
- Boot 3 nodes with `BARADB_RAFT_*` env exactly as
`raft_writes_e2e_test.nim:150-171`.
- Wait for stable leader (same `maxLeader` logic). Leader DDL:
`CREATE TABLE load_test (id INT PRIMARY KEY)`.
- Load phase: spawn a Nim `Thread` that loops `n = 1, 2, ...`:
`INSERT INTO load_test (id) VALUES (n)` against the current leader's
client port; every **acknowledged** `n` appended to a
`seq[int]` guarded by a `Lock`. On exception: reopen client against a
survivor, continue (this models the documented client retry contract).
- At ≥ 50 acked writes: `killNode(leader)`.
- Assert A (availability): some survivor accepts an INSERT within 10 s of
the kill.
- Assert B (durability): after new leader is stable and the remaining
follower caught up (poll `SELECT count(*)` equality or 10 s deadline),
`SELECT id FROM load_test` on **both** survivors contains every acked id.
- Stop the writer thread in `finally`; reuse the `dumpAll`-on-fail
convention.
- [x] **Step 1:** Write the suite skeleton: harness copied from
`raft_writes_e2e_test.nim` (drain/kill/leader-discovery helpers), writer
thread, kill at 50 acked, asserts A + B.
- [x] **Step 2:** Build binary and run:
`nim c -o:build/baradadb src/baradadb.nim && nim c -d:ssl --threads:on --path:src -r tests/raft_failover_load_e2e_test.nim`
Expected: PASS.
- [x] **Step 3:** Run 3 consecutive times (failover timing flakiness check).
- [x] **Step 4:** Add suite to `nimble test` list in `baradadb.nimble`.
- [x] **Step 5:** Commit
`test(raft): failover under sustained write load e2e`
---
### Task 1a: Fix raft put/delete encoding for empty values (bug found in T1)
**Bug:** `execInsert` (`src/barabadb/query/exec/dml.nim:60-90`) stores only
non-PK columns in the value, so a PK-only table row gets `valStr = ""` and
`kvPairs.add((fullKey, @[]))`. `appendWriteToRaft`
(`src/barabadb/core/server.nim:309-330`) encodes an empty value as a
`"delete"` log entry — but `execDelete` (`dml.nim:223-241`) uses the same
`(fullKey, @[])` shape for real deletes. Result: INSERT into a PK-only
table returns OK after majority commit, then every node (leader included,
on apply) **deletes the row**. Verified live in T1: 30 acked inserts → 0
rows on all nodes.
**Files:**
- Modify: `src/barabadb/query/exec/types.nim:139``ExecResult.keyValuePairs`
- Modify: `src/barabadb/query/exec/dml.nim` — 3 producer sites (insert ~90,
delete ~241, update ~316)
- Modify: `src/barabadb/core/server.nim``appendWriteToRaft` (~309) and
its call site (~441-464)
- Test: `tests/bugfix_test.nim` (new suite)
**Interfaces:**
- Change the pair type to carry the op explicitly:
```nim
# exec/types.nim
keyValuePairs*: seq[tuple[key: string, value: seq[byte], deleted: bool]]
```
- `execInsert`/`execUpdate` produce `deleted: false` (even when
`value.len == 0`); `execDelete` produces `deleted: true`.
- `appendWriteToRaft` encodes `deleted``"delete"`, else `"put"`
(empty value stays a put). Apply side (`baradadb.nim:358-368`) already
handles `put` with empty value correctly — no change needed there.
- Check other `keyValuePairs` consumers compile clean (replication path in
`server.nim`); keep `okResult(kvPairs=...)` call sites type-correct.
**Steps:**
- [x] **Step 1:** Write the failing test in `tests/bugfix_test.nim`:
build `ExecResult` via the insert path for a PK-only table (or call
`appendWriteToRaft` semantics directly): assert a PK-only insert yields
a pair with `deleted == false` and encodes as `"put"`, while a delete
yields `deleted == true` and encodes as `"delete"`.
- [x] **Step 2:** Run, expect fail/compile error.
- [x] **Step 3:** Implement the type + producer/consumer changes.
- [x] **Step 4:** `bugfix_test` + `test_all` green; rebuild
`build/baradadb` and re-run `tests/raft_failover_load_e2e_test.nim`
then **switch its table back** to the brief's original
`load_test (id INT PRIMARY KEY)` / `VALUES (n)` shape (remove the
two-column workaround and its header note) and re-run green.
- [x] **Step 5:** Commit
`fix(raft): distinguish put-with-empty-value from delete in write path`
---
### Task 2: Mandatory raft e2e CI gate
**Files:**
- Modify: `.github/workflows/ci.yml`
- Modify: `tests/raft_e2e_test.nim`, `tests/raft_writes_e2e_test.nim`,
`tests/raft_failover_load_e2e_test.nim` (skip→fail under CI)
**Steps:**
- [x] **Step 1:** In each suite's binary-missing branch, replace plain
`skip()` with:
```nim
if not fileExists(BinaryPath):
if getEnv("CI").len > 0:
echo "[FAIL] ", BinaryPath, " missing under CI — build step broken?"
fail()
else:
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
skip()
```
- [x] **Step 2:** Add a dedicated job to `.github/workflows/ci.yml` (after
the `test` job), modeled on its setup steps:
```yaml
raft-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Nim
uses: jiro4989/setup-nim-action@v1
with:
nim-version: '2.2.10'
- name: Install system dependencies
run: sudo apt-get update -qq && sudo apt-get install -y -qq libssl-dev libpcre3-dev openssl ca-certificates
- name: Install Nim dependencies
run: nimble install --depsOnly -y
- name: Build server
run: nim c -d:ssl -o:build/baradadb src/baradadb.nim
- name: Raft e2e suites
env:
CI: "true"
run: |
nim c -d:ssl --threads:on --path:src -r tests/raft_e2e_test.nim
nim c -d:ssl --threads:on --path:src -r tests/raft_writes_e2e_test.nim
nim c -d:ssl --threads:on --path:src -r tests/raft_failover_load_e2e_test.nim
```
- [x] **Step 3:** Push on a branch; confirm the `raft-e2e` job appears and
is green; confirm deleting `build/` would fail (local run with `CI=true`
and no binary → FAIL).
- [x] **Step 4:** Commit
`ci(raft): dedicated mandatory raft e2e job; no silent skips under CI`
---
### Task 3: Raft TLS config + fail-closed startup
**Files:**
- Modify: `src/barabadb/core/config.nim` (fields after `raftLogMaxEntries`
at lines ~46/88; env parsing after line ~212)
- Modify: `src/baradadb.nim` (raft wiring block, lines 337-377)
- Test: `tests/bugfix_test.nim` (new suite)
**Interfaces:**
- Produces config fields (used by T4/T6):
```nim
raftTlsEnabled*: bool # default false
raftTlsCertFile*: string # default ""
raftTlsKeyFile*: string # default ""
raftTlsCaFile*: string # default ""
raftTlsVerifyPeer*: bool # default false
```
- Env parsing (mirror lines 184-212 style):
```nim
cfg.raftTlsEnabled = parseEnvBool(getEnv("BARADB_RAFT_TLS_ENABLED", ""), cfg.raftTlsEnabled)
cfg.raftTlsCertFile = getEnv("BARADB_RAFT_TLS_CERT_FILE", cfg.raftTlsCertFile)
cfg.raftTlsKeyFile = getEnv("BARADB_RAFT_TLS_KEY_FILE", cfg.raftTlsKeyFile)
cfg.raftTlsCaFile = getEnv("BARADB_RAFT_TLS_CA_FILE", cfg.raftTlsCaFile)
cfg.raftTlsVerifyPeer = parseEnvBool(getEnv("BARADB_RAFT_TLS_VERIFY_PEER", ""), cfg.raftTlsVerifyPeer)
```
- Fail-closed in `baradadb.nim` before `newRaftNetwork`:
```nim
if config.raftTlsEnabled:
if config.raftTlsCertFile.len == 0 or config.raftTlsKeyFile.len == 0 or
not fileExists(config.raftTlsCertFile) or not fileExists(config.raftTlsKeyFile):
raise newException(ValueError,
"BARADB_RAFT_TLS_ENABLED=true but cert/key missing " &
"(BARADB_RAFT_TLS_CERT_FILE / BARADB_RAFT_TLS_KEY_FILE)")
```
- [x] **Step 1:** Write failing test in `tests/bugfix_test.nim`: default
config has `raftTlsEnabled == false`; env `BARADB_RAFT_TLS_ENABLED=true`
+ cert paths parse into config.
- [x] **Step 2:** Run test, expect compile/fail (fields don't exist).
- [x] **Step 3:** Implement fields + env parsing + startup check.
- [x] **Step 4:** Test green; `test_all` still green.
- [x] **Step 5:** Commit
`feat(raft): TLS config surface with fail-closed startup`
---
### Task 4: TLS in RaftNetwork transport
**Files:**
- Modify: `src/barabadb/core/raft.nim` (`RaftNetwork` type ~line 731,
`connectToPeer` ~748, `run` ~857)
- Modify: `src/baradadb.nim` (construct TLSContext, pass to network)
- Test: `tests/test_all.nim` (in-process TLS raft pair)
**Interfaces:**
- Consumes: `protocol/ssl.nim` — `newTLSConfig(certFile, keyFile, caFile,
verifyPeer)`, `newTLSContext`, `wrapClient`, `wrapServer`.
- Produces: `RaftNetwork.tls*: TLSContext` (nil = plaintext, unchanged
default); `newRaftNetwork(node, tls = nil)`.
**Implementation:**
```nim
# raft.nim — in connectToPeer, after successful connect:
if net.tls != nil:
try: net.tls.wrapClient(sock)
except CatchableError:
try: sock.close() except CatchableError: discard
return
# raft.nim — in run, after accept, before receiveLoop:
if net.tls != nil:
try: net.tls.wrapServer(client)
except CatchableError:
client.close()
continue
```
- `baradadb.nim`: build the context when enabled:
```nim
var raftTls: TLSContext = nil
if config.raftTlsEnabled:
raftTls = newTLSContext(newTLSConfig(
config.raftTlsCertFile, config.raftTlsKeyFile,
caFile = config.raftTlsCaFile, verifyPeer = config.raftTlsVerifyPeer))
...
raftNet = newRaftNetwork(raftNode, raftTls)
```
- [x] **Step 1:** Write failing in-process test (`test_all.nim`): two
`RaftNode`s over `RaftNetwork` with a self-signed cert from
`generateSelfSignedCert` (`protocol/ssl.nim:79`) — election completes
over TLS; plaintext dial to the TLS port produces no protocol effect
(no state change, connection dropped).
- [x] **Step 2:** Run, expect fail (no `tls` field).
- [x] **Step 3:** Implement transport changes + wiring.
- [x] **Step 4:** Test green; plaintext raft e2e suites still green
(regression: nil-TLS path untouched).
- [x] **Step 5:** Commit
`feat(raft): optional TLS on raft transport (server + dialer)`
---
### Task 5: TLS for leader SQL forwarding
**Files:**
- Modify: `src/barabadb/core/server.nim` (`forwardQueryToLeader`, lines
210-289)
**Interfaces:**
- Consumes: `protocol/ssl.nim` `wrapClient`; server config `tlsEnabled`,
`certFile`, `keyFile`.
- Produces: `forwardQueryToLeader(host, port, query, tls: TLSContext = nil,
...)`.
- [x] **Step 1:** When the server's client wire port has TLS on
(`server.tls != nil`), wrap the forwarding socket with a client-side
context before sending the wire header; on handshake failure return
`(false, QueryResult(), "leader forward TLS handshake failed")`.
- [x] **Step 2:** Manual check: TLS server + raft forwarding (follower
INSERT forwarded over TLS) works; non-TLS setup unchanged.
- [x] **Step 3:** Commit
`feat(raft): TLS on follower→leader SQL forwarding`
---
### Task 6: Raft TLS E2E
**Files:**
- Create: `tests/raft_tls_e2e_test.nim`
- Modify: `baradadb.nimble` (test list), `.github/workflows/ci.yml`
(raft-e2e job: add this suite)
**Interfaces:**
- Consumes: T3/T4 implementation; harness from T1; openssl CLI for cert
generation (already used by `protocol/ssl.nim`).
**Scenario:**
- Port base `54000 + (tstamp mod 4000)`.
- Generate one self-signed cert per node into the temp data dirs
(`openssl req -x509 ...`, or `generateSelfSignedCert`).
- Boot 3 nodes with `BARADB_RAFT_TLS_ENABLED=true` + per-node cert/key;
assert election + `CREATE TABLE` via raft DDL + one replicated INSERT
visible on a follower.
- Negative: start a 4th process with raft TLS **disabled** pointed at the
same peers; assert the TLS cluster still elects/operates among its 3
members and the plaintext node never becomes leader (its frames are
undecryptable).
- Same `CI`-fail semantics as T2.
- [x] **Step 1:** Write suite; Step 2: run green locally; Step 3: run 3×
(timing); Step 4: wire into `nimble test` + CI job; Step 5: Commit
`test(raft): 3-node TLS cluster e2e with plaintext rejection`
---
### Task 7: InstallSnapshot protocol
**Files:**
- Modify: `src/barabadb/core/raft.nim` (`RaftMessageKind` ~line 80,
`RaftMessage` ~86, `serialize` ~679, `deserializeRaftMessage` ~702)
- Test: `tests/test_all.nim` (serialize/deserialize round-trip)
**Interfaces:**
- Produces:
```nim
# RaftMessageKind += rmkInstallSnapshot, rmkInstallSnapshotReply
# RaftMessage new fields:
snapId*: uint64 # snapshot generation, matches leader's base at build time
snapOffset*: uint64 # byte offset of this chunk within the archive
snapData*: seq[byte] # chunk payload (<= snapChunkBytes)
snapDone*: bool # last chunk
# Reused for this kind: prevLogIndex = snapshot base index,
# prevLogTerm = snapshot base term. Reply uses success/matchIdx as usual.
```
- Serialization: append `snapId`, `snapOffset`, `snapData` (length-prefixed),
`snapDone` **after** `matchIdx`; deserialize each with `if not s.atEnd`
guards (pattern from `loadState`, raft.nim:163-167). Old binaries ignore
trailing bytes; new binaries default missing fields to zero/false.
`RaftProtoVersion` stays 1.
- [x] **Step 1:** Write failing round-trip test: all new fields survive
serialize→deserialize; a buffer serialized by the *old* layout (no
trailing fields) deserializes with zero defaults.
- [x] **Step 2:** Run, expect fail.
- [x] **Step 3:** Implement.
- [x] **Step 4:** Green; existing raft suites still green.
- [x] **Step 5:** Commit
`feat(raft): InstallSnapshot wire protocol (backward-compatible)`
---
### Task 8: Follower snapshot receive + restore
**Files:**
- Modify: `src/barabadb/core/raft.nim` (`RaftNode` — new callback +
incoming-snapshot buffer; `processMessage` ~786)
- Modify: `src/barabadb/core/config.nim` (`raftSnapChunkKb: int`, env
`BARADB_RAFT_SNAP_CHUNK_KB`, default 256; parsing next to the other
`BARADB_RAFT_*` env reads)
- Modify: `src/baradadb.nim` (wire `restoreSnapshot` callback using
`core/backup.nim` + `DatabaseRegistry`; pass chunk size to the node)
- Test: `tests/test_all.nim`
**Interfaces:**
- Produces on `RaftNode`:
```nim
snapChunkBytes*: int # from BARADB_RAFT_SNAP_CHUNK_KB, default 262144
restoreSnapshot*: proc(archivePath: string, baseIndex: uint64,
baseTerm: uint64): bool {.gcsafe.}
snapIncomingId*: uint64
snapIncomingFile*: string # temp path under dataDir/raft/snap_incoming/
```
- `processMessage` case `rmkInstallSnapshot`: append `snapData` at
`snapOffset` to the temp file (create/truncate when `snapId !=
snapIncomingId`); on `snapDone`: call `restoreSnapshot`; on success set
`lastSnapshotIndex/Term = prevLogIndex/prevLogTerm`,
`commitIndex = lastApplied = lastSnapshotIndex`, clear `log`,
`saveState()`, reply success with `matchIdx = lastSnapshotIndex`; on
failure reply `success = false` and delete the temp file.
- `baradadb.nim` `restoreSnapshot` implementation: close default DB via
registry, `restoreDataDir(archivePath, defaultDbDir)`
(`backup.nim:263`), reopen, swap `ctx`. Return false on any exception.
- [x] **Step 1:** Write failing test: feed a node two chunks + done with a
real tar.gz fixture; assert callback received the assembled file, state
fields updated, log cleared.
- [x] **Step 2:** Run, expect fail. **Step 3:** Implement.
- [x] **Step 4:** Green + regression suites. **Step 5:** Commit
`feat(raft): follower InstallSnapshot receive and restore`
---
### Task 9: Leader snapshot send
**Files:**
- Modify: `src/barabadb/core/raft.nim` (`handleAppendReply` floor branch
~526-531, new `sendSnapshot` proc, per-peer reject counter)
- Modify: `src/baradadb.nim` (wire `buildSnapshot` callback)
- Test: `tests/test_all.nim`
**Interfaces:**
- Consumes: T7 protocol, `backupDataDir` (`backup.nim:225`).
- Produces on `RaftNode`:
```nim
buildSnapshot*: proc(destPath: string): bool {.gcsafe.}
snapRejectStreak*: Table[string, int] # consecutive floor-level rejects per peer
```
- Logic: in `handleAppendReply`, when a reject arrives **and**
`nextIndex[peerId] == lastSnapshotIndex + 1` (floor reached): increment
streak; at streak ≥ 2 the leader knows the follower needs a snapshot →
`asyncCheck sendSnapshot(peerId)`. Reset streak on any successful reply.
- `sendSnapshot`: `buildSnapshot` into `dataDir/raft/snap_out_<snapId>.tar.gz`
(`snapId = lastSnapshotIndex`); stream chunks of `snapChunkBytes` as
`rmkInstallSnapshot`; on final success reply set
`matchIndex[peer] = lastSnapshotIndex`,
`nextIndex[peer] = lastSnapshotIndex + 1`; delete the temp archive.
- [x] **Step 1:** Write failing test: leader with compacted log
(`lastSnapshotIndex = 100`) + peer at floor rejecting twice → snapshot
messages emitted; success reply advances `matchIndex`/`nextIndex`.
- [x] **Step 2:** Run, expect fail. **Step 3:** Implement.
- [x] **Step 4:** Green + regression. **Step 5:** Commit
`feat(raft): leader InstallSnapshot send on unrecoverable lag`
---
### Task 10: Unpin compaction from dead peers
**Files:**
- Modify: `src/barabadb/core/raft.nim` (`compactLog` ~244, `becomeLeader`
~327, `handleAppendReply` ~487)
- Modify: `src/barabadb/core/config.nim` (`raftPeerStaleMs`, env
`BARADB_RAFT_PEER_STALE_MS`, default 30000)
- Test: `tests/test_all.nim`
**Interfaces:**
- Produces: `matchIndexSeenMs*: Table[string, int64]` on `RaftNode` —
monotonic ms timestamp of the last successful reply per peer, updated in
`handleAppendReply` success branch and initialized to "now" in
`becomeLeader`.
**Logic:** leader-side `compactLog` computes `minMatch` only over peers
with `now - matchIndexSeenMs[peer] <= raftPeerStaleMs`; peers stale longer
are excluded (they'll be snapshotted on return per T9). Follower path
unchanged. Guard: never compact past `lastApplied`.
- [x] **Step 1:** Write failing test: leader, one peer never replies,
log > maxEntries → with default stale window, log compacts through
lastApplied anyway; with the peer responsive, compaction still pins at
its matchIndex (existing safety preserved).
- [x] **Step 2:** Run, expect fail. **Step 3:** Implement.
- [x] **Step 4:** Green + regression. **Step 5:** Commit
`feat(raft): compaction unpinned from stale peers (snapshot fallback)`
---
### Task 11: Cold-node E2E
**Files:**
- Create: `tests/raft_coldnode_e2e_test.nim`
- Modify: `baradadb.nimble`, `.github/workflows/ci.yml` (raft-e2e job)
**Interfaces:** Consumes T7T10; harness from T1. Port base
`58000 + (tstamp mod 4000)`. Small `BARADB_RAFT_LOG_MAX_ENTRIES=16` and
`BARADB_RAFT_PEER_STALE_MS=3000` to force compaction quickly.
**Scenario A — node returns after compaction:**
1. 3-node cluster, create table, kill node n3.
2. Write 100 rows through the leader (forces compaction past n3's
matchIndex once n3 is stale).
3. Assert via `/metrics` on the leader HTTP port
(`baradb_raft_log_entries`) that the log stayed bounded.
4. Restart n3 with its intact data dir; assert it receives a snapshot
(leader log line / `baradb_raft_snapshot_index` advances on n3) and
within 15 s `SELECT count(*)` on n3 matches the leader.
**Scenario B — wiped node joins:**
1. Stop n3, **delete its data dir**, restart with the same node id.
2. Assert it converges (snapshot → catch-up) and serves the full row set
within 20 s.
- [x] **Step 1:** Write suite. **Step 2:** Green locally. **Step 3:** 3×
stability runs. **Step 4:** `nimble test` + CI wiring. **Step 5:** Commit
`test(raft): cold-node rejoin and wiped-node join e2e`
---
### Task 12: Docs, limitations, version 1.3.0
**Files:**
- Modify: `docs/en/distributed.md`, `docs/bg/distributed.md` — client
failover contract (in-flight writes fail fast, retry; acked writes
durable), TLS setup (`BARADB_RAFT_TLS_*`), snapshot behavior/tunables
(`BARADB_RAFT_SNAP_CHUNK_KB`, `BARADB_RAFT_PEER_STALE_MS`)
- Modify: `docs/en/known-limitations.md`, `docs/bg/known-limitations.md` —
raft 3-node moves from "Experimental" to "Supported" for the covered
scope; remaining non-goals (multi-DB raft, membership changes, read
consistency levels) stay listed
- Modify: `docs/superpowers/specs/2026-07-30-raft-cluster-status.md` —
status → v1.3.0 supported
- Modify: `CHANGELOG.md` — `## [1.3.0] — <ship date>`
- Modify: `baradadb.nimble` → `version = "1.3.0"`; README status lines
- Modify: `docs/en/release-checklist.md` — add raft TLS + cold-node suites
- [x] **Step 1:** Doc edits. **Step 2:** Full `nimble test` green.
- [x] **Step 3:** Commit
`release: v1.3.0 raft-supported (failover load, CI gate, snapshot, TLS)`
- [ ] **Step 4 (human/controller):** tag `v1.3.0` after review.
---
## Task dependency graph
```
T1 failover-load e2e ──→ T2 CI gate
T3 TLS config ──→ T4 transport TLS ──→ T5 forward TLS ──→ T6 TLS e2e
T7 snapshot protocol ──→ T8 follower restore ──→ T9 leader send ──→ T10 unpin ──→ T11 cold-node e2e
all ──→ T12 docs/version
```
## Explicit out-of-scope
- Membership change (join/leave) protocol
- Multi-database raft; `CREATE`/`DROP DATABASE` replication
- Linearizable follower reads
- Rolling-upgrade compat shims beyond the trailing-field guard
(upgrade = restart all nodes)
## Definition of Done
- [x] All P1P5 tasks complete, `nimble test` green
- [ ] `raft-e2e` CI job green and mandatory (no silent skip)
- [x] Failover-under-load e2e: every acked write survives leader kill
- [x] Cold-node e2e: returning node and wiped node converge automatically
- [x] Raft TLS e2e: full-TLS cluster works; plaintext node excluded
- [x] known-limitations updated: raft supported for the covered scope
## Estimated effort
| Phase | Effort |
|-------|--------|
| P1P2 | 0.51 day |
| P3 | 1 day |
| P4 | 23 days |
| P5 | 0.5 day |
| **Total** | **~45 focused days** |
@@ -1,9 +1,38 @@
# Raft Cluster Status — C3a / C3b / post-C3b
# Raft Cluster Status — C3a / C3b / post-C3b / v1.3.0
Date: 2026-07-30
Status: **Shipped on `main`** (tip includes metrics).
Status: **v1.3.0 — Supported** for the single-`default`-DB scope (see the v1.3.0 section below).
Branch: all work merged to `main` only (feature branch removed).
## v1.3.0 — raft-supported (2026-07-30)
Raft moves from Experimental to **Supported** for a 3-node cluster on the
`default` database. Landed on top of the C3a/C3b base:
- **Failover under load** — `tests/raft_failover_load_e2e_test.nim`: leader
killed under sustained writes; every acked write survives (client contract:
in-flight writes fail fast, retry).
- **Mandatory CI gate** — dedicated `raft-e2e` job runs all five raft e2e
suites; missing binary is a hard FAIL under CI.
- **Raft TLS** — `BARADB_RAFT_TLS_*` config, fail-closed startup, optional
mutual auth, TLS on follower→leader forwarding;
`tests/raft_tls_e2e_test.nim` (plaintext node excluded).
- **InstallSnapshot** — backward-compatible wire protocol, leader chunk send
(`BARADB_RAFT_SNAP_CHUNK_KB`, default 256), follower restore via
backup/restore, compaction unpinned from stale peers
(`BARADB_RAFT_PEER_STALE_MS`, default 30000);
`tests/raft_coldnode_e2e_test.nim` (returning + wiped node converge).
- **Fixes** — put/delete encoding (`deleted` flag), rejoin livelock (cached
peer sockets dropped on leadership), post-restore ctx repoint.
Resolved non-goals from the list below: raft-port TLS, InstallSnapshot with
full SM payload. Still open: multi-database raft, `CREATE`/`DROP DATABASE`
replication, membership changes, linearizable follower reads, rolling
upgrades (restart all nodes together).
Plan: `docs/superpowers/plans/2026-07-30-v1.3.0-raft-supported.md` ·
Design: `docs/superpowers/specs/2026-07-30-raft-supported-design.md`
## Phase map
| Phase | Spec / plan | Status | What landed |
@@ -0,0 +1,212 @@
# v1.3.0 Raft-Supported — Design Spec
Date: 2026-07-30
Status: Draft
Follows: `2026-07-30-raft-cluster-status.md` (C3a/C3b/C3c-lite shipped),
`2026-07-30-production-ga-design.md` ("After GA" section).
## Goal
Move the raft cluster from **experimental** to **supported** by closing the
four gaps named in the GA plan: failover under load (proven, not assumed),
CI e2e mandatory, a cold-node story, and raft-port TLS.
Non-goals (unchanged from C3 status doc): multi-database raft, membership
change protocol, read consistency levels, `CREATE`/`DROP DATABASE`
replication.
## Current state (verified 2026-07-30)
- `src/barabadb/core/raft.nim` (919 lines): election, AppendEntries,
safe-prefix compaction, metrics, plain-TCP `RaftNetwork` transport.
- Wiring: `src/baradadb.nim:337-377` (env `BARADB_RAFT_*`, state in
`dataDir/raft/raft_state.bin`).
- Leader forwarding: `src/barabadb/core/server.nim:210-289`
(`forwardQueryToLeader`, plain TCP).
- TLS infra exists for the client wire port only:
`src/barabadb/protocol/ssl.nim` (`TLSConfig`, `TLSContext`, `wrapClient`,
`wrapServer`); server accept loop wraps at `core/server.nim:876-889`.
- E2E: `tests/raft_e2e_test.nim` (election + failover),
`tests/raft_writes_e2e_test.nim` (DDL/DML replication, forwarding,
failover write probe). Both run under `nimble test`, which CI runs.
## Gap analysis
### G1. Failover under load — unproven
The existing failover test (`raft_writes_e2e_test.nim:349-405`) kills the
leader *while idle* and probes a single INSERT afterwards. Nothing tests a
write workload running *during* the leader crash, and nothing verifies that
every client-acknowledged write survives the failover (raft's core promise:
committed entries are never lost).
### G2. CI e2e — present but silently skippable
Both e2e suites `skip()` when `./build/baradadb` is missing
(`raft_writes_e2e_test.nim:413-417`). `nimble test` builds the binary first,
so CI runs them today — but a broken build step or a renamed binary turns a
raft regression into a silent green skip. There is also no dedicated CI job
that names raft e2e as a first-class gate.
### G3. Cold node — two real failure modes
1. **Log growth pinning.** Leader compaction
(`raft.nim:244-276`, `compactLog`) never discards past any peer's
`matchIndex`. A peer that is down keeps `matchIndex` stale, so the leader's
log grows without bound for as long as the node is down.
2. **Unrecoverable laggard.** Once the leader's log no longer contains a
follower's `nextIndex` (fresh/wiped node, or a node that was down through a
compaction), the follower rejects every AppendEntries
(`handleAppendEntries`, `raft.nim:380-394`) and the leader's
`nextIndex` decrement floor is `lastSnapshotIndex + 1`
(`handleAppendReply`, `raft.nim:526-531`). The pair is stuck forever: no
InstallSnapshot path exists.
### G4. Raft port is plaintext
`RaftNetwork` uses bare `newAsyncSocket()` (`raft.nim:748-765`, `857-871`).
Any host that can reach the raft port can inject RequestVote/AppendEntries
frames. The TLS machinery in `protocol/ssl.nim` is not used here; leader
forwarding (`forwardQueryToLeader`) is likewise plaintext.
### G5. Raft write encoding loses empty-value puts (found 2026-07-30)
`appendWriteToRaft` (`core/server.nim:309-330`) encodes an empty value as
`"delete"`, but PK-only-table inserts legitimately produce empty values
(`execInsert`, `query/exec/dml.nim:60-90`) — such inserts are acked after
majority commit and then deleted everywhere on apply. Fixed as plan Task 1a
(explicit `deleted` flag on `ExecResult.keyValuePairs`).
## Design
### D1. Failover-under-load E2E (test-only)
New suite `tests/raft_failover_load_e2e_test.nim`, same process-management
conventions as `raft_writes_e2e_test.nim` (port base `50000 + tstamp mod
4000` to avoid collisions):
1. Boot a 3-node cluster, elect a leader, create `load_test` table via raft
DDL.
2. Writer thread: sequential `INSERT INTO load_test (id) VALUES (n)`,
`n = 1, 2, ...`, recording every *acknowledged* id. On error ("not
leader", commit timeout, connection reset), probe both survivors and
resume on whichever accepts.
3. At ~50 acknowledged writes, kill the leader.
4. Assert: a survivor accepts a write within **10 s** of the kill
(availability bound).
5. Assert: after the new leader is stable and the remaining follower has
caught up, `SELECT id FROM load_test` on **both** survivors contains
**every acknowledged id** (committed writes never lost). Unacknowledged
writes may be present or absent — this is documented, not asserted.
Also document the client-visible contract in `docs/en/distributed.md`:
in-flight writes during failover fail fast with an error; clients must
retry; acknowledged writes are durable across failover.
### D2. CI e2e mandatory
- New `raft-e2e` job in `.github/workflows/ci.yml`: setup Nim, build
`build/baradadb`, run the three raft e2e suites explicitly with `CI=true`
in the environment.
- Change skip semantics in all raft e2e suites: when `CI` env var is
non-empty and `./build/baradadb` is missing, **fail** instead of `skip()`.
- Add `raft_failover_load_e2e_test` to the `nimble test` list in
`baradadb.nimble`.
### D3. Cold node — InstallSnapshot
Extend the raft wire protocol and apply path:
**Protocol.** New message kinds `rmkInstallSnapshot`,
`rmkInstallSnapshotReply`, and new fields on `RaftMessage`:
`snapId: uint64`, `snapOffset: uint64`, `snapData: seq[byte]`,
`snapDone: bool`. `lastSnapshotIndex`/`lastSnapshotTerm` ride on the
existing fields (`prevLogIndex`/`prevLogTerm` are reused as the snapshot
base for this kind). Serialization appends the new fields with `atEnd`
guards (same backward-compatible pattern as `loadState`,
`raft.nim:163-167`); `RaftProtoVersion` stays 1 — mixed-version clusters
simply never send the new kind (old leaders never trigger it).
**Leader side.** Track consecutive AppendEntries rejections per peer. When
`nextIndex[peer]` has hit the `lastSnapshotIndex + 1` floor and the peer
still rejects, the peer is unrecoverably behind:
1. Build a snapshot archive of the **default database** data dir with the
existing backup machinery (`backupDataDir` in
`src/barabadb/core/backup.nim:225`) into a temp file.
2. Stream it in chunks (`BARADB_RAFT_SNAP_CHUNK_KB`, default 256 KB) as
`rmkInstallSnapshot` messages over the existing peer socket.
3. On final ack, set `matchIndex[peer] = lastSnapshotIndex`,
`nextIndex[peer] = lastSnapshotIndex + 1`, resume normal AppendEntries.
**Follower side.** On `rmkInstallSnapshot`:
1. Buffer chunks to a temp file under `dataDir/raft/snap_incoming/`.
2. On `snapDone`, hand the archive to a new injected callback
`restoreSnapshot: proc(archivePath: string): bool {.gcsafe.}` (wired in
`baradadb.nim` where the `DatabaseRegistry` lives): close the default
DB, `restoreDataDir` (`backup.nim:263`) into the default DB dir, reopen,
and swap execution context.
3. Set `lastSnapshotIndex`/`lastSnapshotTerm`/`commitIndex`/`lastApplied`
from the message, clear the log, `saveState`.
**Unpinning compaction.** Once InstallSnapshot exists, `compactLog` on the
leader compacts through `lastApplied` for peers whose `matchIndex` was
updated within the last `BARADB_RAFT_PEER_STALE_MS` (default 30 000);
long-dead peers no longer pin the log — they get a snapshot when they
return. Follower compaction is unchanged.
**Fresh-node join** falls out for free: a wiped node rejects at the floor
and receives a snapshot.
### D4. Raft TLS
Config (env, mirroring existing `BARADB_TLS_*`):
| Env | Config field | Default |
|-----|--------------|---------|
| `BARADB_RAFT_TLS_ENABLED` | `raftTlsEnabled: bool` | false |
| `BARADB_RAFT_TLS_CERT_FILE` | `raftTlsCertFile: string` | "" |
| `BARADB_RAFT_TLS_KEY_FILE` | `raftTlsKeyFile: string` | "" |
| `BARADB_RAFT_TLS_CA_FILE` | `raftTlsCaFile: string` | "" |
| `BARADB_RAFT_TLS_VERIFY_PEER` | `raftTlsVerifyPeer: bool` | false |
- `RaftNetwork` gains `tls: TLSContext` (nil = plaintext, current
behavior). `connectToPeer` wraps with `wrapClient`; the accept loop in
`run` wraps with `wrapServer` before `receiveLoop` (same pattern as
`core/server.nim:876-889`). `verifyPeer` + CA file gives mutual auth.
- Fail closed: `raftEnabled and raftTlsEnabled` with missing cert/key →
refuse to start (raise at startup, like the JWT check in
`newServerWithRegistry`, `core/server.nim:58-63`).
- Leader SQL forwarding (`forwardQueryToLeader`) wraps its socket with the
**client** TLS context when `BARADB_TLS_ENABLED` is on (it dials the
client wire port, which is already TLS-capable).
- E2E: TLS variant cluster test — generate self-signed certs with
`generateSelfSignedCert` (`protocol/ssl.nim:79`), boot a 3-node cluster
with raft TLS on, assert election + one replicated write; assert a
plaintext peer cannot join (its frames are rejected and the cluster
elects among the TLS nodes).
## Rollout / phases
| Phase | Deliverable | Risk |
|-------|-------------|------|
| P1 | D1 failover-load e2e | none (test-only) |
| P2 | D2 CI e2e job | none |
| P3 | D4 raft TLS | medium (transport) |
| P4 | D3 InstallSnapshot + compaction unpin | high (protocol + apply) |
P3 before P4 so snapshot transfer ships already-encryptable. Each phase is
independently mergeable; P4 is the v1.3.0 gate for calling raft
"supported".
## Acceptance (v1.3.0)
- Failover-under-load e2e green locally and in CI, in the mandatory gate.
- Killed-node-returns and wiped-node-join scenarios converge without manual
intervention (covered by new e2e phases).
- Leader log length stays bounded while a peer is down > `PEER_STALE_MS`
(assert via `baradb_raft_log_entries` metric in e2e).
- Raft port TLS: cluster runs fully over TLS; plaintext injection fails.
- `docs/en/distributed.md` + `known-limitations.md` updated: raft no longer
"experimental" for the covered scope; remaining non-goals listed.
+76
View File
@@ -26,6 +26,8 @@ import std/strutils
import std/times
import std/algorithm
import std/json
import std/asyncdispatch
import std/threadpool
import barabadb/storage/lsm
type
@@ -260,6 +262,80 @@ proc backupDataDir*(dataDir: string, output: string, excludes: seq[string] = @[]
echo " Source: ", dataDir
return true
proc tarDataDir*(dataDir: string, output: string, excludes: seq[string] = @[]): bool =
## Create an UNCOMPRESSED tar of `dataDir` at `output` (no gzip). The raft
## snapshot sender runs this under the storage gate for a consistent file
## capture, then compresses off the event loop via gzipFileAsync.
if not dirExists(dataDir):
echo "ERROR: Data directory not found: ", dataDir
return false
let parent = parentDir(dataDir)
let name = lastPathPart(dataDir)
var excludeArgs = ""
for pattern in excludes:
excludeArgs.add(" --exclude=" & quoteShell(pattern))
let cmd = "tar -cf " & quoteShell(output) & excludeArgs &
" -C " & quoteShell(parent) & " " & quoteShell(name)
let (outputStr, exitCode) = execCmdEx(cmd)
if exitCode != 0:
echo "ERROR: tar command failed with exit code ", exitCode
if outputStr.len > 0:
echo outputStr
return false
return true
proc gzipFile*(input: string, output: string,
compression: int = DEFAULT_COMPRESSION): bool =
## gzip a single file `input` -> `output`. Pure CPU over an already-captured
## file: no shared storage state, so it is safe to run on a worker thread
## outside the storage gate and off the raft event loop.
if not fileExists(input):
echo "ERROR: File not found: ", input
return false
let cmd = "gzip -" & $compression & " -c " & quoteShell(input) &
" > " & quoteShell(output)
let (outputStr, exitCode) = execCmdEx("bash -c " & quoteShell(cmd))
if exitCode != 0:
echo "ERROR: gzip command failed with exit code ", exitCode
if outputStr.len > 0:
echo outputStr
return false
return true
proc gunzipFile*(input: string, output: string): bool =
## Decompress a gzip file `input` -> `output`. Inverse of gzipFile.
if not fileExists(input):
echo "ERROR: File not found: ", input
return false
let cmd = "gzip -dc " & quoteShell(input) & " > " & quoteShell(output)
let (outputStr, exitCode) = execCmdEx("bash -c " & quoteShell(cmd))
if exitCode != 0:
echo "ERROR: gunzip command failed with exit code ", exitCode
if outputStr.len > 0:
echo outputStr
return false
return true
proc gzipFileWorker(input: string, output: string, compression: int): bool {.gcsafe.} =
## Thread entry point: touches only its own (copied) string args + execCmdEx,
## so it is safe to run off the main/event-loop thread under ARC/ORC.
gzipFile(input, output, compression)
proc gzipFileAsync*(input: string, output: string,
compression: int = DEFAULT_COMPRESSION): Future[bool] {.async.} =
## Run gzipFile on a threadpool worker and await completion WITHOUT blocking
## the calling async event loop — heartbeats/election timers keep firing
## during the CPU-heavy compression. Polls the FlowVar via sleepAsync so the
## dispatcher stays responsive instead of stalling on a blocking join.
var fv = spawn gzipFileWorker(input, output, compression)
while not fv.isReady:
await sleepAsync(20)
result = ^fv
proc restoreDataDir*(input: string, dataDir: string, verbose: bool = false, dryRun: bool = false): bool =
## Restore from a tar.gz backup.
## When dryRun is true, only prints what would be done.
+46
View File
@@ -14,6 +14,8 @@ type
tlsEnabled*: bool
certFile*: string
keyFile*: string
tlsCaFile*: string
tlsVerifyPeer*: bool
idleTimeoutMs*: int
queryTimeoutMs*: int
slowQueryThresholdMs*: int
@@ -44,6 +46,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"
@@ -60,6 +69,8 @@ proc defaultConfig*(): BaraConfig =
tlsEnabled: false,
certFile: "",
keyFile: "",
tlsCaFile: "",
tlsVerifyPeer: false,
idleTimeoutMs: 300_000,
queryTimeoutMs: 30_000,
slowQueryThresholdMs: 1_000,
@@ -86,6 +97,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,
)
# ----------------------------------------------------------------------
@@ -120,6 +138,8 @@ proc loadConfigFromJson*(path: string, cfg: var BaraConfig) =
if s.hasKey("enabled"): cfg.tlsEnabled = s["enabled"].getBool()
if s.hasKey("cert_file"): cfg.certFile = s["cert_file"].getStr()
if s.hasKey("key_file"): cfg.keyFile = s["key_file"].getStr()
if s.hasKey("ca_file"): cfg.tlsCaFile = s["ca_file"].getStr()
if s.hasKey("verify_peer"): cfg.tlsVerifyPeer = s["verify_peer"].getBool()
if j.hasKey("auth"):
let s = j["auth"]
if s.hasKey("enabled"): cfg.authEnabled = s["enabled"].getBool()
@@ -163,6 +183,13 @@ proc loadConfigFromEnv*(cfg: var BaraConfig) =
cfg.tlsEnabled = parseEnvBool(getEnv("BARADB_TLS_ENABLED", ""), cfg.tlsEnabled)
cfg.certFile = getEnv("BARADB_CERT_FILE", cfg.certFile)
cfg.keyFile = getEnv("BARADB_KEY_FILE", cfg.keyFile)
cfg.tlsCaFile = getEnv("BARADB_TLS_CA_FILE", cfg.tlsCaFile)
let tlsVerifyEnv = getEnv("BARADB_TLS_VERIFY_PEER", "")
if tlsVerifyEnv.len > 0:
cfg.tlsVerifyPeer = parseEnvBool(tlsVerifyEnv, cfg.tlsVerifyPeer)
elif cfg.tlsCaFile.len > 0:
# CA present and verify flag unset → verify (fail-closed for MITM).
cfg.tlsVerifyPeer = true
cfg.idleTimeoutMs = parseEnvInt(getEnv("BARADB_IDLE_TIMEOUT_MS", ""), cfg.idleTimeoutMs)
cfg.queryTimeoutMs = parseEnvInt(getEnv("BARADB_QUERY_TIMEOUT_MS", ""), cfg.queryTimeoutMs)
cfg.slowQueryThresholdMs = parseEnvInt(getEnv("BARADB_SLOW_QUERY_THRESHOLD_MS", ""), cfg.slowQueryThresholdMs)
@@ -213,6 +240,17 @@ 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)
let raftVerifyEnv = getEnv("BARADB_RAFT_TLS_VERIFY_PEER", "")
if raftVerifyEnv.len > 0:
cfg.raftTlsVerifyPeer = parseEnvBool(raftVerifyEnv, cfg.raftTlsVerifyPeer)
elif cfg.raftTlsCaFile.len > 0:
cfg.raftTlsVerifyPeer = true
# 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", "")
@@ -271,6 +309,14 @@ proc validateProductionConfig*(cfg: BaraConfig) =
if cfg.jwtSecret in ["change-me", "change-me-to-random-32-char-string", "secret", "default"]:
raise newException(ValueError,
"Production refuses insecure JWT secret placeholder. Set a strong BARADB_JWT_SECRET.")
if cfg.tlsEnabled:
if not cfg.tlsVerifyPeer or cfg.tlsCaFile.len == 0:
raise newException(ValueError,
"Production TLS requires peer verification. Set BARADB_TLS_VERIFY_PEER=true and BARADB_TLS_CA_FILE.")
if cfg.raftTlsEnabled:
if not cfg.raftTlsVerifyPeer or cfg.raftTlsCaFile.len == 0:
raise newException(ValueError,
"Production raft TLS requires peer verification. Set BARADB_RAFT_TLS_VERIFY_PEER=true and BARADB_RAFT_TLS_CA_FILE.")
proc getEffectiveJwtSecret*(cfg: BaraConfig): string =
if cfg.jwtSecret.len > 0:
+20 -8
View File
@@ -5,6 +5,8 @@ import std/monotimes
import std/net
import std/strutils
import std/nativesockets
when defined(posix):
import std/posix
type
DistTxnState* = enum
@@ -89,6 +91,13 @@ proc connectWithTimeout(sock: Socket, host: string, port: Port, timeoutMs: int):
var fds = @[sock.getFd]
if selectWrite(fds, timeoutMs) <= 0:
return false
when defined(posix):
# selectWrite reports a refused connect as writable; SO_ERROR tells the truth.
var err: cint = 0
var errLen = SockLen(sizeof(err))
discard posix.getsockopt(sock.getFd, 1'i32, 4'i32, addr err, addr errLen)
if err != 0:
return false
sock.getFd.setBlocking(true)
return true
@@ -96,15 +105,18 @@ proc sendDistTxnRpc(host: string, port: int, txnId: uint64, action: string, time
## Send 2PC RPC to participant node via TCP text protocol.
## Protocol: "DISTTXN <txnId> <action>\n" where action = PREPARE|COMMIT|ROLLBACK
## Response: "OK\n" or "ERR <msg>\n"
var sock = newSocket()
defer: sock.close()
if not connectWithTimeout(sock, host, Port(port), timeoutMs):
try:
var sock = newSocket()
defer: sock.close()
if not connectWithTimeout(sock, host, Port(port), timeoutMs):
return false
let msg = "DISTTXN " & $txnId & " " & action & "\n"
sock.send(msg)
var response = ""
sock.readLine(response)
return response.strip() == "OK"
except CatchableError:
return false
let msg = "DISTTXN " & $txnId & " " & action & "\n"
sock.send(msg)
var response = ""
sock.readLine(response)
return response.strip() == "OK"
type
ParticipantInfo = object
+13 -4
View File
@@ -19,6 +19,7 @@ import ../storage/gate
import ../core/mvcc
import ../protocol/wire
import ../core/websocket
import ../query/exec/rls
import jwt as jwtlib
import ../protocol/auth
import ../protocol/ratelimit
@@ -31,7 +32,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
@@ -55,6 +56,14 @@ proc newHttpServerWithRegistry*(config: BaraConfig, registry: DatabaseRegistry):
ctx.txnManager = newTxnManager()
let secret = config.getEffectiveJwtSecret()
let ws = newWsServer(config, secret)
block:
let wsRef {.cursor.} = ws
let ctxRef {.cursor.} = ctx
wsRef.canSubscribe = proc(username, table: string): bool {.gcsafe.} =
if username.len == 0:
return false
{.cast(gcsafe).}:
return hasPrivilegeFor(ctxRef, username, table, "SELECT")
let rl = newRateLimiter(rlaTokenBucket, config.rateLimitGlobal, config.rateLimitPerClient)
ctx.onChange = proc(ev: ChangeEvent) =
let msg = $ev.kind & " " & ev.table
@@ -262,7 +271,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 +377,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 +915,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)
+5 -1
View File
@@ -178,12 +178,16 @@ proc write*(tm: TxnManager, txn: Transaction, key: string, value: seq[byte]): bo
return false
# Timeout-based deadlock detection: abort stale transactions
# Collect then delete — never mutate activeTxns while iterating it.
let now = getMonoTime().ticks()
var staleIds: seq[TxnId] = @[]
for otherId, otherTxn in tm.activeTxns:
if otherId != txn.id and otherTxn.state == tsActive:
if now - otherTxn.startTime > tm.txnTimeoutMs * 1_000_000:
otherTxn.state = tsAborted
tm.activeTxns.del(otherId)
staleIds.add(otherId)
for id in staleIds:
tm.activeTxns.del(id)
# Check for write-write conflict against other active transactions' write sets
for otherId, otherTxn in tm.activeTxns:
+336 -5
View File
@@ -12,6 +12,8 @@ import std/endians
import std/os
import logging
import ../protocol/wire
import ../protocol/ssl
import backup
type
RaftState* = enum
@@ -66,6 +68,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 +87,35 @@ 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 writes an UNCOMPRESSED tar
## of the data dir to destPath (wired in baradadb.nim via tarDataDir, under
## the storage gate); sendSnapshot then compresses it off the event loop
## (gzipFileAsync) and streams the resulting .tar.gz to the follower.
## 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 +132,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 +231,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 +241,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 +291,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 +367,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 +389,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 +484,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,9 +645,15 @@ 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)
# Update commit index using strict majority — the same form as the election
# check in handleVoteReply. Cluster size N = peers.len + 1; a strict
# majority is N div 2 + 1. The previous (N + 1) div 2 under-counted for
# even-sized clusters (e.g. N=4 committed at 2/4, a minority).
let majority = (node.peers.len + 1) div 2 + 1 # strict majority of cluster
var newCommitIdx = node.commitIndex
# Walk logical indices high→low via findLogEntryByIndex (log may be compacted).
@@ -527,8 +680,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 +885,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 +921,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 +947,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 +975,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 +1005,80 @@ 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 the tar on the loop
## under the storage gate (consistent capture); the CPU-heavy gzip then runs
## on a worker thread via gzipFileAsync, awaited here, so heartbeats and the
## election timer keep firing during compression instead of stalling.
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
# buildSnapshot writes an uncompressed tar (under the storage gate, on this
# loop); gzipFileAsync then compresses it on a worker thread off the loop.
# The follower still receives a normal .tar.gz byte stream.
let tarPath = node.dataDir / ("snap_out_" & $snapId & ".tar")
let destPath = tarPath & ".gz"
defer:
if fileExists(tarPath):
removeFile(tarPath)
if fileExists(destPath):
removeFile(destPath)
if not node.buildSnapshot(tarPath):
warn("sendSnapshot: buildSnapshot failed; aborting snapshot send to " & peerId)
return
if not await gzipFileAsync(tarPath, destPath):
warn("sendSnapshot: snapshot compression failed; aborting 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 +1096,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 +1148,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 +1172,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 +1190,13 @@ proc run*(net: RaftNetwork) {.async.} =
while net.running:
try:
let client = await net.socket.accept()
if net.tls != nil:
try:
net.tls.wrapServer(client)
except CatchableError:
# Handshake failed (e.g. plaintext dial) — drop, no protocol effect.
client.close()
continue
asyncCheck net.receiveLoop(client)
except CatchableError:
break
+26
View File
@@ -203,6 +203,32 @@ proc getDatabaseInfo*(reg: DatabaseRegistry, name: string): DatabaseInfo =
return reg.databases[name]
return nil
proc reopenDatabase*(reg: DatabaseRegistry, name: string): bool =
## Reopen a database from its on-disk directory, swapping the new LSMTree
## and ctx into the EXISTING DatabaseInfo slot so captured references (e.g.
## the raft applyCommand closure) see the new state.
## Minimal API added for raft InstallSnapshot restore: the caller must have
## closed info.db first (snapshot restore closes it before swapping the data
## directory); this proc does not close.
## Returns false if the database is unknown or the reopen fails.
acquire(reg.lock)
let info = if name in reg.databases: reg.databases[name] else: nil
release(reg.lock)
if info == nil:
return false
try:
let dbDir = reg.dataRoot / name
let db = openLsmForRegistry(reg, dbDir)
let ctx = reg.ctxFactory(db, reg)
info.db = db
info.ctx = ctx
return true
except CatchableError as e:
# echo instead of logging: callers include gcsafe raft callbacks, and
# core/logging's info/warn are not gcsafe.
echo "[registry] Error reopening database '", name, "': ", e.msg
return false
proc closeAll*(reg: DatabaseRegistry) =
acquire(reg.lock)
defer: release(reg.lock)
+46 -3
View File
@@ -96,6 +96,41 @@ proc connectWithTimeout(sock: Socket, host: string, port: Port, timeoutMs: int):
sock.getFd.setBlocking(true)
return err == 0
type
RepOp* = enum
ropPut
ropDelete
ropInvalid
proc encodeRepPayload*(deleted: bool, key: string, value: seq[byte]): seq[byte] =
## Tagged legacy-REP payload. A leading op tag makes put/delete explicit so
## an empty put value (PK-only rows store an empty LSM value) is never
## mistaken for a delete on the receiver:
## put -> 'P' & key & "\x00" & value (value may be empty)
## delete -> 'D' & key
## Mirrors the raft convention (explicit "put"/"delete" commands).
if deleted:
cast[seq[byte]]("D" & key)
else:
cast[seq[byte]]("P" & key & "\x00" & cast[string](value))
proc decodeRepPayload*(data: seq[byte]): tuple[op: RepOp, key: string, value: seq[byte]] =
## Inverse of encodeRepPayload. Returns ropInvalid for empty or untagged
## payloads rather than guessing the operation from the value length.
if data.len == 0:
return (ropInvalid, "", @[])
case char(data[0])
of 'P':
let body = data[1 ..< data.len]
let nullPos = find(body, byte(0))
if nullPos < 0:
return (ropInvalid, "", @[])
return (ropPut, cast[string](body[0 ..< nullPos]), body[nullPos + 1 ..< body.len])
of 'D':
return (ropDelete, cast[string](data[1 ..< data.len]), @[])
else:
return (ropInvalid, "", @[])
proc shipToReplica(replica: Replica, lsn: uint64, data: seq[byte]): bool =
## Send replication data to a replica via TCP.
## Protocol: "REP <lsn> <dataLen>\n<data>"
@@ -178,10 +213,18 @@ proc writeLsn*(rm: ReplicationManager, data: seq[byte]): uint64 =
rm.pendingAcks[lsn].excl(id)
if rm.pendingAcks[lsn].len == 0:
rm.pendingAcks.del(lsn)
# Semi-sync requires at least syncReplicaCount acks when replicas are
# connected. With zero connected peers (nothing to ship) the write is
# local-only — same as sync mode with an empty replica set.
if rm.syncReplicaCount > 0 and replicasToShip.len > 0 and
ackCount < rm.syncReplicaCount:
# Drop the LSN from pendingAcks — write is not durable
rm.pendingAcks.del(lsn)
release(rm.lock)
echo "[ERROR] Semi-sync replication failed: only ", ackCount, "/",
rm.syncReplicaCount, " replicas acked for LSN ", lsn
return 0
release(rm.lock)
if replicasToShip.len > 0 and ackCount == 0 and rm.syncReplicaCount > 0:
when defined(debug):
echo "Replication semi-sync: no replicas acked for LSN ", lsn
return lsn
proc ackLsn*(rm: ReplicationManager, replicaId: string, lsn: uint64) =
+84 -27
View File
@@ -1,6 +1,7 @@
## BaraDB Server — async TCP server with wire protocol
import std/asyncdispatch
import std/asyncnet
import std/os
import std/strutils
import std/sequtils
import std/tables
@@ -22,6 +23,7 @@ import ../query/parser
import ../query/ast
import ../query/executor
import ../query/exec/params
import ../query/exec/dml
import ../storage/lsm
import ../storage/gate
import ../core/mvcc
@@ -49,6 +51,10 @@ type
clusterMembership*: ClusterMembership
gossipProtocol*: GossipProtocol
tls*: TLSContext
## Dedicated client-role TLS context for follower→leader forwarding.
## Must not reuse `tls` with verifyPeer — OpenSSL contexts are role-agnostic
## and enabling verify on the server context would break inbound handshakes.
tlsClient*: TLSContext
rateLimiter*: RateLimiter
activeConnections*: int
activeConnectionsLock*: Lock
@@ -65,9 +71,22 @@ proc newServerWithRegistry*(config: BaraConfig, registry: DatabaseRegistry): Ser
let ctx = cast[ExecutionContext](cast[pointer](dbInfo.ctx))
ctx.txnManager = newTxnManager()
var tls: TLSContext = nil
var tlsClient: TLSContext = nil
if config.tlsEnabled and config.certFile.len > 0 and config.keyFile.len > 0:
let tlsConfig = newTLSConfig(config.certFile, config.keyFile)
tls = newTLSContext(tlsConfig)
if config.tlsVerifyPeer and config.tlsCaFile.len == 0:
raise newException(ValueError,
"tlsVerifyPeer is true but CA file is missing. Set BARADB_TLS_CA_FILE")
if config.tlsVerifyPeer and config.tlsCaFile.len > 0 and
not fileExists(config.tlsCaFile):
raise newException(ValueError,
"BARADB_TLS_VERIFY_PEER=true but CA file missing: " & config.tlsCaFile)
tls = newTLSContext(newTLSConfig(config.certFile, config.keyFile))
if config.tlsVerifyPeer:
tlsClient = newTLSContext(newTLSConfig(
config.certFile, config.keyFile,
caFile = config.tlsCaFile, verifyPeer = true))
else:
tlsClient = tls
# Initialize sharding / gossip. Server fields own the refs; locals used inside
# callback closures are {.cursor.} so ARC does not form uncollectable cycles
@@ -84,6 +103,7 @@ proc newServerWithRegistry*(config: BaraConfig, registry: DatabaseRegistry): Ser
clusterMembership: nil,
gossipProtocol: newGossipProtocol(localId, config.address, config.port, gossipPort = gossipPort),
tls: tls,
tlsClient: tlsClient,
rateLimiter: rl)
result.clusterMembership = newClusterMembership(result.shardRouter, localId)
initLock(result.activeConnectionsLock)
@@ -165,6 +185,11 @@ proc parseHeader(data: string): (bool, MessageHeader) =
return (false, MessageHeader())
let kind = cast[MsgKind](rawKind)
let length = readUint32BE(data, 4)
# Reject oversized messages before any buffer allocation: recvExactWithTimeout
# pre-allocates `length` bytes before the auth check, so an unbounded uint32
# (up to ~4 GiB) is a pre-auth memory-exhaustion DoS. Cap at the wire max.
if length > uint32(MaxWireStringLen):
return (false, MessageHeader())
let requestId = readUint32BE(data, 8)
return (true, MessageHeader(kind: kind, length: length, requestId: requestId))
@@ -216,16 +241,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 a *client-role* context (Server.tlsClient). When the wire port
## serves TLS, the leader's does too, so the forwarding dial must complete a
## client handshake. Peer verification is honoured when that context was
## built with verifyPeer (BARADB_TLS_VERIFY_PEER + BARADB_TLS_CA_FILE).
## Do NOT pass Server.tls (the inbound/server context) with verifyPeer
## flipped on — OpenSSL contexts are role-agnostic in Nim's stdlib.
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 +343,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 +356,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 +385,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,12 +437,13 @@ 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
discard replication.writeLsn(data)
for pair in res.keyValuePairs:
# Legacy REP wire format: explicit 'P'/'D' op tag (see
# encodeRepPayload). The tag — not an empty value — distinguishes
# a put from a delete, so PK-only rows (empty value) replicate as
# puts instead of vanishing as deletes.
discard replication.writeLsn(
encodeRepPayload(pair.deleted, pair.key, pair.value))
qr = QueryResult(affectedRows: res.affectedRows, rowCount: res.rows.len)
qr.columns = res.columns
@@ -446,7 +487,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
@@ -637,14 +678,25 @@ proc handleClient(server: Server, client: AsyncSocket, clientId: int) {.async.}
if chunk.len == 0: break
data.add(chunk)
if data.len > 0:
let nullPos = data.find('\0')
if nullPos >= 0:
let key = data[0..<nullPos]
let value = data[nullPos+1..^1]
if value.len > 0:
server.db.put(key, stringToBytes(value))
else:
server.db.delete(key)
# Op tag — not value length — decides put vs delete, so a PK-only
# put (empty value) is applied as a put and the row survives.
let decoded = decodeRepPayload(cast[seq[byte]](data))
case decoded.op
of ropPut, ropDelete:
# Apply through applyReplicatedPut/Delete (not raw db.put/delete)
# so secondary B-tree/FTS/HNSW/graph indexes stay consistent on
# the replica — the same path raft uses. server.ctx is the
# canonical default ctx whose index structures the per-connection
# query clones share. Under the storage gate: those structures
# are shared with hunos HTTP workers and are only safe to mutate
# under it.
withStorageGate:
if decoded.op == ropPut:
applyReplicatedPut(server.ctx, decoded.key, decoded.value)
else:
applyReplicatedDelete(server.ctx, decoded.key)
of ropInvalid:
discard
await client.send("ACK " & $lsn & "\n")
else:
await client.send("ERR\n")
@@ -652,6 +704,9 @@ proc handleClient(server: Server, client: AsyncSocket, clientId: int) {.async.}
# Detect shard migration data (starts with "MIGRATE ")
if headerData.len >= 8 and headerData[0..7] == "MIGRATE ":
if not authenticated:
await client.send("ERR auth required\n")
continue
var rest = headerData[8..^1]
while '\n' notin rest:
let more = await client.recvWithTimeout(1024, idleTimeout)
@@ -769,7 +824,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.tlsClient)
let durationMs = int((getMonoTime().ticks() - startTicks) div 1_000_000)
if durationMs >= slowThreshold:
@@ -792,7 +848,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.tlsClient)
let durationMs = int((getMonoTime().ticks() - startTicks) div 1_000_000)
if durationMs >= slowThreshold:
+91 -38
View File
@@ -15,18 +15,25 @@ else:
import config
import jwt as jwtlib
const
## RFC 6455: reject oversized frames/messages to bound memory.
MaxWsFrameBytes* = 1 * 1024 * 1024
MaxWsMessageBytes* = 4 * 1024 * 1024
MaxWsControlPayload* = 125
type
WsFrame = object
fin: bool
opcode: uint8
masked: bool
payloadLen: uint64
maskKey: array[4, byte]
payload: string
WsFrame* = object
fin*: bool
opcode*: uint8
masked*: bool
payloadLen*: uint64
maskKey*: array[4, byte]
payload*: string
WsClient* = ref object
socket: AsyncSocket
id: int
username: string
subscriptions: HashSet[string]
WsServer* = ref object
@@ -35,6 +42,8 @@ type
running: bool
config*: BaraConfig
secretKey*: string
## Table-level read authorization for SUBSCRIBE. Nil + authEnabled → deny.
canSubscribe*: proc(username, table: string): bool {.closure, gcsafe.}
onInsert*: proc (table, key, value: string) {.closure.}
onDelete*: proc (table, key: string) {.closure.}
@@ -46,20 +55,19 @@ proc newWsServer*(cfg: BaraConfig = defaultConfig(), secret: string = ""): WsSer
# WebSocket frame encoding/decoding (RFC 6455)
# ----------------------------------------------------------------------
proc encodeFrame(opcode: uint8, payload: string): string =
proc encodeFrame*(opcode: uint8, payload: string, masked = false,
maskKey: array[4, byte] = [0'u8, 0, 0, 0]): string =
result = ""
let isMasked = false
var b0 = 0x80'u8 or opcode
result.add(char(b0))
var b1 = 0'u8
if not isMasked:
if payload.len < 126:
b1 = uint8(payload.len)
elif payload.len <= 65535:
b1 = 126
else:
b1 = 127
var b1 = if masked: 0x80'u8 else: 0'u8
if payload.len < 126:
b1 = b1 or uint8(payload.len)
elif payload.len <= 65535:
b1 = b1 or 126
else:
b1 = b1 or 127
result.add(char(b1))
if payload.len >= 126 and payload.len <= 65535:
@@ -71,9 +79,17 @@ proc encodeFrame(opcode: uint8, payload: string): string =
for i in countdown(7, 0):
result.add(char((len64 shr (i * 8)) and 0xFF))
result.add(payload)
if masked:
for i in 0..3:
result.add(char(maskKey[i]))
for i, c in payload:
result.add(char(byte(c) xor maskKey[i mod 4]))
else:
result.add(payload)
proc decodeFrame(data: string): (WsFrame, int) =
proc decodeFrame*(data: string): (WsFrame, int) =
## Returns (frame, consumed). consumed == 0 → need more bytes;
## consumed < 0 → protocol error (close the connection).
if data.len < 2:
return (WsFrame(), 0)
@@ -84,6 +100,10 @@ proc decodeFrame(data: string): (WsFrame, int) =
frame.opcode = b0 and 0x0F
frame.masked = (b1 and 0x80) != 0
# RFC 6455 §5.1 — client-to-server frames MUST be masked.
if not frame.masked:
return (WsFrame(), -1)
var len = uint64(b1 and 0x7F)
var offset = 2
@@ -98,23 +118,24 @@ proc decodeFrame(data: string): (WsFrame, int) =
len = (len shl 8) or uint64(uint8(data[2 + i]))
offset = 10
if frame.masked:
if data.len < offset + 4: return (WsFrame(), 0)
for i in 0..3:
frame.maskKey[i] = byte(data[offset + i])
offset += 4
let isControl = frame.opcode == 0x8 or frame.opcode == 0x9 or frame.opcode == 0xA
if isControl and (not frame.fin or len > uint64(MaxWsControlPayload)):
return (WsFrame(), -1)
if len > uint64(MaxWsFrameBytes):
return (WsFrame(), -1)
if data.len < offset + 4: return (WsFrame(), 0)
for i in 0..3:
frame.maskKey[i] = byte(data[offset + i])
offset += 4
if uint64(data.len) < uint64(offset) + len:
return (Wsframe(), 0)
return (WsFrame(), 0)
if len > uint64(high(int) - 1):
return (Wsframe(), 0)
let plen = int(len)
if frame.masked:
for i in 0..<plen:
frame.payload.add(char(byte(data[offset + i]) xor frame.maskKey[i mod 4]))
else:
frame.payload = data[offset..offset + plen - 1]
frame.payloadLen = len
for i in 0..<plen:
frame.payload.add(char(byte(data[offset + i]) xor frame.maskKey[i mod 4]))
return (frame, offset + plen)
@@ -179,6 +200,14 @@ proc computeAcceptKey(key: string): string =
# Subscription management
# ----------------------------------------------------------------------
proc validSubscribeTable(table: string): bool =
if table.len == 0 or table.len > 128:
return false
for c in table:
if c notin {'a'..'z', 'A'..'Z', '0'..'9', '_'}:
return false
return true
proc subscribe*(client: WsClient, table: string) =
client.subscriptions.incl(table)
@@ -201,9 +230,11 @@ proc broadcastToTable*(server: WsServer, table: string, msg: string) {.async.} =
# WebSocket client handler
# ----------------------------------------------------------------------
proc handleWsClient(server: WsServer, client: AsyncSocket, id: int) {.async.} =
proc handleWsClient(server: WsServer, client: AsyncSocket, id: int,
username: string = "") {.async.} =
echo "WebSocket client ", id, " connected"
var wsClient = WsClient(socket: client, id: id, subscriptions: initHashSet[string]())
var wsClient = WsClient(socket: client, id: id, username: username,
subscriptions: initHashSet[string]())
server.clients[id] = wsClient
var buf = ""
@@ -212,12 +243,22 @@ proc handleWsClient(server: WsServer, client: AsyncSocket, id: int) {.async.} =
let chunk = await client.recv(4096)
if chunk.len == 0:
break
if buf.len + chunk.len > MaxWsMessageBytes:
let closeF = encodeFrame(0x8, "")
try: await client.send(closeF) except CatchableError: discard
break
buf.add(chunk)
while buf.len >= 2:
let (frame, consumed) = decodeFrame(buf)
if consumed == 0:
break
if consumed < 0:
let closeF = encodeFrame(0x8, "")
try: await client.send(closeF) except CatchableError: discard
client.close()
server.clients.del(id)
return
case frame.opcode
of 0x8: # close
@@ -231,9 +272,18 @@ proc handleWsClient(server: WsServer, client: AsyncSocket, id: int) {.async.} =
let msg = frame.payload
if msg.startsWith("SUBSCRIBE "):
let table = msg[10..^1].strip()
wsClient.subscribe(table)
let ack = encodeFrame(0x1, "OK subscribed to " & table)
await client.send(ack)
var allowed = validSubscribeTable(table)
if allowed and server.config.authEnabled:
if username.len == 0 or server.canSubscribe == nil or
not server.canSubscribe(username, table):
allowed = false
if allowed:
wsClient.subscribe(table)
let ack = encodeFrame(0x1, "OK subscribed to " & table)
await client.send(ack)
else:
let nack = encodeFrame(0x1, "ERR subscribe denied for " & table)
await client.send(nack)
elif msg.startsWith("UNSUBSCRIBE "):
let table = msg[12..^1].strip()
wsClient.unsubscribe(table)
@@ -285,6 +335,7 @@ proc handleConnection(server: WsServer, client: AsyncSocket) {.async.} =
return
# Auth check
var username = ""
if server.config.authEnabled:
let authHeader = headers.getOrDefault("authorization", "")
if authHeader.len == 0 or not authHeader.startsWith("Bearer "):
@@ -305,6 +356,8 @@ proc handleConnection(server: WsServer, client: AsyncSocket) {.async.} =
await client.send("HTTP/1.1 401 Unauthorized\r\n\r\n")
client.close()
return
if "sub" in token.claims:
username = token.claims["sub"].node.str
except CatchableError:
await client.send("HTTP/1.1 401 Unauthorized\r\n\r\n")
client.close()
@@ -321,7 +374,7 @@ proc handleConnection(server: WsServer, client: AsyncSocket) {.async.} =
await client.send(response)
inc server.nextId
asyncCheck server.handleWsClient(client, server.nextId)
asyncCheck server.handleWsClient(client, server.nextId, username)
proc setTcpNoDelay(sock: AsyncSocket) =
## Enable TCP_NODELAY using the correct protocol level (IPPROTO_TCP).
+13 -2
View File
@@ -221,10 +221,17 @@ proc registerScramUser*(am: AuthManager, username, password: string,
let cred = createScramCredential(password, iterationCount = iterationCount)
am.scramUsers[username] = cred
proc dummyScramStartWork() =
## Match known-user startScram work (urandom nonce + base64) so unknown
## users cannot be enumerated by timing.
discard generateNonce()
discard encode("0123456789abcdef0123456789abcdef")
proc startScram*(am: AuthManager, clientFirstMessage: string): string =
## Start SCRAM authentication. Returns server-first-message.
let (_, username, clientNonce) = parseClientFirst(clientFirstMessage)
if username notin am.scramUsers:
let (gs2, username, clientNonce) = parseClientFirst(clientFirstMessage)
if username notin am.scramUsers or gs2 notin ["n", "y"]:
dummyScramStartWork()
raise newException(ValueError, "Authentication failed")
let cred = am.scramUsers[username]
@@ -239,6 +246,7 @@ proc startScram*(am: AuthManager, clientFirstMessage: string): string =
var state = ScramServerState(
username: username,
gs2Flag: gs2,
clientFirstMessageBare: clientFirstMessageBare,
serverFirstMessage: serverFirst,
authMessage: authMessage,
@@ -264,6 +272,9 @@ proc finishScram*(am: AuthManager, clientFinalMessage: string): (bool, string) =
var state = am.scramSessions[nonce]
am.scramSessions.del(nonce)
if stripB64Padding(cbind) != expectedChannelBinding(state.gs2Flag):
return (false, "e=channel-bindings-dont-match")
# Update authMessage with client-final-message-without-proof
let clientFinalWithoutProof = "c=" & cbind & ",r=" & nonce
state.authMessage = state.authMessage & "," & clientFinalWithoutProof
Binary file not shown.
+14
View File
@@ -19,6 +19,7 @@ type
ScramServerState* = object
username*: string
gs2Flag*: string
clientFirstMessageBare*: string
serverFirstMessage*: string
authMessage*: string
@@ -189,6 +190,19 @@ proc createScramCredential*(password: string, salt: string = "",
# SCRAM message parsing / building
# ---------------------------------------------------------------------------
proc expectedChannelBinding*(gs2Flag: string): string =
## RFC 5802 cbind-input with no cbind-data is the gs2-header: flag + "," + authzid + ",".
## Authzid is unsupported, so the header is `n,,` or `y,,`.
let header = gs2Flag & ",,"
result = encode(header)
while result.endsWith("="):
result.setLen(result.len - 1)
proc stripB64Padding*(s: string): string =
result = s
while result.endsWith("="):
result.setLen(result.len - 1)
proc parseClientFirst*(msg: string): (string, string, string) =
## Parse client-first-message: gs2-header,username,nonce
## Returns: (gs2_header, username, nonce)
+9 -1
View File
@@ -29,10 +29,14 @@ proc newTLSConfig*(certFile: string, keyFile: string, caFile: string = "",
proc newTLSContext*(config: TLSConfig): TLSContext =
result = TLSContext(config: config)
if fileExists(config.certFile) and fileExists(config.keyFile):
# caFile is only honored by newContext when verifyPeer is true
# (verifyMode != CVerifyNone); a missing CA file then raises IOError,
# which is the desired fail-closed behavior.
result.sslCtx = newContext(
certFile = config.certFile,
keyFile = config.keyFile,
verifyMode = if config.verifyPeer: CVerifyPeer else: CVerifyNone,
caFile = config.caFile,
)
else:
raise newException(IOError, "TLS certificate or key file not found: " &
@@ -40,7 +44,11 @@ proc newTLSContext*(config: TLSConfig): TLSContext =
proc wrapClient*(tls: TLSContext, socket: AsyncSocket) {.inline.} =
if tls.sslCtx != nil:
tls.sslCtx.wrapSocket(socket)
# wrapConnectedSocket (asyncnet overload) sets connect state; the
# handshake itself is driven lazily by the first send/recv. Plain
# wrapSocket leaves the SSL handle in SSL_ST_BEFORE and the first
# SSL_write fails with "uninitialized".
tls.sslCtx.wrapConnectedSocket(socket, handshakeAsClient)
proc wrapServer*(tls: TLSContext, socket: AsyncSocket) {.inline.} =
if tls.sslCtx != nil:
+6 -6
View File
@@ -45,7 +45,7 @@ proc violatesUniqueIndex*(ctx: ExecutionContext, table: string, fields: seq[stri
return ""
proc execInsert*(ctx: ExecutionContext, table: string, fields: seq[string], values: seq[seq[string]],
kvPairs: var seq[(string, seq[byte])]): int =
kvPairs: var seq[tuple[key: string, value: seq[byte], deleted: bool]]): int =
if not hasPrivilege(ctx, table, "INSERT"):
return 0
let tblDef = if table in ctx.tables: ctx.tables[table] else: TableDef()
@@ -87,7 +87,7 @@ proc execInsert*(ctx: ExecutionContext, table: string, fields: seq[string], valu
discard ctx.txnManager.write(ctx.pendingTxn, fullKey, cast[seq[byte]](valStr))
else:
ctx.db.put(fullKey, cast[seq[byte]](valStr))
kvPairs.add((fullKey, cast[seq[byte]](valStr)))
kvPairs.add((fullKey, cast[seq[byte]](valStr), false))
for colName in ctx.btrees.keys.toSeq():
if colName.startsWith(table & "."):
@@ -221,7 +221,7 @@ proc execInsert*(ctx: ExecutionContext, table: string, fields: seq[string], valu
return count
proc execDelete*(ctx: ExecutionContext, table: string, key: string,
kvPairs: var seq[(string, seq[byte])]): int =
kvPairs: var seq[tuple[key: string, value: seq[byte], deleted: bool]]): int =
if not hasPrivilege(ctx, table, "DELETE"):
return 0
let fullKey = table & "." & key
@@ -238,7 +238,7 @@ proc execDelete*(ctx: ExecutionContext, table: string, key: string,
discard ctx.txnManager.delete(ctx.pendingTxn, fullKey)
else:
ctx.db.delete(fullKey)
kvPairs.add((fullKey, @[]))
kvPairs.add((fullKey, @[], true))
# Update BTree indexes
for colName in ctx.btrees.keys.toSeq():
if colName.startsWith(table & "."):
@@ -264,7 +264,7 @@ proc execDelete*(ctx: ExecutionContext, table: string, key: string,
return 0
proc execUpdateRow*(ctx: ExecutionContext, table: string, key: string, sets: Table[string, string],
kvPairs: var seq[(string, seq[byte])]): int =
kvPairs: var seq[tuple[key: string, value: seq[byte], deleted: bool]]): int =
if not hasPrivilege(ctx, table, "UPDATE"):
return 0
let fullKey = table & "." & key
@@ -313,7 +313,7 @@ proc execUpdateRow*(ctx: ExecutionContext, table: string, key: string, sets: Tab
discard ctx.txnManager.write(ctx.pendingTxn, fullKey, cast[seq[byte]](newVal))
else:
ctx.db.put(fullKey, cast[seq[byte]](newVal))
kvPairs.add((fullKey, cast[seq[byte]](newVal)))
kvPairs.add((fullKey, cast[seq[byte]](newVal), false))
# Update FTS indexes: remove old doc, add new
for ftsKey, ftsIdx in ctx.ftsIndexes:
if ftsKey.startsWith(table & "."):
+41 -8
View File
@@ -429,6 +429,8 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
let right = evalExprOld(expr.binRight, row, ctx)
case expr.binOp
of irEq:
# SQL three-valued logic: any NULL operand → unknown, not true.
if isNull(left) or isNull(right): return "\\N"
if left == right: return "true"
# Try numeric comparison
try:
@@ -436,32 +438,45 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
except CatchableError: discard
return "false"
of irNeq:
if left != right: return "true"
# Try numeric comparison
if isNull(left) or isNull(right): return "\\N"
# Numeric-first so `!=` is the exact complement of `=` (irEq): string
# inequality alone would make `5 != 5.0` true while `5 = 5.0` is true.
try:
return if parseFloat(left) != parseFloat(right): "true" else: "false"
except CatchableError: return "false"
except CatchableError:
return if left != right: "true" else: "false"
of irLt:
if isNull(left) or isNull(right): return "\\N"
try:
return if parseFloat(left) < parseFloat(right): "true" else: "false"
except CatchableError: return if left < right: "true" else: "false"
of irLte:
if isNull(left) or isNull(right): return "\\N"
try:
return if parseFloat(left) <= parseFloat(right): "true" else: "false"
except CatchableError: return if left <= right: "true" else: "false"
of irGt:
if isNull(left) or isNull(right): return "\\N"
try:
return if parseFloat(left) > parseFloat(right): "true" else: "false"
except CatchableError: return if left > right: "true" else: "false"
of irGte:
if isNull(left) or isNull(right): return "\\N"
try:
return if parseFloat(left) >= parseFloat(right): "true" else: "false"
except CatchableError: return if left >= right: "true" else: "false"
of irAnd:
if left == "true" and right == "true": return "true"
return "false"
# false AND x = false; unknown AND true/unknown = unknown; else both true.
let lNull = isNull(left)
let rNull = isNull(right)
let lTrue = left == "true"
let rTrue = right == "true"
if (not lNull and not lTrue) or (not rNull and not rTrue): return "false"
if lNull or rNull: return "\\N"
return "true"
of irOr:
if left == "true" or right == "true": return "true"
if isNull(left) or isNull(right): return "\\N"
return "false"
of irAdd, irSub, irMul, irDiv, irMod, irPow:
let v = evalExpr(expr, stringTableToValueRow(row), ctx)
@@ -474,6 +489,7 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
of vkString: return v.strVal
else: return "\\N"
of irLike:
if isNull(left) or isNull(right): return "\\N"
proc escapeRe(s: string): string =
result = ""
for ch in s:
@@ -489,6 +505,7 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
except CatchableError: discard
return "false"
of irILike:
if isNull(left) or isNull(right): return "\\N"
proc escapeRe(s: string): string =
result = ""
for ch in s:
@@ -504,8 +521,10 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
except CatchableError: discard
return "false"
of irIn:
if isNull(left): return "\\N"
if expr.binRight.kind == irekSubquery:
let subRows = requireExecutePlanHook()(ctx, expr.binRight.subqueryPlan)
var sawNull = false
for row in subRows:
# Compare against the first non-internal column only (SQL semantics)
var firstVal = ""
@@ -515,8 +534,14 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
firstVal = valueToString(v)
found = true
break
if found and firstVal == left: return "true"
if not found: continue
if isNull(firstVal):
sawNull = true
continue
if firstVal == left: return "true"
if sawNull: return "\\N"
return "false"
if isNull(right): return "\\N"
try:
let lv = parseFloat(left)
let rv = parseFloat(right)
@@ -524,8 +549,10 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
except CatchableError: discard
return if left == right: "true" else: "false"
of irNotIn:
if isNull(left): return "\\N"
if expr.binRight.kind == irekSubquery:
let subRows = requireExecutePlanHook()(ctx, expr.binRight.subqueryPlan)
var sawNull = false
for row in subRows:
# Compare against the first non-internal column only (SQL semantics)
var firstVal = ""
@@ -535,8 +562,14 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
firstVal = valueToString(v)
found = true
break
if found and firstVal == left: return "false"
if not found: continue
if isNull(firstVal):
sawNull = true
continue
if firstVal == left: return "false"
if sawNull: return "\\N"
return "true"
if isNull(right): return "\\N"
try:
let lv = parseFloat(left)
let rv = parseFloat(right)
@@ -661,6 +694,7 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
case expr.unOp
of irNot:
let v = evalExprOld(expr.unExpr, row, ctx)
if isNull(v): return "\\N"
return if v == "true": "false" else: "true"
of irIsNull:
let v = evalExprOld(expr.unExpr, row, ctx)
@@ -923,7 +957,6 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
ddl.add("\n")
# Sample data
var kvPairs: seq[(string, seq[byte])] = @[]
let rows = requireExecScanHook()(ctx, table)
let sampleLimit = min(5, rows.len)
if sampleLimit > 0:
+4 -4
View File
@@ -30,14 +30,14 @@ proc enforceFkOnDelete*(ctx: ExecutionContext, parentTable: string, parentCol: s
of "CASCADE":
for refRow in refs:
if "$key" in refRow:
var dummy: seq[(string, seq[byte])] = @[]
var dummy: seq[tuple[key: string, value: seq[byte], deleted: bool]] = @[]
discard execDelete(ctx, childTblName, valueToString(refRow["$key"]), dummy)
of "SET NULL":
for refRow in refs:
if "$key" in refRow:
var sets = initTable[string, string]()
sets[col.name] = "\\N"
var dummy: seq[(string, seq[byte])] = @[]
var dummy: seq[tuple[key: string, value: seq[byte], deleted: bool]] = @[]
discard execUpdateRow(ctx, childTblName, valueToString(refRow["$key"]), sets, dummy)
of "RESTRICT", "NO ACTION":
return (false, "FOREIGN KEY violation: row is referenced by " & childTblName & "." & col.name)
@@ -56,14 +56,14 @@ proc enforceFkOnUpdate*(ctx: ExecutionContext, parentTable: string, parentCol: s
if "$key" in refRow:
var sets = initTable[string, string]()
sets[col.name] = newVal
var dummy: seq[(string, seq[byte])] = @[]
var dummy: seq[tuple[key: string, value: seq[byte], deleted: bool]] = @[]
discard execUpdateRow(ctx, childTblName, valueToString(refRow["$key"]), sets, dummy)
of "SET NULL":
for refRow in refs:
if "$key" in refRow:
var sets = initTable[string, string]()
sets[col.name] = "\\N"
var dummy: seq[(string, seq[byte])] = @[]
var dummy: seq[tuple[key: string, value: seq[byte], deleted: bool]] = @[]
discard execUpdateRow(ctx, childTblName, valueToString(refRow["$key"]), sets, dummy)
of "RESTRICT", "NO ACTION":
return (false, "FOREIGN KEY violation: row is referenced by " & childTblName & "." & col.name)
+16 -4
View File
@@ -76,6 +76,8 @@ proc lowerExpr*(node: Node): IRExpr =
of bkJsonContainedBy: irOp = irJsonContainedBy
of bkJsonHasAny: irOp = irJsonHasAny
of bkJsonHasAll: irOp = irJsonHasAll
of bkPow: irOp = irPow
of bkConcat: irOp = irAdd # irAdd concatenates string operands
else: irOp = irEq
result.binOp = irOp
result.binLeft = lowerExpr(node.binLeft)
@@ -120,6 +122,7 @@ proc lowerExpr*(node: Node): IRExpr =
else: discard
result.aggArgs = @[]
for arg in node.funcArgs: result.aggArgs.add(lowerExpr(arg))
result.aggDistinct = node.funcDistinct
if node.funcFilter != nil:
result.aggFilter = lowerExpr(node.funcFilter)
else:
@@ -408,9 +411,18 @@ proc lowerSelect*(node: Node): IRPlan =
if node.selLimit != nil or node.selOffset != nil:
let limitPlan = IRPlan(kind: irpkLimit)
limitPlan.limitSource = result
limitPlan.limitCount = if node.selLimit != nil and node.selLimit.limitExpr.kind == nkIntLit:
node.selLimit.limitExpr.intVal else: 0
limitPlan.limitOffset = if node.selOffset != nil and node.selOffset.offsetExpr.kind == nkIntLit:
node.selOffset.offsetExpr.intVal else: 0
# limitCount: -1 = unlimited (OFFSET without LIMIT). LIMIT 0 is empty.
# Negative LIMIT/OFFSET are clamped so slicing cannot IndexDefect.
if node.selLimit != nil:
if node.selLimit.limitExpr.kind == nkIntLit:
limitPlan.limitCount = max(0'i64, node.selLimit.limitExpr.intVal)
else:
limitPlan.limitCount = 0
else:
limitPlan.limitCount = -1
if node.selOffset != nil and node.selOffset.offsetExpr.kind == nkIntLit:
limitPlan.limitOffset = max(0'i64, node.selOffset.offsetExpr.intVal)
else:
limitPlan.limitOffset = 0
result = limitPlan
+78 -14
View File
@@ -5,6 +5,7 @@
## executor split). Pure code motion — no behavior changes.
import std/strutils
import std/tables
import std/sets
import std/sequtils
import std/algorithm
import ../ir
@@ -19,6 +20,19 @@ import eval
import scan
import window
# ----------------------------------------------------------------------
# Aggregate DISTINCT helpers
# ----------------------------------------------------------------------
proc shouldKeepDistinct(seen: var HashSet[string], s: string, doDistinct: bool): bool =
## Returns true if `s` should be counted/included (first occurrence when distinct).
if not doDistinct:
return true
if s in seen:
return false
seen.incl(s)
return true
# ----------------------------------------------------------------------
# IR Plan Execution (with actual filter/sort/projection)
# ----------------------------------------------------------------------
@@ -113,49 +127,71 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
newRow[alias] = $filteredRows.len
else:
var count = 0
var seen: HashSet[string]
for row in filteredRows:
let v = evalExpr(expr.aggArgs[0], row, ctx)
if v.kind != vkNull: count += 1
if v.kind != vkNull:
let s = valueToString(v)
if shouldKeepDistinct(seen, s, expr.aggDistinct):
count += 1
newRow[alias] = $count
of irSum:
var sum = 0.0
var seen: HashSet[string]
for row in filteredRows:
let v = evalExpr(expr.aggArgs[0], row, ctx)
try: sum += parseFloat(valueToString(v)) except CatchableError: discard
let s = valueToString(v)
if shouldKeepDistinct(seen, s, expr.aggDistinct):
try: sum += parseFloat(s) except CatchableError: discard
newRow[alias] = $sum
of irAvg:
var sum = 0.0
var count = 0
var seen: HashSet[string]
for row in filteredRows:
let v = evalExpr(expr.aggArgs[0], row, ctx)
try: sum += parseFloat(valueToString(v)); count += 1 except CatchableError: discard
let s = valueToString(v)
if shouldKeepDistinct(seen, s, expr.aggDistinct):
try: sum += parseFloat(s); count += 1 except CatchableError: discard
newRow[alias] = if count > 0: $(sum / float(count)) else: "0"
of irMin:
var minVal = ""
var seen: HashSet[string]
for row in filteredRows:
let v = evalExpr(expr.aggArgs[0], row, ctx)
if v.kind == vkNull: continue
if minVal == "" or cmpMin(valueToString(v), minVal): minVal = valueToString(v)
let s = valueToString(v)
if shouldKeepDistinct(seen, s, expr.aggDistinct):
if minVal == "" or cmpMin(s, minVal): minVal = s
newRow[alias] = minVal
of irMax:
var maxVal = ""
var seen: HashSet[string]
for row in filteredRows:
let v = evalExpr(expr.aggArgs[0], row, ctx)
if v.kind == vkNull: continue
if maxVal == "" or cmpMax(valueToString(v), maxVal): maxVal = valueToString(v)
let s = valueToString(v)
if shouldKeepDistinct(seen, s, expr.aggDistinct):
if maxVal == "" or cmpMax(s, maxVal): maxVal = s
newRow[alias] = maxVal
of irArrayAgg:
var arr: seq[string]
var seen: HashSet[string]
for row in filteredRows:
if expr.aggArgs.len > 0:
arr.add(valueToString(evalExpr(expr.aggArgs[0], row, ctx)))
let s = valueToString(evalExpr(expr.aggArgs[0], row, ctx))
if shouldKeepDistinct(seen, s, expr.aggDistinct):
arr.add(s)
newRow[alias] = "[" & arr.join(", ") & "]"
of irStringAgg:
var parts: seq[string]
var seen: HashSet[string]
let delim = if expr.aggArgs.len > 1: evalExpr(expr.aggArgs[1], initTable[string, Value](), ctx) else: Value(kind: vkString, strVal: ",")
for row in filteredRows:
if expr.aggArgs.len > 0:
parts.add(valueToString(evalExpr(expr.aggArgs[0], row, ctx)))
let s = valueToString(evalExpr(expr.aggArgs[0], row, ctx))
if shouldKeepDistinct(seen, s, expr.aggDistinct):
parts.add(s)
newRow[alias] = parts.join(valueToString(delim))
else:
let val = evalExpr(expr, if sourceRows.len > 0: sourceRows[0] else: initTable[string, Value](), ctx)
@@ -219,12 +255,18 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
of irpkLimit:
let sourceRows = executePlan(ctx, plan.limitSource)
var start = int(plan.limitOffset)
if start < 0: start = 0
if start > sourceRows.len: start = sourceRows.len
if plan.limitCount < 0:
# OFFSET without LIMIT — return the remainder.
return sourceRows[start ..< sourceRows.len]
if plan.limitCount == 0:
return @[]
var endIdx = start + int(plan.limitCount)
if endIdx > sourceRows.len:
endIdx = sourceRows.len
if endIdx < start:
endIdx = start
return sourceRows[start..<endIdx]
of irpkGroupBy:
@@ -292,49 +334,71 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
aggRow[aggKey] = $filteredRows.len
else:
var count = 0
var seen: HashSet[string]
for row in filteredRows:
let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
if v.kind != vkNull: count += 1
if v.kind != vkNull:
let s = valueToString(v)
if shouldKeepDistinct(seen, s, aggExpr.aggDistinct):
count += 1
aggRow[aggKey] = $count
of irSum:
var sum = 0.0
var seen: HashSet[string]
for row in filteredRows:
let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
try: sum += parseFloat(valueToString(v)) except CatchableError: discard
let s = valueToString(v)
if shouldKeepDistinct(seen, s, aggExpr.aggDistinct):
try: sum += parseFloat(s) except CatchableError: discard
aggRow[aggKey] = $sum
of irAvg:
var sum = 0.0
var count = 0
var seen: HashSet[string]
for row in filteredRows:
let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
try: sum += parseFloat(valueToString(v)); count += 1 except CatchableError: discard
let s = valueToString(v)
if shouldKeepDistinct(seen, s, aggExpr.aggDistinct):
try: sum += parseFloat(s); count += 1 except CatchableError: discard
aggRow[aggKey] = if count > 0: $(sum / float(count)) else: "0"
of irMin:
var minVal = ""
var seen: HashSet[string]
for row in filteredRows:
let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
if v.kind == vkNull: continue
if minVal == "" or cmpMin(valueToString(v), minVal): minVal = valueToString(v)
let s = valueToString(v)
if shouldKeepDistinct(seen, s, aggExpr.aggDistinct):
if minVal == "" or cmpMin(s, minVal): minVal = s
aggRow[aggKey] = minVal
of irMax:
var maxVal = ""
var seen: HashSet[string]
for row in filteredRows:
let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
if v.kind == vkNull: continue
if maxVal == "" or cmpMax(valueToString(v), maxVal): maxVal = valueToString(v)
let s = valueToString(v)
if shouldKeepDistinct(seen, s, aggExpr.aggDistinct):
if maxVal == "" or cmpMax(s, maxVal): maxVal = s
aggRow[aggKey] = maxVal
of irArrayAgg:
var arr: seq[string]
var seen: HashSet[string]
for row in filteredRows:
if aggExpr.aggArgs.len > 0:
arr.add(valueToString(evalExpr(aggExpr.aggArgs[0], row, ctx)))
let s = valueToString(evalExpr(aggExpr.aggArgs[0], row, ctx))
if shouldKeepDistinct(seen, s, aggExpr.aggDistinct):
arr.add(s)
aggRow[aggKey] = "[" & arr.join(", ") & "]"
of irStringAgg:
var parts: seq[string]
var seen: HashSet[string]
let delim = if aggExpr.aggArgs.len > 1: evalExpr(aggExpr.aggArgs[1], initTable[string, Value](), ctx) else: Value(kind: vkString, strVal: ",")
for row in filteredRows:
if aggExpr.aggArgs.len > 0:
parts.add(valueToString(evalExpr(aggExpr.aggArgs[0], row, ctx)))
let s = valueToString(evalExpr(aggExpr.aggArgs[0], row, ctx))
if shouldKeepDistinct(seen, s, aggExpr.aggDistinct):
parts.add(s)
aggRow[aggKey] = parts.join(valueToString(delim))
# Apply HAVING filter
if plan.groupHaving != nil:
+8 -6
View File
@@ -11,20 +11,22 @@ import lower
# Row-Level Security
# ----------------------------------------------------------------------
proc hasPrivilege*(ctx: ExecutionContext, tableName, command: string): bool =
if ctx.currentUser.len == 0: return true
let user = ctx.users.getOrDefault(ctx.currentUser)
proc hasPrivilegeFor*(ctx: ExecutionContext, username, tableName, command: string): bool =
## Privilege check for an explicit username (does not mutate ctx.currentUser).
if username.len == 0: return true
let user = ctx.users.getOrDefault(username)
if user.isSuperuser: return true
# Check table-level policies for user or PUBLIC
# For now: if no policies exist, allow everything (backward compatible)
if tableName notin ctx.policies: return true
let policies = ctx.policies[tableName]
# If RLS is enabled (policies exist), check if user matches any policy
for pol in policies:
if pol.command == "ALL" or pol.command == command:
return true
return false
proc hasPrivilege*(ctx: ExecutionContext, tableName, command: string): bool =
if ctx.currentUser.len == 0: return true
hasPrivilegeFor(ctx, ctx.currentUser, tableName, command)
proc passesPolicy*(ctx: ExecutionContext, tableName, command: string, row: Row): bool =
if ctx.currentUser.len == 0: return true
let user = ctx.users.getOrDefault(ctx.currentUser)
+2 -2
View File
@@ -136,13 +136,13 @@ type
rows*: seq[Row]
affectedRows*: int
message*: string
keyValuePairs*: seq[(string, seq[byte])]
keyValuePairs*: seq[tuple[key: string, value: seq[byte], deleted: bool]]
proc `==`*(a, b: IndexEntry): bool =
a.lsmKey == b.lsmKey and a.rowValue == b.rowValue
proc okResult*(rows: seq[Row] = @[], cols: seq[string] = @[], affected: int = 0, msg: string = "",
kvPairs: seq[(string, seq[byte])] = @[]): ExecResult =
kvPairs: seq[tuple[key: string, value: seq[byte], deleted: bool]] = @[]): ExecResult =
ExecResult(success: true, columns: cols, rows: rows, affectedRows: affected, message: msg,
keyValuePairs: kvPairs)
+58
View File
@@ -177,6 +177,64 @@ proc computeWindowValues*(rows: seq[Row], expr: IRExpr, ctx: ExecutionContext =
for pos, rowIdx in sortedIdxs:
let (_, fEnd) = resolveFrameBounds(pos, sortedIdxs.len, frameStart, frameEnd)
result[rowIdx] = valueToString(evalExpr(expr.wfArgs[0], rows[sortedIdxs[fEnd]], ctx))
of "sum", "avg", "count", "min", "max":
let countAll = wfName == "count" and
(expr.wfArgs.len == 0 or expr.wfArgs[0].kind == irekStar)
for pos, rowIdx in sortedIdxs:
let (fStart, fEnd) = resolveFrameBounds(pos, sortedIdxs.len, frameStart, frameEnd)
if wfName == "count" and countAll:
result[rowIdx] = $(fEnd - fStart + 1)
continue
if wfName == "count":
var cnt = 0
if expr.wfArgs.len > 0:
for i in fStart .. fEnd:
let s = valueToString(evalExpr(expr.wfArgs[0], rows[sortedIdxs[i]], ctx))
if not isNull(s) and s.len > 0:
inc cnt
result[rowIdx] = $cnt
continue
if expr.wfArgs.len == 0:
result[rowIdx] = "\\N"
continue
var sum = 0.0
var cnt = 0
var minF = 0.0
var maxF = 0.0
var minS = ""
var maxS = ""
var allNumeric = true
for i in fStart .. fEnd:
let s = valueToString(evalExpr(expr.wfArgs[0], rows[sortedIdxs[i]], ctx))
if isNull(s) or s.len == 0: continue
inc cnt
if cnt == 1 or s < minS: minS = s
if cnt == 1 or s > maxS: maxS = s
try:
let f = parseFloat(s)
sum += f
if cnt == 1:
minF = f
maxF = f
else:
if f < minF: minF = f
if f > maxF: maxF = f
except CatchableError:
allNumeric = false
if cnt == 0:
result[rowIdx] = "\\N"
else:
case wfName
of "sum":
result[rowIdx] = if allNumeric: $sum else: "\\N"
of "avg":
result[rowIdx] = if allNumeric: $(sum / float(cnt)) else: "\\N"
of "min":
result[rowIdx] = if allNumeric: $minF else: minS
of "max":
result[rowIdx] = if allNumeric: $maxF else: maxS
else:
result[rowIdx] = "\\N"
else:
# Unknown window function — fill with null
for rowIdx in sortedIdxs:
+69 -33
View File
@@ -448,6 +448,26 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
if cols.len == 0:
cols = rightRes.columns
# Fingerprint a projected row for set-op dedup. Prefer declared columns;
# fall back to non-system keys so UNION/INTERSECT/EXCEPT work without `$value`.
proc setOpRowKey(row: Row, colNames: seq[string]): string =
var parts: seq[string] = @[]
if colNames.len > 0:
for c in colNames:
if c in row:
parts.add(valueToString(row[c]))
else:
parts.add("")
else:
var keys: seq[string] = @[]
for k, _ in row:
if not k.startsWith("$"):
keys.add(k)
keys.sort()
for k in keys:
parts.add(k & "=" & valueToString(row[k]))
return parts.join("\x1f")
var rows: seq[Row] = @[]
case stmt.setOpKind
of sdkUnion:
@@ -460,28 +480,30 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
# UNION: deduplicate
var seen: Table[string, bool]
for row in leftRes.rows:
seen[valueToString(row["$value"])] = true
seen[setOpRowKey(row, cols)] = true
for row in rightRes.rows:
if not seen.getOrDefault(valueToString(row["$value"]), false):
seen[valueToString(row["$value"])] = true
let k = setOpRowKey(row, cols)
if not seen.getOrDefault(k, false):
seen[k] = true
rows.add(row)
of sdkIntersect:
var leftSet: Table[string, bool]
for row in leftRes.rows:
leftSet[valueToString(row["$value"])] = true
leftSet[setOpRowKey(row, cols)] = true
for row in rightRes.rows:
if leftSet.getOrDefault(valueToString(row["$value"]), false):
let k = setOpRowKey(row, cols)
if leftSet.getOrDefault(k, false):
rows.add(row)
if not stmt.setOpAll:
leftSet.del(valueToString(row["$value"])) # remove to prevent duplicates for INTERSECT (not ALL)
leftSet.del(k) # remove to prevent duplicates for INTERSECT (not ALL)
of sdkExcept:
var rightSet: Table[string, bool]
for row in rightRes.rows:
rightSet[valueToString(row["$value"])] = true
rightSet[setOpRowKey(row, cols)] = true
for row in leftRes.rows:
if not rightSet.getOrDefault(valueToString(row["$value"]), false):
if not rightSet.getOrDefault(setOpRowKey(row, cols), false):
rows.add(row)
return okResult(rows, cols)
@@ -574,7 +596,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 +648,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 +723,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 +766,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
@@ -759,21 +781,34 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
let onExpr = lowerExpr(stmt.mergeOn)
if valueToString(evalExpr(onExpr, rowWithTarget, ctx)) == "true":
matched = true
if stmt.mergeMatchedUpdate.len > 0 and "$key" in tgtRow:
var updateSets = initTable[string, string]()
for s in stmt.mergeMatchedUpdate:
if s.kind == nkBinOp and s.binOp == bkAssign:
if s.binLeft.kind == nkIdent:
let valExpr = lowerExpr(s.binRight)
updateSets[s.binLeft.identName] = valueToString(evalExpr(valExpr, rowWithTarget, ctx))
var newRow = tgtRow
for col, val in updateSets:
newRow[col] = Value(kind: vkString, strVal: val)
fireTriggers(ctx, stmt.mergeTarget, "before", "update", tgtRow)
count += execUpdateRow(ctx, stmt.mergeTarget, valueToString(tgtRow["$key"]), updateSets, kvPairs)
fireTriggers(ctx, stmt.mergeTarget, "after", "update", newRow)
if ctx.onChange != nil:
ctx.onChange(ChangeEvent(table: stmt.mergeTarget, kind: ckUpdate, key: valueToString(tgtRow["$key"]), data: ""))
# Optional AND <condition> after WHEN MATCHED
var applyMatched = true
if stmt.mergeMatchedCondition != nil:
let condExpr = lowerExpr(stmt.mergeMatchedCondition)
applyMatched = valueToString(evalExpr(condExpr, rowWithTarget, ctx)) == "true"
if applyMatched and "$key" in tgtRow:
if stmt.mergeMatchedDelete:
fireTriggers(ctx, stmt.mergeTarget, "before", "delete", tgtRow)
count += execDelete(ctx, stmt.mergeTarget, valueToString(tgtRow["$key"]), kvPairs)
fireTriggers(ctx, stmt.mergeTarget, "after", "delete", tgtRow)
if ctx.onChange != nil:
ctx.onChange(ChangeEvent(table: stmt.mergeTarget, kind: ckDelete,
key: valueToString(tgtRow["$key"]), data: ""))
elif stmt.mergeMatchedUpdate.len > 0:
var updateSets = initTable[string, string]()
for s in stmt.mergeMatchedUpdate:
if s.kind == nkBinOp and s.binOp == bkAssign:
if s.binLeft.kind == nkIdent:
let valExpr = lowerExpr(s.binRight)
updateSets[s.binLeft.identName] = valueToString(evalExpr(valExpr, rowWithTarget, ctx))
var newRow = tgtRow
for col, val in updateSets:
newRow[col] = Value(kind: vkString, strVal: val)
fireTriggers(ctx, stmt.mergeTarget, "before", "update", tgtRow)
count += execUpdateRow(ctx, stmt.mergeTarget, valueToString(tgtRow["$key"]), updateSets, kvPairs)
fireTriggers(ctx, stmt.mergeTarget, "after", "update", newRow)
if ctx.onChange != nil:
ctx.onChange(ChangeEvent(table: stmt.mergeTarget, kind: ckUpdate, key: valueToString(tgtRow["$key"]), data: ""))
break
if not matched and stmt.mergeNotMatchedInsert.len > 0:
@@ -793,7 +828,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 +1017,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)
+5
View File
@@ -194,6 +194,11 @@ proc parsePrimary(p: var Parser): Node =
discard p.expect(tkWhere)
node.funcFilter = p.parseExpr()
discard p.expect(tkRParen)
# Window aggregate: SUM/AVG/COUNT/MIN/MAX(...) OVER (...)
if p.peek().kind == tkOver:
let overClause = p.parseOverClause()
return Node(kind: nkWindowExpr, winFunc: funcName.toLower(), winArgs: args,
winOver: overClause, line: tok.line, col: tok.col)
return node
of tkCase:
discard p.advance()
+73 -9
View File
@@ -175,6 +175,61 @@ proc scan*[K, V](btree: BTreeIndex[K, V], startKey, endKey: K): seq[(K, seq[V])]
finally:
release(btree.lock)
proc subtreeMinMax[K, V](node: BTreeNode[K, V]): (bool, K, K) =
## Inclusive min/max of keys stored in this subtree's leaves.
if node == nil:
return (false, default(K), default(K))
if node.isLeaf:
if node.keys.len == 0:
return (false, default(K), default(K))
return (true, node.keys[0], node.keys[^1])
var have = false
var mn, mx: K
for c in node.children:
let (ok, a, b) = subtreeMinMax(c)
if ok:
if not have:
mn = a
mx = b
have = true
else:
if a < mn: mn = a
if b > mx: mx = b
return (have, mn, mx)
proc collectSeparatorErrors[K, V](node: BTreeNode[K, V], errors: var seq[string]) =
## Search uses `key > separator → right child`, so every key in the left
## subtree must be <= sep (otherwise it is routed right and missed — no
## prev-leaf pointer). Boundary duplicates are allowed: the same key may be
## max(left) and min(right); get/remove walk `next` to collect them.
if node == nil or node.isLeaf:
return
for i in 0..<node.keys.len:
if i + 1 >= node.children.len:
errors.add("internal node has fewer children than keys+1")
break
let (lok, _, lmax) = subtreeMinMax(node.children[i])
let (rok, rmin, _) = subtreeMinMax(node.children[i + 1])
let sep = node.keys[i]
if lok and lmax > sep:
errors.add("separator[" & $i & "]=" & $sep &
" < max(left)=" & $lmax & " (search would miss left keys)")
if lok and rok and rmin < lmax:
errors.add("separator[" & $i & "]=" & $sep &
" leaf order inverted: max(left)=" & $lmax & " > min(right)=" & $rmin)
collectSeparatorErrors(node.children[i], errors)
if node.children.len > 0:
collectSeparatorErrors(node.children[^1], errors)
proc checkInvariants*[K, V](btree: BTreeIndex[K, V]): seq[string] =
## Returns a list of separator/search-routing violations (empty = healthy).
acquire(btree.lock)
try:
result = @[]
collectSeparatorErrors(btree.root, result)
finally:
release(btree.lock)
proc len*[K, V](btree: BTreeIndex[K, V]): int =
acquire(btree.lock)
try:
@@ -209,7 +264,9 @@ proc borrowFromLeft[K, V](node: BTreeNode[K, V], parent: BTreeNode[K, V], parent
node.values.insert(borrowVal, 0)
sibling.keys.setLen(sibling.keys.len - 1)
sibling.values.setLen(sibling.values.len - 1)
parent.keys[parentIdx - 1] = node.keys[0]
# Search is `key > sep → right` (left-max). After lending, sep is the
# left sibling's new max — not the borrowed key now sitting in `node`.
parent.keys[parentIdx - 1] = sibling.keys[^1]
else:
# Borrow from internal sibling
let borrowKey = sibling.keys[^1]
@@ -231,7 +288,8 @@ proc borrowFromRight[K, V](node: BTreeNode[K, V], parent: BTreeNode[K, V], paren
node.values.add(borrowVal)
sibling.keys.delete(0)
sibling.values.delete(0)
parent.keys[parentIdx] = sibling.keys[0]
# Borrowed key is now left's max; search must keep it on the left.
parent.keys[parentIdx] = node.keys[^1]
else:
let borrowKey = sibling.keys[0]
let borrowChild = sibling.children[0]
@@ -325,10 +383,14 @@ proc rebalanceAfterDelete[K, V](node: BTreeNode[K, V], root: var BTreeNode[K, V]
mergeWithLeft(node, parent, parentIdx)
elif hasRight:
mergeWithRight(node, parent, parentIdx)
else:
return
# Recursively rebalance parent if it fell below minimum
# Merge dropped a separator from parent — rebalance up the tree.
if parent == root and parent.keys.len == 0 and parent.children.len == 1:
root = parent.children[0]
elif parent.keys.len < minKeysForLeaf(parent, order):
rebalanceAfterDelete(parent, root, order)
proc remove*[K, V](btree: var BTreeIndex[K, V], key: K, value: V) =
acquire(btree.lock)
@@ -368,15 +430,17 @@ proc remove*[K, V](btree: var BTreeIndex[K, V], key: K, value: V) =
else:
# Internal node: recurse into child
let child = node.children[i]
let oldFirstKey = if child.keys.len > 0: child.keys[0] else: default(K)
let found = removeRec(child, root, order)
if found:
# Update separator if child's first key changed.
# Separator node.keys[i-1] represents child's first key (for i > 0).
if i > 0 and child.keys.len > 0 and child.keys[0] != oldFirstKey:
node.keys[i - 1] = child.keys[0]
# Rebalance the child if needed
# Rebalance first — merge/borrow rewrite parent separators.
rebalanceAfterDelete(child, root, order)
# Leaf children only: refresh left-max separator. Skip if merge
# already unlinked this child. Internal keys are promoted
# separators, not copies of child.keys[^1]; copying those (the
# naive H10 rewrite) makes sep < max(left) and misses keys.
if i < node.children.len and node.children[i] == child and
child.isLeaf and child.keys.len > 0 and i < node.keys.len:
node.keys[i] = child.keys[^1]
return found
if removeRec(btree.root, btree.root, btree.order):
+8 -10
View File
@@ -125,13 +125,16 @@ proc compact*(cs: CompactionStrategy, level: int): CompactionResult =
return cmp(b.timestamp, a.timestamp) # newest first
)
# Deduplicate: keep only the newest version of each key
# Deduplicate: keep only the newest version of each key.
# Use a haveLast flag — sentinel lastKey="" would skip the empty-string key.
var merged: seq[Entry] = @[]
var lastKey = ""
var haveLast = false
for entry in allEntries:
if entry.key != lastKey:
if not haveLast or entry.key != lastKey:
merged.add(entry)
lastKey = entry.key
haveLast = true
# Keep tombstones to prevent deleted keys from resurrecting in lower levels
var final: seq[Entry] = @[]
@@ -155,20 +158,15 @@ proc compact*(cs: CompactionStrategy, level: int): CompactionResult =
createdAt: tables[^1].createdAt,
)
# Verify output SSTable before deleting sources
# Verify output SSTable before mutating the catalog. Input files stay on
# disk until the caller loads the output and writes MANIFEST (crash-safe
# order: output durable + catalog updated, then unlink inputs).
let (ok, msg) = verifySSTable(outputPath)
if not ok:
echo "[ERROR] Compaction output verification failed: ", msg
try: removeFile(outputPath) except CatchableError: discard
return CompactionResult()
# Remove old SSTable files
for t in tables:
try:
removeFile(t.path)
except CatchableError as e:
echo "[WARN] Failed to remove old SSTable: ", t.path, ": ", e.msg
# Update level arrays
var newTables: seq[SSTableMeta] = @[]
for t in cs.levels[level]:
+51 -25
View File
@@ -696,6 +696,10 @@ proc newLSMTree*(
var version: uint32 = 0
if stream.readData(addr magic, 4) == 4 and magic == WALMagic:
if stream.readData(addr version, 4) == 4:
# Cap per-record sizes to avoid multi-GiB alloc on torn/corrupt WAL.
# Kind must be a known WalEntryKind value (1..4) — out-of-range casts
# raise CaseStmtError (Defect) and crash the process.
const MaxWalRecordField = 64 * 1024 * 1024 # 64 MB
while not stream.atEnd():
var kind: uint8 = 0
var timestamp: uint64 = 0
@@ -704,10 +708,20 @@ proc newLSMTree*(
if stream.readData(addr kind, 1) != 1: break
if stream.readData(addr timestamp, 8) != 8: break
if stream.readData(addr keyLen, 4) != 4: break
if keyLen.int > MaxWalRecordField:
echo "[WARN] WAL recovery: torn/corrupt record (keyLen=", keyLen, ") — stopping replay"
break
# Validate kind before allocating or branching (avoids CaseStmtError Defect)
if kind < uint8(wekPut) or kind > uint8(wekCommit):
echo "[WARN] WAL recovery: invalid entry kind ", kind, " — stopping replay"
break
var key = newString(keyLen.int)
if keyLen > 0:
if stream.readData(addr key[0], keyLen.int) != keyLen.int: break
if stream.readData(addr valLen, 4) != 4: break
if valLen.int > MaxWalRecordField:
echo "[WARN] WAL recovery: torn/corrupt record (valLen=", valLen, ") — stopping replay"
break
var value = newSeq[byte](valLen.int)
if valLen > 0:
if stream.readData(addr value[0], valLen.int) != valLen.int: break
@@ -866,26 +880,36 @@ proc flushUnsafe(db: LSMTree) =
if db.immutableMem.len == 0 and db.memTable.len == 0:
return
# Flush immutable memtable if present, otherwise flush current memtable
var toFlush = db.immutableMem
if toFlush.len == 0:
toFlush = db.memTable
db.memTable = newMemTable(db.memMaxSize)
# Flush immutable memtable if present, otherwise flush current memtable.
# Do NOT clear the source memtable until the SSTable is written — an IOError
# mid-write must leave the data still visible to live reads (WAL still has it).
var flushingImmutable = false
var toFlush: MemTable
if db.immutableMem.len > 0:
toFlush = db.immutableMem
flushingImmutable = true
else:
db.immutableMem = newMemTable(0)
toFlush = db.memTable
if toFlush.len == 0:
return
let path = db.dir / "sstables" / ($db.nextSSTableId & ".sst")
let sstId = db.nextSSTableId
inc db.nextSSTableId
# Sort once at flush time (O(n log n)) — put/get stay O(1)
var sst = writeSSTable(toFlush.sortedEntries(), path, level = 0)
sst.id = db.nextSSTableId - 1
sst.id = sstId
db.sstables.add(sst)
# SSTables are kept in insertion order (newest last) so getUnsafe can search newest-first
# Only now drop the in-memory copy — SSTable is durable on disk
if flushingImmutable:
db.immutableMem = newMemTable(0)
else:
db.memTable = newMemTable(db.memMaxSize)
# Update MANIFEST atomically
inc db.manifestSequence
try:
@@ -930,27 +954,29 @@ proc checkpoint*(db: LSMTree) =
## rotate WAL, and write MANIFEST. This provides a clean boundary
## for online backup without stopping the server.
acquireWrite(db.lock)
try:
# Flush any pending immutable memtable first
if db.immutableMem.len > 0:
flushUnsafe(db)
# Flush any pending immutable memtable first
if db.immutableMem.len > 0:
flushUnsafe(db)
# Freeze current memtable so writes can continue on a new one
if db.memTable.len > 0:
db.immutableMem = db.memTable
db.memTable = newMemTable(db.memMaxSize)
# Freeze current memtable so writes can continue on a new one
if db.memTable.len > 0:
db.immutableMem = db.memTable
db.memTable = newMemTable(db.memMaxSize)
# Flush the frozen memtable
if db.immutableMem.len > 0:
flushUnsafe(db)
# Flush the frozen memtable
if db.immutableMem.len > 0:
flushUnsafe(db)
# Rotate WAL for a clean backup boundary
acquire(db.walLock)
db.wal.maybeRotate()
db.wal.sync()
release(db.walLock)
releaseWrite(db.lock)
# Rotate WAL for a clean backup boundary
acquire(db.walLock)
try:
db.wal.maybeRotate()
db.wal.sync()
finally:
release(db.walLock)
finally:
releaseWrite(db.lock)
proc close*(db: LSMTree) =
acquireWrite(db.lock)
+8 -4
View File
@@ -80,7 +80,8 @@ proc readAt*(mf: MmapFile, offset: int, size: int): seq[byte] =
if mf.regions.len == 0:
return @[]
let region = mf.regions[0]
if offset < 0 or size < 0 or offset + size > region.size:
# overflow-safe bound: offset > size - length (not offset + length > size)
if offset < 0 or size < 0 or size > region.size or offset > region.size - size:
return @[]
result = newSeq[byte](size)
copyMem(addr result[0], unsafeAddr region.data[offset], size)
@@ -91,21 +92,24 @@ proc readByte*(mf: MmapFile, offset: int): byte =
return mf.regions[0].data[offset]
proc readUint32*(mf: MmapFile, offset: int): uint32 =
if mf.regions.len == 0 or offset < 0 or offset + 4 > mf.regions[0].size:
if mf.regions.len == 0 or offset < 0 or 4 > mf.regions[0].size or
offset > mf.regions[0].size - 4:
return 0
var val: uint32
copyMem(addr val, unsafeAddr mf.regions[0].data[offset], 4)
return val
proc readUint64*(mf: MmapFile, offset: int): uint64 =
if mf.regions.len == 0 or offset < 0 or offset + 8 > mf.regions[0].size:
if mf.regions.len == 0 or offset < 0 or 8 > mf.regions[0].size or
offset > mf.regions[0].size - 8:
return 0
var val: uint64
copyMem(addr val, unsafeAddr mf.regions[0].data[offset], 8)
return val
proc readString*(mf: MmapFile, offset: int, size: int): string =
if mf.regions.len == 0 or offset < 0 or size < 0 or offset + size > mf.regions[0].size:
if mf.regions.len == 0 or offset < 0 or size < 0 or
size > mf.regions[0].size or offset > mf.regions[0].size - size:
return ""
result = newString(size)
copyMem(addr result[0], unsafeAddr mf.regions[0].data[offset], size)
+4
View File
@@ -68,6 +68,7 @@ proc scanWAL*(rec: CrashRecovery): seq[RecoveredEntry] =
var txnId: uint64 = 0
var entryCount = 0
const MaxWalRecordField = 64 * 1024 * 1024 # 64 MB
while not stream.atEnd():
var kind: uint8 = 0
var timestamp: uint64 = 0
@@ -77,12 +78,15 @@ proc scanWAL*(rec: CrashRecovery): seq[RecoveredEntry] =
if stream.readData(addr kind, 1) != 1: break
if stream.readData(addr timestamp, 8) != 8: break
if stream.readData(addr keyLen, 4) != 4: break
if keyLen.int > MaxWalRecordField: break
if kind < uint8(wekPut) or kind > uint8(wekCommit): break
var key = newString(keyLen.int)
if keyLen > 0:
if stream.readData(addr key[0], keyLen.int) != keyLen.int: break
if stream.readData(addr valLen, 4) != 4: break
if valLen.int > MaxWalRecordField: break
var value = newSeq[byte](valLen.int)
if valLen > 0:
if stream.readData(addr value[0], valLen.int) != valLen.int: break
+7 -2
View File
@@ -326,8 +326,9 @@ proc rewriteLive*(wal: var WriteAheadLog,
if wal.stream != nil:
wal.stream.close()
if fileExists(wal.path):
removeFile(wal.path)
wal.stream = nil
# Atomic replace: moveFile overwrites the destination on POSIX rename(2).
# Do not removeFile first — a crash between unlink and rename would lose the WAL.
moveFile(tmpPath, wal.path)
wal.stream = newFileStream(wal.path, fmAppend)
if wal.stream == nil:
@@ -363,6 +364,7 @@ proc readEntries*(walPath: string, untilTimestamp: uint64 = 0): seq[WalEntry] =
if s.readData(addr magic, 4) != 4: return
if s.readData(addr version, 4) != 4: return
if magic != WALMagic: return
const MaxWalRecordField = 64 * 1024 * 1024 # 64 MB
while not s.atEnd:
var kind: uint8
if s.readData(addr kind, 1) != 1: break
@@ -372,11 +374,14 @@ proc readEntries*(walPath: string, untilTimestamp: uint64 = 0): seq[WalEntry] =
break
var keyLen: uint32
if s.readData(addr keyLen, 4) != 4: break
if keyLen.int > MaxWalRecordField: break
if kind < uint8(wekPut) or kind > uint8(wekCommit): break
var key = newSeq[byte](keyLen)
if keyLen > 0:
if s.readData(addr key[0], int(keyLen)) != int(keyLen): break
var valLen: uint32
if s.readData(addr valLen, 4) != 4: break
if valLen.int > MaxWalRecordField: break
var value = newSeq[byte](valLen)
if valLen > 0:
if s.readData(addr value[0], int(valLen)) != int(valLen): break
+124 -17
View File
@@ -23,6 +23,7 @@ import barabadb/core/gossip
import barabadb/core/replication
import barabadb/core/disttxn
import barabadb/core/registry
import barabadb/core/backup
import barabadb/tools/repair
import barabadb/tools/migrate
@@ -40,27 +41,18 @@ proc newCompactionManager*(db: LSMTree): CompactionManager =
result.strategy.rebuildFromLSM(db)
proc applyCompactionResult(db: LSMTree, result: compaction.CompactionResult) =
## Apply compaction output under the caller's lock: update sstables + MANIFEST.
## On Linux, compact may already have unlinked inputs; we still close our mmaps.
## Crash-safe apply: load output while inputs still exist, swap catalog,
## write MANIFEST, then unlink inputs. A crash before MANIFEST leaves the
## old set intact (orphan output is ignored); a crash after MANIFEST leaves
## at worst unlinked-but-closed input files.
if result.outputTables.len == 0:
return
var newSSTables: seq[SSTable] = @[]
var removedPaths = initTable[string, bool]()
for t in result.inputTables:
removedPaths[t.path] = true
for sst in db.sstables.mitems:
if sst.path notin removedPaths:
newSSTables.add(sst)
else:
# Drop mmap after compact unlinked the path (fd remains valid until close)
sst.close()
var loaded: seq[SSTable] = @[]
for meta in result.outputTables:
try:
var sst = loadSSTable(meta.path)
let name = splitFile(meta.path).name
# Prefer numeric id from filename; otherwise allocate
let parsed = try: parseInt(name) except CatchableError: -1
if parsed >= 0:
sst.id = parsed
@@ -68,11 +60,30 @@ proc applyCompactionResult(db: LSMTree, result: compaction.CompactionResult) =
sst.id = db.nextSSTableId
inc db.nextSSTableId
sst.level = meta.level
newSSTables.add(sst)
loaded.add(sst)
db.nextSSTableId = max(db.nextSSTableId, sst.id + 1)
except CatchableError as e:
warn("Compaction output SSTable failed to load: " & meta.path & "" & e.msg)
for s in loaded.mitems:
s.close()
for m in result.outputTables:
try: removeFile(m.path) except CatchableError: discard
return
var removedPaths = initTable[string, bool]()
for t in result.inputTables:
removedPaths[t.path] = true
var newSSTables: seq[SSTable] = @[]
var toDrop: seq[SSTable] = @[]
for sst in db.sstables.mitems:
if sst.path notin removedPaths:
newSSTables.add(sst)
else:
toDrop.add(sst)
for sst in loaded:
newSSTables.add(sst)
newSSTables.sort(proc(a, b: SSTable): int = cmp(a.id, b.id))
db.sstables = newSSTables
db.needsCompaction = db.countL0() >= L0CompactionTrigger
@@ -83,6 +94,13 @@ proc applyCompactionResult(db: LSMTree, result: compaction.CompactionResult) =
except CatchableError as e:
warn("Failed to write MANIFEST after compaction: " & e.msg)
for sst in toDrop.mitems:
try:
removeFile(sst.path)
except CatchableError as e:
warn("Failed to remove compacted SSTable: " & sst.path & "" & e.msg)
sst.close()
proc compact*(cm: CompactionManager) =
# Gate first (cross-thread), then per-DB write lock
withStorageGate:
@@ -288,7 +306,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 +356,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 +383,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 +404,83 @@ 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: tar the default DB's data directory into the
# path raft picks (dataDir/raft/snap_out_<snapId>.tar). The tar runs here on
# the raft event loop under the storage gate; sendSnapshot then gzips it on
# a worker thread off the loop (gzipFileAsync) so heartbeats keep flowing.
raftNode.buildSnapshot = proc(destPath: string): bool {.gcsafe.} =
echo "[raft] Building snapshot tar ", 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. Compression happens later, off the gate (see
# sendSnapshot), so it neither stalls the loop nor blocks applies.
withStorageGate:
try:
result = tarDataDir(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
+498
View File
@@ -4,9 +4,19 @@ 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/core/replication
import ../src/barabadb/core/disttxn
import ../src/barabadb/core/websocket
import ../src/barabadb/protocol/auth
import ../src/barabadb/protocol/scram
import ../src/barabadb/storage/lsm
import ../src/barabadb/storage/compaction
import ../src/barabadb/storage/btree
import std/random
import std/sets
const testDir = "/tmp/baradb_bugfix_test"
@@ -417,6 +427,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 +530,419 @@ 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
suite "Legacy REP payload encoding — empty value is not a delete":
test "PK-only put (empty value) round-trips as a put, not a delete":
## Regression: the legacy REP receiver used to infer a delete from an empty
## value, so PK-only rows (empty LSM value) vanished on the replica.
let decoded = decodeRepPayload(encodeRepPayload(false, "pkonly.id=3", @[]))
check decoded.op == ropPut
check decoded.key == "pkonly.id=3"
check decoded.value.len == 0
test "delete round-trips as a delete":
let decoded = decodeRepPayload(encodeRepPayload(true, "users.id=1", @[]))
check decoded.op == ropDelete
check decoded.key == "users.id=1"
check decoded.value.len == 0
test "put with a non-empty value preserves the value bytes":
let decoded = decodeRepPayload(
encodeRepPayload(false, "users.id=1", cast[seq[byte]]("bob")))
check decoded.op == ropPut
check decoded.key == "users.id=1"
check cast[string](decoded.value) == "bob"
test "value containing a null byte survives the round-trip":
## Decode splits on the FIRST null (the key/value separator) only.
let value = @[byte('a'), byte(0), byte('b')]
let decoded = decodeRepPayload(encodeRepPayload(false, "k", value))
check decoded.op == ropPut
check decoded.key == "k"
check decoded.value == value
test "empty or untagged payloads decode as invalid, not delete":
check decodeRepPayload(@[]).op == ropInvalid
check decodeRepPayload(cast[seq[byte]]("Xfoo")).op == ropInvalid
suite "Query operator correctness — audit batch 1":
test "power operator ** evaluates, not lowered to equality":
## Regression: bkPow used to fall through to `else: irOp = irEq`, so
## `2 ** 3` evaluated as `2 = 3` (false) instead of 8.
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'a')"))
let r = executeQuery(ctx, parse("SELECT 2 ** 3 AS x FROM users"))
check r.success
check r.rows.len == 1
check parseFloat(valueToString(r.rows[0]["x"])) == 8.0
test "concat operator ++ concatenates strings":
## Regression: bkConcat also fell through to irEq, so `'a' ++ 'b'`
## evaluated as `'a' = 'b'` (false) instead of "ab".
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'a')"))
let r = executeQuery(ctx, parse("SELECT 'a' ++ 'b' AS x FROM users"))
check r.success
check r.rows.len == 1
check valueToString(r.rows[0]["x"]) == "ab"
test "!= is the complement of = for numerically equal values":
## Regression: irNeq short-circuited on string inequality, so `1 != 1.0`
## was true while `1 = 1.0` was also true (not complements).
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'alice')"))
let eq = executeQuery(ctx, parse("SELECT * FROM users WHERE id = 1.0"))
let neq = executeQuery(ctx, parse("SELECT * FROM users WHERE id != 1.0"))
check eq.rows.len == 1 # 1 = 1.0 -> true
check neq.rows.len == 0 # 1 != 1.0 -> false (old bug returned the row)
suite "Query correctness — audit batch 2":
test "COUNT(DISTINCT) deduplicates values":
## Regression: funcDistinct was parsed but never copied to aggDistinct /
## never consulted during aggregation.
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'alice')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (2, 'bob')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (3, 'alice')"))
let r = executeQuery(ctx, parse("SELECT COUNT(DISTINCT name) AS c FROM users"))
check r.success
check r.rows.len == 1
check valueToString(r.rows[0]["c"]) == "2"
test "SUM(DISTINCT) sums unique values only":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'a')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (2, 'b')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (5, 'c')"))
# ids 1, 2, 5 — insert another row with id-like values via a number col
discard executeQuery(ctx, parse("CREATE TABLE nums (id INTEGER PRIMARY KEY, n INTEGER)"))
discard executeQuery(ctx, parse("INSERT INTO nums (id, n) VALUES (1, 10)"))
discard executeQuery(ctx, parse("INSERT INTO nums (id, n) VALUES (2, 10)"))
discard executeQuery(ctx, parse("INSERT INTO nums (id, n) VALUES (3, 20)"))
let r = executeQuery(ctx, parse("SELECT SUM(DISTINCT n) AS s FROM nums"))
check r.success
check r.rows.len == 1
check parseFloat(valueToString(r.rows[0]["s"])) == 30.0
test "UNION deduplicates without KeyError":
## Regression: set-op dedup used row["$value"] which projected rows lack.
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'alice')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (2, 'bob')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (3, 'alice')"))
let r = executeQuery(ctx, parse(
"SELECT name FROM users WHERE id = 1 UNION SELECT name FROM users WHERE id = 3"))
check r.success
check r.rows.len == 1
check valueToString(r.rows[0]["name"]) == "alice"
test "INTERSECT returns common rows":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'alice')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (2, 'bob')"))
let r = executeQuery(ctx, parse(
"SELECT name FROM users WHERE id <= 2 INTERSECT SELECT name FROM users WHERE id = 1"))
check r.success
check r.rows.len == 1
check valueToString(r.rows[0]["name"]) == "alice"
test "EXCEPT removes right-side rows":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'alice')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (2, 'bob')"))
let r = executeQuery(ctx, parse(
"SELECT name FROM users EXCEPT SELECT name FROM users WHERE id = 1"))
check r.success
check r.rows.len == 1
check valueToString(r.rows[0]["name"]) == "bob"
test "MERGE WHEN MATCHED THEN DELETE removes the row":
## Regression: mergeMatchedDelete was parsed but never executed.
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("CREATE TABLE inv (id INTEGER PRIMARY KEY, qty INTEGER)"))
discard executeQuery(ctx, parse("INSERT INTO inv (id, qty) VALUES (1, 10)"))
discard executeQuery(ctx, parse("INSERT INTO inv (id, qty) VALUES (2, 20)"))
discard executeQuery(ctx, parse("CREATE TABLE deltas (id INTEGER PRIMARY KEY, qty INTEGER)"))
discard executeQuery(ctx, parse("INSERT INTO deltas (id, qty) VALUES (1, 0)"))
let r = executeQuery(ctx, parse("""
MERGE INTO inv AS t
USING deltas AS s
ON t.id = s.id
WHEN MATCHED THEN DELETE
"""))
check r.success
check r.affectedRows >= 1
let left = executeQuery(ctx, parse("SELECT id FROM inv ORDER BY id"))
check left.success
check left.rows.len == 1
check valueToString(left.rows[0]["id"]) == "2"
test "semi-sync writeLsn returns 0 when replicas do not ack":
var rm = newReplicationManager(rmSemiSync, syncCount = 1)
rm.addReplica(newReplica("r1", "10.0.0.1", 9472))
rm.connectReplica("r1")
let lsn = rm.writeLsn(@[1'u8, 2, 3])
check lsn == 0
suite "Audit batch 3 — remaining 2026-08 findings":
test "TLS CA file auto-enables peer verify":
putEnv("BARADB_TLS_CA_FILE", "/tmp/ca.crt")
defer: delEnv("BARADB_TLS_CA_FILE")
var cfg = defaultConfig()
loadConfigFromEnv(cfg)
check cfg.tlsCaFile == "/tmp/ca.crt"
check cfg.tlsVerifyPeer == true
test "explicit BARADB_TLS_VERIFY_PEER=false wins over CA auto-enable":
putEnv("BARADB_TLS_CA_FILE", "/tmp/ca.crt")
putEnv("BARADB_TLS_VERIFY_PEER", "false")
defer:
delEnv("BARADB_TLS_CA_FILE")
delEnv("BARADB_TLS_VERIFY_PEER")
var cfg = defaultConfig()
loadConfigFromEnv(cfg)
check cfg.tlsVerifyPeer == false
test "production TLS without verify is rejected":
putEnv("BARADB_ENV", "production")
defer: delEnv("BARADB_ENV")
var cfg = defaultConfig()
cfg.authEnabled = true
cfg.jwtSecret = "a".repeat(32)
cfg.tlsEnabled = true
var msg = ""
try:
validateProductionConfig(cfg)
except ValueError as e:
msg = e.msg
check "TLS" in msg or "verify" in msg.toLower()
test "OFFSET without LIMIT returns remaining rows":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'a')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (2, 'b')"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (3, 'c')"))
let r = executeQuery(ctx, parse("SELECT id FROM users ORDER BY id OFFSET 1"))
check r.success
check r.rows.len == 2
check valueToString(r.rows[0]["id"]) == "2"
check valueToString(r.rows[1]["id"]) == "3"
test "LIMIT 0 returns no rows":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'a')"))
let r = executeQuery(ctx, parse("SELECT id FROM users LIMIT 0"))
check r.success
check r.rows.len == 0
test "negative LIMIT is clamped to empty, not IndexDefect":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'a')"))
let r = executeQuery(ctx, parse("SELECT id FROM users LIMIT -5"))
check r.success
check r.rows.len == 0
test "WebSocket decode rejects unmasked client frames":
let raw = encodeFrame(0x1, "SUBSCRIBE t", masked = false)
let (_, n) = decodeFrame(raw)
check n < 0
test "WebSocket decode accepts masked client frames":
let raw = encodeFrame(0x1, "SUBSCRIBE t", masked = true,
maskKey = [1'u8, 2, 3, 4])
let (frame, n) = decodeFrame(raw)
check n > 0
check frame.masked
check frame.payload == "SUBSCRIBE t"
test "WebSocket decode rejects oversized frame length":
var raw = newString(10)
raw[0] = char(0x81) # FIN + text
raw[1] = char(0xFF) # MASK + 127
# 8-byte length = 2 GiB
raw[2] = char(0)
raw[3] = char(0)
raw[4] = char(0)
raw[5] = char(0)
raw[6] = char(0x80)
raw[7] = char(0)
raw[8] = char(0)
raw[9] = char(0)
let (_, n) = decodeFrame(raw)
check n < 0
test "disttxn prepare against refused port fails closed":
var tm = newDistTxnManager()
let txn = tm.beginTransaction("coord")
txn.addParticipant("n1", "127.0.0.1", 1)
check txn.prepare() == false
check txn.isAborted
test "compact leaves input files on disk for catalog apply":
let testDir = "/tmp/baradb_bugfix_compact_order"
removeDir(testDir)
var db = newLSMTree(testDir, 128)
defer:
db.close()
removeDir(testDir)
for round in 0 ..< L0CompactionTrigger:
db.put("r" & $round, cast[seq[byte]]("v" & $round))
db.flush()
var cs = newCompactionStrategy(testDir)
cs.rebuildFromLSM(db)
let cr = cs.compact(0)
check cr.outputTables.len == 1
check fileExists(cr.outputTables[0].path)
for t in cr.inputTables:
check fileExists(t.path)
test "SCRAM unknown user fails without leaking existence":
var am = newAuthManager()
am.registerScramUser("alice", "wonderland")
var msg = ""
try:
discard am.startScram("n,,n=eve,r=abcnonceabcnonceabcn")
except ValueError as e:
msg = e.msg
check msg == "Authentication failed"
test "SCRAM rejects mismatched channel binding":
var am = newAuthManager()
am.registerScramUser("alice", "wonderland")
let clientNonce = generateNonce()
let clientFirst = "n,,n=alice,r=" & clientNonce
let serverFirst = am.startScram(clientFirst)
var combinedNonce = ""
for part in serverFirst.split(","):
if part.startsWith("r="): combinedNonce = part[2..^1]
let (ok, err) = am.finishScram("c=AAAA,r=" & combinedNonce & ",p=AA")
check ok == false
check err == "e=channel-bindings-dont-match"
test "SCRAM expected cbind for gs2 n is biws":
check expectedChannelBinding("n") == "biws"
suite "Audit batch 4 — B-tree separator (H10) and NULL equality (L4)":
test "B-tree separators stay valid after interleaved insert/remove":
## Search routes with `key > sep → right`. After delete, removeRec used to
## copy the right child's first key into the separator (right-min), which
## breaks max(left) <= sep < min(right).
var rng = initRand(20260828)
var btree = newBTreeIndex[int, string](order = 5)
var tracker = initTable[int, seq[string]]()
for i in 0..<400:
let k = rng.rand(0..80)
if rng.rand(0..2) < 2:
let v = "v" & $i
btree.insert(k, v)
if k notin tracker: tracker[k] = @[]
tracker[k].add(v)
else:
if k in tracker and tracker[k].len > 0:
let v = tracker[k][0]
btree.remove(k, v)
tracker[k].del(0)
if tracker[k].len == 0: tracker.del(k)
if i mod 50 == 49:
check btree.checkInvariants().len == 0
for k, vals in tracker:
check btree.get(k).toHashSet == vals.toHashSet
test "B-tree sequential fill + prefix delete keeps separators":
var btree = newBTreeIndex[int, string](order = 4)
for i in 0..<60:
btree.insert(i, "v" & $i)
for i in 0..<30:
btree.remove(i, "v" & $i)
check btree.checkInvariants().len == 0
for i in 30..<60:
check btree.get(i) == @["v" & $i]
for i in 0..<30:
check btree.get(i).len == 0
test "NULL = NULL is unknown, not true":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, NULL)"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (2, 'bob')"))
let eqNull = executeQuery(ctx, parse("SELECT id FROM users WHERE name = NULL"))
check eqNull.success
check eqNull.rows.len == 0
let isNull = executeQuery(ctx, parse("SELECT id FROM users WHERE name IS NULL"))
check isNull.success
check isNull.rows.len == 1
check valueToString(isNull.rows[0]["id"]) == "1"
test "NULL != value is unknown so WHERE excludes the row":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, NULL)"))
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (2, 'bob')"))
let r = executeQuery(ctx, parse("SELECT id FROM users WHERE name != 'bob'"))
check r.success
check r.rows.len == 0
test "SELECT NULL = NULL yields NULL, IS NULL is true":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'a')"))
let eq = executeQuery(ctx, parse("SELECT (NULL = NULL) AS x FROM users"))
check eq.success
check eq.rows.len == 1
check valueToString(eq.rows[0]["x"]) == "\\N"
let isn = executeQuery(ctx, parse("SELECT (NULL IS NULL) AS x FROM users"))
check isn.success
check valueToString(isn.rows[0]["x"]) == "true"
+480
View File
@@ -0,0 +1,480 @@
## Raft cold-node E2E — real 3-node cluster; end-to-end proof of the
## InstallSnapshot work (compaction, snapshot build/send, follower restore).
## Starts three actual build/baradadb processes with a tiny raft log
## (BARADB_RAFT_LOG_MAX_ENTRIES=16) and a short stale window
## (BARADB_RAFT_PEER_STALE_MS=3000) so the leader compacts quickly past a
## downed peer's matchIndex.
##
## Scenario A: a node is killed, 100 rows are written through the leader
## (forcing compaction past its matchIndex), the node restarts with its
## intact data dir and must catch up via InstallSnapshot within 15 s.
## Scenario B: the node is stopped, its data dir is WIPED, it rejoins with
## the same node id and must serve the full row set within 20 s.
##
## Process-management conventions follow tests/raft_writes_e2e_test.nim
## (copying is the repo's e2e convention — do not factor out).
import std/unittest
import std/osproc
import std/os
import std/strtabs
import std/strutils
import std/times
import std/net
import std/posix
import std/httpclient
import ../adaptors/nim/baradb_sqlite as sqlite
const
BinaryPath = "./build/baradadb"
LeaderMarker = "became leader"
SnapshotMarker = "Installing snapshot"
TableName = "cold_test"
RowCount = 100
type
NodeProc = object
id: string
clientPort: int
raftPort: int
peers: string
clientPeers: string
p: Process
dataDir: string
output: string
alive: bool
proc drainOutput(n: var NodeProc) =
## Reads whatever the child has written so far. The pipe was set O_NONBLOCK
## at start, so this never blocks — a hung read is impossible here.
var tmp: array[8192, char]
while true:
let count = posix.read(n.p.outputHandle.cint, tmp[0].addr, tmp.len)
if count <= 0: break
for i in 0 ..< count: n.output.add tmp[i]
proc drainAll(nodes: var seq[NodeProc]) =
for n in nodes.mitems:
if n.p != nil: n.drainOutput()
proc dumpAll(nodes: var seq[NodeProc]) =
## Debuggability: on failure, everything the nodes said.
nodes.drainAll()
for n in nodes:
echo "===== output of ", n.id, " (port ", n.clientPort, ") ====="
echo n.output
proc portOpen(port: int): bool =
var s: Socket
try:
s = newSocket()
s.connect("127.0.0.1", Port(port), timeout = 250)
s.close()
result = true
except CatchableError:
if s != nil: s.close()
result = false
proc killNode(n: var NodeProc) =
if n.p != nil and n.alive:
try:
n.p.terminate()
discard n.p.waitForExit()
except CatchableError:
discard
n.alive = false
proc leaderTerms(output: string): seq[int] =
## All terms this node logged leadership for ("became leader for term T").
var pos = 0
while true:
let idx = output.find(LeaderMarker, pos)
if idx < 0: break
let tIdx = output.find("term ", idx)
if tIdx < 0: break
let numStart = tIdx + 5
var numEnd = numStart
while numEnd < output.len and output[numEnd] in Digits: inc numEnd
if numEnd > numStart:
result.add(parseInt(output[numStart ..< numEnd]))
pos = numEnd
proc maxLeader(nodes: seq[NodeProc]): tuple[idx, term: int] =
## Node that logged leadership for the highest term seen so far.
result = (-1, 0)
for i in 0 ..< nodes.len:
for t in leaderTerms(nodes[i].output):
if t > result.term: result = (i, t)
proc drainFor(nodes: var seq[NodeProc], ms: int) =
let start = getTime()
while getTime() - start < initDuration(milliseconds = ms):
nodes.drainAll()
sleep(50)
proc openClient(port: int): DbConn =
## Connect with retries — the port may accept TCP before the DB is usable.
for i in 0 ..< 50:
try:
return open("127.0.0.1:" & $port, "", "", "default")
except CatchableError:
sleep(100)
raise newException(IOError, "cannot connect to port " & $port)
proc rowCountOn(port: int): int =
## SELECT COUNT(*) — raises while the table is not there yet (e.g. before
## the snapshot restore has landed); callers poll and tolerate that.
let db = openClient(port)
defer: db.close()
parseInt(db.getValue(sql("SELECT COUNT(*) FROM " & TableName)))
proc fetchMetric(port: int, name: string): int =
## GET /metrics on the node's HTTP port (clientPort + 440) and parse
## `name{...} <value>`. Returns -1 on any error or when absent.
let client = newHttpClient(timeout = 1500)
defer: client.close()
try:
let body = client.getContent("http://127.0.0.1:" & $(port + 440) & "/metrics")
for line in body.splitLines():
if line.startsWith(name & "{"):
let parts = line.splitWhitespace()
if parts.len >= 2:
return parseInt(parts[^1])
except CatchableError:
discard
return -1
proc startNode(id, dataDir: string, clientPort, raftPort: int,
peers, clientPeers: string): NodeProc =
## Boots one build/baradadb process. Takes the data dir explicitly so a
## node can be restarted with the same dir (scenario A) or a wiped dir
## (scenario B). Compaction/stale-window env goes on EVERY node.
createDir(dataDir)
var env = newStringTable()
for key, val in envPairs():
env[key] = val
env["BARADB_PORT"] = $clientPort
env["BARADB_RAFT_ENABLED"] = "true"
env["BARADB_RAFT_PORT"] = $raftPort
env["BARADB_RAFT_NODE_ID"] = id
env["BARADB_RAFT_PEERS"] = peers
env["BARADB_RAFT_CLIENT_PEERS"] = clientPeers
env["BARADB_RAFT_LOG_MAX_ENTRIES"] = "16"
env["BARADB_RAFT_PEER_STALE_MS"] = "3000"
env["BARADB_DATA_DIR"] = dataDir
env["BARADB_LOG_LEVEL"] = "info"
let p = startProcess(BinaryPath, env = env,
options = {poStdErrToStdOut, poDaemon})
discard fcntl(p.outputHandle.cint, F_SETFL,
fcntl(p.outputHandle.cint, F_GETFL) or O_NONBLOCK)
NodeProc(id: id, clientPort: clientPort, raftPort: raftPort,
peers: peers, clientPeers: clientPeers, p: p,
dataDir: dataDir, alive: true)
proc restartNode(n: var NodeProc, wipe: bool) =
## Kill, then boot the same node id again. wipe=false keeps the data dir
## (cold-node return); wipe=true deletes it first (fresh node rejoin).
n.killNode()
if n.p != nil: n.p.close()
if wipe:
removeDir(n.dataDir)
let fresh = startNode(n.id, n.dataDir, n.clientPort, n.raftPort,
n.peers, n.clientPeers)
n.p = fresh.p
n.alive = true
proc waitReady(n: NodeProc, deadlineSec: int): bool =
let start = getTime()
while getTime() - start < initDuration(seconds = deadlineSec):
if portOpen(n.clientPort):
return true
sleep(100)
return false
proc runColdNodeScenario() =
## Fatal phase failures dump all captured node output, record a test
## failure, and return; cleanup happens in the finally below either way.
let tstamp = getTime().toUnix.int
# Port base per the T11 brief: distinct from raft_writes_e2e_test
# (46000+mod4000) and raft_tls_e2e_test (54000+mod4000). Client ports are
# spaced by 10 because the server derives HTTP (port+440), WS (port+441)
# and gossip (raftPort+100) ports — consecutive client ports collide.
let cbase = 58000 + (tstamp mod 4000)
let rbase = cbase + 100
let peers = "n1@127.0.0.1:" & $(rbase + 1) &
",n2@127.0.0.1:" & $(rbase + 2) &
",n3@127.0.0.1:" & $(rbase + 3)
# SQL client ports for transparent leader write forwarding.
let clientPeers = "n1@127.0.0.1:" & $(cbase + 10) &
",n2@127.0.0.1:" & $(cbase + 20) &
",n3@127.0.0.1:" & $(cbase + 30)
var nodes: seq[NodeProc]
try:
# Node starts live inside the try so a raise from start #2/#3 still
# reaches the cleanup in the finally below.
for i in 1 .. 3:
let id = "n" & $i
let dataDir = getTempDir() / "baradb_raft_coldnode_e2e_" & $tstamp & "_" & id
nodes.add startNode(id, dataDir, cbase + i * 10, rbase + i,
peers, clientPeers)
# Readiness: all three client ports accept TCP connections (10s each).
for i in 0 ..< nodes.len:
if not waitReady(nodes[i], 10):
echo "node ", nodes[i].id, " never became ready"
dumpAll(nodes)
fail()
return
# Election: timeouts are 150-300ms, heartbeat 50ms — a leader should
# emerge within ~2s; 10s deadline for margin.
var elected = false
let electStart = getTime()
while getTime() - electStart < initDuration(seconds = 10):
nodes.drainAll()
if maxLeader(nodes).idx >= 0:
elected = true
break
sleep(50)
if not elected:
echo "no leader elected within 10s"
dumpAll(nodes)
fail()
return
# Settle and require stability, same as raft_writes_e2e_test.
nodes.drainFor(2000)
let (leaderIdx, leaderTerm) = maxLeader(nodes)
nodes.drainFor(1000)
let (stableIdx, stableTerm) = maxLeader(nodes)
if stableIdx != leaderIdx or stableTerm != leaderTerm:
echo "cluster unstable: leadership moved from ", nodes[leaderIdx].id,
" (term ", leaderTerm, ") to ", nodes[stableIdx].id,
" (term ", stableTerm, ")"
dumpAll(nodes)
fail()
return
echo "leader elected: ", nodes[leaderIdx].id, " (term ", leaderTerm, ")"
# The cold node: n3 unless n3 is the leader (then n1) — it must be a
# follower so killing it never forces a re-election.
let coldIdx = (if leaderIdx == 2: 0 else: 2)
let otherIdx = 3 - leaderIdx - coldIdx
echo "cold node: ", nodes[coldIdx].id, "; surviving follower: ",
nodes[otherIdx].id
# Schema: CREATE TABLE goes through the raft "ddl" log (C3c). Create on
# the leader; wait until the cold node has applied it before killing it.
block:
let db = openClient(nodes[leaderIdx].clientPort)
try:
db.exec(sql("CREATE TABLE " & TableName & " (id INT PRIMARY KEY, name STRING)"))
except CatchableError as e:
echo "leader CREATE TABLE failed: ", e.msg
dumpAll(nodes)
fail()
return
db.close()
block:
let start = getTime()
var ready = false
while getTime() - start < initDuration(seconds = 5):
try:
discard rowCountOn(nodes[coldIdx].clientPort)
ready = true
break
except CatchableError:
sleep(100)
if not ready:
echo "cold node ", nodes[coldIdx].id,
" never applied CREATE TABLE within 5s"
dumpAll(nodes)
fail()
return
echo "schema replicated to cold node ", nodes[coldIdx].id
# ---- Kill the cold node; write 100 rows through the leader. ----
# With logMaxEntries=16 and peerStaleMs=3000 the leader compacts past the
# dead node's matchIndex partway through the writes, so the node can only
# catch up via InstallSnapshot on return.
killNode(nodes[coldIdx])
echo "cold node ", nodes[coldIdx].id, " killed; writing ", RowCount, " rows"
block:
let db = openClient(nodes[leaderIdx].clientPort)
var atRow = 0
try:
for i in 1 .. RowCount:
atRow = i
db.exec(sql("INSERT INTO " & TableName & " (id, name) VALUES (" &
$i & ", 'row-" & $i & "')"))
except CatchableError as e:
echo "leader INSERT failed at row ", atRow, ": ", e.msg
dumpAll(nodes)
fail()
return
db.close()
echo RowCount, " rows committed with ", nodes[coldIdx].id, " down"
# Compaction evidence on the leader: the in-memory log stayed bounded
# (<= 64, far below the 100 entries written) and the snapshot base moved.
nodes.drainAll()
let leaderLogLen = fetchMetric(nodes[leaderIdx].clientPort,
"baradb_raft_log_entries")
let leaderSnapIdx = fetchMetric(nodes[leaderIdx].clientPort,
"baradb_raft_snapshot_index")
echo "leader after writes: log_entries=", leaderLogLen,
" snapshot_index=", leaderSnapIdx
if leaderLogLen < 0 or leaderLogLen > 64:
echo "leader raft log not bounded: baradb_raft_log_entries=",
leaderLogLen, " (want <= 64)"
dumpAll(nodes)
fail()
return
if leaderSnapIdx <= 0:
echo "leader never compacted: baradb_raft_snapshot_index=",
leaderSnapIdx, " (want > 0)"
dumpAll(nodes)
fail()
return
echo "leader log stayed bounded and compaction advanced"
# Sanity: leader and surviving follower agree on the row count.
let wantCount = rowCountOn(nodes[leaderIdx].clientPort)
if wantCount != RowCount:
echo "leader count=", wantCount, " want ", RowCount
dumpAll(nodes)
fail()
return
# ---- Scenario A: cold node returns with its intact data dir. ----
nodes[coldIdx].output.setLen(0) # fresh log for the snapshot marker scan
restartNode(nodes[coldIdx], wipe = false)
if not waitReady(nodes[coldIdx], 10):
echo "cold node ", nodes[coldIdx].id, " never became ready after restart"
dumpAll(nodes)
fail()
return
echo "scenario A: ", nodes[coldIdx].id, " restarted with intact data dir"
# Within 15s: snapshot evidence (metric on the cold node, or its restore
# log line) AND data convergence with the leader.
block:
let start = getTime()
var snapSeen = false
var converged = false
while getTime() - start < initDuration(seconds = 15):
nodes.drainAll()
if not snapSeen:
snapSeen = fetchMetric(nodes[coldIdx].clientPort,
"baradb_raft_snapshot_index") > 0 or
nodes[coldIdx].output.contains(SnapshotMarker)
if not converged:
try:
converged = rowCountOn(nodes[coldIdx].clientPort) == wantCount
except CatchableError:
discard
if snapSeen and converged: break
sleep(200)
if not snapSeen:
echo "scenario A: no snapshot evidence on ", nodes[coldIdx].id,
" (baradb_raft_snapshot_index stayed 0, no restore log line)"
dumpAll(nodes)
fail()
return
if not converged:
echo "scenario A: ", nodes[coldIdx].id,
" count did not converge to ", wantCount, " within 15s"
dumpAll(nodes)
fail()
return
echo "scenario A: snapshot installed, count converged to ", wantCount
# Spot-check a few ids survived the snapshot round-trip.
block:
let db = openClient(nodes[coldIdx].clientPort)
defer: db.close()
for i in [1, 42, RowCount]:
let v = db.getValue(sql("SELECT name FROM " & TableName &
" WHERE id = " & $i))
if v != "row-" & $i:
echo "scenario A: spot-check id=", i, " got '", v,
"' want 'row-", i, "'"
dumpAll(nodes)
fail()
return
echo "scenario A: spot-checks passed"
# ---- Scenario B: wiped node rejoins with the same node id. ----
nodes[coldIdx].output.setLen(0)
restartNode(nodes[coldIdx], wipe = true)
if not waitReady(nodes[coldIdx], 10):
echo "wiped node ", nodes[coldIdx].id, " never became ready after restart"
dumpAll(nodes)
fail()
return
echo "scenario B: ", nodes[coldIdx].id, " restarted with a wiped data dir"
# Within 20s: snapshot → catch-up, then the full row set.
block:
let start = getTime()
var snapSeen = false
var converged = false
while getTime() - start < initDuration(seconds = 20):
nodes.drainAll()
if not snapSeen:
snapSeen = fetchMetric(nodes[coldIdx].clientPort,
"baradb_raft_snapshot_index") > 0 or
nodes[coldIdx].output.contains(SnapshotMarker)
if not converged:
try:
converged = rowCountOn(nodes[coldIdx].clientPort) == wantCount
except CatchableError:
discard
if snapSeen and converged: break
sleep(200)
if not snapSeen:
echo "scenario B: no snapshot evidence on wiped ", nodes[coldIdx].id
dumpAll(nodes)
fail()
return
if not converged:
echo "scenario B: wiped ", nodes[coldIdx].id,
" count did not converge to ", wantCount, " within 20s"
dumpAll(nodes)
fail()
return
echo "scenario B: wiped node converged to ", wantCount, " rows"
# Spot-check again on the wiped node.
block:
let db = openClient(nodes[coldIdx].clientPort)
defer: db.close()
for i in [1, 42, RowCount]:
let v = db.getValue(sql("SELECT name FROM " & TableName &
" WHERE id = " & $i))
if v != "row-" & $i:
echo "scenario B: spot-check id=", i, " got '", v,
"' want 'row-", i, "'"
dumpAll(nodes)
fail()
return
echo "scenario B: spot-checks passed"
finally:
for n in nodes.mitems:
n.killNode()
if n.p != nil: n.p.close()
removeDir(n.dataDir)
suite "Raft cold-node E2E":
test "compacted-away node and wiped node rejoin and converge":
if not fileExists(BinaryPath):
if getEnv("CI").len > 0:
echo "[FAIL] ", BinaryPath, " missing under CI — build step broken?"
fail()
else:
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
skip()
else:
runColdNodeScenario()
+6 -2
View File
@@ -208,7 +208,11 @@ proc runClusterScenario() =
suite "Raft E2E cluster":
test "3-node election and failover":
if not fileExists(BinaryPath):
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
skip()
if getEnv("CI").len > 0:
echo "[FAIL] ", BinaryPath, " missing under CI — build step broken?"
fail()
else:
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
skip()
else:
runClusterScenario()
+435
View File
@@ -0,0 +1,435 @@
## Raft failover-under-load E2E — real 3-node cluster over the TCP transport.
## A writer thread hammers the current leader with INSERTs; once 50 writes
## have been acknowledged the leader is killed mid-load. Asserts:
## A (availability): a survivor accepts an INSERT within 10s of the kill.
## B (durability): every acknowledged id is present on BOTH survivors
## once the new leader is stable and the remaining
## follower has caught up.
## Process-management conventions follow tests/raft_writes_e2e_test.nim;
## client access follows tests/nimforum_smoke_test.nim.
import std/unittest
import std/osproc
import std/os
import std/strtabs
import std/strutils
import std/sequtils
import std/times
import std/net
import std/posix
import std/sets
import std/locks
import std/typedthreads
import ../adaptors/nim/baradb_sqlite as sqlite
const
BinaryPath = "./build/baradadb"
LeaderMarker = "became leader"
AckTarget = 50 # kill the leader once this many writes are acked
ProbeId = 1000001 # availability-probe row id (clear of writer's 1..N)
type
NodeProc = object
id: string
clientPort: int
p: Process
dataDir: string
output: string
alive: bool
# Shared writer-thread state, passed by pointer into the thread (same
# convention as tests/test_storage_hardening.nim); all access goes through
# the lock.
type
WriterArgs = object
lock: ptr Lock
acked: ptr seq[int] # ids whose INSERT was acknowledged by the cluster
stop: ptr bool # main thread sets this to end the writer loop
ports: array[3, int] # candidate client ports (leader first, then survivors)
proc drainOutput(n: var NodeProc) =
## Reads whatever the child has written so far. The pipe was set O_NONBLOCK
## at start, so this never blocks — a hung read is impossible here.
var tmp: array[8192, char]
while true:
let count = posix.read(n.p.outputHandle.cint, tmp[0].addr, tmp.len)
if count <= 0: break
for i in 0 ..< count: n.output.add tmp[i]
proc drainAll(nodes: var seq[NodeProc]) =
for n in nodes.mitems:
if n.p != nil: n.drainOutput()
proc dumpAll(nodes: var seq[NodeProc]) =
## Debuggability: on failure, everything the nodes said.
nodes.drainAll()
for n in nodes:
echo "===== output of ", n.id, " (port ", n.clientPort, ") ====="
echo n.output
proc portOpen(port: int): bool =
var s: Socket
try:
s = newSocket()
s.connect("127.0.0.1", Port(port), timeout = 250)
s.close()
result = true
except CatchableError:
if s != nil: s.close()
result = false
proc killNode(n: var NodeProc) =
if n.p != nil and n.alive:
try:
n.p.terminate()
discard n.p.waitForExit()
except CatchableError:
discard
n.alive = false
proc leaderTerms(output: string): seq[int] =
## All terms this node logged leadership for ("became leader for term T").
var pos = 0
while true:
let idx = output.find(LeaderMarker, pos)
if idx < 0: break
let tIdx = output.find("term ", idx)
if tIdx < 0: break
let numStart = tIdx + 5
var numEnd = numStart
while numEnd < output.len and output[numEnd] in Digits: inc numEnd
if numEnd > numStart:
result.add(parseInt(output[numStart ..< numEnd]))
pos = numEnd
proc maxLeader(nodes: seq[NodeProc]): tuple[idx, term: int] =
## Node that logged leadership for the highest term seen so far.
result = (-1, 0)
for i in 0 ..< nodes.len:
for t in leaderTerms(nodes[i].output):
if t > result.term: result = (i, t)
proc drainFor(nodes: var seq[NodeProc], ms: int) =
let start = getTime()
while getTime() - start < initDuration(milliseconds = ms):
nodes.drainAll()
sleep(50)
proc openClient(port: int): DbConn =
## Connect with retries — the port may accept TCP before the DB is usable.
for i in 0 ..< 50:
try:
return open("127.0.0.1:" & $port, "", "", "default")
except CatchableError:
sleep(100)
raise newException(IOError, "cannot connect to port " & $port)
proc countRows(port: int): int =
## Row count of load_test on `port`, or -1 on any error (not ready yet).
result = -1
try:
let db = openClient(port)
try:
result = parseInt(db.getValue(sql"SELECT count(*) FROM load_test"))
finally:
db.close()
except CatchableError:
discard
proc allIds(port: int): HashSet[int] =
## Every id in load_test on `port`. Raises on error.
let db = openClient(port)
try:
for row in db.getAllRows(sql"SELECT id FROM load_test"):
if row.len >= 1 and row[0].len > 0:
result.incl(parseInt(row[0]))
finally:
db.close()
proc writerLoop(args: WriterArgs) {.thread.} =
## INSERTs n = 1, 2, ... against the cluster. Every acknowledged n is
## appended to args.acked. On any error the client is dropped and reopened
## against the next candidate port — the documented client retry contract.
## Writes against followers are forwarded to the leader by the server.
var n = 1
var portIdx = 0
var db: DbConn
while true:
withLock args.lock[]:
if args.stop[]: break
if cast[pointer](db) == nil:
let port = args.ports[portIdx]
try:
db = open("127.0.0.1:" & $port, "", "", "default")
except CatchableError:
portIdx = (portIdx + 1) mod args.ports.len
sleep(50)
continue
try:
db.exec(sql("INSERT INTO load_test (id) VALUES (" & $n & ")"))
withLock args.lock[]:
args.acked[].add n
inc n
except CatchableError:
if cast[pointer](db) != nil:
try: db.close()
except CatchableError: discard
db = default(DbConn)
portIdx = (portIdx + 1) mod args.ports.len
sleep(50)
if cast[pointer](db) != nil:
try: db.close()
except CatchableError: discard
proc runFailoverLoadScenario() =
## Fatal phase failures dump all captured node output, record a test
## failure, and return; cleanup happens in the finally below either way.
let tstamp = getTime().toUnix.int
# Port bases: distinct from nimforum_smoke_test (35000+mod10000),
# raft_e2e_test (41000+mod5000) and raft_writes_e2e_test (46000+mod4000).
# Client ports are spaced by 10 because the server derives HTTP (port+440),
# WS (port+441) and gossip (raftPort+100) ports — consecutive client ports
# collide.
let cbase = 50000 + (tstamp mod 4000)
let rbase = cbase + 100
let peers = "n1@127.0.0.1:" & $(rbase + 1) &
",n2@127.0.0.1:" & $(rbase + 2) &
",n3@127.0.0.1:" & $(rbase + 3)
# SQL client ports for transparent leader write forwarding.
let clientPeers = "n1@127.0.0.1:" & $(cbase + 10) &
",n2@127.0.0.1:" & $(cbase + 20) &
",n3@127.0.0.1:" & $(cbase + 30)
var nodes: seq[NodeProc]
for i in 1 .. 3:
let id = "n" & $i
let dataDir = getTempDir() / "baradb_raft_failover_load_e2e_" & $tstamp & "_" & id
createDir(dataDir)
var env = newStringTable()
for key, val in envPairs():
env[key] = val
env["BARADB_PORT"] = $(cbase + i * 10)
env["BARADB_RAFT_ENABLED"] = "true"
env["BARADB_RAFT_PORT"] = $(rbase + i)
env["BARADB_RAFT_NODE_ID"] = id
env["BARADB_RAFT_PEERS"] = peers
env["BARADB_RAFT_CLIENT_PEERS"] = clientPeers
env["BARADB_DATA_DIR"] = dataDir
env["BARADB_LOG_LEVEL"] = "info"
let p = startProcess(BinaryPath, env = env,
options = {poStdErrToStdOut, poDaemon})
discard fcntl(p.outputHandle.cint, F_SETFL,
fcntl(p.outputHandle.cint, F_GETFL) or O_NONBLOCK)
nodes.add NodeProc(id: id, clientPort: cbase + i * 10, p: p,
dataDir: dataDir, alive: true)
var
writer: Thread[WriterArgs]
writerStarted = false
wLock: Lock
wAcked: seq[int]
wStop = false
initLock(wLock)
try:
# Readiness: all three client ports accept TCP connections (10s each).
for i in 0 ..< nodes.len:
let readyStart = getTime()
var ok = false
while getTime() - readyStart < initDuration(seconds = 10):
if portOpen(nodes[i].clientPort):
ok = true
break
sleep(100)
if not ok:
echo "node ", nodes[i].id, " never became ready"
dumpAll(nodes)
fail()
return
# Election: timeouts are 150-300ms, heartbeat 50ms — a leader should
# emerge within ~2s; 10s deadline for margin.
var elected = false
let electStart = getTime()
while getTime() - electStart < initDuration(seconds = 10):
nodes.drainAll()
if maxLeader(nodes).idx >= 0:
elected = true
break
sleep(50)
if not elected:
echo "no leader elected within 10s"
dumpAll(nodes)
fail()
return
# Settle and require stability, same as raft_writes_e2e_test.
nodes.drainFor(2000)
let (leaderIdx, leaderTerm) = maxLeader(nodes)
nodes.drainFor(1000)
let (stableIdx, stableTerm) = maxLeader(nodes)
if stableIdx != leaderIdx or stableTerm != leaderTerm:
echo "cluster unstable: leadership moved from ", nodes[leaderIdx].id,
" (term ", leaderTerm, ") to ", nodes[stableIdx].id,
" (term ", stableTerm, ")"
dumpAll(nodes)
fail()
return
echo "leader elected: ", nodes[leaderIdx].id, " (term ", leaderTerm, ")"
# Schema goes through the raft ddl log on the leader (C3c).
block:
let db = openClient(nodes[leaderIdx].clientPort)
try:
db.exec(sql"CREATE TABLE load_test (id INT PRIMARY KEY)")
except CatchableError as e:
echo "leader CREATE TABLE failed: ", e.msg
dumpAll(nodes)
fail()
return
db.close()
echo "leader schema committed via raft ddl"
# Load phase: sustained INSERTs from a background writer thread.
let wArgs = WriterArgs(
lock: addr wLock, acked: addr wAcked, stop: addr wStop,
ports: [nodes[leaderIdx].clientPort,
nodes[(leaderIdx + 1) mod 3].clientPort,
nodes[(leaderIdx + 2) mod 3].clientPort])
createThread(writer, writerLoop, wArgs)
writerStarted = true
# Wait until AckTarget writes are acknowledged (30s deadline).
var ackedAtKill = 0
let loadStart = getTime()
while getTime() - loadStart < initDuration(seconds = 30):
withLock wLock:
ackedAtKill = wAcked.len
if ackedAtKill >= AckTarget: break
sleep(20)
if ackedAtKill < AckTarget:
echo "only ", ackedAtKill, " writes acked within 30s (need ", AckTarget, ")"
dumpAll(nodes)
fail()
return
echo "load phase: ", ackedAtKill, " writes acknowledged, killing leader ",
nodes[leaderIdx].id
# Kill the leader mid-load.
killNode(nodes[leaderIdx])
let killTime = getTime()
# Assert A (availability): a survivor accepts an INSERT within 10s of
# the kill. Probe both survivors; keep the one that answers. Each attempt
# uses a fresh id: an attempt may commit but lose its response during the
# failover, and retrying the same id would then loop on UNIQUE violations.
var writerSurvivor = -1
var writeErr = ""
var probeAttempt = 0
while getTime() - killTime < initDuration(seconds = 10):
for i in 0 ..< nodes.len:
if i == leaderIdx: continue
inc probeAttempt
let probeId = ProbeId + probeAttempt
try:
let db = openClient(nodes[i].clientPort)
try:
db.exec(sql("INSERT INTO load_test (id) VALUES (" & $probeId & ")"))
writerSurvivor = i
finally:
db.close()
if writerSurvivor >= 0: break
except CatchableError as e:
writeErr = e.msg
# "not leader" / commit timeout / connection blips — keep probing.
if writerSurvivor >= 0: break
sleep(100)
if writerSurvivor < 0:
echo "ASSERT A FAILED: no survivor accepted a write within 10s of the kill",
(if writeErr.len > 0: " (last error: " & writeErr & ")" else: "")
dumpAll(nodes)
fail()
return
let availMs = inMilliseconds(getTime() - killTime)
echo "availability: ", nodes[writerSurvivor].id,
" accepted a write ", availMs, "ms after the kill"
# Stop the writer thread and snapshot what was acknowledged.
withLock wLock:
wStop = true
joinThreads(writer)
writerStarted = false
withLock wLock:
ackedAtKill = wAcked.len
echo "writer stopped; ", ackedAtKill, " total acknowledged writes"
# Let the new leader stabilize and the remaining follower catch up:
# poll until both survivors agree on a row count that covers every
# acknowledged write (10s deadline). The >= ackedAtKill guard prevents
# a trivial 0 == 0 pass before any raft entries have been applied.
let survivorIdx = [0, 1, 2].filterIt(it != leaderIdx)
var caughtUp = false
let cuStart = getTime()
while getTime() - cuStart < initDuration(seconds = 10):
let c0 = countRows(nodes[survivorIdx[0]].clientPort)
let c1 = countRows(nodes[survivorIdx[1]].clientPort)
if c0 >= ackedAtKill and c0 == c1:
caughtUp = true
break
sleep(100)
if not caughtUp:
echo "survivors never reached equal row counts within 10s (",
countRows(nodes[survivorIdx[0]].clientPort), " vs ",
countRows(nodes[survivorIdx[1]].clientPort), ")"
dumpAll(nodes)
fail()
return
echo "survivors caught up: equal row counts"
# Assert B (durability): every acknowledged id must be present on BOTH
# survivors.
var acked: HashSet[int]
withLock wLock:
acked = toHashSet(wAcked)
for i in survivorIdx:
var ids: HashSet[int]
try:
ids = allIds(nodes[i].clientPort)
except CatchableError as e:
echo "ASSERT B FAILED: could not read ids from ", nodes[i].id,
": ", e.msg
dumpAll(nodes)
fail()
return
let missing = acked - ids
if missing.len > 0:
echo "ASSERT B FAILED: ", nodes[i].id, " is missing ",
missing.len, " acknowledged ids"
dumpAll(nodes)
fail()
return
echo "durability: ", nodes[i].id, " contains all ",
acked.len, " acknowledged ids"
check acked.len >= AckTarget
finally:
if writerStarted:
withLock wLock:
wStop = true
joinThreads(writer)
deinitLock(wLock)
for n in nodes.mitems:
n.killNode()
if n.p != nil: n.p.close()
removeDir(n.dataDir)
suite "Raft failover under load E2E":
test "committed writes survive a leader kill under sustained write load":
if not fileExists(BinaryPath):
if getEnv("CI").len > 0:
echo "[FAIL] ", BinaryPath, " missing under CI — build step broken?"
fail()
else:
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
skip()
else:
runFailoverLoadScenario()
+445
View File
@@ -0,0 +1,445 @@
## Raft TLS E2E — real 3-node cluster with TLS on the raft transport.
## Starts three actual build/baradadb processes with per-node self-signed
## certs (BARADB_RAFT_TLS_ENABLED + CERT/KEY_FILE), asserts election and
## replicated writes work over the encrypted transport, then starts a 4th
## plaintext node pointed at the same peers and asserts it never becomes
## leader (its frames are undecryptable) while the TLS cluster keeps
## operating among its 3 members.
## Process-management conventions follow tests/raft_writes_e2e_test.nim
## (copying is the repo's e2e convention — do not factor out).
##
## NOTE: only the raft port is TLS here. The SQL client port stays
## plaintext (BARADB_TLS_ENABLED is the client wire port — a different
## feature), so the baradb_sqlite adaptor connects as usual.
import std/unittest
import std/osproc
import std/os
import std/strtabs
import std/strutils
import std/times
import std/net
import std/posix
import ../adaptors/nim/baradb_sqlite as sqlite
import barabadb/protocol/ssl
const
BinaryPath = "./build/baradadb"
LeaderMarker = "became leader"
type
NodeProc = object
id: string
clientPort: int
raftPort: int
p: Process
dataDir: string
output: string
alive: bool
proc drainOutput(n: var NodeProc) =
## Reads whatever the child has written so far. The pipe was set O_NONBLOCK
## at start, so this never blocks — a hung read is impossible here.
var tmp: array[8192, char]
while true:
let count = posix.read(n.p.outputHandle.cint, tmp[0].addr, tmp.len)
if count <= 0: break
for i in 0 ..< count: n.output.add tmp[i]
proc drainAll(nodes: var seq[NodeProc]) =
for n in nodes.mitems:
if n.p != nil: n.drainOutput()
proc dumpAll(nodes: var seq[NodeProc]) =
## Debuggability: on failure, everything the nodes said.
nodes.drainAll()
for n in nodes:
echo "===== output of ", n.id, " (port ", n.clientPort, ") ====="
echo n.output
proc portOpen(port: int): bool =
var s: Socket
try:
s = newSocket()
s.connect("127.0.0.1", Port(port), timeout = 250)
s.close()
result = true
except CatchableError:
if s != nil: s.close()
result = false
proc tlsHandshake(port: int): bool =
## True when a real TLS client handshake completes against `port`.
## Wire-level discriminator: against a plaintext peer it fails — either
## immediately (SSL error on the garbage reply) or, when the peer swallows
## our ClientHello and waits for more bytes, via the 3s recv timeout.
var ctx: SslContext
var s: Socket
try:
ctx = newContext(verifyMode = CVerifyNone)
s = newSocket()
var tv = Timeval(tvSec: posix.Time(3), tvUsec: Suseconds(0))
discard setsockopt(s.getFd(), SOL_SOCKET, SO_RCVTIMEO,
addr tv, SockLen(sizeof(tv)))
s.connect("127.0.0.1", Port(port), timeout = 3000)
ctx.wrapConnectedSocket(s, handshakeAsClient)
result = true
except CatchableError:
result = false
finally:
if s != nil: s.close()
if ctx != nil: ctx.destroyContext()
proc killNode(n: var NodeProc) =
if n.p != nil and n.alive:
try:
n.p.terminate()
discard n.p.waitForExit()
except CatchableError:
discard
n.alive = false
proc leaderTerms(output: string): seq[int] =
## All terms this node logged leadership for ("became leader for term T").
var pos = 0
while true:
let idx = output.find(LeaderMarker, pos)
if idx < 0: break
let tIdx = output.find("term ", idx)
if tIdx < 0: break
let numStart = tIdx + 5
var numEnd = numStart
while numEnd < output.len and output[numEnd] in Digits: inc numEnd
if numEnd > numStart:
result.add(parseInt(output[numStart ..< numEnd]))
pos = numEnd
proc maxLeader(nodes: seq[NodeProc]): tuple[idx, term: int] =
## Node that logged leadership for the highest term seen so far.
result = (-1, 0)
for i in 0 ..< nodes.len:
for t in leaderTerms(nodes[i].output):
if t > result.term: result = (i, t)
proc drainFor(nodes: var seq[NodeProc], ms: int) =
let start = getTime()
while getTime() - start < initDuration(milliseconds = ms):
nodes.drainAll()
sleep(50)
proc openClient(port: int): DbConn =
## Connect with retries — the port may accept TCP before the DB is usable.
for i in 0 ..< 50:
try:
return open("127.0.0.1:" & $port, "", "", "default")
except CatchableError:
sleep(100)
raise newException(IOError, "cannot connect to port " & $port)
proc waitForRow(port: int, table, name: string, deadlineSec: int): bool =
## Poll SELECT on `port` until a row with `name` appears. Tolerates errors
## (e.g. "unknown table" while schema has not been created yet) by retrying.
let db = openClient(port)
defer: db.close()
let start = getTime()
while getTime() - start < initDuration(seconds = deadlineSec):
try:
let rows = db.getAllRows(sql("SELECT * FROM " & table))
for row in rows:
if row.len >= 2 and row[1] == name:
return true
except CatchableError:
discard
sleep(100)
return false
proc startNode(id: string, clientPort, raftPort: int, peers, clientPeers: string,
tlsEnabled: bool): NodeProc =
## Boots one build/baradadb process. When tlsEnabled, a self-signed cert
## (CN = node id) is generated into the node's data dir first.
let tstamp = getTime().toUnix.int
let dataDir = getTempDir() / "baradb_raft_tls_e2e_" & $tstamp & "_" & id
createDir(dataDir)
var env = newStringTable()
for key, val in envPairs():
env[key] = val
env["BARADB_PORT"] = $clientPort
env["BARADB_RAFT_ENABLED"] = "true"
env["BARADB_RAFT_PORT"] = $raftPort
env["BARADB_RAFT_NODE_ID"] = id
env["BARADB_RAFT_PEERS"] = peers
env["BARADB_RAFT_CLIENT_PEERS"] = clientPeers
env["BARADB_DATA_DIR"] = dataDir
env["BARADB_LOG_LEVEL"] = "info"
if tlsEnabled:
let (certFile, keyFile) = generateSelfSignedCert(dataDir, id)
doAssert certFile.len > 0 and keyFile.len > 0,
"openssl cert generation failed for " & id
env["BARADB_RAFT_TLS_ENABLED"] = "true"
env["BARADB_RAFT_TLS_CERT_FILE"] = certFile
env["BARADB_RAFT_TLS_KEY_FILE"] = keyFile
let p = startProcess(BinaryPath, env = env,
options = {poStdErrToStdOut, poDaemon})
discard fcntl(p.outputHandle.cint, F_SETFL,
fcntl(p.outputHandle.cint, F_GETFL) or O_NONBLOCK)
NodeProc(id: id, clientPort: clientPort, raftPort: raftPort, p: p,
dataDir: dataDir, alive: true)
proc runTlsScenario() =
## Fatal phase failures dump all captured node output, record a test
## failure, and return; cleanup happens in the finally below either way.
let tstamp = getTime().toUnix.int
# Port base per the T6 brief: distinct from raft_writes_e2e_test
# (46000+mod4000). Client ports are spaced by 10 because the server
# derives HTTP (port+440), WS (port+441) and gossip (raftPort+100)
# ports — consecutive client ports collide.
let cbase = 54000 + (tstamp mod 4000)
let rbase = cbase + 100
let peers = "n1@127.0.0.1:" & $(rbase + 1) &
",n2@127.0.0.1:" & $(rbase + 2) &
",n3@127.0.0.1:" & $(rbase + 3)
# SQL client ports for transparent leader write forwarding.
let clientPeers = "n1@127.0.0.1:" & $(cbase + 10) &
",n2@127.0.0.1:" & $(cbase + 20) &
",n3@127.0.0.1:" & $(cbase + 30)
var nodes: seq[NodeProc]
# Negative-case node: same peers, raft TLS DISABLED, own dir and ports
# (4th port in each base range). Started later, after the TLS cluster is
# up, so the positive assertions are not polluted by its noise.
var rogue: NodeProc
try:
# Node starts live inside the try so a raise from start #2/#3 still
# reaches the cleanup in the finally below.
for i in 1 .. 3:
nodes.add startNode("n" & $i, cbase + i * 10, rbase + i,
peers, clientPeers, tlsEnabled = true)
# Readiness: all three client ports accept TCP connections (10s each).
for i in 0 ..< nodes.len:
let readyStart = getTime()
var ok = false
while getTime() - readyStart < initDuration(seconds = 10):
if portOpen(nodes[i].clientPort):
ok = true
break
sleep(100)
if not ok:
echo "node ", nodes[i].id, " never became ready"
dumpAll(nodes)
fail()
return
# Election over TLS: timeouts are 150-300ms, heartbeat 50ms — a leader
# should emerge within ~2s; 10s deadline for margin.
var elected = false
let electStart = getTime()
while getTime() - electStart < initDuration(seconds = 10):
nodes.drainAll()
if maxLeader(nodes).idx >= 0:
elected = true
break
sleep(50)
if not elected:
echo "no leader elected within 10s over TLS"
dumpAll(nodes)
fail()
return
# Settle and require stability, same as raft_writes_e2e_test.
nodes.drainFor(2000)
let (leaderIdx, leaderTerm) = maxLeader(nodes)
nodes.drainFor(1000)
let (stableIdx, stableTerm) = maxLeader(nodes)
if stableIdx != leaderIdx or stableTerm != leaderTerm:
echo "TLS cluster unstable: leadership moved from ", nodes[leaderIdx].id,
" (term ", leaderTerm, ") to ", nodes[stableIdx].id,
" (term ", stableTerm, ")"
dumpAll(nodes)
fail()
return
echo "TLS leader elected: ", nodes[leaderIdx].id, " (term ", leaderTerm, ")"
# Wire-level TLS assertion: a real client handshake must complete
# against every cluster node's raft port. Without this, the negative
# "plaintext node never becomes leader" check alone cannot distinguish
# TLS rejection from "can't win an election anyway" — if TLS wrapping
# were silently dropped from the transport, these handshakes raise and
# the suite fails.
for n in nodes.items:
if not tlsHandshake(n.raftPort):
echo "TLS handshake failed against raft port of ", n.id,
" (port ", n.raftPort, ") — raft transport not encrypted?"
dumpAll(nodes)
fail()
return
echo "TLS handshake verified against all 3 raft ports"
let followerIdx = (if leaderIdx == 0: 1 else: 0)
# Schema: CREATE TABLE goes through the raft "ddl" log (C3c) — this
# proves raft DDL replication works over the TLS transport.
block:
let db = openClient(nodes[leaderIdx].clientPort)
try:
db.exec(sql"CREATE TABLE tls_test (id INT PRIMARY KEY, name STRING)")
except CatchableError as e:
echo "leader CREATE TABLE failed: ", e.msg
dumpAll(nodes)
fail()
return
db.close()
echo "leader schema committed via raft ddl over TLS"
# Wait until the follower has applied CREATE TABLE (SELECT no longer
# errors with unknown table). Deadline 5s.
block:
let db = openClient(nodes[followerIdx].clientPort)
defer: db.close()
let start = getTime()
var ready = false
while getTime() - start < initDuration(seconds = 5):
try:
discard db.getAllRows(sql"SELECT * FROM tls_test")
ready = true
break
except CatchableError:
sleep(100)
if not ready:
echo "follower ", nodes[followerIdx].id,
" never applied CREATE TABLE within 5s"
dumpAll(nodes)
fail()
return
echo "schema replicated to follower ", nodes[followerIdx].id, " over TLS"
# Leader write: INSERT goes through the raft log and waits for majority
# commit before responding — expect success over TLS.
block:
let db = openClient(nodes[leaderIdx].clientPort)
try:
db.exec(sql"INSERT INTO tls_test (id, name) VALUES (1, 'tls-row')")
except CatchableError as e:
echo "leader INSERT failed: ", e.msg
dumpAll(nodes)
fail()
return
db.close()
echo "leader INSERT committed over TLS"
# Follower visibility: poll until the row shows up (5s deadline).
if not waitForRow(nodes[followerIdx].clientPort, "tls_test", "tls-row", 5):
echo "follower ", nodes[followerIdx].id,
" never saw the replicated row within 5s"
dumpAll(nodes)
fail()
return
echo "row replicated to follower ", nodes[followerIdx].id, " over TLS"
# Negative scenario: start the plaintext node pointed at the same peers.
# Its TLS-less frames are undecryptable to the cluster, so it can never
# collect votes and must never become leader.
rogue = startNode("n4", cbase + 40, rbase + 4, peers, clientPeers,
tlsEnabled = false)
block:
let readyStart = getTime()
var ok = false
while getTime() - readyStart < initDuration(seconds = 10):
if portOpen(rogue.clientPort):
ok = true
break
sleep(100)
if not ok:
echo "plaintext node never became ready"
rogue.drainOutput()
echo "===== output of ", rogue.id, " (port ", rogue.clientPort, ") ====="
echo rogue.output
dumpAll(nodes)
fail()
return
echo "plaintext node n4 started against the TLS peers"
# Complementary wire-level negative: the rogue node's raft port speaks
# plaintext, so a TLS handshake against it must FAIL.
if tlsHandshake(rogue.raftPort):
echo "TLS handshake unexpectedly succeeded against plaintext n4 ",
"raft port ", rogue.raftPort
dumpAll(nodes)
fail()
return
echo "TLS handshake correctly fails against plaintext n4 raft port"
# Give n4 many election cycles (timeouts 150-300ms) to try its luck.
# The TLS cluster must keep operating among its 3 members meanwhile.
# Drain n4's pipe alongside the others — its connection-refused spam
# must not fill the pipe and block the child.
block:
let negStart = getTime()
while getTime() - negStart < initDuration(seconds = 6):
nodes.drainAll()
rogue.drainOutput()
sleep(50)
# Cluster still elects/operates: leader among the 3 TLS nodes accepts a
# write and it replicates to a follower, with n4 running.
nodes.drainAll()
let (leaderIdx2, leaderTerm2) = maxLeader(nodes)
if leaderIdx2 < 0:
echo "TLS cluster lost its leader after plaintext node joined"
dumpAll(nodes)
fail()
return
let followerIdx2 = (if leaderIdx2 == 0: 1 else: 0)
block:
let db = openClient(nodes[leaderIdx2].clientPort)
try:
db.exec(sql"INSERT INTO tls_test (id, name) VALUES (2, 'still-tls')")
except CatchableError as e:
echo "post-plaintext INSERT failed on ", nodes[leaderIdx2].id,
" (term ", leaderTerm2, "): ", e.msg
rogue.drainOutput()
echo "===== output of ", rogue.id, " (port ", rogue.clientPort, ") ====="
echo rogue.output
dumpAll(nodes)
fail()
return
db.close()
if not waitForRow(nodes[followerIdx2].clientPort, "tls_test", "still-tls", 5):
echo "post-plaintext row never replicated to ", nodes[followerIdx2].id
dumpAll(nodes)
fail()
return
echo "TLS cluster kept operating with plaintext node present"
# Final negative assertion: n4 never won an election.
rogue.drainOutput()
if rogue.output.contains(LeaderMarker):
echo "plaintext node became leader — TLS isolation broken"
echo "===== output of ", rogue.id, " (port ", rogue.clientPort, ") ====="
echo rogue.output
dumpAll(nodes)
fail()
return
echo "plaintext node never became leader (TLS rejection confirmed)"
finally:
for n in nodes.mitems:
n.killNode()
if n.p != nil: n.p.close()
removeDir(n.dataDir)
if rogue.p != nil:
rogue.killNode()
rogue.p.close()
removeDir(rogue.dataDir)
suite "Raft TLS E2E":
test "3-node TLS cluster elects and replicates; plaintext node rejected":
if not fileExists(BinaryPath):
if getEnv("CI").len > 0:
echo "[FAIL] ", BinaryPath, " missing under CI — build step broken?"
fail()
else:
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
skip()
else:
runTlsScenario()
+6 -2
View File
@@ -412,7 +412,11 @@ proc runWritesScenario() =
suite "Raft replicated writes E2E":
test "writes replicate, followers reject, failover resumes writes":
if not fileExists(BinaryPath):
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
skip()
if getEnv("CI").len > 0:
echo "[FAIL] ", BinaryPath, " missing under CI — build step broken?"
fail()
else:
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
skip()
else:
runWritesScenario()
+731 -8
View File
@@ -1,6 +1,7 @@
## BaraDB — Test Suite
import std/unittest
import std/tables
import std/sets
import std/strutils
import std/os
import std/asyncdispatch
@@ -8,11 +9,13 @@ import std/asyncnet
import std/monotimes
import std/base64
import std/json
import std/streams
import barabadb/core/types
import barabadb/core/mvcc
import barabadb/core/deadlock
import barabadb/core/config
import barabadb/core/backup
import barabadb/core/server
import barabadb/core/columnar
import barabadb/core/raft
@@ -1656,14 +1659,28 @@ suite "Replication":
rm.connectReplica("r2")
rm.connectReplica("r3")
# Unreachable replicas cannot ack — semi-sync must fail closed (return 0)
let lsn = rm.writeLsn(@[1'u8])
check not rm.isFullyAcked(lsn) # needs 2 acks
check lsn == 0
rm.ackLsn("r1", lsn)
check not rm.isFullyAcked(lsn) # still needs 1 more
# No connected replicas → nothing to wait for; write succeeds
var rm2 = newReplicationManager(rmSemiSync, syncCount = 2)
rm2.addReplica(newReplica("r1", "10.0.0.1", 9472))
# not connected
let lsn2 = rm2.writeLsn(@[1'u8])
check lsn2 > 0
check rm2.isFullyAcked(lsn2)
rm.ackLsn("r2", lsn)
check rm.isFullyAcked(lsn) # 2 acks received
# ackLsn bookkeeping still clears pendingAcks at the required quorum
var rm3 = newReplicationManager(rmSemiSync, syncCount = 2)
rm3.pendingAcks[1'u64] = initHashSet[string]()
rm3.pendingAcks[1'u64].incl("r1")
rm3.pendingAcks[1'u64].incl("r2")
check not rm3.isFullyAcked(1)
rm3.ackLsn("r1", 1)
check not rm3.isFullyAcked(1)
rm3.ackLsn("r2", 1)
check rm3.isFullyAcked(1)
test "Replica status":
var rm = newReplicationManager(rmAsync)
@@ -2607,6 +2624,613 @@ 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 "commit requires strict majority for even-sized clusters":
## Regression: the commit quorum used (N+1) div 2, which for a 4-node
## cluster commits at 2/4 (a minority). Strict majority is N div 2 + 1.
var node = newRaftNode("leader", @["p1", "p2", "p3"])
node.currentTerm = 5
node.state = rsLeader
let e = node.appendLog("put", cast[seq[byte]]("k\x00v"))
check e.index == 1
check e.term == 5
node.nextIndex["p1"] = 2
node.nextIndex["p2"] = 2
node.nextIndex["p3"] = 2
# Leader + 1 peer (count=2) is NOT a majority of 4.
node.handleAppendReply("p1", RaftMessage(
kind: rmkAppendEntriesReply, term: 5, senderId: "p1",
success: true, matchIdx: 1))
check node.commitIndex == 0
# Leader + 2 peers (count=3) IS a strict majority of 4 -> commits.
node.handleAppendReply("p2", RaftMessage(
kind: rmkAppendEntriesReply, term: 5, senderId: "p2",
success: true, matchIdx: 1))
check node.commitIndex == 1
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
# sendSnapshot now gzips the tar off the event loop, so the assembled
# archive is gzip-compressed; decompress before comparing the bytes.
let raw = p & ".raw"
defer:
if fileExists(raw): removeFile(raw)
if not gunzipFile(p, raw):
return false
result = readFile(raw) == 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 archives (uncompressed tar + compressed .tar.gz) cleaned up after
# the transfer
check not fileExists(tmp / "raft-l" / "snap_out_100.tar.gz")
check not fileExists(tmp / "raft-l" / "snap_out_100.tar")
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 +3294,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 +3384,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 +3453,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 +3462,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
@@ -2816,6 +3487,43 @@ suite "Raft SQL Write Path":
let (found, _) = db.get("t.id=1")
check not found
test "REP receiver chain (encode -> decode -> apply) maintains indexes":
## Mirrors server.nim's legacy REP handler: decodeRepPayload decides the op,
## then applyReplicatedPut/Delete keep secondary indexes consistent. Guards
## the wiring the receiver relies on — an indexed put must populate the
## B-tree and a PK-only put (empty value) must apply as a put, not vanish.
var testDir = getTempDir() / "baradb_rep_recv_idx_" & $getCurrentProcessId() & "_" & $getMonoTime().ticks
createDir(testDir)
defer: removeDir(testDir)
var db = newLSMTree(testDir)
var ctx = qexec.newExecutionContext(db)
discard qexec.executeQuery(ctx, parse(
"CREATE TABLE t (id INT PRIMARY KEY, name STRING)"))
discard qexec.executeQuery(ctx, parse(
"CREATE INDEX idx_name ON t (name)"))
# Leader ships an indexed put; the receiver decodes and applies it.
let put = decodeRepPayload(
encodeRepPayload(false, "t.id=1", cast[seq[byte]]("name=alice")))
check put.op == ropPut
if put.op == ropPut:
applyReplicatedPut(ctx, put.key, put.value)
check ctx.btrees["t.name"].get("alice").len >= 1
# A PK-only put (empty value) must apply as a put, not a delete.
let pk = decodeRepPayload(encodeRepPayload(false, "t.id=2", @[]))
check pk.op == ropPut
if pk.op == ropPut:
applyReplicatedPut(ctx, pk.key, pk.value)
let (foundPk, _) = db.get("t.id=2")
check foundPk
# Leader ships a delete; the receiver drops the row and the index entry.
let del = decodeRepPayload(encodeRepPayload(true, "t.id=1", @[]))
check del.op == ropDelete
if del.op == ropDelete:
applyReplicatedDelete(ctx, del.key)
check ctx.btrees["t.name"].get("alice").len == 0
let (foundDel, _) = db.get("t.id=1")
check not foundDel
test "applyReplicatedPut updates in-memory graphs":
var testDir = getTempDir() / "baradb_raft_apply_g_" & $getCurrentProcessId() & "_" & $getMonoTime().ticks
createDir(testDir)
@@ -3590,6 +4298,21 @@ suite "Window Functions":
if row["name"] == "Bob":
check row["last_sal"] == "90000"
test "SUM/AVG/COUNT window aggregates over a running frame":
## Default frame is UNBOUNDED PRECEDING .. CURRENT ROW (lower.nim).
let r = qexec.executeQuery(ctx, parse(
"SELECT name, salary, SUM(salary) OVER (ORDER BY salary) AS running, COUNT(*) OVER (ORDER BY salary) AS cnt FROM employees"))
check r.success
check r.rows.len == 5
for row in r.rows:
if row["name"] == "Charlie":
check parseFloat($row["running"]) == 70000.0
check $row["cnt"] == "1"
if row["name"] == "Eve":
# 70000+75000+80000+90000+95000
check parseFloat($row["running"]) == 410000.0
check $row["cnt"] == "5"
suite "GROUP BY Aggregates":
var db: LSMTree
var ctx: qexec.ExecutionContext