Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ed97fb075 | |||
| e44341e47c | |||
| ccc54e8f18 | |||
| a843f0a1a3 | |||
| dac92d1741 | |||
| d303cc5658 | |||
| ad90ebcd5e | |||
| 9ff9c2f6be | |||
| c94bac43e5 | |||
| 862d62590e | |||
| efa04e4b36 | |||
| cb9cd7415d | |||
| f416fe930e | |||
| f2b7ed1ce2 | |||
| 2f30a59216 | |||
| ed89c88afa | |||
| 8d083f5fdc | |||
| efa46b05c6 | |||
| 431334b70a | |||
| 63cb05afe2 |
@@ -61,6 +61,30 @@ jobs:
|
|||||||
done
|
done
|
||||||
echo "--- 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:
|
verify:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ tests/bugfix_test
|
|||||||
tests/nimforum_smoke_test
|
tests/nimforum_smoke_test
|
||||||
tests/raft_e2e_test
|
tests/raft_e2e_test
|
||||||
tests/raft_writes_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/bench_all
|
||||||
benchmarks/compare
|
benchmarks/compare
|
||||||
clients/nim/tests/test_client
|
clients/nim/tests/test_client
|
||||||
@@ -68,5 +71,6 @@ src/barabadb/storage/lsm
|
|||||||
src/barabadb/storage/wal
|
src/barabadb/storage/wal
|
||||||
src/barabadb/storage/btree
|
src/barabadb/storage/btree
|
||||||
src/barabadb/storage/gate
|
src/barabadb/storage/gate
|
||||||
|
src/barabadb/protocol/scram
|
||||||
clients/nim/tests/test_pool
|
clients/nim/tests/test_pool
|
||||||
clients/nim/tests/test_wire
|
clients/nim/tests/test_wire
|
||||||
|
|||||||
@@ -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`.*
|
||||||
@@ -2,6 +2,78 @@
|
|||||||
|
|
||||||
All notable changes to BaraDB are documented in this file.
|
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 1–4)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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
|
## [1.2.0] — 2026-07-30
|
||||||
|
|
||||||
### Production GA (single-node)
|
### Production GA (single-node)
|
||||||
|
|||||||
@@ -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 (M10–M12), SCRAM (L1–L2).
|
||||||
|
|
||||||
|
**Батч 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 | ✅ Завършен |
|
| **Този план** — Сесии 10, 11, 12 | ✅ Завършен |
|
||||||
| Raft C3a/C3b + DDL/forward/compact/metrics (2026-07-30) | ✅ Завършен на `main` — `docs/superpowers/specs/2026-07-30-raft-cluster-status.md` |
|
| 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` |
|
| **Production GA v1.2.0** (single-node) | ✅ `docs/superpowers/plans/2026-07-30-production-ga.md` |
|
||||||
|
| **Сесия 13** — Stabilization & Deep Audit (2026-08) | ✅ Батч 1–4 (28 поправки); `BUG_AUDIT_2026-08.md` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -170,4 +199,4 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*План версия: 2026-05-17*
|
*План версия: 2026-08-02*
|
||||||
|
|||||||
@@ -1576,7 +1576,7 @@ features are still being refined:
|
|||||||
| LSM-Tree SSTable reads | ✅ Implemented | Full disk I/O with compaction, WAL, and bloom filters. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
## 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)
|
- **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
|
## License
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,5 @@
|
|||||||
# Package
|
# Package
|
||||||
version = "1.2.0"
|
version = "1.3.0"
|
||||||
author = "BaraDB Team"
|
author = "BaraDB Team"
|
||||||
description = "BaraDB — Multimodal database written in Nim"
|
description = "BaraDB — Multimodal database written in Nim"
|
||||||
license = "BSD-3-Clause"
|
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",
|
for t in ["test_minimal", "test_all", "bugfix_test", "join_tests", "test_lock",
|
||||||
"test_schema_persist", "test_storage_hardening", "tla_faithfulness",
|
"test_schema_persist", "test_storage_hardening", "tla_faithfulness",
|
||||||
"nimforum_smoke_test", "raft_e2e_test", "raft_writes_e2e_test",
|
"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",
|
"fuzz_test", "prop_test",
|
||||||
"test_wire_insert_stress", "stress_test"]:
|
"test_wire_insert_stress", "stress_test"]:
|
||||||
exec "nim c -r tests/" & t & ".nim"
|
exec "nim c -r tests/" & t & ".nim"
|
||||||
|
|||||||
+13
-2
@@ -5,7 +5,7 @@ BaraDB поддържа разпределено внедряване с Raft к
|
|||||||
> ⚠️ **Ограничение при множество бази данни**
|
> ⚠️ **Ограничение при множество бази данни**
|
||||||
> Разпределените модули (Raft, шардиране и репликация) в момента работят само с **`default`** базата данни. Ако използвате множество бази (`CREATE DATABASE`, `USE DATABASE`), разпределените функции още не ги обхващат. Всяка база данни се нуждае от отделна кластър конфигурация.
|
> Разпределените модули (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 Консенсус
|
## Raft Консенсус
|
||||||
|
|
||||||
@@ -20,10 +20,21 @@ Leader election и log репликация през TCP; SQL DML/DDL за **def
|
|||||||
| `BARADB_RAFT_WRITE_TIMEOUT_MS` | Макс. изчакване за majority commit при SQL записи (по подразбиране 5000) |
|
| `BARADB_RAFT_WRITE_TIMEOUT_MS` | Макс. изчакване за majority commit при SQL записи (по подразбиране 5000) |
|
||||||
| `BARADB_RAFT_CLIENT_PEERS` | Опционален `id@host:clientPort` map за leader write forwarding |
|
| `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_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 индекси/графи.
|
Когато 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`, …).
|
**Metrics:** при включен raft `GET /metrics` (HTTP = `BARADB_PORT + 440`) дава Prometheus редове: `baradb_raft_is_leader`, `baradb_raft_term`, `baradb_raft_log_entries`, `baradb_raft_apply_lag`, `baradb_raft_commit_wait_ms_total`, `baradb_raft_elections_total`, `baradb_raft_forwards_total`, `baradb_raft_compactions_total`. `GET /health` включва обект `raft` (`role`, `term`, `leader_id`, …).
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Известни ограничения — v1.2.0 Production GA
|
# Известни ограничения — v1.3.0
|
||||||
|
|
||||||
| Ниво | Значение |
|
| Ниво | Значение |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
@@ -8,25 +8,36 @@
|
|||||||
|
|
||||||
## Матрица
|
## Матрица
|
||||||
|
|
||||||
| Област | GA (v1.2.0) | Experimental / по-късно |
|
| Област | v1.3.0 | Experimental / по-късно |
|
||||||
|--------|-------------|-------------------------|
|
|--------|--------|-------------------------|
|
||||||
| Single-node SQL + LSM | **Supported** | — |
|
| Single-node SQL + LSM | **Supported** | — |
|
||||||
| Schema / FTS / HNSW / graphs persist | **Supported** | — |
|
| Schema / FTS / HNSW / graphs persist | **Supported** | — |
|
||||||
| Auth + JWT (когато е конфигуриран) | **Supported** | — |
|
| Auth + JWT (когато е конфигуриран) | **Supported** | — |
|
||||||
| Backup / restore | **Supported** | — |
|
| Backup / restore | **Supported** | — |
|
||||||
| Multi-DB (без Raft) | **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` |
|
| 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 |
|
| 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 по подразбиране |
|
| ORC multi-thread shared LSM | **Not supported** | ARC по подразбиране |
|
||||||
|
|
||||||
## GA (single-node)
|
## GA (single-node)
|
||||||
|
|
||||||
Crash recovery с WAL, schema/index persist, `/health` + `/metrics`, offline backup/restore.
|
Crash recovery с WAL, schema/index persist, `/health` + `/metrics`, offline backup/restore.
|
||||||
|
|
||||||
## Raft
|
## Raft (supported)
|
||||||
|
|
||||||
Виж [distributed.md](distributed.md). Staging/ops, **не** v1.2.0 HA продукт.
|
Виж [distributed.md](distributed.md). Поддържан обхват: 3-node, DML/DDL само върху `default`, failover под товар (acked writes оцеляват; in-flight writes → грешка, retry), raft TLS, cold-node recovery чрез InstallSnapshot.
|
||||||
|
|
||||||
|
## Нови ограничения
|
||||||
|
|
||||||
|
- **Legacy REP replication (без raft)** — пътят още извежда delete от празна стойност; insert в PK-only таблица се прилага грешно по него (редът изчезва). Използвай raft.
|
||||||
|
- **Snapshot-restore ctx** — след InstallSnapshot restore HTTP endpoints със startup-captured ctx може да сервират стари данни до рестарт (`/query` е свеж per-request); съществуващите клиентски връзки виждат pre-restore състояние — reconnect след restore.
|
||||||
|
- **FK-cascade дивергенция под raft** — ефектите на `ON DELETE/UPDATE CASCADE` (и `SET NULL`) не се реплицират през raft: followers прилагат само KV промяната на родителския ред, така че каскадираните дъщерни редове остават на followers. Избягвай FK actions върху raft-реплицирани таблици или приеми периодичен snapshot resync.
|
||||||
|
- **Непотвърдени записи в snapshots** — leader прилага записите локално преди raft majority commit; snapshot, направен в този прозорец, може да включи записи, които никога не се commit-ват (фантомни редове след restore + смяна на leadership). Тесен прозорец; поправката е планирана за следващ release.
|
||||||
|
- **Блокиране на event loop при snapshot build/restore** — snapshot build/restore изпълнява блокиращ tar/gzip на event loop на възела; големи data dirs могат да забавят heartbeats и да предизвикат election по средата на трансфер.
|
||||||
|
|
||||||
## Виж също
|
## Виж също
|
||||||
|
|
||||||
|
|||||||
+18
-2
@@ -5,7 +5,7 @@ BaraDB supports distributed deployment with Raft consensus, sharding, and replic
|
|||||||
> ⚠️ **Multi-Database Limitation**
|
> ⚠️ **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.
|
> 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
|
## 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_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_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_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.
|
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`, …).
|
**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_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_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
|
## Sharding
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Known Limitations — v1.2.0 Production GA
|
# Known Limitations — v1.3.0
|
||||||
|
|
||||||
This page defines **what BaraDB promises** in the v1.2.0 production cut.
|
This page defines **what BaraDB promises** in the v1.3.0 production cut.
|
||||||
|
|
||||||
| Tier | Meaning |
|
| Tier | Meaning |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
@@ -10,20 +10,22 @@ This page defines **what BaraDB promises** in the v1.2.0 production cut.
|
|||||||
|
|
||||||
## Support matrix
|
## Support matrix
|
||||||
|
|
||||||
| Area | GA (v1.2.0) | Experimental / later |
|
| Area | v1.3.0 | Notes |
|
||||||
|------|-------------|----------------------|
|
|------|--------|-------|
|
||||||
| Single-node SQL + LSM storage | **Supported** | — |
|
| Single-node SQL + LSM storage | **Supported** | — |
|
||||||
| Schema persistence (tables, indexes) | **Supported** | — |
|
| Schema persistence (tables, indexes) | **Supported** | — |
|
||||||
| FTS / HNSW / graphs across restart | **Supported** | — |
|
| FTS / HNSW / graphs across restart | **Supported** | — |
|
||||||
| Auth + JWT (when configured) | **Supported** | — |
|
| Auth + JWT (when configured) | **Supported** | — |
|
||||||
| Backup / restore (offline, all-databases) | **Supported** | — |
|
| Backup / restore (offline, all-databases) | **Supported** | — |
|
||||||
| Multi-database (non-Raft) | **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` |
|
| 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 |
|
| 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`) |
|
| 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 |
|
| Postgres wire protocol | **Not supported** | Bara wire + HTTP |
|
||||||
|
|
||||||
## Single-node GA (what you can rely on)
|
## 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
|
- HTTP `/health` and `/metrics` for process liveness
|
||||||
- Offline backup of `data/databases` and restore onto an empty data root
|
- 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**
|
- 3-node cluster, SQL DML/DDL on **`default` only**
|
||||||
- Safe log prefix compact (not full InstallSnapshot)
|
- Failover under write load: every acknowledged write survives a leader kill; in-flight writes fail fast — clients must retry
|
||||||
- Failover proven in process e2e tests
|
- 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
|
## Operational requirements
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Release checklist — v1.2.0 Production GA
|
# Release checklist — v1.3.0 raft-supported
|
||||||
|
|
||||||
Use before tagging and publishing artifacts.
|
Use before tagging and publishing artifacts.
|
||||||
|
|
||||||
@@ -6,8 +6,8 @@ Use before tagging and publishing artifacts.
|
|||||||
|
|
||||||
- [ ] Working tree clean on `main`
|
- [ ] Working tree clean on `main`
|
||||||
- [ ] [Known limitations](known-limitations.md) accurate
|
- [ ] [Known limitations](known-limitations.md) accurate
|
||||||
- [ ] `CHANGELOG.md` has dated `## [1.2.0]` (not Unreleased for shipped items)
|
- [ ] `CHANGELOG.md` has dated `## [1.3.0]` (not Unreleased for shipped items)
|
||||||
- [ ] `baradadb.nimble` version `1.2.0`
|
- [ ] `baradadb.nimble` version `1.3.0`
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -23,9 +23,12 @@ nim c -d:ssl --threads:on --path:src -r tests/test_schema_persist.nim
|
|||||||
./scripts/backup-restore-drill.sh
|
./scripts/backup-restore-drill.sh
|
||||||
DRILL_PORT=19482 ./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_e2e_test
|
||||||
./tests/raft_writes_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
|
## Production compose
|
||||||
@@ -41,17 +44,17 @@ docker compose -f docker-compose.prod.yml config >/dev/null
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
nimble build_release # or: nim c -d:release -o:build/baradadb src/baradadb.nim
|
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
|
## Tag
|
||||||
|
|
||||||
```bash
|
```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
|
git push origin main --tags
|
||||||
```
|
```
|
||||||
|
|
||||||
## Post-release
|
## Post-release
|
||||||
|
|
||||||
- [ ] Smoke: start prod compose, `/health` → ok, auth required for `/query`
|
- [ ] Smoke: start prod compose, `/health` → ok, auth required for `/query`
|
||||||
- [ ] Announce: single-node GA; Raft experimental (link known-limitations)
|
- [ ] Announce: raft-supported release (3-node, `default` DB); link known-limitations
|
||||||
|
|||||||
@@ -0,0 +1,598 @@
|
|||||||
|
# v1.3.0 Raft-Supported — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
> Spec first: `docs/superpowers/specs/2026-07-30-raft-supported-design.md`.
|
||||||
|
|
||||||
|
**Goal:** Move raft from experimental to supported: proven failover under
|
||||||
|
load, mandatory CI e2e, cold-node recovery via InstallSnapshot, raft-port TLS.
|
||||||
|
|
||||||
|
**Architecture:** No changes to election/AppendEntries semantics. New
|
||||||
|
InstallSnapshot message pair rides the existing framed TCP transport
|
||||||
|
(backward-compatible trailing fields, `RaftProtoVersion` stays 1). TLS wraps
|
||||||
|
the existing transport via `protocol/ssl.nim`. Snapshot payload reuses
|
||||||
|
`core/backup.nim` tar.gz backup/restore.
|
||||||
|
|
||||||
|
**Tech stack:** Nim 2.2.x, std/asyncnet + std/net SSL, existing
|
||||||
|
`tests/raft_*_e2e_test.nim` process harness, GitHub Actions.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Spec: `docs/superpowers/specs/2026-07-30-raft-supported-design.md`.
|
||||||
|
- Do **not** change election safety, commit rules, or the DDL/DML
|
||||||
|
classification from C3b/C3c.
|
||||||
|
- Raft remains default-DB-only; snapshot payload covers the default DB only.
|
||||||
|
- Compile all touched Nim with `-d:ssl --threads:on --path:src`.
|
||||||
|
- Test baseline per task: `tests/test_all.nim` + `tests/bugfix_test.nim` must
|
||||||
|
stay green; raft e2e suites green where the binary is built.
|
||||||
|
- Branch: `main` (short-lived feature branches merged same day are fine).
|
||||||
|
- Version bump to 1.3.0 happens only in the final task (T12).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase map
|
||||||
|
|
||||||
|
| Phase | Tasks | Outcome |
|
||||||
|
|-------|-------|---------|
|
||||||
|
| P1 Proof | T1 | Failover-under-load e2e |
|
||||||
|
| P2 CI | T2 | Mandatory raft e2e CI gate |
|
||||||
|
| P3 TLS | T3–T6 | Raft port TLS + mutual auth + TLS e2e |
|
||||||
|
| P4 Cold node | T7–T11 | InstallSnapshot + compaction unpin + cold-node e2e |
|
||||||
|
| P5 Release | T12 | Docs, limitations, version 1.3.0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Failover-under-load E2E
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/raft_failover_load_e2e_test.nim`
|
||||||
|
- Modify: `baradadb.nimble` (task `test`, line ~30-34: add `raft_failover_load_e2e_test` after `raft_writes_e2e_test`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: process harness conventions from `tests/raft_writes_e2e_test.nim`
|
||||||
|
(`NodeProc`, `drainOutput`, `portOpen`, `openClient`, `waitForRow` pattern);
|
||||||
|
client `adaptors/nim/baradb_sqlite`.
|
||||||
|
- Produces: suite `Raft failover under load E2E`.
|
||||||
|
|
||||||
|
**Scenario (spec D1):**
|
||||||
|
- Port base `cbase = 50000 + (tstamp mod 4000)`, `rbase = cbase + 100`
|
||||||
|
(distinct from 35000/41000/46000 bases already in use).
|
||||||
|
- Boot 3 nodes with `BARADB_RAFT_*` env exactly as
|
||||||
|
`raft_writes_e2e_test.nim:150-171`.
|
||||||
|
- Wait for stable leader (same `maxLeader` logic). Leader DDL:
|
||||||
|
`CREATE TABLE load_test (id INT PRIMARY KEY)`.
|
||||||
|
- Load phase: spawn a Nim `Thread` that loops `n = 1, 2, ...`:
|
||||||
|
`INSERT INTO load_test (id) VALUES (n)` against the current leader's
|
||||||
|
client port; every **acknowledged** `n` appended to a
|
||||||
|
`seq[int]` guarded by a `Lock`. On exception: reopen client against a
|
||||||
|
survivor, continue (this models the documented client retry contract).
|
||||||
|
- At ≥ 50 acked writes: `killNode(leader)`.
|
||||||
|
- Assert A (availability): some survivor accepts an INSERT within 10 s of
|
||||||
|
the kill.
|
||||||
|
- Assert B (durability): after new leader is stable and the remaining
|
||||||
|
follower caught up (poll `SELECT count(*)` equality or 10 s deadline),
|
||||||
|
`SELECT id FROM load_test` on **both** survivors contains every acked id.
|
||||||
|
- Stop the writer thread in `finally`; reuse the `dumpAll`-on-fail
|
||||||
|
convention.
|
||||||
|
|
||||||
|
- [x] **Step 1:** Write the suite skeleton: harness copied from
|
||||||
|
`raft_writes_e2e_test.nim` (drain/kill/leader-discovery helpers), writer
|
||||||
|
thread, kill at 50 acked, asserts A + B.
|
||||||
|
- [x] **Step 2:** Build binary and run:
|
||||||
|
`nim c -o:build/baradadb src/baradadb.nim && nim c -d:ssl --threads:on --path:src -r tests/raft_failover_load_e2e_test.nim`
|
||||||
|
Expected: PASS.
|
||||||
|
- [x] **Step 3:** Run 3 consecutive times (failover timing flakiness check).
|
||||||
|
- [x] **Step 4:** Add suite to `nimble test` list in `baradadb.nimble`.
|
||||||
|
- [x] **Step 5:** Commit
|
||||||
|
`test(raft): failover under sustained write load e2e`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1a: Fix raft put/delete encoding for empty values (bug found in T1)
|
||||||
|
|
||||||
|
**Bug:** `execInsert` (`src/barabadb/query/exec/dml.nim:60-90`) stores only
|
||||||
|
non-PK columns in the value, so a PK-only table row gets `valStr = ""` and
|
||||||
|
`kvPairs.add((fullKey, @[]))`. `appendWriteToRaft`
|
||||||
|
(`src/barabadb/core/server.nim:309-330`) encodes an empty value as a
|
||||||
|
`"delete"` log entry — but `execDelete` (`dml.nim:223-241`) uses the same
|
||||||
|
`(fullKey, @[])` shape for real deletes. Result: INSERT into a PK-only
|
||||||
|
table returns OK after majority commit, then every node (leader included,
|
||||||
|
on apply) **deletes the row**. Verified live in T1: 30 acked inserts → 0
|
||||||
|
rows on all nodes.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/barabadb/query/exec/types.nim:139` — `ExecResult.keyValuePairs`
|
||||||
|
- Modify: `src/barabadb/query/exec/dml.nim` — 3 producer sites (insert ~90,
|
||||||
|
delete ~241, update ~316)
|
||||||
|
- Modify: `src/barabadb/core/server.nim` — `appendWriteToRaft` (~309) and
|
||||||
|
its call site (~441-464)
|
||||||
|
- Test: `tests/bugfix_test.nim` (new suite)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Change the pair type to carry the op explicitly:
|
||||||
|
|
||||||
|
```nim
|
||||||
|
# exec/types.nim
|
||||||
|
keyValuePairs*: seq[tuple[key: string, value: seq[byte], deleted: bool]]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `execInsert`/`execUpdate` produce `deleted: false` (even when
|
||||||
|
`value.len == 0`); `execDelete` produces `deleted: true`.
|
||||||
|
- `appendWriteToRaft` encodes `deleted` → `"delete"`, else `"put"`
|
||||||
|
(empty value stays a put). Apply side (`baradadb.nim:358-368`) already
|
||||||
|
handles `put` with empty value correctly — no change needed there.
|
||||||
|
- Check other `keyValuePairs` consumers compile clean (replication path in
|
||||||
|
`server.nim`); keep `okResult(kvPairs=...)` call sites type-correct.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
- [x] **Step 1:** Write the failing test in `tests/bugfix_test.nim`:
|
||||||
|
build `ExecResult` via the insert path for a PK-only table (or call
|
||||||
|
`appendWriteToRaft` semantics directly): assert a PK-only insert yields
|
||||||
|
a pair with `deleted == false` and encodes as `"put"`, while a delete
|
||||||
|
yields `deleted == true` and encodes as `"delete"`.
|
||||||
|
- [x] **Step 2:** Run, expect fail/compile error.
|
||||||
|
- [x] **Step 3:** Implement the type + producer/consumer changes.
|
||||||
|
- [x] **Step 4:** `bugfix_test` + `test_all` green; rebuild
|
||||||
|
`build/baradadb` and re-run `tests/raft_failover_load_e2e_test.nim` —
|
||||||
|
then **switch its table back** to the brief's original
|
||||||
|
`load_test (id INT PRIMARY KEY)` / `VALUES (n)` shape (remove the
|
||||||
|
two-column workaround and its header note) and re-run green.
|
||||||
|
- [x] **Step 5:** Commit
|
||||||
|
`fix(raft): distinguish put-with-empty-value from delete in write path`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Mandatory raft e2e CI gate
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.github/workflows/ci.yml`
|
||||||
|
- Modify: `tests/raft_e2e_test.nim`, `tests/raft_writes_e2e_test.nim`,
|
||||||
|
`tests/raft_failover_load_e2e_test.nim` (skip→fail under CI)
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
- [x] **Step 1:** In each suite's binary-missing branch, replace plain
|
||||||
|
`skip()` with:
|
||||||
|
|
||||||
|
```nim
|
||||||
|
if not fileExists(BinaryPath):
|
||||||
|
if getEnv("CI").len > 0:
|
||||||
|
echo "[FAIL] ", BinaryPath, " missing under CI — build step broken?"
|
||||||
|
fail()
|
||||||
|
else:
|
||||||
|
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
|
||||||
|
skip()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2:** Add a dedicated job to `.github/workflows/ci.yml` (after
|
||||||
|
the `test` job), modeled on its setup steps:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
raft-e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup Nim
|
||||||
|
uses: jiro4989/setup-nim-action@v1
|
||||||
|
with:
|
||||||
|
nim-version: '2.2.10'
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: sudo apt-get update -qq && sudo apt-get install -y -qq libssl-dev libpcre3-dev openssl ca-certificates
|
||||||
|
- name: Install Nim dependencies
|
||||||
|
run: nimble install --depsOnly -y
|
||||||
|
- name: Build server
|
||||||
|
run: nim c -d:ssl -o:build/baradadb src/baradadb.nim
|
||||||
|
- name: Raft e2e suites
|
||||||
|
env:
|
||||||
|
CI: "true"
|
||||||
|
run: |
|
||||||
|
nim c -d:ssl --threads:on --path:src -r tests/raft_e2e_test.nim
|
||||||
|
nim c -d:ssl --threads:on --path:src -r tests/raft_writes_e2e_test.nim
|
||||||
|
nim c -d:ssl --threads:on --path:src -r tests/raft_failover_load_e2e_test.nim
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3:** Push on a branch; confirm the `raft-e2e` job appears and
|
||||||
|
is green; confirm deleting `build/` would fail (local run with `CI=true`
|
||||||
|
and no binary → FAIL).
|
||||||
|
- [x] **Step 4:** Commit
|
||||||
|
`ci(raft): dedicated mandatory raft e2e job; no silent skips under CI`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Raft TLS config + fail-closed startup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/barabadb/core/config.nim` (fields after `raftLogMaxEntries`
|
||||||
|
at lines ~46/88; env parsing after line ~212)
|
||||||
|
- Modify: `src/baradadb.nim` (raft wiring block, lines 337-377)
|
||||||
|
- Test: `tests/bugfix_test.nim` (new suite)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces config fields (used by T4/T6):
|
||||||
|
|
||||||
|
```nim
|
||||||
|
raftTlsEnabled*: bool # default false
|
||||||
|
raftTlsCertFile*: string # default ""
|
||||||
|
raftTlsKeyFile*: string # default ""
|
||||||
|
raftTlsCaFile*: string # default ""
|
||||||
|
raftTlsVerifyPeer*: bool # default false
|
||||||
|
```
|
||||||
|
|
||||||
|
- Env parsing (mirror lines 184-212 style):
|
||||||
|
|
||||||
|
```nim
|
||||||
|
cfg.raftTlsEnabled = parseEnvBool(getEnv("BARADB_RAFT_TLS_ENABLED", ""), cfg.raftTlsEnabled)
|
||||||
|
cfg.raftTlsCertFile = getEnv("BARADB_RAFT_TLS_CERT_FILE", cfg.raftTlsCertFile)
|
||||||
|
cfg.raftTlsKeyFile = getEnv("BARADB_RAFT_TLS_KEY_FILE", cfg.raftTlsKeyFile)
|
||||||
|
cfg.raftTlsCaFile = getEnv("BARADB_RAFT_TLS_CA_FILE", cfg.raftTlsCaFile)
|
||||||
|
cfg.raftTlsVerifyPeer = parseEnvBool(getEnv("BARADB_RAFT_TLS_VERIFY_PEER", ""), cfg.raftTlsVerifyPeer)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Fail-closed in `baradadb.nim` before `newRaftNetwork`:
|
||||||
|
|
||||||
|
```nim
|
||||||
|
if config.raftTlsEnabled:
|
||||||
|
if config.raftTlsCertFile.len == 0 or config.raftTlsKeyFile.len == 0 or
|
||||||
|
not fileExists(config.raftTlsCertFile) or not fileExists(config.raftTlsKeyFile):
|
||||||
|
raise newException(ValueError,
|
||||||
|
"BARADB_RAFT_TLS_ENABLED=true but cert/key missing " &
|
||||||
|
"(BARADB_RAFT_TLS_CERT_FILE / BARADB_RAFT_TLS_KEY_FILE)")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 1:** Write failing test in `tests/bugfix_test.nim`: default
|
||||||
|
config has `raftTlsEnabled == false`; env `BARADB_RAFT_TLS_ENABLED=true`
|
||||||
|
+ cert paths parse into config.
|
||||||
|
- [x] **Step 2:** Run test, expect compile/fail (fields don't exist).
|
||||||
|
- [x] **Step 3:** Implement fields + env parsing + startup check.
|
||||||
|
- [x] **Step 4:** Test green; `test_all` still green.
|
||||||
|
- [x] **Step 5:** Commit
|
||||||
|
`feat(raft): TLS config surface with fail-closed startup`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: TLS in RaftNetwork transport
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/barabadb/core/raft.nim` (`RaftNetwork` type ~line 731,
|
||||||
|
`connectToPeer` ~748, `run` ~857)
|
||||||
|
- Modify: `src/baradadb.nim` (construct TLSContext, pass to network)
|
||||||
|
- Test: `tests/test_all.nim` (in-process TLS raft pair)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `protocol/ssl.nim` — `newTLSConfig(certFile, keyFile, caFile,
|
||||||
|
verifyPeer)`, `newTLSContext`, `wrapClient`, `wrapServer`.
|
||||||
|
- Produces: `RaftNetwork.tls*: TLSContext` (nil = plaintext, unchanged
|
||||||
|
default); `newRaftNetwork(node, tls = nil)`.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
```nim
|
||||||
|
# raft.nim — in connectToPeer, after successful connect:
|
||||||
|
if net.tls != nil:
|
||||||
|
try: net.tls.wrapClient(sock)
|
||||||
|
except CatchableError:
|
||||||
|
try: sock.close() except CatchableError: discard
|
||||||
|
return
|
||||||
|
|
||||||
|
# raft.nim — in run, after accept, before receiveLoop:
|
||||||
|
if net.tls != nil:
|
||||||
|
try: net.tls.wrapServer(client)
|
||||||
|
except CatchableError:
|
||||||
|
client.close()
|
||||||
|
continue
|
||||||
|
```
|
||||||
|
|
||||||
|
- `baradadb.nim`: build the context when enabled:
|
||||||
|
|
||||||
|
```nim
|
||||||
|
var raftTls: TLSContext = nil
|
||||||
|
if config.raftTlsEnabled:
|
||||||
|
raftTls = newTLSContext(newTLSConfig(
|
||||||
|
config.raftTlsCertFile, config.raftTlsKeyFile,
|
||||||
|
caFile = config.raftTlsCaFile, verifyPeer = config.raftTlsVerifyPeer))
|
||||||
|
...
|
||||||
|
raftNet = newRaftNetwork(raftNode, raftTls)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 1:** Write failing in-process test (`test_all.nim`): two
|
||||||
|
`RaftNode`s over `RaftNetwork` with a self-signed cert from
|
||||||
|
`generateSelfSignedCert` (`protocol/ssl.nim:79`) — election completes
|
||||||
|
over TLS; plaintext dial to the TLS port produces no protocol effect
|
||||||
|
(no state change, connection dropped).
|
||||||
|
- [x] **Step 2:** Run, expect fail (no `tls` field).
|
||||||
|
- [x] **Step 3:** Implement transport changes + wiring.
|
||||||
|
- [x] **Step 4:** Test green; plaintext raft e2e suites still green
|
||||||
|
(regression: nil-TLS path untouched).
|
||||||
|
- [x] **Step 5:** Commit
|
||||||
|
`feat(raft): optional TLS on raft transport (server + dialer)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: TLS for leader SQL forwarding
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/barabadb/core/server.nim` (`forwardQueryToLeader`, lines
|
||||||
|
210-289)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `protocol/ssl.nim` `wrapClient`; server config `tlsEnabled`,
|
||||||
|
`certFile`, `keyFile`.
|
||||||
|
- Produces: `forwardQueryToLeader(host, port, query, tls: TLSContext = nil,
|
||||||
|
...)`.
|
||||||
|
|
||||||
|
- [x] **Step 1:** When the server's client wire port has TLS on
|
||||||
|
(`server.tls != nil`), wrap the forwarding socket with a client-side
|
||||||
|
context before sending the wire header; on handshake failure return
|
||||||
|
`(false, QueryResult(), "leader forward TLS handshake failed")`.
|
||||||
|
- [x] **Step 2:** Manual check: TLS server + raft forwarding (follower
|
||||||
|
INSERT forwarded over TLS) works; non-TLS setup unchanged.
|
||||||
|
- [x] **Step 3:** Commit
|
||||||
|
`feat(raft): TLS on follower→leader SQL forwarding`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Raft TLS E2E
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/raft_tls_e2e_test.nim`
|
||||||
|
- Modify: `baradadb.nimble` (test list), `.github/workflows/ci.yml`
|
||||||
|
(raft-e2e job: add this suite)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: T3/T4 implementation; harness from T1; openssl CLI for cert
|
||||||
|
generation (already used by `protocol/ssl.nim`).
|
||||||
|
|
||||||
|
**Scenario:**
|
||||||
|
- Port base `54000 + (tstamp mod 4000)`.
|
||||||
|
- Generate one self-signed cert per node into the temp data dirs
|
||||||
|
(`openssl req -x509 ...`, or `generateSelfSignedCert`).
|
||||||
|
- Boot 3 nodes with `BARADB_RAFT_TLS_ENABLED=true` + per-node cert/key;
|
||||||
|
assert election + `CREATE TABLE` via raft DDL + one replicated INSERT
|
||||||
|
visible on a follower.
|
||||||
|
- Negative: start a 4th process with raft TLS **disabled** pointed at the
|
||||||
|
same peers; assert the TLS cluster still elects/operates among its 3
|
||||||
|
members and the plaintext node never becomes leader (its frames are
|
||||||
|
undecryptable).
|
||||||
|
- Same `CI`-fail semantics as T2.
|
||||||
|
|
||||||
|
- [x] **Step 1:** Write suite; Step 2: run green locally; Step 3: run 3×
|
||||||
|
(timing); Step 4: wire into `nimble test` + CI job; Step 5: Commit
|
||||||
|
`test(raft): 3-node TLS cluster e2e with plaintext rejection`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: InstallSnapshot protocol
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/barabadb/core/raft.nim` (`RaftMessageKind` ~line 80,
|
||||||
|
`RaftMessage` ~86, `serialize` ~679, `deserializeRaftMessage` ~702)
|
||||||
|
- Test: `tests/test_all.nim` (serialize/deserialize round-trip)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces:
|
||||||
|
|
||||||
|
```nim
|
||||||
|
# RaftMessageKind += rmkInstallSnapshot, rmkInstallSnapshotReply
|
||||||
|
# RaftMessage new fields:
|
||||||
|
snapId*: uint64 # snapshot generation, matches leader's base at build time
|
||||||
|
snapOffset*: uint64 # byte offset of this chunk within the archive
|
||||||
|
snapData*: seq[byte] # chunk payload (<= snapChunkBytes)
|
||||||
|
snapDone*: bool # last chunk
|
||||||
|
# Reused for this kind: prevLogIndex = snapshot base index,
|
||||||
|
# prevLogTerm = snapshot base term. Reply uses success/matchIdx as usual.
|
||||||
|
```
|
||||||
|
|
||||||
|
- Serialization: append `snapId`, `snapOffset`, `snapData` (length-prefixed),
|
||||||
|
`snapDone` **after** `matchIdx`; deserialize each with `if not s.atEnd`
|
||||||
|
guards (pattern from `loadState`, raft.nim:163-167). Old binaries ignore
|
||||||
|
trailing bytes; new binaries default missing fields to zero/false.
|
||||||
|
`RaftProtoVersion` stays 1.
|
||||||
|
|
||||||
|
- [x] **Step 1:** Write failing round-trip test: all new fields survive
|
||||||
|
serialize→deserialize; a buffer serialized by the *old* layout (no
|
||||||
|
trailing fields) deserializes with zero defaults.
|
||||||
|
- [x] **Step 2:** Run, expect fail.
|
||||||
|
- [x] **Step 3:** Implement.
|
||||||
|
- [x] **Step 4:** Green; existing raft suites still green.
|
||||||
|
- [x] **Step 5:** Commit
|
||||||
|
`feat(raft): InstallSnapshot wire protocol (backward-compatible)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Follower snapshot receive + restore
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/barabadb/core/raft.nim` (`RaftNode` — new callback +
|
||||||
|
incoming-snapshot buffer; `processMessage` ~786)
|
||||||
|
- Modify: `src/barabadb/core/config.nim` (`raftSnapChunkKb: int`, env
|
||||||
|
`BARADB_RAFT_SNAP_CHUNK_KB`, default 256; parsing next to the other
|
||||||
|
`BARADB_RAFT_*` env reads)
|
||||||
|
- Modify: `src/baradadb.nim` (wire `restoreSnapshot` callback using
|
||||||
|
`core/backup.nim` + `DatabaseRegistry`; pass chunk size to the node)
|
||||||
|
- Test: `tests/test_all.nim`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces on `RaftNode`:
|
||||||
|
|
||||||
|
```nim
|
||||||
|
snapChunkBytes*: int # from BARADB_RAFT_SNAP_CHUNK_KB, default 262144
|
||||||
|
restoreSnapshot*: proc(archivePath: string, baseIndex: uint64,
|
||||||
|
baseTerm: uint64): bool {.gcsafe.}
|
||||||
|
snapIncomingId*: uint64
|
||||||
|
snapIncomingFile*: string # temp path under dataDir/raft/snap_incoming/
|
||||||
|
```
|
||||||
|
|
||||||
|
- `processMessage` case `rmkInstallSnapshot`: append `snapData` at
|
||||||
|
`snapOffset` to the temp file (create/truncate when `snapId !=
|
||||||
|
snapIncomingId`); on `snapDone`: call `restoreSnapshot`; on success set
|
||||||
|
`lastSnapshotIndex/Term = prevLogIndex/prevLogTerm`,
|
||||||
|
`commitIndex = lastApplied = lastSnapshotIndex`, clear `log`,
|
||||||
|
`saveState()`, reply success with `matchIdx = lastSnapshotIndex`; on
|
||||||
|
failure reply `success = false` and delete the temp file.
|
||||||
|
- `baradadb.nim` `restoreSnapshot` implementation: close default DB via
|
||||||
|
registry, `restoreDataDir(archivePath, defaultDbDir)`
|
||||||
|
(`backup.nim:263`), reopen, swap `ctx`. Return false on any exception.
|
||||||
|
|
||||||
|
- [x] **Step 1:** Write failing test: feed a node two chunks + done with a
|
||||||
|
real tar.gz fixture; assert callback received the assembled file, state
|
||||||
|
fields updated, log cleared.
|
||||||
|
- [x] **Step 2:** Run, expect fail. **Step 3:** Implement.
|
||||||
|
- [x] **Step 4:** Green + regression suites. **Step 5:** Commit
|
||||||
|
`feat(raft): follower InstallSnapshot receive and restore`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Leader snapshot send
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/barabadb/core/raft.nim` (`handleAppendReply` floor branch
|
||||||
|
~526-531, new `sendSnapshot` proc, per-peer reject counter)
|
||||||
|
- Modify: `src/baradadb.nim` (wire `buildSnapshot` callback)
|
||||||
|
- Test: `tests/test_all.nim`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: T7 protocol, `backupDataDir` (`backup.nim:225`).
|
||||||
|
- Produces on `RaftNode`:
|
||||||
|
|
||||||
|
```nim
|
||||||
|
buildSnapshot*: proc(destPath: string): bool {.gcsafe.}
|
||||||
|
snapRejectStreak*: Table[string, int] # consecutive floor-level rejects per peer
|
||||||
|
```
|
||||||
|
|
||||||
|
- Logic: in `handleAppendReply`, when a reject arrives **and**
|
||||||
|
`nextIndex[peerId] == lastSnapshotIndex + 1` (floor reached): increment
|
||||||
|
streak; at streak ≥ 2 the leader knows the follower needs a snapshot →
|
||||||
|
`asyncCheck sendSnapshot(peerId)`. Reset streak on any successful reply.
|
||||||
|
- `sendSnapshot`: `buildSnapshot` into `dataDir/raft/snap_out_<snapId>.tar.gz`
|
||||||
|
(`snapId = lastSnapshotIndex`); stream chunks of `snapChunkBytes` as
|
||||||
|
`rmkInstallSnapshot`; on final success reply set
|
||||||
|
`matchIndex[peer] = lastSnapshotIndex`,
|
||||||
|
`nextIndex[peer] = lastSnapshotIndex + 1`; delete the temp archive.
|
||||||
|
|
||||||
|
- [x] **Step 1:** Write failing test: leader with compacted log
|
||||||
|
(`lastSnapshotIndex = 100`) + peer at floor rejecting twice → snapshot
|
||||||
|
messages emitted; success reply advances `matchIndex`/`nextIndex`.
|
||||||
|
- [x] **Step 2:** Run, expect fail. **Step 3:** Implement.
|
||||||
|
- [x] **Step 4:** Green + regression. **Step 5:** Commit
|
||||||
|
`feat(raft): leader InstallSnapshot send on unrecoverable lag`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: Unpin compaction from dead peers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/barabadb/core/raft.nim` (`compactLog` ~244, `becomeLeader`
|
||||||
|
~327, `handleAppendReply` ~487)
|
||||||
|
- Modify: `src/barabadb/core/config.nim` (`raftPeerStaleMs`, env
|
||||||
|
`BARADB_RAFT_PEER_STALE_MS`, default 30000)
|
||||||
|
- Test: `tests/test_all.nim`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `matchIndexSeenMs*: Table[string, int64]` on `RaftNode` —
|
||||||
|
monotonic ms timestamp of the last successful reply per peer, updated in
|
||||||
|
`handleAppendReply` success branch and initialized to "now" in
|
||||||
|
`becomeLeader`.
|
||||||
|
|
||||||
|
**Logic:** leader-side `compactLog` computes `minMatch` only over peers
|
||||||
|
with `now - matchIndexSeenMs[peer] <= raftPeerStaleMs`; peers stale longer
|
||||||
|
are excluded (they'll be snapshotted on return per T9). Follower path
|
||||||
|
unchanged. Guard: never compact past `lastApplied`.
|
||||||
|
|
||||||
|
- [x] **Step 1:** Write failing test: leader, one peer never replies,
|
||||||
|
log > maxEntries → with default stale window, log compacts through
|
||||||
|
lastApplied anyway; with the peer responsive, compaction still pins at
|
||||||
|
its matchIndex (existing safety preserved).
|
||||||
|
- [x] **Step 2:** Run, expect fail. **Step 3:** Implement.
|
||||||
|
- [x] **Step 4:** Green + regression. **Step 5:** Commit
|
||||||
|
`feat(raft): compaction unpinned from stale peers (snapshot fallback)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 11: Cold-node E2E
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/raft_coldnode_e2e_test.nim`
|
||||||
|
- Modify: `baradadb.nimble`, `.github/workflows/ci.yml` (raft-e2e job)
|
||||||
|
|
||||||
|
**Interfaces:** Consumes T7–T10; harness from T1. Port base
|
||||||
|
`58000 + (tstamp mod 4000)`. Small `BARADB_RAFT_LOG_MAX_ENTRIES=16` and
|
||||||
|
`BARADB_RAFT_PEER_STALE_MS=3000` to force compaction quickly.
|
||||||
|
|
||||||
|
**Scenario A — node returns after compaction:**
|
||||||
|
1. 3-node cluster, create table, kill node n3.
|
||||||
|
2. Write 100 rows through the leader (forces compaction past n3's
|
||||||
|
matchIndex once n3 is stale).
|
||||||
|
3. Assert via `/metrics` on the leader HTTP port
|
||||||
|
(`baradb_raft_log_entries`) that the log stayed bounded.
|
||||||
|
4. Restart n3 with its intact data dir; assert it receives a snapshot
|
||||||
|
(leader log line / `baradb_raft_snapshot_index` advances on n3) and
|
||||||
|
within 15 s `SELECT count(*)` on n3 matches the leader.
|
||||||
|
|
||||||
|
**Scenario B — wiped node joins:**
|
||||||
|
1. Stop n3, **delete its data dir**, restart with the same node id.
|
||||||
|
2. Assert it converges (snapshot → catch-up) and serves the full row set
|
||||||
|
within 20 s.
|
||||||
|
|
||||||
|
- [x] **Step 1:** Write suite. **Step 2:** Green locally. **Step 3:** 3×
|
||||||
|
stability runs. **Step 4:** `nimble test` + CI wiring. **Step 5:** Commit
|
||||||
|
`test(raft): cold-node rejoin and wiped-node join e2e`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 12: Docs, limitations, version 1.3.0
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/en/distributed.md`, `docs/bg/distributed.md` — client
|
||||||
|
failover contract (in-flight writes fail fast, retry; acked writes
|
||||||
|
durable), TLS setup (`BARADB_RAFT_TLS_*`), snapshot behavior/tunables
|
||||||
|
(`BARADB_RAFT_SNAP_CHUNK_KB`, `BARADB_RAFT_PEER_STALE_MS`)
|
||||||
|
- Modify: `docs/en/known-limitations.md`, `docs/bg/known-limitations.md` —
|
||||||
|
raft 3-node moves from "Experimental" to "Supported" for the covered
|
||||||
|
scope; remaining non-goals (multi-DB raft, membership changes, read
|
||||||
|
consistency levels) stay listed
|
||||||
|
- Modify: `docs/superpowers/specs/2026-07-30-raft-cluster-status.md` —
|
||||||
|
status → v1.3.0 supported
|
||||||
|
- Modify: `CHANGELOG.md` — `## [1.3.0] — <ship date>`
|
||||||
|
- Modify: `baradadb.nimble` → `version = "1.3.0"`; README status lines
|
||||||
|
- Modify: `docs/en/release-checklist.md` — add raft TLS + cold-node suites
|
||||||
|
|
||||||
|
- [x] **Step 1:** Doc edits. **Step 2:** Full `nimble test` green.
|
||||||
|
- [x] **Step 3:** Commit
|
||||||
|
`release: v1.3.0 raft-supported (failover load, CI gate, snapshot, TLS)`
|
||||||
|
- [ ] **Step 4 (human/controller):** tag `v1.3.0` after review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task dependency graph
|
||||||
|
|
||||||
|
```
|
||||||
|
T1 failover-load e2e ──→ T2 CI gate
|
||||||
|
T3 TLS config ──→ T4 transport TLS ──→ T5 forward TLS ──→ T6 TLS e2e
|
||||||
|
T7 snapshot protocol ──→ T8 follower restore ──→ T9 leader send ──→ T10 unpin ──→ T11 cold-node e2e
|
||||||
|
all ──→ T12 docs/version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Explicit out-of-scope
|
||||||
|
|
||||||
|
- Membership change (join/leave) protocol
|
||||||
|
- Multi-database raft; `CREATE`/`DROP DATABASE` replication
|
||||||
|
- Linearizable follower reads
|
||||||
|
- Rolling-upgrade compat shims beyond the trailing-field guard
|
||||||
|
(upgrade = restart all nodes)
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- [x] All P1–P5 tasks complete, `nimble test` green
|
||||||
|
- [ ] `raft-e2e` CI job green and mandatory (no silent skip)
|
||||||
|
- [x] Failover-under-load e2e: every acked write survives leader kill
|
||||||
|
- [x] Cold-node e2e: returning node and wiped node converge automatically
|
||||||
|
- [x] Raft TLS e2e: full-TLS cluster works; plaintext node excluded
|
||||||
|
- [x] known-limitations updated: raft supported for the covered scope
|
||||||
|
|
||||||
|
## Estimated effort
|
||||||
|
|
||||||
|
| Phase | Effort |
|
||||||
|
|-------|--------|
|
||||||
|
| P1–P2 | 0.5–1 day |
|
||||||
|
| P3 | 1 day |
|
||||||
|
| P4 | 2–3 days |
|
||||||
|
| P5 | 0.5 day |
|
||||||
|
| **Total** | **~4–5 focused days** |
|
||||||
@@ -1,9 +1,38 @@
|
|||||||
# Raft Cluster Status — C3a / C3b / post-C3b
|
# Raft Cluster Status — C3a / C3b / post-C3b / v1.3.0
|
||||||
|
|
||||||
Date: 2026-07-30
|
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).
|
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 map
|
||||||
|
|
||||||
| Phase | Spec / plan | Status | What landed |
|
| 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.
|
||||||
@@ -26,6 +26,8 @@ import std/strutils
|
|||||||
import std/times
|
import std/times
|
||||||
import std/algorithm
|
import std/algorithm
|
||||||
import std/json
|
import std/json
|
||||||
|
import std/asyncdispatch
|
||||||
|
import std/threadpool
|
||||||
import barabadb/storage/lsm
|
import barabadb/storage/lsm
|
||||||
|
|
||||||
type
|
type
|
||||||
@@ -260,6 +262,80 @@ proc backupDataDir*(dataDir: string, output: string, excludes: seq[string] = @[]
|
|||||||
echo " Source: ", dataDir
|
echo " Source: ", dataDir
|
||||||
return true
|
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 =
|
proc restoreDataDir*(input: string, dataDir: string, verbose: bool = false, dryRun: bool = false): bool =
|
||||||
## Restore from a tar.gz backup.
|
## Restore from a tar.gz backup.
|
||||||
## When dryRun is true, only prints what would be done.
|
## When dryRun is true, only prints what would be done.
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ type
|
|||||||
tlsEnabled*: bool
|
tlsEnabled*: bool
|
||||||
certFile*: string
|
certFile*: string
|
||||||
keyFile*: string
|
keyFile*: string
|
||||||
|
tlsCaFile*: string
|
||||||
|
tlsVerifyPeer*: bool
|
||||||
idleTimeoutMs*: int
|
idleTimeoutMs*: int
|
||||||
queryTimeoutMs*: int
|
queryTimeoutMs*: int
|
||||||
slowQueryThresholdMs*: int
|
slowQueryThresholdMs*: int
|
||||||
@@ -44,6 +46,13 @@ type
|
|||||||
raftPeerClientAddrs*: Table[string, tuple[host: string, port: int]]
|
raftPeerClientAddrs*: Table[string, tuple[host: string, port: int]]
|
||||||
raftWriteTimeoutMs*: int
|
raftWriteTimeoutMs*: int
|
||||||
raftLogMaxEntries*: int
|
raftLogMaxEntries*: int
|
||||||
|
raftSnapChunkKb*: int
|
||||||
|
raftPeerStaleMs*: int
|
||||||
|
raftTlsEnabled*: bool
|
||||||
|
raftTlsCertFile*: string
|
||||||
|
raftTlsKeyFile*: string
|
||||||
|
raftTlsCaFile*: string
|
||||||
|
raftTlsVerifyPeer*: bool
|
||||||
|
|
||||||
CompactionStrategy* = enum
|
CompactionStrategy* = enum
|
||||||
csSizeTiered = "size_tiered"
|
csSizeTiered = "size_tiered"
|
||||||
@@ -60,6 +69,8 @@ proc defaultConfig*(): BaraConfig =
|
|||||||
tlsEnabled: false,
|
tlsEnabled: false,
|
||||||
certFile: "",
|
certFile: "",
|
||||||
keyFile: "",
|
keyFile: "",
|
||||||
|
tlsCaFile: "",
|
||||||
|
tlsVerifyPeer: false,
|
||||||
idleTimeoutMs: 300_000,
|
idleTimeoutMs: 300_000,
|
||||||
queryTimeoutMs: 30_000,
|
queryTimeoutMs: 30_000,
|
||||||
slowQueryThresholdMs: 1_000,
|
slowQueryThresholdMs: 1_000,
|
||||||
@@ -86,6 +97,13 @@ proc defaultConfig*(): BaraConfig =
|
|||||||
raftPeerClientAddrs: initTable[string, tuple[host: string, port: int]](),
|
raftPeerClientAddrs: initTable[string, tuple[host: string, port: int]](),
|
||||||
raftWriteTimeoutMs: 5_000,
|
raftWriteTimeoutMs: 5_000,
|
||||||
raftLogMaxEntries: 256,
|
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("enabled"): cfg.tlsEnabled = s["enabled"].getBool()
|
||||||
if s.hasKey("cert_file"): cfg.certFile = s["cert_file"].getStr()
|
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("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"):
|
if j.hasKey("auth"):
|
||||||
let s = j["auth"]
|
let s = j["auth"]
|
||||||
if s.hasKey("enabled"): cfg.authEnabled = s["enabled"].getBool()
|
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.tlsEnabled = parseEnvBool(getEnv("BARADB_TLS_ENABLED", ""), cfg.tlsEnabled)
|
||||||
cfg.certFile = getEnv("BARADB_CERT_FILE", cfg.certFile)
|
cfg.certFile = getEnv("BARADB_CERT_FILE", cfg.certFile)
|
||||||
cfg.keyFile = getEnv("BARADB_KEY_FILE", cfg.keyFile)
|
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.idleTimeoutMs = parseEnvInt(getEnv("BARADB_IDLE_TIMEOUT_MS", ""), cfg.idleTimeoutMs)
|
||||||
cfg.queryTimeoutMs = parseEnvInt(getEnv("BARADB_QUERY_TIMEOUT_MS", ""), cfg.queryTimeoutMs)
|
cfg.queryTimeoutMs = parseEnvInt(getEnv("BARADB_QUERY_TIMEOUT_MS", ""), cfg.queryTimeoutMs)
|
||||||
cfg.slowQueryThresholdMs = parseEnvInt(getEnv("BARADB_SLOW_QUERY_THRESHOLD_MS", ""), cfg.slowQueryThresholdMs)
|
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.raftNodeId = getEnv("BARADB_RAFT_NODE_ID", cfg.raftNodeId)
|
||||||
cfg.raftWriteTimeoutMs = parseEnvInt(getEnv("BARADB_RAFT_WRITE_TIMEOUT_MS", ""), cfg.raftWriteTimeoutMs)
|
cfg.raftWriteTimeoutMs = parseEnvInt(getEnv("BARADB_RAFT_WRITE_TIMEOUT_MS", ""), cfg.raftWriteTimeoutMs)
|
||||||
cfg.raftLogMaxEntries = parseEnvInt(getEnv("BARADB_RAFT_LOG_MAX_ENTRIES", ""), cfg.raftLogMaxEntries)
|
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.
|
# Optional: client (SQL) addresses for leader write forwarding.
|
||||||
# Same id@host:port shape as BARADB_RAFT_PEERS, but ports are BARADB_PORT values.
|
# Same id@host:port shape as BARADB_RAFT_PEERS, but ports are BARADB_PORT values.
|
||||||
let clientPeersEnv = getEnv("BARADB_RAFT_CLIENT_PEERS", "")
|
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"]:
|
if cfg.jwtSecret in ["change-me", "change-me-to-random-32-char-string", "secret", "default"]:
|
||||||
raise newException(ValueError,
|
raise newException(ValueError,
|
||||||
"Production refuses insecure JWT secret placeholder. Set a strong BARADB_JWT_SECRET.")
|
"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 =
|
proc getEffectiveJwtSecret*(cfg: BaraConfig): string =
|
||||||
if cfg.jwtSecret.len > 0:
|
if cfg.jwtSecret.len > 0:
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import std/monotimes
|
|||||||
import std/net
|
import std/net
|
||||||
import std/strutils
|
import std/strutils
|
||||||
import std/nativesockets
|
import std/nativesockets
|
||||||
|
when defined(posix):
|
||||||
|
import std/posix
|
||||||
|
|
||||||
type
|
type
|
||||||
DistTxnState* = enum
|
DistTxnState* = enum
|
||||||
@@ -89,6 +91,13 @@ proc connectWithTimeout(sock: Socket, host: string, port: Port, timeoutMs: int):
|
|||||||
var fds = @[sock.getFd]
|
var fds = @[sock.getFd]
|
||||||
if selectWrite(fds, timeoutMs) <= 0:
|
if selectWrite(fds, timeoutMs) <= 0:
|
||||||
return false
|
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)
|
sock.getFd.setBlocking(true)
|
||||||
return true
|
return true
|
||||||
|
|
||||||
@@ -96,6 +105,7 @@ proc sendDistTxnRpc(host: string, port: int, txnId: uint64, action: string, time
|
|||||||
## Send 2PC RPC to participant node via TCP text protocol.
|
## Send 2PC RPC to participant node via TCP text protocol.
|
||||||
## Protocol: "DISTTXN <txnId> <action>\n" where action = PREPARE|COMMIT|ROLLBACK
|
## Protocol: "DISTTXN <txnId> <action>\n" where action = PREPARE|COMMIT|ROLLBACK
|
||||||
## Response: "OK\n" or "ERR <msg>\n"
|
## Response: "OK\n" or "ERR <msg>\n"
|
||||||
|
try:
|
||||||
var sock = newSocket()
|
var sock = newSocket()
|
||||||
defer: sock.close()
|
defer: sock.close()
|
||||||
if not connectWithTimeout(sock, host, Port(port), timeoutMs):
|
if not connectWithTimeout(sock, host, Port(port), timeoutMs):
|
||||||
@@ -105,6 +115,8 @@ proc sendDistTxnRpc(host: string, port: int, txnId: uint64, action: string, time
|
|||||||
var response = ""
|
var response = ""
|
||||||
sock.readLine(response)
|
sock.readLine(response)
|
||||||
return response.strip() == "OK"
|
return response.strip() == "OK"
|
||||||
|
except CatchableError:
|
||||||
|
return false
|
||||||
|
|
||||||
type
|
type
|
||||||
ParticipantInfo = object
|
ParticipantInfo = object
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import ../storage/gate
|
|||||||
import ../core/mvcc
|
import ../core/mvcc
|
||||||
import ../protocol/wire
|
import ../protocol/wire
|
||||||
import ../core/websocket
|
import ../core/websocket
|
||||||
|
import ../query/exec/rls
|
||||||
import jwt as jwtlib
|
import jwt as jwtlib
|
||||||
import ../protocol/auth
|
import ../protocol/auth
|
||||||
import ../protocol/ratelimit
|
import ../protocol/ratelimit
|
||||||
@@ -31,7 +32,7 @@ type
|
|||||||
config: BaraConfig
|
config: BaraConfig
|
||||||
running: bool
|
running: bool
|
||||||
db*: LSMTree
|
db*: LSMTree
|
||||||
ctx: ExecutionContext
|
ctx*: ExecutionContext # read/write only under the storage gate
|
||||||
registry*: DatabaseRegistry
|
registry*: DatabaseRegistry
|
||||||
metrics*: Metrics
|
metrics*: Metrics
|
||||||
secretKey*: string
|
secretKey*: string
|
||||||
@@ -55,6 +56,14 @@ proc newHttpServerWithRegistry*(config: BaraConfig, registry: DatabaseRegistry):
|
|||||||
ctx.txnManager = newTxnManager()
|
ctx.txnManager = newTxnManager()
|
||||||
let secret = config.getEffectiveJwtSecret()
|
let secret = config.getEffectiveJwtSecret()
|
||||||
let ws = newWsServer(config, secret)
|
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)
|
let rl = newRateLimiter(rlaTokenBucket, config.rateLimitGlobal, config.rateLimitPerClient)
|
||||||
ctx.onChange = proc(ev: ChangeEvent) =
|
ctx.onChange = proc(ev: ChangeEvent) =
|
||||||
let msg = $ev.kind & " " & ev.table
|
let msg = $ev.kind & " " & ev.table
|
||||||
@@ -262,7 +271,7 @@ proc healthHandler(server: HttpServer): RequestHandler =
|
|||||||
let ctx = newContext(request)
|
let ctx = newContext(request)
|
||||||
var body = %*{
|
var body = %*{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"version": "1.2.0"
|
"version": "1.3.0"
|
||||||
}
|
}
|
||||||
if server.raftNode != nil:
|
if server.raftNode != nil:
|
||||||
let n = server.raftNode
|
let n = server.raftNode
|
||||||
@@ -368,7 +377,7 @@ proc openApiHandler(): RequestHandler =
|
|||||||
let ctx = newContext(request)
|
let ctx = newContext(request)
|
||||||
ctx.json(%*{
|
ctx.json(%*{
|
||||||
"openapi": "3.0.0",
|
"openapi": "3.0.0",
|
||||||
"info": {"title": "BaraDB API", "version": "1.2.0"},
|
"info": {"title": "BaraDB API", "version": "1.3.0"},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/query": {
|
"/query": {
|
||||||
"post": {
|
"post": {
|
||||||
@@ -906,7 +915,7 @@ function showTab(idx){
|
|||||||
}
|
}
|
||||||
setInterval(() => { if(document.querySelectorAll('.panel')[4].classList.contains('active')) loadMetrics() }, 5000)
|
setInterval(() => { if(document.querySelectorAll('.panel')[4].classList.contains('active')) loadMetrics() }, 5000)
|
||||||
</script>
|
</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>"""
|
</body></html>"""
|
||||||
request.respond(200, @[("Content-Type", "text/html; charset=utf-8")], html)
|
request.respond(200, @[("Content-Type", "text/html; charset=utf-8")], html)
|
||||||
|
|
||||||
|
|||||||
@@ -178,12 +178,16 @@ proc write*(tm: TxnManager, txn: Transaction, key: string, value: seq[byte]): bo
|
|||||||
return false
|
return false
|
||||||
|
|
||||||
# Timeout-based deadlock detection: abort stale transactions
|
# Timeout-based deadlock detection: abort stale transactions
|
||||||
|
# Collect then delete — never mutate activeTxns while iterating it.
|
||||||
let now = getMonoTime().ticks()
|
let now = getMonoTime().ticks()
|
||||||
|
var staleIds: seq[TxnId] = @[]
|
||||||
for otherId, otherTxn in tm.activeTxns:
|
for otherId, otherTxn in tm.activeTxns:
|
||||||
if otherId != txn.id and otherTxn.state == tsActive:
|
if otherId != txn.id and otherTxn.state == tsActive:
|
||||||
if now - otherTxn.startTime > tm.txnTimeoutMs * 1_000_000:
|
if now - otherTxn.startTime > tm.txnTimeoutMs * 1_000_000:
|
||||||
otherTxn.state = tsAborted
|
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
|
# Check for write-write conflict against other active transactions' write sets
|
||||||
for otherId, otherTxn in tm.activeTxns:
|
for otherId, otherTxn in tm.activeTxns:
|
||||||
|
|||||||
+336
-5
@@ -12,6 +12,8 @@ import std/endians
|
|||||||
import std/os
|
import std/os
|
||||||
import logging
|
import logging
|
||||||
import ../protocol/wire
|
import ../protocol/wire
|
||||||
|
import ../protocol/ssl
|
||||||
|
import backup
|
||||||
|
|
||||||
type
|
type
|
||||||
RaftState* = enum
|
RaftState* = enum
|
||||||
@@ -66,6 +68,15 @@ type
|
|||||||
# Leader state
|
# Leader state
|
||||||
nextIndex*: Table[string, uint64]
|
nextIndex*: Table[string, uint64]
|
||||||
matchIndex*: 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
|
# Cluster
|
||||||
peers*: seq[string]
|
peers*: seq[string]
|
||||||
leaderId*: string
|
leaderId*: string
|
||||||
@@ -76,12 +87,35 @@ type
|
|||||||
peerAddrs*: Table[string, tuple[host: string, port: int]]
|
peerAddrs*: Table[string, tuple[host: string, port: int]]
|
||||||
raftPort*: int
|
raftPort*: int
|
||||||
dataDir*: string
|
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
|
RaftMessageKind* = enum
|
||||||
rmkRequestVote
|
rmkRequestVote
|
||||||
rmkRequestVoteReply
|
rmkRequestVoteReply
|
||||||
rmkAppendEntries
|
rmkAppendEntries
|
||||||
rmkAppendEntriesReply
|
rmkAppendEntriesReply
|
||||||
|
rmkInstallSnapshot
|
||||||
|
rmkInstallSnapshotReply
|
||||||
|
|
||||||
RaftMessage* = object
|
RaftMessage* = object
|
||||||
kind*: RaftMessageKind
|
kind*: RaftMessageKind
|
||||||
@@ -98,6 +132,12 @@ type
|
|||||||
# Reply
|
# Reply
|
||||||
success*: bool
|
success*: bool
|
||||||
matchIdx*: uint64
|
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
|
RaftCluster* = ref object
|
||||||
nodes*: Table[string, RaftNode]
|
nodes*: Table[string, RaftNode]
|
||||||
@@ -191,6 +231,8 @@ proc newRaftNode*(id: string, peers: seq[string], raftPort: int = 0,
|
|||||||
metrics: RaftMetrics(),
|
metrics: RaftMetrics(),
|
||||||
nextIndex: initTable[string, uint64](),
|
nextIndex: initTable[string, uint64](),
|
||||||
matchIndex: initTable[string, uint64](),
|
matchIndex: initTable[string, uint64](),
|
||||||
|
matchIndexSeenMs: initTable[string, int64](),
|
||||||
|
raftPeerStaleMs: 30000,
|
||||||
peers: peers,
|
peers: peers,
|
||||||
leaderId: "",
|
leaderId: "",
|
||||||
electionTimeout: 150 + rand(150),
|
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]](),
|
peerAddrs: initTable[string, tuple[host: string, port: int]](),
|
||||||
raftPort: raftPort,
|
raftPort: raftPort,
|
||||||
dataDir: dataDir,
|
dataDir: dataDir,
|
||||||
|
snapChunkBytes: 262144,
|
||||||
|
snapIncomingId: 0,
|
||||||
|
snapIncomingFile: "",
|
||||||
|
snapRejectStreak: initTable[string, int](),
|
||||||
|
snapPending: initHashSet[string](),
|
||||||
|
snapSending: initHashSet[string](),
|
||||||
)
|
)
|
||||||
result.loadState()
|
result.loadState()
|
||||||
|
|
||||||
@@ -243,15 +291,22 @@ proc termAtIndex(node: RaftNode, index: uint64): uint64 =
|
|||||||
|
|
||||||
proc compactLog*(node: RaftNode) =
|
proc compactLog*(node: RaftNode) =
|
||||||
## Drop a fully-replicated / applied log prefix so the in-memory log stays
|
## 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
|
## bounded. Leader: never discard past any responsive peer's matchIndex
|
||||||
## AppendEntries remains possible). Follower: discard through lastApplied.
|
## (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
|
let maxEntries = if node.logMaxEntries > 0: node.logMaxEntries else: 256
|
||||||
if node.log.len <= maxEntries:
|
if node.log.len <= maxEntries:
|
||||||
return
|
return
|
||||||
var through = node.lastApplied
|
var through = node.lastApplied
|
||||||
if node.state == rsLeader and node.peers.len > 0:
|
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
|
var minMatch = through
|
||||||
for peer in node.peers:
|
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)
|
let m = node.matchIndex.getOrDefault(peer, 0'u64)
|
||||||
if m < minMatch: minMatch = m
|
if m < minMatch: minMatch = m
|
||||||
through = minMatch
|
through = minMatch
|
||||||
@@ -312,6 +367,10 @@ proc becomeFollower*(node: RaftNode, term: uint64) =
|
|||||||
node.votesReceived.clear()
|
node.votesReceived.clear()
|
||||||
node.nextIndex.clear()
|
node.nextIndex.clear()
|
||||||
node.matchIndex.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()
|
node.saveState()
|
||||||
|
|
||||||
proc becomeCandidate*(node: RaftNode) =
|
proc becomeCandidate*(node: RaftNode) =
|
||||||
@@ -330,9 +389,16 @@ proc becomeLeader*(node: RaftNode) =
|
|||||||
if node.metrics != nil:
|
if node.metrics != nil:
|
||||||
inc node.metrics.electionsTotal
|
inc node.metrics.electionsTotal
|
||||||
info("Raft node " & node.id & " became leader for term " & $node.currentTerm)
|
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:
|
for peer in node.peers:
|
||||||
node.nextIndex[peer] = node.lastLogIndex + 1
|
node.nextIndex[peer] = node.lastLogIndex + 1
|
||||||
node.matchIndex[peer] = 0
|
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 =
|
proc handleRequestVote*(node: RaftNode, msg: RaftMessage): RaftMessage =
|
||||||
var reply = RaftMessage(
|
var reply = RaftMessage(
|
||||||
@@ -418,6 +484,87 @@ proc handleAppendEntries*(node: RaftNode, msg: RaftMessage): RaftMessage =
|
|||||||
reply.matchIdx = node.lastLogIndex
|
reply.matchIdx = node.lastLogIndex
|
||||||
return reply
|
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] =
|
proc requestVote*(node: RaftNode): seq[RaftMessage] =
|
||||||
result = @[]
|
result = @[]
|
||||||
for peer in node.peers:
|
for peer in node.peers:
|
||||||
@@ -498,9 +645,15 @@ proc handleAppendReply*(node: RaftNode, peerId: string, reply: RaftMessage) =
|
|||||||
if reply.success:
|
if reply.success:
|
||||||
node.matchIndex[peerId] = reply.matchIdx
|
node.matchIndex[peerId] = reply.matchIdx
|
||||||
node.nextIndex[peerId] = reply.matchIdx + 1
|
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
|
# Update commit index using strict majority — the same form as the election
|
||||||
let majority = (node.peers.len + 1 + 1) div 2 # majority of cluster (peers + leader)
|
# 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
|
var newCommitIdx = node.commitIndex
|
||||||
|
|
||||||
# Walk logical indices high→low via findLogEntryByIndex (log may be compacted).
|
# 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
|
let floor = node.lastSnapshotIndex + 1
|
||||||
if node.nextIndex.getOrDefault(peerId, 1) > floor:
|
if node.nextIndex.getOrDefault(peerId, 1) > floor:
|
||||||
dec node.nextIndex[peerId]
|
dec node.nextIndex[peerId]
|
||||||
|
# Not a floor-level reject, so it breaks any floor-reject streak.
|
||||||
|
node.snapRejectStreak.del(peerId)
|
||||||
else:
|
else:
|
||||||
node.nextIndex[peerId] = floor
|
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 state*(node: RaftNode): RaftState = node.state
|
||||||
proc isLeader*(node: RaftNode): bool = node.state == rsLeader
|
proc isLeader*(node: RaftNode): bool = node.state == rsLeader
|
||||||
@@ -693,6 +885,14 @@ proc serialize*(msg: RaftMessage): seq[byte] =
|
|||||||
stream.write(msg.leaderCommit)
|
stream.write(msg.leaderCommit)
|
||||||
stream.write(char(if msg.success: 1 else: 0))
|
stream.write(char(if msg.success: 1 else: 0))
|
||||||
stream.write(msg.matchIdx)
|
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
|
let strData = stream.data
|
||||||
result = newSeq[byte](strData.len)
|
result = newSeq[byte](strData.len)
|
||||||
for i in 0 ..< strData.len:
|
for i in 0 ..< strData.len:
|
||||||
@@ -721,6 +921,19 @@ proc deserializeRaftMessage*(data: seq[byte]): RaftMessage =
|
|||||||
result.leaderCommit = stream.readUint64()
|
result.leaderCommit = stream.readUint64()
|
||||||
result.success = stream.readChar() != '\0'
|
result.success = stream.readChar() != '\0'
|
||||||
result.matchIdx = stream.readUint64()
|
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()
|
stream.close()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -734,13 +947,16 @@ type
|
|||||||
running*: bool
|
running*: bool
|
||||||
peerSockets*: Table[string, AsyncSocket]
|
peerSockets*: Table[string, AsyncSocket]
|
||||||
timer*: ElectionTimer
|
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(
|
RaftNetwork(
|
||||||
node: node,
|
node: node,
|
||||||
running: false,
|
running: false,
|
||||||
peerSockets: initTable[string, AsyncSocket](),
|
peerSockets: initTable[string, AsyncSocket](),
|
||||||
timer: newElectionTimer(node, node.electionTimeout),
|
timer: newElectionTimer(node, node.electionTimeout),
|
||||||
|
tls: tls,
|
||||||
)
|
)
|
||||||
|
|
||||||
const RaftConnectTimeoutMs = 200
|
const RaftConnectTimeoutMs = 200
|
||||||
@@ -759,6 +975,12 @@ proc connectToPeer(net: RaftNetwork, peerId: string) {.async.} =
|
|||||||
if not ok:
|
if not ok:
|
||||||
sock.close()
|
sock.close()
|
||||||
return
|
return
|
||||||
|
if net.tls != nil:
|
||||||
|
try:
|
||||||
|
net.tls.wrapClient(sock)
|
||||||
|
except CatchableError:
|
||||||
|
try: sock.close() except CatchableError: discard
|
||||||
|
return
|
||||||
net.peerSockets[peerId] = sock
|
net.peerSockets[peerId] = sock
|
||||||
except CatchableError:
|
except CatchableError:
|
||||||
if sock != nil:
|
if sock != nil:
|
||||||
@@ -783,6 +1005,80 @@ proc broadcast*(net: RaftNetwork, msgs: seq[RaftMessage]) {.async.} =
|
|||||||
if i < msgs.len:
|
if i < msgs.len:
|
||||||
await net.send(peer, msgs[i])
|
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.} =
|
proc processMessage*(net: RaftNetwork, msg: RaftMessage) {.async.} =
|
||||||
case msg.kind
|
case msg.kind
|
||||||
of rmkRequestVote:
|
of rmkRequestVote:
|
||||||
@@ -800,6 +1096,19 @@ proc processMessage*(net: RaftNetwork, msg: RaftMessage) {.async.} =
|
|||||||
await net.send(msg.senderId, reply)
|
await net.send(msg.senderId, reply)
|
||||||
of rmkAppendEntriesReply:
|
of rmkAppendEntriesReply:
|
||||||
net.node.handleAppendReply(msg.senderId, msg)
|
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.} =
|
proc recvExact*(client: AsyncSocket, size: int): Future[string] {.async.} =
|
||||||
## Reads exactly `size` bytes from `client`. A short return means the peer
|
## 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.} =
|
proc heartbeatLoop(net: RaftNetwork) {.async.} =
|
||||||
## Fan out heartbeats in parallel so a slow/dead peer cannot delay
|
## Fan out heartbeats in parallel so a slow/dead peer cannot delay
|
||||||
## AppendEntries to the rest of the cluster.
|
## AppendEntries to the rest of the cluster.
|
||||||
|
var wasLeader = false
|
||||||
while net.running:
|
while net.running:
|
||||||
if net.node.state == rsLeader:
|
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]] = @[]
|
var futs: seq[Future[void]] = @[]
|
||||||
for peer in net.node.peers:
|
for peer in net.node.peers:
|
||||||
let msg = net.node.appendEntries(peer)
|
let msg = net.node.appendEntries(peer)
|
||||||
@@ -850,6 +1172,8 @@ proc heartbeatLoop(net: RaftNetwork) {.async.} =
|
|||||||
await f
|
await f
|
||||||
except CatchableError:
|
except CatchableError:
|
||||||
discard
|
discard
|
||||||
|
else:
|
||||||
|
wasLeader = false
|
||||||
await sleepAsync(net.node.heartbeatTimeout)
|
await sleepAsync(net.node.heartbeatTimeout)
|
||||||
|
|
||||||
proc timerLoop*(net: RaftNetwork) {.async.}
|
proc timerLoop*(net: RaftNetwork) {.async.}
|
||||||
@@ -866,6 +1190,13 @@ proc run*(net: RaftNetwork) {.async.} =
|
|||||||
while net.running:
|
while net.running:
|
||||||
try:
|
try:
|
||||||
let client = await net.socket.accept()
|
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)
|
asyncCheck net.receiveLoop(client)
|
||||||
except CatchableError:
|
except CatchableError:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -203,6 +203,32 @@ proc getDatabaseInfo*(reg: DatabaseRegistry, name: string): DatabaseInfo =
|
|||||||
return reg.databases[name]
|
return reg.databases[name]
|
||||||
return nil
|
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) =
|
proc closeAll*(reg: DatabaseRegistry) =
|
||||||
acquire(reg.lock)
|
acquire(reg.lock)
|
||||||
defer: release(reg.lock)
|
defer: release(reg.lock)
|
||||||
|
|||||||
@@ -96,6 +96,41 @@ proc connectWithTimeout(sock: Socket, host: string, port: Port, timeoutMs: int):
|
|||||||
sock.getFd.setBlocking(true)
|
sock.getFd.setBlocking(true)
|
||||||
return err == 0
|
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 =
|
proc shipToReplica(replica: Replica, lsn: uint64, data: seq[byte]): bool =
|
||||||
## Send replication data to a replica via TCP.
|
## Send replication data to a replica via TCP.
|
||||||
## Protocol: "REP <lsn> <dataLen>\n<data>"
|
## Protocol: "REP <lsn> <dataLen>\n<data>"
|
||||||
@@ -178,10 +213,18 @@ proc writeLsn*(rm: ReplicationManager, data: seq[byte]): uint64 =
|
|||||||
rm.pendingAcks[lsn].excl(id)
|
rm.pendingAcks[lsn].excl(id)
|
||||||
if rm.pendingAcks[lsn].len == 0:
|
if rm.pendingAcks[lsn].len == 0:
|
||||||
rm.pendingAcks.del(lsn)
|
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)
|
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
|
return lsn
|
||||||
|
|
||||||
proc ackLsn*(rm: ReplicationManager, replicaId: string, lsn: uint64) =
|
proc ackLsn*(rm: ReplicationManager, replicaId: string, lsn: uint64) =
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
## BaraDB Server — async TCP server with wire protocol
|
## BaraDB Server — async TCP server with wire protocol
|
||||||
import std/asyncdispatch
|
import std/asyncdispatch
|
||||||
import std/asyncnet
|
import std/asyncnet
|
||||||
|
import std/os
|
||||||
import std/strutils
|
import std/strutils
|
||||||
import std/sequtils
|
import std/sequtils
|
||||||
import std/tables
|
import std/tables
|
||||||
@@ -22,6 +23,7 @@ import ../query/parser
|
|||||||
import ../query/ast
|
import ../query/ast
|
||||||
import ../query/executor
|
import ../query/executor
|
||||||
import ../query/exec/params
|
import ../query/exec/params
|
||||||
|
import ../query/exec/dml
|
||||||
import ../storage/lsm
|
import ../storage/lsm
|
||||||
import ../storage/gate
|
import ../storage/gate
|
||||||
import ../core/mvcc
|
import ../core/mvcc
|
||||||
@@ -49,6 +51,10 @@ type
|
|||||||
clusterMembership*: ClusterMembership
|
clusterMembership*: ClusterMembership
|
||||||
gossipProtocol*: GossipProtocol
|
gossipProtocol*: GossipProtocol
|
||||||
tls*: TLSContext
|
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
|
rateLimiter*: RateLimiter
|
||||||
activeConnections*: int
|
activeConnections*: int
|
||||||
activeConnectionsLock*: Lock
|
activeConnectionsLock*: Lock
|
||||||
@@ -65,9 +71,22 @@ proc newServerWithRegistry*(config: BaraConfig, registry: DatabaseRegistry): Ser
|
|||||||
let ctx = cast[ExecutionContext](cast[pointer](dbInfo.ctx))
|
let ctx = cast[ExecutionContext](cast[pointer](dbInfo.ctx))
|
||||||
ctx.txnManager = newTxnManager()
|
ctx.txnManager = newTxnManager()
|
||||||
var tls: TLSContext = nil
|
var tls: TLSContext = nil
|
||||||
|
var tlsClient: TLSContext = nil
|
||||||
if config.tlsEnabled and config.certFile.len > 0 and config.keyFile.len > 0:
|
if config.tlsEnabled and config.certFile.len > 0 and config.keyFile.len > 0:
|
||||||
let tlsConfig = newTLSConfig(config.certFile, config.keyFile)
|
if config.tlsVerifyPeer and config.tlsCaFile.len == 0:
|
||||||
tls = newTLSContext(tlsConfig)
|
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
|
# Initialize sharding / gossip. Server fields own the refs; locals used inside
|
||||||
# callback closures are {.cursor.} so ARC does not form uncollectable cycles
|
# callback closures are {.cursor.} so ARC does not form uncollectable cycles
|
||||||
@@ -84,6 +103,7 @@ proc newServerWithRegistry*(config: BaraConfig, registry: DatabaseRegistry): Ser
|
|||||||
clusterMembership: nil,
|
clusterMembership: nil,
|
||||||
gossipProtocol: newGossipProtocol(localId, config.address, config.port, gossipPort = gossipPort),
|
gossipProtocol: newGossipProtocol(localId, config.address, config.port, gossipPort = gossipPort),
|
||||||
tls: tls,
|
tls: tls,
|
||||||
|
tlsClient: tlsClient,
|
||||||
rateLimiter: rl)
|
rateLimiter: rl)
|
||||||
result.clusterMembership = newClusterMembership(result.shardRouter, localId)
|
result.clusterMembership = newClusterMembership(result.shardRouter, localId)
|
||||||
initLock(result.activeConnectionsLock)
|
initLock(result.activeConnectionsLock)
|
||||||
@@ -165,6 +185,11 @@ proc parseHeader(data: string): (bool, MessageHeader) =
|
|||||||
return (false, MessageHeader())
|
return (false, MessageHeader())
|
||||||
let kind = cast[MsgKind](rawKind)
|
let kind = cast[MsgKind](rawKind)
|
||||||
let length = readUint32BE(data, 4)
|
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)
|
let requestId = readUint32BE(data, 8)
|
||||||
return (true, MessageHeader(kind: kind, length: length, requestId: requestId))
|
return (true, MessageHeader(kind: kind, length: length, requestId: requestId))
|
||||||
|
|
||||||
@@ -216,16 +241,28 @@ proc forwardRecvExact(sock: AsyncSocket, size: int): Future[string] {.async.} =
|
|||||||
return buf
|
return buf
|
||||||
|
|
||||||
proc forwardQueryToLeader*(host: string, port: int, query: string,
|
proc forwardQueryToLeader*(host: string, port: int, query: string,
|
||||||
|
tls: TLSContext = nil,
|
||||||
params: seq[WireValue] = @[],
|
params: seq[WireValue] = @[],
|
||||||
timeoutMs: int = 5000): Future[(bool, QueryResult, string)] {.async.} =
|
timeoutMs: int = 5000): Future[(bool, QueryResult, string)] {.async.} =
|
||||||
## Proxy a write/DDL to the known leader's SQL port. Used by followers when
|
## Proxy a write/DDL to the known leader's SQL port. Used by followers when
|
||||||
## BARADB_RAFT_CLIENT_PEERS maps leader id → host:clientPort.
|
## 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
|
var sock: AsyncSocket = nil
|
||||||
try:
|
try:
|
||||||
sock = newAsyncSocket()
|
sock = newAsyncSocket()
|
||||||
let okConn = await withTimeout(sock.connect(host, Port(port)), min(timeoutMs, 2000))
|
let okConn = await withTimeout(sock.connect(host, Port(port)), min(timeoutMs, 2000))
|
||||||
if not okConn:
|
if not okConn:
|
||||||
return (false, QueryResult(), "leader forward connect timeout")
|
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 reqId = 1'u32
|
||||||
let msg = if params.len > 0:
|
let msg = if params.len > 0:
|
||||||
makeQueryParamsMessage(reqId, query, params)
|
makeQueryParamsMessage(reqId, query, params)
|
||||||
@@ -306,10 +343,12 @@ proc waitRaftCommit(node: RaftNode, lastIdx: uint64, timeoutMs: int): Future[(bo
|
|||||||
node.metrics.commitWaitMsTotal += waitedMs
|
node.metrics.commitWaitMsTotal += waitedMs
|
||||||
return (true, "")
|
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.} =
|
timeoutMs: int): Future[(bool, string)] {.async.} =
|
||||||
## C3b leader write path: append each written KV pair to the Raft log and
|
## 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).
|
## format matches applyCommand ("put": key \x00 value, "delete": key).
|
||||||
##
|
##
|
||||||
## MUST be called from the async event-loop thread that owns `node` and
|
## 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
|
## handleAppendReply on the same loop, and applyCommand re-enters the
|
||||||
## (non-reentrant) gate — waiting under the gate would deadlock the loop.
|
## (non-reentrant) gate — waiting under the gate would deadlock the loop.
|
||||||
var lastIdx = 0'u64
|
var lastIdx = 0'u64
|
||||||
for (key, value) in kvPairs:
|
for pair in kvPairs:
|
||||||
let entry = if value.len > 0:
|
let entry = if pair.deleted:
|
||||||
node.appendLog("put", cast[seq[byte]](key & "\x00" & cast[string](value)))
|
node.appendLog("delete", cast[seq[byte]](pair.key))
|
||||||
else:
|
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 entry.index == 0:
|
||||||
if node.metrics != nil:
|
if node.metrics != nil:
|
||||||
inc node.metrics.lostLeadershipTotal
|
inc node.metrics.lostLeadershipTotal
|
||||||
@@ -346,14 +385,15 @@ proc executeQuery(db: LSMTree, ctx: ExecutionContext, query: string, params: seq
|
|||||||
raftNode: RaftNode = nil,
|
raftNode: RaftNode = nil,
|
||||||
raftWriteTimeoutMs: int = 5000,
|
raftWriteTimeoutMs: int = 5000,
|
||||||
raftPeerClientAddrs: Table[string, tuple[host: string, port: int]] =
|
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
|
## 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.
|
## and the TCP event loop never touch ORC-managed LSM/executor state concurrently.
|
||||||
## The gate is released BEFORE the Raft commit wait — see appendWriteToRaft.
|
## The gate is released BEFORE the Raft commit wait — see appendWriteToRaft.
|
||||||
var ok = false
|
var ok = false
|
||||||
var qr = QueryResult()
|
var qr = QueryResult()
|
||||||
var msg = ""
|
var msg = ""
|
||||||
var kvPairs: seq[(string, seq[byte])] = @[]
|
var kvPairs: seq[tuple[key: string, value: seq[byte], deleted: bool]] = @[]
|
||||||
var needsRaftDdl = false
|
var needsRaftDdl = false
|
||||||
var needsForward = false
|
var needsForward = false
|
||||||
var forwardHost = ""
|
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
|
# Ship written key-value pairs to replicas (legacy path; skipped when
|
||||||
# the raft path below handles the statement).
|
# the raft path below handles the statement).
|
||||||
if raftNode == nil and replication != nil and res.keyValuePairs.len > 0:
|
if raftNode == nil and replication != nil and res.keyValuePairs.len > 0:
|
||||||
for (key, value) in res.keyValuePairs:
|
for pair in res.keyValuePairs:
|
||||||
var data = newSeq[byte](key.len + 1 + value.len)
|
# Legacy REP wire format: explicit 'P'/'D' op tag (see
|
||||||
for i, c in key: data[i] = byte(c)
|
# encodeRepPayload). The tag — not an empty value — distinguishes
|
||||||
data[key.len] = byte(0)
|
# a put from a delete, so PK-only rows (empty value) replicate as
|
||||||
for i, c in value: data[key.len + 1 + i] = c
|
# puts instead of vanishing as deletes.
|
||||||
discard replication.writeLsn(data)
|
discard replication.writeLsn(
|
||||||
|
encodeRepPayload(pair.deleted, pair.key, pair.value))
|
||||||
qr = QueryResult(affectedRows: res.affectedRows, rowCount: res.rows.len)
|
qr = QueryResult(affectedRows: res.affectedRows, rowCount: res.rows.len)
|
||||||
qr.columns = res.columns
|
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).
|
# Follower write/DDL: proxy to leader SQL port (outside the storage gate).
|
||||||
if needsForward:
|
if needsForward:
|
||||||
let (okF, qrF, errF) = await forwardQueryToLeader(forwardHost, forwardPort,
|
let (okF, qrF, errF) = await forwardQueryToLeader(forwardHost, forwardPort,
|
||||||
query, params, raftWriteTimeoutMs)
|
query, forwardTls, params, raftWriteTimeoutMs)
|
||||||
if raftNode != nil and raftNode.metrics != nil:
|
if raftNode != nil and raftNode.metrics != nil:
|
||||||
if okF: inc raftNode.metrics.forwardsTotal
|
if okF: inc raftNode.metrics.forwardsTotal
|
||||||
else: inc raftNode.metrics.forwardErrorsTotal
|
else: inc raftNode.metrics.forwardErrorsTotal
|
||||||
@@ -637,14 +678,25 @@ proc handleClient(server: Server, client: AsyncSocket, clientId: int) {.async.}
|
|||||||
if chunk.len == 0: break
|
if chunk.len == 0: break
|
||||||
data.add(chunk)
|
data.add(chunk)
|
||||||
if data.len > 0:
|
if data.len > 0:
|
||||||
let nullPos = data.find('\0')
|
# Op tag — not value length — decides put vs delete, so a PK-only
|
||||||
if nullPos >= 0:
|
# put (empty value) is applied as a put and the row survives.
|
||||||
let key = data[0..<nullPos]
|
let decoded = decodeRepPayload(cast[seq[byte]](data))
|
||||||
let value = data[nullPos+1..^1]
|
case decoded.op
|
||||||
if value.len > 0:
|
of ropPut, ropDelete:
|
||||||
server.db.put(key, stringToBytes(value))
|
# 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:
|
else:
|
||||||
server.db.delete(key)
|
applyReplicatedDelete(server.ctx, decoded.key)
|
||||||
|
of ropInvalid:
|
||||||
|
discard
|
||||||
await client.send("ACK " & $lsn & "\n")
|
await client.send("ACK " & $lsn & "\n")
|
||||||
else:
|
else:
|
||||||
await client.send("ERR\n")
|
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 ")
|
# Detect shard migration data (starts with "MIGRATE ")
|
||||||
if headerData.len >= 8 and headerData[0..7] == "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]
|
var rest = headerData[8..^1]
|
||||||
while '\n' notin rest:
|
while '\n' notin rest:
|
||||||
let more = await client.recvWithTimeout(1024, idleTimeout)
|
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,
|
let (success, result, errorMsg) = await executeQuery(connCtx.db, connCtx, queryStr,
|
||||||
replication=server.replicationManager, raftNode=server.raftNode,
|
replication=server.replicationManager, raftNode=server.raftNode,
|
||||||
raftWriteTimeoutMs=server.config.raftWriteTimeoutMs,
|
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)
|
let durationMs = int((getMonoTime().ticks() - startTicks) div 1_000_000)
|
||||||
|
|
||||||
if durationMs >= slowThreshold:
|
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,
|
let (success, result, errorMsg) = await executeQuery(connCtx.db, connCtx, queryStr, params,
|
||||||
replication=server.replicationManager, raftNode=server.raftNode,
|
replication=server.replicationManager, raftNode=server.raftNode,
|
||||||
raftWriteTimeoutMs=server.config.raftWriteTimeoutMs,
|
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)
|
let durationMs = int((getMonoTime().ticks() - startTicks) div 1_000_000)
|
||||||
|
|
||||||
if durationMs >= slowThreshold:
|
if durationMs >= slowThreshold:
|
||||||
|
|||||||
@@ -15,18 +15,25 @@ else:
|
|||||||
import config
|
import config
|
||||||
import jwt as jwtlib
|
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
|
type
|
||||||
WsFrame = object
|
WsFrame* = object
|
||||||
fin: bool
|
fin*: bool
|
||||||
opcode: uint8
|
opcode*: uint8
|
||||||
masked: bool
|
masked*: bool
|
||||||
payloadLen: uint64
|
payloadLen*: uint64
|
||||||
maskKey: array[4, byte]
|
maskKey*: array[4, byte]
|
||||||
payload: string
|
payload*: string
|
||||||
|
|
||||||
WsClient* = ref object
|
WsClient* = ref object
|
||||||
socket: AsyncSocket
|
socket: AsyncSocket
|
||||||
id: int
|
id: int
|
||||||
|
username: string
|
||||||
subscriptions: HashSet[string]
|
subscriptions: HashSet[string]
|
||||||
|
|
||||||
WsServer* = ref object
|
WsServer* = ref object
|
||||||
@@ -35,6 +42,8 @@ type
|
|||||||
running: bool
|
running: bool
|
||||||
config*: BaraConfig
|
config*: BaraConfig
|
||||||
secretKey*: string
|
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.}
|
onInsert*: proc (table, key, value: string) {.closure.}
|
||||||
onDelete*: proc (table, key: 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)
|
# 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 = ""
|
result = ""
|
||||||
let isMasked = false
|
|
||||||
var b0 = 0x80'u8 or opcode
|
var b0 = 0x80'u8 or opcode
|
||||||
result.add(char(b0))
|
result.add(char(b0))
|
||||||
|
|
||||||
var b1 = 0'u8
|
var b1 = if masked: 0x80'u8 else: 0'u8
|
||||||
if not isMasked:
|
|
||||||
if payload.len < 126:
|
if payload.len < 126:
|
||||||
b1 = uint8(payload.len)
|
b1 = b1 or uint8(payload.len)
|
||||||
elif payload.len <= 65535:
|
elif payload.len <= 65535:
|
||||||
b1 = 126
|
b1 = b1 or 126
|
||||||
else:
|
else:
|
||||||
b1 = 127
|
b1 = b1 or 127
|
||||||
result.add(char(b1))
|
result.add(char(b1))
|
||||||
|
|
||||||
if payload.len >= 126 and payload.len <= 65535:
|
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):
|
for i in countdown(7, 0):
|
||||||
result.add(char((len64 shr (i * 8)) and 0xFF))
|
result.add(char((len64 shr (i * 8)) and 0xFF))
|
||||||
|
|
||||||
|
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)
|
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:
|
if data.len < 2:
|
||||||
return (WsFrame(), 0)
|
return (WsFrame(), 0)
|
||||||
|
|
||||||
@@ -84,6 +100,10 @@ proc decodeFrame(data: string): (WsFrame, int) =
|
|||||||
frame.opcode = b0 and 0x0F
|
frame.opcode = b0 and 0x0F
|
||||||
frame.masked = (b1 and 0x80) != 0
|
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 len = uint64(b1 and 0x7F)
|
||||||
var offset = 2
|
var offset = 2
|
||||||
|
|
||||||
@@ -98,23 +118,24 @@ proc decodeFrame(data: string): (WsFrame, int) =
|
|||||||
len = (len shl 8) or uint64(uint8(data[2 + i]))
|
len = (len shl 8) or uint64(uint8(data[2 + i]))
|
||||||
offset = 10
|
offset = 10
|
||||||
|
|
||||||
if frame.masked:
|
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)
|
if data.len < offset + 4: return (WsFrame(), 0)
|
||||||
for i in 0..3:
|
for i in 0..3:
|
||||||
frame.maskKey[i] = byte(data[offset + i])
|
frame.maskKey[i] = byte(data[offset + i])
|
||||||
offset += 4
|
offset += 4
|
||||||
|
|
||||||
if uint64(data.len) < uint64(offset) + len:
|
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)
|
let plen = int(len)
|
||||||
if frame.masked:
|
frame.payloadLen = len
|
||||||
for i in 0..<plen:
|
for i in 0..<plen:
|
||||||
frame.payload.add(char(byte(data[offset + i]) xor frame.maskKey[i mod 4]))
|
frame.payload.add(char(byte(data[offset + i]) xor frame.maskKey[i mod 4]))
|
||||||
else:
|
|
||||||
frame.payload = data[offset..offset + plen - 1]
|
|
||||||
|
|
||||||
return (frame, offset + plen)
|
return (frame, offset + plen)
|
||||||
|
|
||||||
@@ -179,6 +200,14 @@ proc computeAcceptKey(key: string): string =
|
|||||||
# Subscription management
|
# 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) =
|
proc subscribe*(client: WsClient, table: string) =
|
||||||
client.subscriptions.incl(table)
|
client.subscriptions.incl(table)
|
||||||
|
|
||||||
@@ -201,9 +230,11 @@ proc broadcastToTable*(server: WsServer, table: string, msg: string) {.async.} =
|
|||||||
# WebSocket client handler
|
# 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"
|
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
|
server.clients[id] = wsClient
|
||||||
|
|
||||||
var buf = ""
|
var buf = ""
|
||||||
@@ -212,12 +243,22 @@ proc handleWsClient(server: WsServer, client: AsyncSocket, id: int) {.async.} =
|
|||||||
let chunk = await client.recv(4096)
|
let chunk = await client.recv(4096)
|
||||||
if chunk.len == 0:
|
if chunk.len == 0:
|
||||||
break
|
break
|
||||||
|
if buf.len + chunk.len > MaxWsMessageBytes:
|
||||||
|
let closeF = encodeFrame(0x8, "")
|
||||||
|
try: await client.send(closeF) except CatchableError: discard
|
||||||
|
break
|
||||||
buf.add(chunk)
|
buf.add(chunk)
|
||||||
|
|
||||||
while buf.len >= 2:
|
while buf.len >= 2:
|
||||||
let (frame, consumed) = decodeFrame(buf)
|
let (frame, consumed) = decodeFrame(buf)
|
||||||
if consumed == 0:
|
if consumed == 0:
|
||||||
break
|
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
|
case frame.opcode
|
||||||
of 0x8: # close
|
of 0x8: # close
|
||||||
@@ -231,9 +272,18 @@ proc handleWsClient(server: WsServer, client: AsyncSocket, id: int) {.async.} =
|
|||||||
let msg = frame.payload
|
let msg = frame.payload
|
||||||
if msg.startsWith("SUBSCRIBE "):
|
if msg.startsWith("SUBSCRIBE "):
|
||||||
let table = msg[10..^1].strip()
|
let table = msg[10..^1].strip()
|
||||||
|
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)
|
wsClient.subscribe(table)
|
||||||
let ack = encodeFrame(0x1, "OK subscribed to " & table)
|
let ack = encodeFrame(0x1, "OK subscribed to " & table)
|
||||||
await client.send(ack)
|
await client.send(ack)
|
||||||
|
else:
|
||||||
|
let nack = encodeFrame(0x1, "ERR subscribe denied for " & table)
|
||||||
|
await client.send(nack)
|
||||||
elif msg.startsWith("UNSUBSCRIBE "):
|
elif msg.startsWith("UNSUBSCRIBE "):
|
||||||
let table = msg[12..^1].strip()
|
let table = msg[12..^1].strip()
|
||||||
wsClient.unsubscribe(table)
|
wsClient.unsubscribe(table)
|
||||||
@@ -285,6 +335,7 @@ proc handleConnection(server: WsServer, client: AsyncSocket) {.async.} =
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Auth check
|
# Auth check
|
||||||
|
var username = ""
|
||||||
if server.config.authEnabled:
|
if server.config.authEnabled:
|
||||||
let authHeader = headers.getOrDefault("authorization", "")
|
let authHeader = headers.getOrDefault("authorization", "")
|
||||||
if authHeader.len == 0 or not authHeader.startsWith("Bearer "):
|
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")
|
await client.send("HTTP/1.1 401 Unauthorized\r\n\r\n")
|
||||||
client.close()
|
client.close()
|
||||||
return
|
return
|
||||||
|
if "sub" in token.claims:
|
||||||
|
username = token.claims["sub"].node.str
|
||||||
except CatchableError:
|
except CatchableError:
|
||||||
await client.send("HTTP/1.1 401 Unauthorized\r\n\r\n")
|
await client.send("HTTP/1.1 401 Unauthorized\r\n\r\n")
|
||||||
client.close()
|
client.close()
|
||||||
@@ -321,7 +374,7 @@ proc handleConnection(server: WsServer, client: AsyncSocket) {.async.} =
|
|||||||
await client.send(response)
|
await client.send(response)
|
||||||
|
|
||||||
inc server.nextId
|
inc server.nextId
|
||||||
asyncCheck server.handleWsClient(client, server.nextId)
|
asyncCheck server.handleWsClient(client, server.nextId, username)
|
||||||
|
|
||||||
proc setTcpNoDelay(sock: AsyncSocket) =
|
proc setTcpNoDelay(sock: AsyncSocket) =
|
||||||
## Enable TCP_NODELAY using the correct protocol level (IPPROTO_TCP).
|
## Enable TCP_NODELAY using the correct protocol level (IPPROTO_TCP).
|
||||||
|
|||||||
@@ -221,10 +221,17 @@ proc registerScramUser*(am: AuthManager, username, password: string,
|
|||||||
let cred = createScramCredential(password, iterationCount = iterationCount)
|
let cred = createScramCredential(password, iterationCount = iterationCount)
|
||||||
am.scramUsers[username] = cred
|
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 =
|
proc startScram*(am: AuthManager, clientFirstMessage: string): string =
|
||||||
## Start SCRAM authentication. Returns server-first-message.
|
## Start SCRAM authentication. Returns server-first-message.
|
||||||
let (_, username, clientNonce) = parseClientFirst(clientFirstMessage)
|
let (gs2, username, clientNonce) = parseClientFirst(clientFirstMessage)
|
||||||
if username notin am.scramUsers:
|
if username notin am.scramUsers or gs2 notin ["n", "y"]:
|
||||||
|
dummyScramStartWork()
|
||||||
raise newException(ValueError, "Authentication failed")
|
raise newException(ValueError, "Authentication failed")
|
||||||
|
|
||||||
let cred = am.scramUsers[username]
|
let cred = am.scramUsers[username]
|
||||||
@@ -239,6 +246,7 @@ proc startScram*(am: AuthManager, clientFirstMessage: string): string =
|
|||||||
|
|
||||||
var state = ScramServerState(
|
var state = ScramServerState(
|
||||||
username: username,
|
username: username,
|
||||||
|
gs2Flag: gs2,
|
||||||
clientFirstMessageBare: clientFirstMessageBare,
|
clientFirstMessageBare: clientFirstMessageBare,
|
||||||
serverFirstMessage: serverFirst,
|
serverFirstMessage: serverFirst,
|
||||||
authMessage: authMessage,
|
authMessage: authMessage,
|
||||||
@@ -264,6 +272,9 @@ proc finishScram*(am: AuthManager, clientFinalMessage: string): (bool, string) =
|
|||||||
var state = am.scramSessions[nonce]
|
var state = am.scramSessions[nonce]
|
||||||
am.scramSessions.del(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
|
# Update authMessage with client-final-message-without-proof
|
||||||
let clientFinalWithoutProof = "c=" & cbind & ",r=" & nonce
|
let clientFinalWithoutProof = "c=" & cbind & ",r=" & nonce
|
||||||
state.authMessage = state.authMessage & "," & clientFinalWithoutProof
|
state.authMessage = state.authMessage & "," & clientFinalWithoutProof
|
||||||
|
|||||||
Binary file not shown.
@@ -19,6 +19,7 @@ type
|
|||||||
|
|
||||||
ScramServerState* = object
|
ScramServerState* = object
|
||||||
username*: string
|
username*: string
|
||||||
|
gs2Flag*: string
|
||||||
clientFirstMessageBare*: string
|
clientFirstMessageBare*: string
|
||||||
serverFirstMessage*: string
|
serverFirstMessage*: string
|
||||||
authMessage*: string
|
authMessage*: string
|
||||||
@@ -189,6 +190,19 @@ proc createScramCredential*(password: string, salt: string = "",
|
|||||||
# SCRAM message parsing / building
|
# 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) =
|
proc parseClientFirst*(msg: string): (string, string, string) =
|
||||||
## Parse client-first-message: gs2-header,username,nonce
|
## Parse client-first-message: gs2-header,username,nonce
|
||||||
## Returns: (gs2_header, username, nonce)
|
## Returns: (gs2_header, username, nonce)
|
||||||
|
|||||||
@@ -29,10 +29,14 @@ proc newTLSConfig*(certFile: string, keyFile: string, caFile: string = "",
|
|||||||
proc newTLSContext*(config: TLSConfig): TLSContext =
|
proc newTLSContext*(config: TLSConfig): TLSContext =
|
||||||
result = TLSContext(config: config)
|
result = TLSContext(config: config)
|
||||||
if fileExists(config.certFile) and fileExists(config.keyFile):
|
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(
|
result.sslCtx = newContext(
|
||||||
certFile = config.certFile,
|
certFile = config.certFile,
|
||||||
keyFile = config.keyFile,
|
keyFile = config.keyFile,
|
||||||
verifyMode = if config.verifyPeer: CVerifyPeer else: CVerifyNone,
|
verifyMode = if config.verifyPeer: CVerifyPeer else: CVerifyNone,
|
||||||
|
caFile = config.caFile,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise newException(IOError, "TLS certificate or key file not found: " &
|
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.} =
|
proc wrapClient*(tls: TLSContext, socket: AsyncSocket) {.inline.} =
|
||||||
if tls.sslCtx != nil:
|
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.} =
|
proc wrapServer*(tls: TLSContext, socket: AsyncSocket) {.inline.} =
|
||||||
if tls.sslCtx != nil:
|
if tls.sslCtx != nil:
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ proc violatesUniqueIndex*(ctx: ExecutionContext, table: string, fields: seq[stri
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
proc execInsert*(ctx: ExecutionContext, table: string, fields: seq[string], values: seq[seq[string]],
|
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"):
|
if not hasPrivilege(ctx, table, "INSERT"):
|
||||||
return 0
|
return 0
|
||||||
let tblDef = if table in ctx.tables: ctx.tables[table] else: TableDef()
|
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))
|
discard ctx.txnManager.write(ctx.pendingTxn, fullKey, cast[seq[byte]](valStr))
|
||||||
else:
|
else:
|
||||||
ctx.db.put(fullKey, cast[seq[byte]](valStr))
|
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():
|
for colName in ctx.btrees.keys.toSeq():
|
||||||
if colName.startsWith(table & "."):
|
if colName.startsWith(table & "."):
|
||||||
@@ -221,7 +221,7 @@ proc execInsert*(ctx: ExecutionContext, table: string, fields: seq[string], valu
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
proc execDelete*(ctx: ExecutionContext, table: string, key: string,
|
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"):
|
if not hasPrivilege(ctx, table, "DELETE"):
|
||||||
return 0
|
return 0
|
||||||
let fullKey = table & "." & key
|
let fullKey = table & "." & key
|
||||||
@@ -238,7 +238,7 @@ proc execDelete*(ctx: ExecutionContext, table: string, key: string,
|
|||||||
discard ctx.txnManager.delete(ctx.pendingTxn, fullKey)
|
discard ctx.txnManager.delete(ctx.pendingTxn, fullKey)
|
||||||
else:
|
else:
|
||||||
ctx.db.delete(fullKey)
|
ctx.db.delete(fullKey)
|
||||||
kvPairs.add((fullKey, @[]))
|
kvPairs.add((fullKey, @[], true))
|
||||||
# Update BTree indexes
|
# Update BTree indexes
|
||||||
for colName in ctx.btrees.keys.toSeq():
|
for colName in ctx.btrees.keys.toSeq():
|
||||||
if colName.startsWith(table & "."):
|
if colName.startsWith(table & "."):
|
||||||
@@ -264,7 +264,7 @@ proc execDelete*(ctx: ExecutionContext, table: string, key: string,
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
proc execUpdateRow*(ctx: ExecutionContext, table: string, key: string, sets: Table[string, string],
|
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"):
|
if not hasPrivilege(ctx, table, "UPDATE"):
|
||||||
return 0
|
return 0
|
||||||
let fullKey = table & "." & key
|
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))
|
discard ctx.txnManager.write(ctx.pendingTxn, fullKey, cast[seq[byte]](newVal))
|
||||||
else:
|
else:
|
||||||
ctx.db.put(fullKey, cast[seq[byte]](newVal))
|
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
|
# Update FTS indexes: remove old doc, add new
|
||||||
for ftsKey, ftsIdx in ctx.ftsIndexes:
|
for ftsKey, ftsIdx in ctx.ftsIndexes:
|
||||||
if ftsKey.startsWith(table & "."):
|
if ftsKey.startsWith(table & "."):
|
||||||
|
|||||||
@@ -429,6 +429,8 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
|
|||||||
let right = evalExprOld(expr.binRight, row, ctx)
|
let right = evalExprOld(expr.binRight, row, ctx)
|
||||||
case expr.binOp
|
case expr.binOp
|
||||||
of irEq:
|
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"
|
if left == right: return "true"
|
||||||
# Try numeric comparison
|
# Try numeric comparison
|
||||||
try:
|
try:
|
||||||
@@ -436,32 +438,45 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
|
|||||||
except CatchableError: discard
|
except CatchableError: discard
|
||||||
return "false"
|
return "false"
|
||||||
of irNeq:
|
of irNeq:
|
||||||
if left != right: return "true"
|
if isNull(left) or isNull(right): return "\\N"
|
||||||
# Try numeric comparison
|
# 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:
|
try:
|
||||||
return if parseFloat(left) != parseFloat(right): "true" else: "false"
|
return if parseFloat(left) != parseFloat(right): "true" else: "false"
|
||||||
except CatchableError: return "false"
|
except CatchableError:
|
||||||
|
return if left != right: "true" else: "false"
|
||||||
of irLt:
|
of irLt:
|
||||||
|
if isNull(left) or isNull(right): return "\\N"
|
||||||
try:
|
try:
|
||||||
return if parseFloat(left) < parseFloat(right): "true" else: "false"
|
return if parseFloat(left) < parseFloat(right): "true" else: "false"
|
||||||
except CatchableError: return if left < right: "true" else: "false"
|
except CatchableError: return if left < right: "true" else: "false"
|
||||||
of irLte:
|
of irLte:
|
||||||
|
if isNull(left) or isNull(right): return "\\N"
|
||||||
try:
|
try:
|
||||||
return if parseFloat(left) <= parseFloat(right): "true" else: "false"
|
return if parseFloat(left) <= parseFloat(right): "true" else: "false"
|
||||||
except CatchableError: return if left <= right: "true" else: "false"
|
except CatchableError: return if left <= right: "true" else: "false"
|
||||||
of irGt:
|
of irGt:
|
||||||
|
if isNull(left) or isNull(right): return "\\N"
|
||||||
try:
|
try:
|
||||||
return if parseFloat(left) > parseFloat(right): "true" else: "false"
|
return if parseFloat(left) > parseFloat(right): "true" else: "false"
|
||||||
except CatchableError: return if left > right: "true" else: "false"
|
except CatchableError: return if left > right: "true" else: "false"
|
||||||
of irGte:
|
of irGte:
|
||||||
|
if isNull(left) or isNull(right): return "\\N"
|
||||||
try:
|
try:
|
||||||
return if parseFloat(left) >= parseFloat(right): "true" else: "false"
|
return if parseFloat(left) >= parseFloat(right): "true" else: "false"
|
||||||
except CatchableError: return if left >= right: "true" else: "false"
|
except CatchableError: return if left >= right: "true" else: "false"
|
||||||
of irAnd:
|
of irAnd:
|
||||||
if left == "true" and right == "true": return "true"
|
# false AND x = false; unknown AND true/unknown = unknown; else both true.
|
||||||
return "false"
|
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:
|
of irOr:
|
||||||
if left == "true" or right == "true": return "true"
|
if left == "true" or right == "true": return "true"
|
||||||
|
if isNull(left) or isNull(right): return "\\N"
|
||||||
return "false"
|
return "false"
|
||||||
of irAdd, irSub, irMul, irDiv, irMod, irPow:
|
of irAdd, irSub, irMul, irDiv, irMod, irPow:
|
||||||
let v = evalExpr(expr, stringTableToValueRow(row), ctx)
|
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
|
of vkString: return v.strVal
|
||||||
else: return "\\N"
|
else: return "\\N"
|
||||||
of irLike:
|
of irLike:
|
||||||
|
if isNull(left) or isNull(right): return "\\N"
|
||||||
proc escapeRe(s: string): string =
|
proc escapeRe(s: string): string =
|
||||||
result = ""
|
result = ""
|
||||||
for ch in s:
|
for ch in s:
|
||||||
@@ -489,6 +505,7 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
|
|||||||
except CatchableError: discard
|
except CatchableError: discard
|
||||||
return "false"
|
return "false"
|
||||||
of irILike:
|
of irILike:
|
||||||
|
if isNull(left) or isNull(right): return "\\N"
|
||||||
proc escapeRe(s: string): string =
|
proc escapeRe(s: string): string =
|
||||||
result = ""
|
result = ""
|
||||||
for ch in s:
|
for ch in s:
|
||||||
@@ -504,8 +521,10 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
|
|||||||
except CatchableError: discard
|
except CatchableError: discard
|
||||||
return "false"
|
return "false"
|
||||||
of irIn:
|
of irIn:
|
||||||
|
if isNull(left): return "\\N"
|
||||||
if expr.binRight.kind == irekSubquery:
|
if expr.binRight.kind == irekSubquery:
|
||||||
let subRows = requireExecutePlanHook()(ctx, expr.binRight.subqueryPlan)
|
let subRows = requireExecutePlanHook()(ctx, expr.binRight.subqueryPlan)
|
||||||
|
var sawNull = false
|
||||||
for row in subRows:
|
for row in subRows:
|
||||||
# Compare against the first non-internal column only (SQL semantics)
|
# Compare against the first non-internal column only (SQL semantics)
|
||||||
var firstVal = ""
|
var firstVal = ""
|
||||||
@@ -515,8 +534,14 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
|
|||||||
firstVal = valueToString(v)
|
firstVal = valueToString(v)
|
||||||
found = true
|
found = true
|
||||||
break
|
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"
|
return "false"
|
||||||
|
if isNull(right): return "\\N"
|
||||||
try:
|
try:
|
||||||
let lv = parseFloat(left)
|
let lv = parseFloat(left)
|
||||||
let rv = parseFloat(right)
|
let rv = parseFloat(right)
|
||||||
@@ -524,8 +549,10 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
|
|||||||
except CatchableError: discard
|
except CatchableError: discard
|
||||||
return if left == right: "true" else: "false"
|
return if left == right: "true" else: "false"
|
||||||
of irNotIn:
|
of irNotIn:
|
||||||
|
if isNull(left): return "\\N"
|
||||||
if expr.binRight.kind == irekSubquery:
|
if expr.binRight.kind == irekSubquery:
|
||||||
let subRows = requireExecutePlanHook()(ctx, expr.binRight.subqueryPlan)
|
let subRows = requireExecutePlanHook()(ctx, expr.binRight.subqueryPlan)
|
||||||
|
var sawNull = false
|
||||||
for row in subRows:
|
for row in subRows:
|
||||||
# Compare against the first non-internal column only (SQL semantics)
|
# Compare against the first non-internal column only (SQL semantics)
|
||||||
var firstVal = ""
|
var firstVal = ""
|
||||||
@@ -535,8 +562,14 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
|
|||||||
firstVal = valueToString(v)
|
firstVal = valueToString(v)
|
||||||
found = true
|
found = true
|
||||||
break
|
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"
|
return "true"
|
||||||
|
if isNull(right): return "\\N"
|
||||||
try:
|
try:
|
||||||
let lv = parseFloat(left)
|
let lv = parseFloat(left)
|
||||||
let rv = parseFloat(right)
|
let rv = parseFloat(right)
|
||||||
@@ -661,6 +694,7 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
|
|||||||
case expr.unOp
|
case expr.unOp
|
||||||
of irNot:
|
of irNot:
|
||||||
let v = evalExprOld(expr.unExpr, row, ctx)
|
let v = evalExprOld(expr.unExpr, row, ctx)
|
||||||
|
if isNull(v): return "\\N"
|
||||||
return if v == "true": "false" else: "true"
|
return if v == "true": "false" else: "true"
|
||||||
of irIsNull:
|
of irIsNull:
|
||||||
let v = evalExprOld(expr.unExpr, row, ctx)
|
let v = evalExprOld(expr.unExpr, row, ctx)
|
||||||
@@ -923,7 +957,6 @@ proc evalExprOld*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContex
|
|||||||
ddl.add("\n")
|
ddl.add("\n")
|
||||||
|
|
||||||
# Sample data
|
# Sample data
|
||||||
var kvPairs: seq[(string, seq[byte])] = @[]
|
|
||||||
let rows = requireExecScanHook()(ctx, table)
|
let rows = requireExecScanHook()(ctx, table)
|
||||||
let sampleLimit = min(5, rows.len)
|
let sampleLimit = min(5, rows.len)
|
||||||
if sampleLimit > 0:
|
if sampleLimit > 0:
|
||||||
|
|||||||
@@ -30,14 +30,14 @@ proc enforceFkOnDelete*(ctx: ExecutionContext, parentTable: string, parentCol: s
|
|||||||
of "CASCADE":
|
of "CASCADE":
|
||||||
for refRow in refs:
|
for refRow in refs:
|
||||||
if "$key" in refRow:
|
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)
|
discard execDelete(ctx, childTblName, valueToString(refRow["$key"]), dummy)
|
||||||
of "SET NULL":
|
of "SET NULL":
|
||||||
for refRow in refs:
|
for refRow in refs:
|
||||||
if "$key" in refRow:
|
if "$key" in refRow:
|
||||||
var sets = initTable[string, string]()
|
var sets = initTable[string, string]()
|
||||||
sets[col.name] = "\\N"
|
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)
|
discard execUpdateRow(ctx, childTblName, valueToString(refRow["$key"]), sets, dummy)
|
||||||
of "RESTRICT", "NO ACTION":
|
of "RESTRICT", "NO ACTION":
|
||||||
return (false, "FOREIGN KEY violation: row is referenced by " & childTblName & "." & col.name)
|
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:
|
if "$key" in refRow:
|
||||||
var sets = initTable[string, string]()
|
var sets = initTable[string, string]()
|
||||||
sets[col.name] = newVal
|
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)
|
discard execUpdateRow(ctx, childTblName, valueToString(refRow["$key"]), sets, dummy)
|
||||||
of "SET NULL":
|
of "SET NULL":
|
||||||
for refRow in refs:
|
for refRow in refs:
|
||||||
if "$key" in refRow:
|
if "$key" in refRow:
|
||||||
var sets = initTable[string, string]()
|
var sets = initTable[string, string]()
|
||||||
sets[col.name] = "\\N"
|
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)
|
discard execUpdateRow(ctx, childTblName, valueToString(refRow["$key"]), sets, dummy)
|
||||||
of "RESTRICT", "NO ACTION":
|
of "RESTRICT", "NO ACTION":
|
||||||
return (false, "FOREIGN KEY violation: row is referenced by " & childTblName & "." & col.name)
|
return (false, "FOREIGN KEY violation: row is referenced by " & childTblName & "." & col.name)
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ proc lowerExpr*(node: Node): IRExpr =
|
|||||||
of bkJsonContainedBy: irOp = irJsonContainedBy
|
of bkJsonContainedBy: irOp = irJsonContainedBy
|
||||||
of bkJsonHasAny: irOp = irJsonHasAny
|
of bkJsonHasAny: irOp = irJsonHasAny
|
||||||
of bkJsonHasAll: irOp = irJsonHasAll
|
of bkJsonHasAll: irOp = irJsonHasAll
|
||||||
|
of bkPow: irOp = irPow
|
||||||
|
of bkConcat: irOp = irAdd # irAdd concatenates string operands
|
||||||
else: irOp = irEq
|
else: irOp = irEq
|
||||||
result.binOp = irOp
|
result.binOp = irOp
|
||||||
result.binLeft = lowerExpr(node.binLeft)
|
result.binLeft = lowerExpr(node.binLeft)
|
||||||
@@ -120,6 +122,7 @@ proc lowerExpr*(node: Node): IRExpr =
|
|||||||
else: discard
|
else: discard
|
||||||
result.aggArgs = @[]
|
result.aggArgs = @[]
|
||||||
for arg in node.funcArgs: result.aggArgs.add(lowerExpr(arg))
|
for arg in node.funcArgs: result.aggArgs.add(lowerExpr(arg))
|
||||||
|
result.aggDistinct = node.funcDistinct
|
||||||
if node.funcFilter != nil:
|
if node.funcFilter != nil:
|
||||||
result.aggFilter = lowerExpr(node.funcFilter)
|
result.aggFilter = lowerExpr(node.funcFilter)
|
||||||
else:
|
else:
|
||||||
@@ -408,9 +411,18 @@ proc lowerSelect*(node: Node): IRPlan =
|
|||||||
if node.selLimit != nil or node.selOffset != nil:
|
if node.selLimit != nil or node.selOffset != nil:
|
||||||
let limitPlan = IRPlan(kind: irpkLimit)
|
let limitPlan = IRPlan(kind: irpkLimit)
|
||||||
limitPlan.limitSource = result
|
limitPlan.limitSource = result
|
||||||
limitPlan.limitCount = if node.selLimit != nil and node.selLimit.limitExpr.kind == nkIntLit:
|
# limitCount: -1 = unlimited (OFFSET without LIMIT). LIMIT 0 is empty.
|
||||||
node.selLimit.limitExpr.intVal else: 0
|
# Negative LIMIT/OFFSET are clamped so slicing cannot IndexDefect.
|
||||||
limitPlan.limitOffset = if node.selOffset != nil and node.selOffset.offsetExpr.kind == nkIntLit:
|
if node.selLimit != nil:
|
||||||
node.selOffset.offsetExpr.intVal else: 0
|
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
|
result = limitPlan
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
## executor split). Pure code motion — no behavior changes.
|
## executor split). Pure code motion — no behavior changes.
|
||||||
import std/strutils
|
import std/strutils
|
||||||
import std/tables
|
import std/tables
|
||||||
|
import std/sets
|
||||||
import std/sequtils
|
import std/sequtils
|
||||||
import std/algorithm
|
import std/algorithm
|
||||||
import ../ir
|
import ../ir
|
||||||
@@ -19,6 +20,19 @@ import eval
|
|||||||
import scan
|
import scan
|
||||||
import window
|
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)
|
# IR Plan Execution (with actual filter/sort/projection)
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
@@ -113,49 +127,71 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
|
|||||||
newRow[alias] = $filteredRows.len
|
newRow[alias] = $filteredRows.len
|
||||||
else:
|
else:
|
||||||
var count = 0
|
var count = 0
|
||||||
|
var seen: HashSet[string]
|
||||||
for row in filteredRows:
|
for row in filteredRows:
|
||||||
let v = evalExpr(expr.aggArgs[0], row, ctx)
|
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
|
newRow[alias] = $count
|
||||||
of irSum:
|
of irSum:
|
||||||
var sum = 0.0
|
var sum = 0.0
|
||||||
|
var seen: HashSet[string]
|
||||||
for row in filteredRows:
|
for row in filteredRows:
|
||||||
let v = evalExpr(expr.aggArgs[0], row, ctx)
|
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
|
newRow[alias] = $sum
|
||||||
of irAvg:
|
of irAvg:
|
||||||
var sum = 0.0
|
var sum = 0.0
|
||||||
var count = 0
|
var count = 0
|
||||||
|
var seen: HashSet[string]
|
||||||
for row in filteredRows:
|
for row in filteredRows:
|
||||||
let v = evalExpr(expr.aggArgs[0], row, ctx)
|
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"
|
newRow[alias] = if count > 0: $(sum / float(count)) else: "0"
|
||||||
of irMin:
|
of irMin:
|
||||||
var minVal = ""
|
var minVal = ""
|
||||||
|
var seen: HashSet[string]
|
||||||
for row in filteredRows:
|
for row in filteredRows:
|
||||||
let v = evalExpr(expr.aggArgs[0], row, ctx)
|
let v = evalExpr(expr.aggArgs[0], row, ctx)
|
||||||
if v.kind == vkNull: continue
|
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
|
newRow[alias] = minVal
|
||||||
of irMax:
|
of irMax:
|
||||||
var maxVal = ""
|
var maxVal = ""
|
||||||
|
var seen: HashSet[string]
|
||||||
for row in filteredRows:
|
for row in filteredRows:
|
||||||
let v = evalExpr(expr.aggArgs[0], row, ctx)
|
let v = evalExpr(expr.aggArgs[0], row, ctx)
|
||||||
if v.kind == vkNull: continue
|
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
|
newRow[alias] = maxVal
|
||||||
of irArrayAgg:
|
of irArrayAgg:
|
||||||
var arr: seq[string]
|
var arr: seq[string]
|
||||||
|
var seen: HashSet[string]
|
||||||
for row in filteredRows:
|
for row in filteredRows:
|
||||||
if expr.aggArgs.len > 0:
|
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(", ") & "]"
|
newRow[alias] = "[" & arr.join(", ") & "]"
|
||||||
of irStringAgg:
|
of irStringAgg:
|
||||||
var parts: seq[string]
|
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: ",")
|
let delim = if expr.aggArgs.len > 1: evalExpr(expr.aggArgs[1], initTable[string, Value](), ctx) else: Value(kind: vkString, strVal: ",")
|
||||||
for row in filteredRows:
|
for row in filteredRows:
|
||||||
if expr.aggArgs.len > 0:
|
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))
|
newRow[alias] = parts.join(valueToString(delim))
|
||||||
else:
|
else:
|
||||||
let val = evalExpr(expr, if sourceRows.len > 0: sourceRows[0] else: initTable[string, Value](), ctx)
|
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:
|
of irpkLimit:
|
||||||
let sourceRows = executePlan(ctx, plan.limitSource)
|
let sourceRows = executePlan(ctx, plan.limitSource)
|
||||||
var start = int(plan.limitOffset)
|
var start = int(plan.limitOffset)
|
||||||
|
if start < 0: start = 0
|
||||||
if start > sourceRows.len: start = sourceRows.len
|
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:
|
if plan.limitCount == 0:
|
||||||
return @[]
|
return @[]
|
||||||
var endIdx = start + int(plan.limitCount)
|
var endIdx = start + int(plan.limitCount)
|
||||||
if endIdx > sourceRows.len:
|
if endIdx > sourceRows.len:
|
||||||
endIdx = sourceRows.len
|
endIdx = sourceRows.len
|
||||||
|
if endIdx < start:
|
||||||
|
endIdx = start
|
||||||
return sourceRows[start..<endIdx]
|
return sourceRows[start..<endIdx]
|
||||||
|
|
||||||
of irpkGroupBy:
|
of irpkGroupBy:
|
||||||
@@ -292,49 +334,71 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
|
|||||||
aggRow[aggKey] = $filteredRows.len
|
aggRow[aggKey] = $filteredRows.len
|
||||||
else:
|
else:
|
||||||
var count = 0
|
var count = 0
|
||||||
|
var seen: HashSet[string]
|
||||||
for row in filteredRows:
|
for row in filteredRows:
|
||||||
let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
|
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
|
aggRow[aggKey] = $count
|
||||||
of irSum:
|
of irSum:
|
||||||
var sum = 0.0
|
var sum = 0.0
|
||||||
|
var seen: HashSet[string]
|
||||||
for row in filteredRows:
|
for row in filteredRows:
|
||||||
let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
|
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
|
aggRow[aggKey] = $sum
|
||||||
of irAvg:
|
of irAvg:
|
||||||
var sum = 0.0
|
var sum = 0.0
|
||||||
var count = 0
|
var count = 0
|
||||||
|
var seen: HashSet[string]
|
||||||
for row in filteredRows:
|
for row in filteredRows:
|
||||||
let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
|
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"
|
aggRow[aggKey] = if count > 0: $(sum / float(count)) else: "0"
|
||||||
of irMin:
|
of irMin:
|
||||||
var minVal = ""
|
var minVal = ""
|
||||||
|
var seen: HashSet[string]
|
||||||
for row in filteredRows:
|
for row in filteredRows:
|
||||||
let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
|
let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
|
||||||
if v.kind == vkNull: continue
|
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
|
aggRow[aggKey] = minVal
|
||||||
of irMax:
|
of irMax:
|
||||||
var maxVal = ""
|
var maxVal = ""
|
||||||
|
var seen: HashSet[string]
|
||||||
for row in filteredRows:
|
for row in filteredRows:
|
||||||
let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
|
let v = evalExpr(aggExpr.aggArgs[0], row, ctx)
|
||||||
if v.kind == vkNull: continue
|
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
|
aggRow[aggKey] = maxVal
|
||||||
of irArrayAgg:
|
of irArrayAgg:
|
||||||
var arr: seq[string]
|
var arr: seq[string]
|
||||||
|
var seen: HashSet[string]
|
||||||
for row in filteredRows:
|
for row in filteredRows:
|
||||||
if aggExpr.aggArgs.len > 0:
|
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(", ") & "]"
|
aggRow[aggKey] = "[" & arr.join(", ") & "]"
|
||||||
of irStringAgg:
|
of irStringAgg:
|
||||||
var parts: seq[string]
|
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: ",")
|
let delim = if aggExpr.aggArgs.len > 1: evalExpr(aggExpr.aggArgs[1], initTable[string, Value](), ctx) else: Value(kind: vkString, strVal: ",")
|
||||||
for row in filteredRows:
|
for row in filteredRows:
|
||||||
if aggExpr.aggArgs.len > 0:
|
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))
|
aggRow[aggKey] = parts.join(valueToString(delim))
|
||||||
# Apply HAVING filter
|
# Apply HAVING filter
|
||||||
if plan.groupHaving != nil:
|
if plan.groupHaving != nil:
|
||||||
|
|||||||
@@ -11,20 +11,22 @@ import lower
|
|||||||
# Row-Level Security
|
# Row-Level Security
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
proc hasPrivilege*(ctx: ExecutionContext, tableName, command: string): bool =
|
proc hasPrivilegeFor*(ctx: ExecutionContext, username, tableName, command: string): bool =
|
||||||
if ctx.currentUser.len == 0: return true
|
## Privilege check for an explicit username (does not mutate ctx.currentUser).
|
||||||
let user = ctx.users.getOrDefault(ctx.currentUser)
|
if username.len == 0: return true
|
||||||
|
let user = ctx.users.getOrDefault(username)
|
||||||
if user.isSuperuser: return true
|
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
|
if tableName notin ctx.policies: return true
|
||||||
let policies = ctx.policies[tableName]
|
let policies = ctx.policies[tableName]
|
||||||
# If RLS is enabled (policies exist), check if user matches any policy
|
|
||||||
for pol in policies:
|
for pol in policies:
|
||||||
if pol.command == "ALL" or pol.command == command:
|
if pol.command == "ALL" or pol.command == command:
|
||||||
return true
|
return true
|
||||||
return false
|
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 =
|
proc passesPolicy*(ctx: ExecutionContext, tableName, command: string, row: Row): bool =
|
||||||
if ctx.currentUser.len == 0: return true
|
if ctx.currentUser.len == 0: return true
|
||||||
let user = ctx.users.getOrDefault(ctx.currentUser)
|
let user = ctx.users.getOrDefault(ctx.currentUser)
|
||||||
|
|||||||
@@ -136,13 +136,13 @@ type
|
|||||||
rows*: seq[Row]
|
rows*: seq[Row]
|
||||||
affectedRows*: int
|
affectedRows*: int
|
||||||
message*: string
|
message*: string
|
||||||
keyValuePairs*: seq[(string, seq[byte])]
|
keyValuePairs*: seq[tuple[key: string, value: seq[byte], deleted: bool]]
|
||||||
|
|
||||||
proc `==`*(a, b: IndexEntry): bool =
|
proc `==`*(a, b: IndexEntry): bool =
|
||||||
a.lsmKey == b.lsmKey and a.rowValue == b.rowValue
|
a.lsmKey == b.lsmKey and a.rowValue == b.rowValue
|
||||||
|
|
||||||
proc okResult*(rows: seq[Row] = @[], cols: seq[string] = @[], affected: int = 0, msg: string = "",
|
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,
|
ExecResult(success: true, columns: cols, rows: rows, affectedRows: affected, message: msg,
|
||||||
keyValuePairs: kvPairs)
|
keyValuePairs: kvPairs)
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,64 @@ proc computeWindowValues*(rows: seq[Row], expr: IRExpr, ctx: ExecutionContext =
|
|||||||
for pos, rowIdx in sortedIdxs:
|
for pos, rowIdx in sortedIdxs:
|
||||||
let (_, fEnd) = resolveFrameBounds(pos, sortedIdxs.len, frameStart, frameEnd)
|
let (_, fEnd) = resolveFrameBounds(pos, sortedIdxs.len, frameStart, frameEnd)
|
||||||
result[rowIdx] = valueToString(evalExpr(expr.wfArgs[0], rows[sortedIdxs[fEnd]], ctx))
|
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:
|
else:
|
||||||
# Unknown window function — fill with null
|
# Unknown window function — fill with null
|
||||||
for rowIdx in sortedIdxs:
|
for rowIdx in sortedIdxs:
|
||||||
|
|||||||
@@ -448,6 +448,26 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
|||||||
if cols.len == 0:
|
if cols.len == 0:
|
||||||
cols = rightRes.columns
|
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] = @[]
|
var rows: seq[Row] = @[]
|
||||||
case stmt.setOpKind
|
case stmt.setOpKind
|
||||||
of sdkUnion:
|
of sdkUnion:
|
||||||
@@ -460,28 +480,30 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
|||||||
# UNION: deduplicate
|
# UNION: deduplicate
|
||||||
var seen: Table[string, bool]
|
var seen: Table[string, bool]
|
||||||
for row in leftRes.rows:
|
for row in leftRes.rows:
|
||||||
seen[valueToString(row["$value"])] = true
|
seen[setOpRowKey(row, cols)] = true
|
||||||
for row in rightRes.rows:
|
for row in rightRes.rows:
|
||||||
if not seen.getOrDefault(valueToString(row["$value"]), false):
|
let k = setOpRowKey(row, cols)
|
||||||
seen[valueToString(row["$value"])] = true
|
if not seen.getOrDefault(k, false):
|
||||||
|
seen[k] = true
|
||||||
rows.add(row)
|
rows.add(row)
|
||||||
|
|
||||||
of sdkIntersect:
|
of sdkIntersect:
|
||||||
var leftSet: Table[string, bool]
|
var leftSet: Table[string, bool]
|
||||||
for row in leftRes.rows:
|
for row in leftRes.rows:
|
||||||
leftSet[valueToString(row["$value"])] = true
|
leftSet[setOpRowKey(row, cols)] = true
|
||||||
for row in rightRes.rows:
|
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)
|
rows.add(row)
|
||||||
if not stmt.setOpAll:
|
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:
|
of sdkExcept:
|
||||||
var rightSet: Table[string, bool]
|
var rightSet: Table[string, bool]
|
||||||
for row in rightRes.rows:
|
for row in rightRes.rows:
|
||||||
rightSet[valueToString(row["$value"])] = true
|
rightSet[setOpRowKey(row, cols)] = true
|
||||||
for row in leftRes.rows:
|
for row in leftRes.rows:
|
||||||
if not rightSet.getOrDefault(valueToString(row["$value"]), false):
|
if not rightSet.getOrDefault(setOpRowKey(row, cols), false):
|
||||||
rows.add(row)
|
rows.add(row)
|
||||||
|
|
||||||
return okResult(rows, cols)
|
return okResult(rows, cols)
|
||||||
@@ -574,7 +596,7 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
|||||||
row[f] = mutableValues[0][i]
|
row[f] = mutableValues[0][i]
|
||||||
fireTriggers(ctx, stmt.insTarget, "before", "insert", row)
|
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)
|
let count = execInsert(ctx, stmt.insTarget, mutableFields, mutableValues, kvPairs)
|
||||||
|
|
||||||
# Fire AFTER INSERT triggers
|
# Fire AFTER INSERT triggers
|
||||||
@@ -626,7 +648,7 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
|||||||
# Scan and apply
|
# Scan and apply
|
||||||
let rows = execScan(ctx, stmt.updTarget)
|
let rows = execScan(ctx, stmt.updTarget)
|
||||||
var count = 0
|
var count = 0
|
||||||
var kvPairs: seq[(string, seq[byte])]
|
var kvPairs: seq[tuple[key: string, value: seq[byte], deleted: bool]]
|
||||||
for row in rows:
|
for row in rows:
|
||||||
# Compute sets for this row (expressions may reference columns)
|
# Compute sets for this row (expressions may reference columns)
|
||||||
var sets = initTable[string, string]()
|
var sets = initTable[string, string]()
|
||||||
@@ -701,7 +723,7 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
|||||||
# Delete all rows matching WHERE
|
# Delete all rows matching WHERE
|
||||||
let rows = execScan(ctx, stmt.delTarget)
|
let rows = execScan(ctx, stmt.delTarget)
|
||||||
var count = 0
|
var count = 0
|
||||||
var kvPairs: seq[(string, seq[byte])]
|
var kvPairs: seq[tuple[key: string, value: seq[byte], deleted: bool]]
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if stmt.delWhere != nil and stmt.delWhere.whereExpr != nil:
|
if stmt.delWhere != nil and stmt.delWhere.whereExpr != nil:
|
||||||
let whereExpr = lowerExpr(stmt.delWhere.whereExpr)
|
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)
|
let targetRows = execScan(ctx, stmt.mergeTarget)
|
||||||
var count = 0
|
var count = 0
|
||||||
var kvPairs: seq[(string, seq[byte])]
|
var kvPairs: seq[tuple[key: string, value: seq[byte], deleted: bool]]
|
||||||
|
|
||||||
for srcRow in sourceRows:
|
for srcRow in sourceRows:
|
||||||
var matched = false
|
var matched = false
|
||||||
@@ -759,7 +781,20 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
|||||||
let onExpr = lowerExpr(stmt.mergeOn)
|
let onExpr = lowerExpr(stmt.mergeOn)
|
||||||
if valueToString(evalExpr(onExpr, rowWithTarget, ctx)) == "true":
|
if valueToString(evalExpr(onExpr, rowWithTarget, ctx)) == "true":
|
||||||
matched = true
|
matched = true
|
||||||
if stmt.mergeMatchedUpdate.len > 0 and "$key" in tgtRow:
|
# 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]()
|
var updateSets = initTable[string, string]()
|
||||||
for s in stmt.mergeMatchedUpdate:
|
for s in stmt.mergeMatchedUpdate:
|
||||||
if s.kind == nkBinOp and s.binOp == bkAssign:
|
if s.kind == nkBinOp and s.binOp == bkAssign:
|
||||||
@@ -793,7 +828,7 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
|||||||
for i, f in fields:
|
for i, f in fields:
|
||||||
if i < values.len: row[f] = Value(kind: vkString, strVal: values[i])
|
if i < values.len: row[f] = Value(kind: vkString, strVal: values[i])
|
||||||
fireTriggers(ctx, stmt.mergeTarget, "before", "insert", row)
|
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)
|
count += execInsert(ctx, stmt.mergeTarget, fields, @[values], insKvPairs)
|
||||||
for kv in insKvPairs: kvPairs.add(kv)
|
for kv in insKvPairs: kvPairs.add(kv)
|
||||||
fireTriggers(ctx, stmt.mergeTarget, "after", "insert", row)
|
fireTriggers(ctx, stmt.mergeTarget, "after", "insert", row)
|
||||||
@@ -982,16 +1017,17 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
|||||||
|
|
||||||
of nkCommitTxn:
|
of nkCommitTxn:
|
||||||
if ctx.pendingTxn != nil and ctx.pendingTxn.state == tsActive:
|
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:
|
for key, version in ctx.pendingTxn.writeSet:
|
||||||
if version.isDelete:
|
if version.isDelete:
|
||||||
ctx.db.delete(key)
|
ctx.db.delete(key)
|
||||||
# Empty value is the raft/replication "delete" convention — never
|
# Empty value + deleted=true is the raft/replication "delete"
|
||||||
# ship a non-empty body for isDelete or followers will resurrect.
|
# convention — never ship a non-empty body for isDelete or
|
||||||
kvPairs.add((key, @[]))
|
# followers will resurrect.
|
||||||
|
kvPairs.add((key, @[], true))
|
||||||
else:
|
else:
|
||||||
ctx.db.put(key, version.value)
|
ctx.db.put(key, version.value)
|
||||||
kvPairs.add((key, version.value))
|
kvPairs.add((key, version.value, false))
|
||||||
discard ctx.txnManager.commit(ctx.pendingTxn)
|
discard ctx.txnManager.commit(ctx.pendingTxn)
|
||||||
ctx.pendingTxn = nil
|
ctx.pendingTxn = nil
|
||||||
return okResult(msg="Transaction committed", kvPairs=kvPairs)
|
return okResult(msg="Transaction committed", kvPairs=kvPairs)
|
||||||
|
|||||||
@@ -194,6 +194,11 @@ proc parsePrimary(p: var Parser): Node =
|
|||||||
discard p.expect(tkWhere)
|
discard p.expect(tkWhere)
|
||||||
node.funcFilter = p.parseExpr()
|
node.funcFilter = p.parseExpr()
|
||||||
discard p.expect(tkRParen)
|
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
|
return node
|
||||||
of tkCase:
|
of tkCase:
|
||||||
discard p.advance()
|
discard p.advance()
|
||||||
|
|||||||
@@ -175,6 +175,61 @@ proc scan*[K, V](btree: BTreeIndex[K, V], startKey, endKey: K): seq[(K, seq[V])]
|
|||||||
finally:
|
finally:
|
||||||
release(btree.lock)
|
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 =
|
proc len*[K, V](btree: BTreeIndex[K, V]): int =
|
||||||
acquire(btree.lock)
|
acquire(btree.lock)
|
||||||
try:
|
try:
|
||||||
@@ -209,7 +264,9 @@ proc borrowFromLeft[K, V](node: BTreeNode[K, V], parent: BTreeNode[K, V], parent
|
|||||||
node.values.insert(borrowVal, 0)
|
node.values.insert(borrowVal, 0)
|
||||||
sibling.keys.setLen(sibling.keys.len - 1)
|
sibling.keys.setLen(sibling.keys.len - 1)
|
||||||
sibling.values.setLen(sibling.values.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:
|
else:
|
||||||
# Borrow from internal sibling
|
# Borrow from internal sibling
|
||||||
let borrowKey = sibling.keys[^1]
|
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)
|
node.values.add(borrowVal)
|
||||||
sibling.keys.delete(0)
|
sibling.keys.delete(0)
|
||||||
sibling.values.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:
|
else:
|
||||||
let borrowKey = sibling.keys[0]
|
let borrowKey = sibling.keys[0]
|
||||||
let borrowChild = sibling.children[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)
|
mergeWithLeft(node, parent, parentIdx)
|
||||||
elif hasRight:
|
elif hasRight:
|
||||||
mergeWithRight(node, parent, parentIdx)
|
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:
|
if parent == root and parent.keys.len == 0 and parent.children.len == 1:
|
||||||
root = parent.children[0]
|
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) =
|
proc remove*[K, V](btree: var BTreeIndex[K, V], key: K, value: V) =
|
||||||
acquire(btree.lock)
|
acquire(btree.lock)
|
||||||
@@ -368,15 +430,17 @@ proc remove*[K, V](btree: var BTreeIndex[K, V], key: K, value: V) =
|
|||||||
else:
|
else:
|
||||||
# Internal node: recurse into child
|
# Internal node: recurse into child
|
||||||
let child = node.children[i]
|
let child = node.children[i]
|
||||||
let oldFirstKey = if child.keys.len > 0: child.keys[0] else: default(K)
|
|
||||||
let found = removeRec(child, root, order)
|
let found = removeRec(child, root, order)
|
||||||
if found:
|
if found:
|
||||||
# Update separator if child's first key changed.
|
# Rebalance first — merge/borrow rewrite parent separators.
|
||||||
# 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
|
|
||||||
rebalanceAfterDelete(child, root, order)
|
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
|
return found
|
||||||
|
|
||||||
if removeRec(btree.root, btree.root, btree.order):
|
if removeRec(btree.root, btree.root, btree.order):
|
||||||
|
|||||||
@@ -125,13 +125,16 @@ proc compact*(cs: CompactionStrategy, level: int): CompactionResult =
|
|||||||
return cmp(b.timestamp, a.timestamp) # newest first
|
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 merged: seq[Entry] = @[]
|
||||||
var lastKey = ""
|
var lastKey = ""
|
||||||
|
var haveLast = false
|
||||||
for entry in allEntries:
|
for entry in allEntries:
|
||||||
if entry.key != lastKey:
|
if not haveLast or entry.key != lastKey:
|
||||||
merged.add(entry)
|
merged.add(entry)
|
||||||
lastKey = entry.key
|
lastKey = entry.key
|
||||||
|
haveLast = true
|
||||||
|
|
||||||
# Keep tombstones to prevent deleted keys from resurrecting in lower levels
|
# Keep tombstones to prevent deleted keys from resurrecting in lower levels
|
||||||
var final: seq[Entry] = @[]
|
var final: seq[Entry] = @[]
|
||||||
@@ -155,20 +158,15 @@ proc compact*(cs: CompactionStrategy, level: int): CompactionResult =
|
|||||||
createdAt: tables[^1].createdAt,
|
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)
|
let (ok, msg) = verifySSTable(outputPath)
|
||||||
if not ok:
|
if not ok:
|
||||||
echo "[ERROR] Compaction output verification failed: ", msg
|
echo "[ERROR] Compaction output verification failed: ", msg
|
||||||
try: removeFile(outputPath) except CatchableError: discard
|
try: removeFile(outputPath) except CatchableError: discard
|
||||||
return CompactionResult()
|
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
|
# Update level arrays
|
||||||
var newTables: seq[SSTableMeta] = @[]
|
var newTables: seq[SSTableMeta] = @[]
|
||||||
for t in cs.levels[level]:
|
for t in cs.levels[level]:
|
||||||
|
|||||||
@@ -696,6 +696,10 @@ proc newLSMTree*(
|
|||||||
var version: uint32 = 0
|
var version: uint32 = 0
|
||||||
if stream.readData(addr magic, 4) == 4 and magic == WALMagic:
|
if stream.readData(addr magic, 4) == 4 and magic == WALMagic:
|
||||||
if stream.readData(addr version, 4) == 4:
|
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():
|
while not stream.atEnd():
|
||||||
var kind: uint8 = 0
|
var kind: uint8 = 0
|
||||||
var timestamp: uint64 = 0
|
var timestamp: uint64 = 0
|
||||||
@@ -704,10 +708,20 @@ proc newLSMTree*(
|
|||||||
if stream.readData(addr kind, 1) != 1: break
|
if stream.readData(addr kind, 1) != 1: break
|
||||||
if stream.readData(addr timestamp, 8) != 8: break
|
if stream.readData(addr timestamp, 8) != 8: break
|
||||||
if stream.readData(addr keyLen, 4) != 4: 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)
|
var key = newString(keyLen.int)
|
||||||
if keyLen > 0:
|
if keyLen > 0:
|
||||||
if stream.readData(addr key[0], keyLen.int) != keyLen.int: break
|
if stream.readData(addr key[0], keyLen.int) != keyLen.int: break
|
||||||
if stream.readData(addr valLen, 4) != 4: 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)
|
var value = newSeq[byte](valLen.int)
|
||||||
if valLen > 0:
|
if valLen > 0:
|
||||||
if stream.readData(addr value[0], valLen.int) != valLen.int: break
|
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:
|
if db.immutableMem.len == 0 and db.memTable.len == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Flush immutable memtable if present, otherwise flush current memtable
|
# Flush immutable memtable if present, otherwise flush current memtable.
|
||||||
var toFlush = db.immutableMem
|
# Do NOT clear the source memtable until the SSTable is written — an IOError
|
||||||
if toFlush.len == 0:
|
# mid-write must leave the data still visible to live reads (WAL still has it).
|
||||||
toFlush = db.memTable
|
var flushingImmutable = false
|
||||||
db.memTable = newMemTable(db.memMaxSize)
|
var toFlush: MemTable
|
||||||
|
if db.immutableMem.len > 0:
|
||||||
|
toFlush = db.immutableMem
|
||||||
|
flushingImmutable = true
|
||||||
else:
|
else:
|
||||||
db.immutableMem = newMemTable(0)
|
toFlush = db.memTable
|
||||||
|
|
||||||
if toFlush.len == 0:
|
if toFlush.len == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
let path = db.dir / "sstables" / ($db.nextSSTableId & ".sst")
|
let path = db.dir / "sstables" / ($db.nextSSTableId & ".sst")
|
||||||
|
let sstId = db.nextSSTableId
|
||||||
inc db.nextSSTableId
|
inc db.nextSSTableId
|
||||||
|
|
||||||
# Sort once at flush time (O(n log n)) — put/get stay O(1)
|
# Sort once at flush time (O(n log n)) — put/get stay O(1)
|
||||||
var sst = writeSSTable(toFlush.sortedEntries(), path, level = 0)
|
var sst = writeSSTable(toFlush.sortedEntries(), path, level = 0)
|
||||||
sst.id = db.nextSSTableId - 1
|
sst.id = sstId
|
||||||
db.sstables.add(sst)
|
db.sstables.add(sst)
|
||||||
# SSTables are kept in insertion order (newest last) so getUnsafe can search newest-first
|
# 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
|
# Update MANIFEST atomically
|
||||||
inc db.manifestSequence
|
inc db.manifestSequence
|
||||||
try:
|
try:
|
||||||
@@ -930,7 +954,7 @@ proc checkpoint*(db: LSMTree) =
|
|||||||
## rotate WAL, and write MANIFEST. This provides a clean boundary
|
## rotate WAL, and write MANIFEST. This provides a clean boundary
|
||||||
## for online backup without stopping the server.
|
## for online backup without stopping the server.
|
||||||
acquireWrite(db.lock)
|
acquireWrite(db.lock)
|
||||||
|
try:
|
||||||
# Flush any pending immutable memtable first
|
# Flush any pending immutable memtable first
|
||||||
if db.immutableMem.len > 0:
|
if db.immutableMem.len > 0:
|
||||||
flushUnsafe(db)
|
flushUnsafe(db)
|
||||||
@@ -946,10 +970,12 @@ proc checkpoint*(db: LSMTree) =
|
|||||||
|
|
||||||
# Rotate WAL for a clean backup boundary
|
# Rotate WAL for a clean backup boundary
|
||||||
acquire(db.walLock)
|
acquire(db.walLock)
|
||||||
|
try:
|
||||||
db.wal.maybeRotate()
|
db.wal.maybeRotate()
|
||||||
db.wal.sync()
|
db.wal.sync()
|
||||||
|
finally:
|
||||||
release(db.walLock)
|
release(db.walLock)
|
||||||
|
finally:
|
||||||
releaseWrite(db.lock)
|
releaseWrite(db.lock)
|
||||||
|
|
||||||
proc close*(db: LSMTree) =
|
proc close*(db: LSMTree) =
|
||||||
|
|||||||
@@ -80,7 +80,8 @@ proc readAt*(mf: MmapFile, offset: int, size: int): seq[byte] =
|
|||||||
if mf.regions.len == 0:
|
if mf.regions.len == 0:
|
||||||
return @[]
|
return @[]
|
||||||
let region = mf.regions[0]
|
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 @[]
|
return @[]
|
||||||
result = newSeq[byte](size)
|
result = newSeq[byte](size)
|
||||||
copyMem(addr result[0], unsafeAddr region.data[offset], 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]
|
return mf.regions[0].data[offset]
|
||||||
|
|
||||||
proc readUint32*(mf: MmapFile, offset: int): uint32 =
|
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
|
return 0
|
||||||
var val: uint32
|
var val: uint32
|
||||||
copyMem(addr val, unsafeAddr mf.regions[0].data[offset], 4)
|
copyMem(addr val, unsafeAddr mf.regions[0].data[offset], 4)
|
||||||
return val
|
return val
|
||||||
|
|
||||||
proc readUint64*(mf: MmapFile, offset: int): uint64 =
|
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
|
return 0
|
||||||
var val: uint64
|
var val: uint64
|
||||||
copyMem(addr val, unsafeAddr mf.regions[0].data[offset], 8)
|
copyMem(addr val, unsafeAddr mf.regions[0].data[offset], 8)
|
||||||
return val
|
return val
|
||||||
|
|
||||||
proc readString*(mf: MmapFile, offset: int, size: int): string =
|
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 ""
|
return ""
|
||||||
result = newString(size)
|
result = newString(size)
|
||||||
copyMem(addr result[0], unsafeAddr mf.regions[0].data[offset], size)
|
copyMem(addr result[0], unsafeAddr mf.regions[0].data[offset], size)
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ proc scanWAL*(rec: CrashRecovery): seq[RecoveredEntry] =
|
|||||||
var txnId: uint64 = 0
|
var txnId: uint64 = 0
|
||||||
var entryCount = 0
|
var entryCount = 0
|
||||||
|
|
||||||
|
const MaxWalRecordField = 64 * 1024 * 1024 # 64 MB
|
||||||
while not stream.atEnd():
|
while not stream.atEnd():
|
||||||
var kind: uint8 = 0
|
var kind: uint8 = 0
|
||||||
var timestamp: uint64 = 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 kind, 1) != 1: break
|
||||||
if stream.readData(addr timestamp, 8) != 8: break
|
if stream.readData(addr timestamp, 8) != 8: break
|
||||||
if stream.readData(addr keyLen, 4) != 4: 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)
|
var key = newString(keyLen.int)
|
||||||
if keyLen > 0:
|
if keyLen > 0:
|
||||||
if stream.readData(addr key[0], keyLen.int) != keyLen.int: break
|
if stream.readData(addr key[0], keyLen.int) != keyLen.int: break
|
||||||
|
|
||||||
if stream.readData(addr valLen, 4) != 4: break
|
if stream.readData(addr valLen, 4) != 4: break
|
||||||
|
if valLen.int > MaxWalRecordField: break
|
||||||
var value = newSeq[byte](valLen.int)
|
var value = newSeq[byte](valLen.int)
|
||||||
if valLen > 0:
|
if valLen > 0:
|
||||||
if stream.readData(addr value[0], valLen.int) != valLen.int: break
|
if stream.readData(addr value[0], valLen.int) != valLen.int: break
|
||||||
|
|||||||
@@ -326,8 +326,9 @@ proc rewriteLive*(wal: var WriteAheadLog,
|
|||||||
|
|
||||||
if wal.stream != nil:
|
if wal.stream != nil:
|
||||||
wal.stream.close()
|
wal.stream.close()
|
||||||
if fileExists(wal.path):
|
wal.stream = nil
|
||||||
removeFile(wal.path)
|
# 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)
|
moveFile(tmpPath, wal.path)
|
||||||
wal.stream = newFileStream(wal.path, fmAppend)
|
wal.stream = newFileStream(wal.path, fmAppend)
|
||||||
if wal.stream == nil:
|
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 magic, 4) != 4: return
|
||||||
if s.readData(addr version, 4) != 4: return
|
if s.readData(addr version, 4) != 4: return
|
||||||
if magic != WALMagic: return
|
if magic != WALMagic: return
|
||||||
|
const MaxWalRecordField = 64 * 1024 * 1024 # 64 MB
|
||||||
while not s.atEnd:
|
while not s.atEnd:
|
||||||
var kind: uint8
|
var kind: uint8
|
||||||
if s.readData(addr kind, 1) != 1: break
|
if s.readData(addr kind, 1) != 1: break
|
||||||
@@ -372,11 +374,14 @@ proc readEntries*(walPath: string, untilTimestamp: uint64 = 0): seq[WalEntry] =
|
|||||||
break
|
break
|
||||||
var keyLen: uint32
|
var keyLen: uint32
|
||||||
if s.readData(addr keyLen, 4) != 4: break
|
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)
|
var key = newSeq[byte](keyLen)
|
||||||
if keyLen > 0:
|
if keyLen > 0:
|
||||||
if s.readData(addr key[0], int(keyLen)) != int(keyLen): break
|
if s.readData(addr key[0], int(keyLen)) != int(keyLen): break
|
||||||
var valLen: uint32
|
var valLen: uint32
|
||||||
if s.readData(addr valLen, 4) != 4: break
|
if s.readData(addr valLen, 4) != 4: break
|
||||||
|
if valLen.int > MaxWalRecordField: break
|
||||||
var value = newSeq[byte](valLen)
|
var value = newSeq[byte](valLen)
|
||||||
if valLen > 0:
|
if valLen > 0:
|
||||||
if s.readData(addr value[0], int(valLen)) != int(valLen): break
|
if s.readData(addr value[0], int(valLen)) != int(valLen): break
|
||||||
|
|||||||
+124
-17
@@ -23,6 +23,7 @@ import barabadb/core/gossip
|
|||||||
import barabadb/core/replication
|
import barabadb/core/replication
|
||||||
import barabadb/core/disttxn
|
import barabadb/core/disttxn
|
||||||
import barabadb/core/registry
|
import barabadb/core/registry
|
||||||
|
import barabadb/core/backup
|
||||||
import barabadb/tools/repair
|
import barabadb/tools/repair
|
||||||
import barabadb/tools/migrate
|
import barabadb/tools/migrate
|
||||||
|
|
||||||
@@ -40,27 +41,18 @@ proc newCompactionManager*(db: LSMTree): CompactionManager =
|
|||||||
result.strategy.rebuildFromLSM(db)
|
result.strategy.rebuildFromLSM(db)
|
||||||
|
|
||||||
proc applyCompactionResult(db: LSMTree, result: compaction.CompactionResult) =
|
proc applyCompactionResult(db: LSMTree, result: compaction.CompactionResult) =
|
||||||
## Apply compaction output under the caller's lock: update sstables + MANIFEST.
|
## Crash-safe apply: load output while inputs still exist, swap catalog,
|
||||||
## On Linux, compact may already have unlinked inputs; we still close our mmaps.
|
## 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:
|
if result.outputTables.len == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
var newSSTables: seq[SSTable] = @[]
|
var loaded: 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()
|
|
||||||
|
|
||||||
for meta in result.outputTables:
|
for meta in result.outputTables:
|
||||||
try:
|
try:
|
||||||
var sst = loadSSTable(meta.path)
|
var sst = loadSSTable(meta.path)
|
||||||
let name = splitFile(meta.path).name
|
let name = splitFile(meta.path).name
|
||||||
# Prefer numeric id from filename; otherwise allocate
|
|
||||||
let parsed = try: parseInt(name) except CatchableError: -1
|
let parsed = try: parseInt(name) except CatchableError: -1
|
||||||
if parsed >= 0:
|
if parsed >= 0:
|
||||||
sst.id = parsed
|
sst.id = parsed
|
||||||
@@ -68,11 +60,30 @@ proc applyCompactionResult(db: LSMTree, result: compaction.CompactionResult) =
|
|||||||
sst.id = db.nextSSTableId
|
sst.id = db.nextSSTableId
|
||||||
inc db.nextSSTableId
|
inc db.nextSSTableId
|
||||||
sst.level = meta.level
|
sst.level = meta.level
|
||||||
newSSTables.add(sst)
|
loaded.add(sst)
|
||||||
db.nextSSTableId = max(db.nextSSTableId, sst.id + 1)
|
db.nextSSTableId = max(db.nextSSTableId, sst.id + 1)
|
||||||
except CatchableError as e:
|
except CatchableError as e:
|
||||||
warn("Compaction output SSTable failed to load: " & meta.path & " — " & e.msg)
|
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))
|
newSSTables.sort(proc(a, b: SSTable): int = cmp(a.id, b.id))
|
||||||
db.sstables = newSSTables
|
db.sstables = newSSTables
|
||||||
db.needsCompaction = db.countL0() >= L0CompactionTrigger
|
db.needsCompaction = db.countL0() >= L0CompactionTrigger
|
||||||
@@ -83,6 +94,13 @@ proc applyCompactionResult(db: LSMTree, result: compaction.CompactionResult) =
|
|||||||
except CatchableError as e:
|
except CatchableError as e:
|
||||||
warn("Failed to write MANIFEST after compaction: " & e.msg)
|
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) =
|
proc compact*(cm: CompactionManager) =
|
||||||
# Gate first (cross-thread), then per-DB write lock
|
# Gate first (cross-thread), then per-DB write lock
|
||||||
withStorageGate:
|
withStorageGate:
|
||||||
@@ -288,7 +306,7 @@ proc main() =
|
|||||||
# Init structured logger from config
|
# Init structured logger from config
|
||||||
let logLvl = parseEnum[LogLevel]("ll" & capitalizeAscii(config.logLevel))
|
let logLvl = parseEnum[LogLevel]("ll" & capitalizeAscii(config.logLevel))
|
||||||
defaultLogger = newLogger(logLvl, config.logFile)
|
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)")
|
info("Storage gate initialized (serializes HTTP/TCP/compaction access)")
|
||||||
|
|
||||||
# Security check: warn if JWT secret is not configured (non-production only)
|
# Security check: warn if JWT secret is not configured (non-production only)
|
||||||
@@ -338,6 +356,21 @@ proc main() =
|
|||||||
var raftNet: RaftNetwork = nil
|
var raftNet: RaftNetwork = nil
|
||||||
if config.raftEnabled:
|
if config.raftEnabled:
|
||||||
info("Starting Raft node " & config.raftNodeId & " on port " & $config.raftPort)
|
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"
|
let raftDataDir = config.dataDir / "raft"
|
||||||
createDir(raftDataDir) # idempotent; loadState reads from it, saveState writes
|
createDir(raftDataDir) # idempotent; loadState reads from it, saveState writes
|
||||||
# Raft convention: `peers` excludes the node itself (majority math and
|
# Raft convention: `peers` excludes the node itself (majority math and
|
||||||
@@ -350,6 +383,10 @@ proc main() =
|
|||||||
raftNode.peerAddrs = config.raftPeerAddrs
|
raftNode.peerAddrs = config.raftPeerAddrs
|
||||||
if config.raftLogMaxEntries > 0:
|
if config.raftLogMaxEntries > 0:
|
||||||
raftNode.logMaxEntries = config.raftLogMaxEntries
|
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
|
tcpServer.raftNode = raftNode # C3b: executeQuery rejects writes on followers
|
||||||
httpServer.raftNode = raftNode # /metrics + /health raft gauges
|
httpServer.raftNode = raftNode # /metrics + /health raft gauges
|
||||||
# Wire state machine: committed entries update LSM + secondary indexes
|
# Wire state machine: committed entries update LSM + secondary indexes
|
||||||
@@ -367,13 +404,83 @@ proc main() =
|
|||||||
elif cmd == "ddl":
|
elif cmd == "ddl":
|
||||||
applyReplicatedDdl(ctx, cast[string](data))
|
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
|
# Wire RAFT ↔ DistTxn
|
||||||
wireRaftDistTxn(raftNode, tcpServer)
|
wireRaftDistTxn(raftNode, tcpServer)
|
||||||
|
|
||||||
# Wire replication ↔ DistTxn
|
# Wire replication ↔ DistTxn
|
||||||
wireReplicationDistTxn(tcpServer.replicationManager, tcpServer.distTxnManager)
|
wireReplicationDistTxn(tcpServer.replicationManager, tcpServer.distTxnManager)
|
||||||
|
|
||||||
raftNet = newRaftNetwork(raftNode)
|
raftNet = newRaftNetwork(raftNode, raftTls)
|
||||||
asyncCheck raftNet.run()
|
asyncCheck raftNet.run()
|
||||||
|
|
||||||
# HTTP (hunos) after raft wiring so /metrics can see raftNode
|
# HTTP (hunos) after raft wiring so /metrics can see raftNode
|
||||||
|
|||||||
@@ -4,9 +4,19 @@ import std/os
|
|||||||
import std/tables
|
import std/tables
|
||||||
import ../src/barabadb/query/[parser, executor, lexer, ast]
|
import ../src/barabadb/query/[parser, executor, lexer, ast]
|
||||||
import ../src/barabadb/query/exec/params
|
import ../src/barabadb/query/exec/params
|
||||||
|
import ../src/barabadb/query/exec/dml
|
||||||
import ../src/barabadb/core/types
|
import ../src/barabadb/core/types
|
||||||
import ../src/barabadb/core/config
|
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/lsm
|
||||||
|
import ../src/barabadb/storage/compaction
|
||||||
|
import ../src/barabadb/storage/btree
|
||||||
|
import std/random
|
||||||
|
import std/sets
|
||||||
|
|
||||||
const testDir = "/tmp/baradb_bugfix_test"
|
const testDir = "/tmp/baradb_bugfix_test"
|
||||||
|
|
||||||
@@ -417,6 +427,78 @@ suite "Raft peer address parsing":
|
|||||||
check msg.len > 0
|
check msg.len > 0
|
||||||
check bad in msg
|
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":
|
suite "Raft write classification":
|
||||||
|
|
||||||
test "isWrite classifies DML and COMMIT":
|
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("CREATE DATABASE other").stmts[0])
|
||||||
check not isRaftDdl(parse("INSERT INTO t (id) VALUES (1)").stmts[0])
|
check not isRaftDdl(parse("INSERT INTO t (id) VALUES (1)").stmts[0])
|
||||||
check not isRaftDdl(parse("SELECT 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"
|
||||||
|
|||||||
@@ -0,0 +1,480 @@
|
|||||||
|
## Raft cold-node E2E — real 3-node cluster; end-to-end proof of the
|
||||||
|
## InstallSnapshot work (compaction, snapshot build/send, follower restore).
|
||||||
|
## Starts three actual build/baradadb processes with a tiny raft log
|
||||||
|
## (BARADB_RAFT_LOG_MAX_ENTRIES=16) and a short stale window
|
||||||
|
## (BARADB_RAFT_PEER_STALE_MS=3000) so the leader compacts quickly past a
|
||||||
|
## downed peer's matchIndex.
|
||||||
|
##
|
||||||
|
## Scenario A: a node is killed, 100 rows are written through the leader
|
||||||
|
## (forcing compaction past its matchIndex), the node restarts with its
|
||||||
|
## intact data dir and must catch up via InstallSnapshot within 15 s.
|
||||||
|
## Scenario B: the node is stopped, its data dir is WIPED, it rejoins with
|
||||||
|
## the same node id and must serve the full row set within 20 s.
|
||||||
|
##
|
||||||
|
## Process-management conventions follow tests/raft_writes_e2e_test.nim
|
||||||
|
## (copying is the repo's e2e convention — do not factor out).
|
||||||
|
import std/unittest
|
||||||
|
import std/osproc
|
||||||
|
import std/os
|
||||||
|
import std/strtabs
|
||||||
|
import std/strutils
|
||||||
|
import std/times
|
||||||
|
import std/net
|
||||||
|
import std/posix
|
||||||
|
import std/httpclient
|
||||||
|
|
||||||
|
import ../adaptors/nim/baradb_sqlite as sqlite
|
||||||
|
|
||||||
|
const
|
||||||
|
BinaryPath = "./build/baradadb"
|
||||||
|
LeaderMarker = "became leader"
|
||||||
|
SnapshotMarker = "Installing snapshot"
|
||||||
|
TableName = "cold_test"
|
||||||
|
RowCount = 100
|
||||||
|
|
||||||
|
type
|
||||||
|
NodeProc = object
|
||||||
|
id: string
|
||||||
|
clientPort: int
|
||||||
|
raftPort: int
|
||||||
|
peers: string
|
||||||
|
clientPeers: string
|
||||||
|
p: Process
|
||||||
|
dataDir: string
|
||||||
|
output: string
|
||||||
|
alive: bool
|
||||||
|
|
||||||
|
proc drainOutput(n: var NodeProc) =
|
||||||
|
## Reads whatever the child has written so far. The pipe was set O_NONBLOCK
|
||||||
|
## at start, so this never blocks — a hung read is impossible here.
|
||||||
|
var tmp: array[8192, char]
|
||||||
|
while true:
|
||||||
|
let count = posix.read(n.p.outputHandle.cint, tmp[0].addr, tmp.len)
|
||||||
|
if count <= 0: break
|
||||||
|
for i in 0 ..< count: n.output.add tmp[i]
|
||||||
|
|
||||||
|
proc drainAll(nodes: var seq[NodeProc]) =
|
||||||
|
for n in nodes.mitems:
|
||||||
|
if n.p != nil: n.drainOutput()
|
||||||
|
|
||||||
|
proc dumpAll(nodes: var seq[NodeProc]) =
|
||||||
|
## Debuggability: on failure, everything the nodes said.
|
||||||
|
nodes.drainAll()
|
||||||
|
for n in nodes:
|
||||||
|
echo "===== output of ", n.id, " (port ", n.clientPort, ") ====="
|
||||||
|
echo n.output
|
||||||
|
|
||||||
|
proc portOpen(port: int): bool =
|
||||||
|
var s: Socket
|
||||||
|
try:
|
||||||
|
s = newSocket()
|
||||||
|
s.connect("127.0.0.1", Port(port), timeout = 250)
|
||||||
|
s.close()
|
||||||
|
result = true
|
||||||
|
except CatchableError:
|
||||||
|
if s != nil: s.close()
|
||||||
|
result = false
|
||||||
|
|
||||||
|
proc killNode(n: var NodeProc) =
|
||||||
|
if n.p != nil and n.alive:
|
||||||
|
try:
|
||||||
|
n.p.terminate()
|
||||||
|
discard n.p.waitForExit()
|
||||||
|
except CatchableError:
|
||||||
|
discard
|
||||||
|
n.alive = false
|
||||||
|
|
||||||
|
proc leaderTerms(output: string): seq[int] =
|
||||||
|
## All terms this node logged leadership for ("became leader for term T").
|
||||||
|
var pos = 0
|
||||||
|
while true:
|
||||||
|
let idx = output.find(LeaderMarker, pos)
|
||||||
|
if idx < 0: break
|
||||||
|
let tIdx = output.find("term ", idx)
|
||||||
|
if tIdx < 0: break
|
||||||
|
let numStart = tIdx + 5
|
||||||
|
var numEnd = numStart
|
||||||
|
while numEnd < output.len and output[numEnd] in Digits: inc numEnd
|
||||||
|
if numEnd > numStart:
|
||||||
|
result.add(parseInt(output[numStart ..< numEnd]))
|
||||||
|
pos = numEnd
|
||||||
|
|
||||||
|
proc maxLeader(nodes: seq[NodeProc]): tuple[idx, term: int] =
|
||||||
|
## Node that logged leadership for the highest term seen so far.
|
||||||
|
result = (-1, 0)
|
||||||
|
for i in 0 ..< nodes.len:
|
||||||
|
for t in leaderTerms(nodes[i].output):
|
||||||
|
if t > result.term: result = (i, t)
|
||||||
|
|
||||||
|
proc drainFor(nodes: var seq[NodeProc], ms: int) =
|
||||||
|
let start = getTime()
|
||||||
|
while getTime() - start < initDuration(milliseconds = ms):
|
||||||
|
nodes.drainAll()
|
||||||
|
sleep(50)
|
||||||
|
|
||||||
|
proc openClient(port: int): DbConn =
|
||||||
|
## Connect with retries — the port may accept TCP before the DB is usable.
|
||||||
|
for i in 0 ..< 50:
|
||||||
|
try:
|
||||||
|
return open("127.0.0.1:" & $port, "", "", "default")
|
||||||
|
except CatchableError:
|
||||||
|
sleep(100)
|
||||||
|
raise newException(IOError, "cannot connect to port " & $port)
|
||||||
|
|
||||||
|
proc rowCountOn(port: int): int =
|
||||||
|
## SELECT COUNT(*) — raises while the table is not there yet (e.g. before
|
||||||
|
## the snapshot restore has landed); callers poll and tolerate that.
|
||||||
|
let db = openClient(port)
|
||||||
|
defer: db.close()
|
||||||
|
parseInt(db.getValue(sql("SELECT COUNT(*) FROM " & TableName)))
|
||||||
|
|
||||||
|
proc fetchMetric(port: int, name: string): int =
|
||||||
|
## GET /metrics on the node's HTTP port (clientPort + 440) and parse
|
||||||
|
## `name{...} <value>`. Returns -1 on any error or when absent.
|
||||||
|
let client = newHttpClient(timeout = 1500)
|
||||||
|
defer: client.close()
|
||||||
|
try:
|
||||||
|
let body = client.getContent("http://127.0.0.1:" & $(port + 440) & "/metrics")
|
||||||
|
for line in body.splitLines():
|
||||||
|
if line.startsWith(name & "{"):
|
||||||
|
let parts = line.splitWhitespace()
|
||||||
|
if parts.len >= 2:
|
||||||
|
return parseInt(parts[^1])
|
||||||
|
except CatchableError:
|
||||||
|
discard
|
||||||
|
return -1
|
||||||
|
|
||||||
|
proc startNode(id, dataDir: string, clientPort, raftPort: int,
|
||||||
|
peers, clientPeers: string): NodeProc =
|
||||||
|
## Boots one build/baradadb process. Takes the data dir explicitly so a
|
||||||
|
## node can be restarted with the same dir (scenario A) or a wiped dir
|
||||||
|
## (scenario B). Compaction/stale-window env goes on EVERY node.
|
||||||
|
createDir(dataDir)
|
||||||
|
var env = newStringTable()
|
||||||
|
for key, val in envPairs():
|
||||||
|
env[key] = val
|
||||||
|
env["BARADB_PORT"] = $clientPort
|
||||||
|
env["BARADB_RAFT_ENABLED"] = "true"
|
||||||
|
env["BARADB_RAFT_PORT"] = $raftPort
|
||||||
|
env["BARADB_RAFT_NODE_ID"] = id
|
||||||
|
env["BARADB_RAFT_PEERS"] = peers
|
||||||
|
env["BARADB_RAFT_CLIENT_PEERS"] = clientPeers
|
||||||
|
env["BARADB_RAFT_LOG_MAX_ENTRIES"] = "16"
|
||||||
|
env["BARADB_RAFT_PEER_STALE_MS"] = "3000"
|
||||||
|
env["BARADB_DATA_DIR"] = dataDir
|
||||||
|
env["BARADB_LOG_LEVEL"] = "info"
|
||||||
|
let p = startProcess(BinaryPath, env = env,
|
||||||
|
options = {poStdErrToStdOut, poDaemon})
|
||||||
|
discard fcntl(p.outputHandle.cint, F_SETFL,
|
||||||
|
fcntl(p.outputHandle.cint, F_GETFL) or O_NONBLOCK)
|
||||||
|
NodeProc(id: id, clientPort: clientPort, raftPort: raftPort,
|
||||||
|
peers: peers, clientPeers: clientPeers, p: p,
|
||||||
|
dataDir: dataDir, alive: true)
|
||||||
|
|
||||||
|
proc restartNode(n: var NodeProc, wipe: bool) =
|
||||||
|
## Kill, then boot the same node id again. wipe=false keeps the data dir
|
||||||
|
## (cold-node return); wipe=true deletes it first (fresh node rejoin).
|
||||||
|
n.killNode()
|
||||||
|
if n.p != nil: n.p.close()
|
||||||
|
if wipe:
|
||||||
|
removeDir(n.dataDir)
|
||||||
|
let fresh = startNode(n.id, n.dataDir, n.clientPort, n.raftPort,
|
||||||
|
n.peers, n.clientPeers)
|
||||||
|
n.p = fresh.p
|
||||||
|
n.alive = true
|
||||||
|
|
||||||
|
proc waitReady(n: NodeProc, deadlineSec: int): bool =
|
||||||
|
let start = getTime()
|
||||||
|
while getTime() - start < initDuration(seconds = deadlineSec):
|
||||||
|
if portOpen(n.clientPort):
|
||||||
|
return true
|
||||||
|
sleep(100)
|
||||||
|
return false
|
||||||
|
|
||||||
|
proc runColdNodeScenario() =
|
||||||
|
## Fatal phase failures dump all captured node output, record a test
|
||||||
|
## failure, and return; cleanup happens in the finally below either way.
|
||||||
|
let tstamp = getTime().toUnix.int
|
||||||
|
# Port base per the T11 brief: distinct from raft_writes_e2e_test
|
||||||
|
# (46000+mod4000) and raft_tls_e2e_test (54000+mod4000). Client ports are
|
||||||
|
# spaced by 10 because the server derives HTTP (port+440), WS (port+441)
|
||||||
|
# and gossip (raftPort+100) ports — consecutive client ports collide.
|
||||||
|
let cbase = 58000 + (tstamp mod 4000)
|
||||||
|
let rbase = cbase + 100
|
||||||
|
let peers = "n1@127.0.0.1:" & $(rbase + 1) &
|
||||||
|
",n2@127.0.0.1:" & $(rbase + 2) &
|
||||||
|
",n3@127.0.0.1:" & $(rbase + 3)
|
||||||
|
# SQL client ports for transparent leader write forwarding.
|
||||||
|
let clientPeers = "n1@127.0.0.1:" & $(cbase + 10) &
|
||||||
|
",n2@127.0.0.1:" & $(cbase + 20) &
|
||||||
|
",n3@127.0.0.1:" & $(cbase + 30)
|
||||||
|
|
||||||
|
var nodes: seq[NodeProc]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Node starts live inside the try so a raise from start #2/#3 still
|
||||||
|
# reaches the cleanup in the finally below.
|
||||||
|
for i in 1 .. 3:
|
||||||
|
let id = "n" & $i
|
||||||
|
let dataDir = getTempDir() / "baradb_raft_coldnode_e2e_" & $tstamp & "_" & id
|
||||||
|
nodes.add startNode(id, dataDir, cbase + i * 10, rbase + i,
|
||||||
|
peers, clientPeers)
|
||||||
|
|
||||||
|
# Readiness: all three client ports accept TCP connections (10s each).
|
||||||
|
for i in 0 ..< nodes.len:
|
||||||
|
if not waitReady(nodes[i], 10):
|
||||||
|
echo "node ", nodes[i].id, " never became ready"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Election: timeouts are 150-300ms, heartbeat 50ms — a leader should
|
||||||
|
# emerge within ~2s; 10s deadline for margin.
|
||||||
|
var elected = false
|
||||||
|
let electStart = getTime()
|
||||||
|
while getTime() - electStart < initDuration(seconds = 10):
|
||||||
|
nodes.drainAll()
|
||||||
|
if maxLeader(nodes).idx >= 0:
|
||||||
|
elected = true
|
||||||
|
break
|
||||||
|
sleep(50)
|
||||||
|
if not elected:
|
||||||
|
echo "no leader elected within 10s"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Settle and require stability, same as raft_writes_e2e_test.
|
||||||
|
nodes.drainFor(2000)
|
||||||
|
let (leaderIdx, leaderTerm) = maxLeader(nodes)
|
||||||
|
nodes.drainFor(1000)
|
||||||
|
let (stableIdx, stableTerm) = maxLeader(nodes)
|
||||||
|
if stableIdx != leaderIdx or stableTerm != leaderTerm:
|
||||||
|
echo "cluster unstable: leadership moved from ", nodes[leaderIdx].id,
|
||||||
|
" (term ", leaderTerm, ") to ", nodes[stableIdx].id,
|
||||||
|
" (term ", stableTerm, ")"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "leader elected: ", nodes[leaderIdx].id, " (term ", leaderTerm, ")"
|
||||||
|
|
||||||
|
# The cold node: n3 unless n3 is the leader (then n1) — it must be a
|
||||||
|
# follower so killing it never forces a re-election.
|
||||||
|
let coldIdx = (if leaderIdx == 2: 0 else: 2)
|
||||||
|
let otherIdx = 3 - leaderIdx - coldIdx
|
||||||
|
echo "cold node: ", nodes[coldIdx].id, "; surviving follower: ",
|
||||||
|
nodes[otherIdx].id
|
||||||
|
|
||||||
|
# Schema: CREATE TABLE goes through the raft "ddl" log (C3c). Create on
|
||||||
|
# the leader; wait until the cold node has applied it before killing it.
|
||||||
|
block:
|
||||||
|
let db = openClient(nodes[leaderIdx].clientPort)
|
||||||
|
try:
|
||||||
|
db.exec(sql("CREATE TABLE " & TableName & " (id INT PRIMARY KEY, name STRING)"))
|
||||||
|
except CatchableError as e:
|
||||||
|
echo "leader CREATE TABLE failed: ", e.msg
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
db.close()
|
||||||
|
block:
|
||||||
|
let start = getTime()
|
||||||
|
var ready = false
|
||||||
|
while getTime() - start < initDuration(seconds = 5):
|
||||||
|
try:
|
||||||
|
discard rowCountOn(nodes[coldIdx].clientPort)
|
||||||
|
ready = true
|
||||||
|
break
|
||||||
|
except CatchableError:
|
||||||
|
sleep(100)
|
||||||
|
if not ready:
|
||||||
|
echo "cold node ", nodes[coldIdx].id,
|
||||||
|
" never applied CREATE TABLE within 5s"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "schema replicated to cold node ", nodes[coldIdx].id
|
||||||
|
|
||||||
|
# ---- Kill the cold node; write 100 rows through the leader. ----
|
||||||
|
# With logMaxEntries=16 and peerStaleMs=3000 the leader compacts past the
|
||||||
|
# dead node's matchIndex partway through the writes, so the node can only
|
||||||
|
# catch up via InstallSnapshot on return.
|
||||||
|
killNode(nodes[coldIdx])
|
||||||
|
echo "cold node ", nodes[coldIdx].id, " killed; writing ", RowCount, " rows"
|
||||||
|
block:
|
||||||
|
let db = openClient(nodes[leaderIdx].clientPort)
|
||||||
|
var atRow = 0
|
||||||
|
try:
|
||||||
|
for i in 1 .. RowCount:
|
||||||
|
atRow = i
|
||||||
|
db.exec(sql("INSERT INTO " & TableName & " (id, name) VALUES (" &
|
||||||
|
$i & ", 'row-" & $i & "')"))
|
||||||
|
except CatchableError as e:
|
||||||
|
echo "leader INSERT failed at row ", atRow, ": ", e.msg
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
db.close()
|
||||||
|
echo RowCount, " rows committed with ", nodes[coldIdx].id, " down"
|
||||||
|
|
||||||
|
# Compaction evidence on the leader: the in-memory log stayed bounded
|
||||||
|
# (<= 64, far below the 100 entries written) and the snapshot base moved.
|
||||||
|
nodes.drainAll()
|
||||||
|
let leaderLogLen = fetchMetric(nodes[leaderIdx].clientPort,
|
||||||
|
"baradb_raft_log_entries")
|
||||||
|
let leaderSnapIdx = fetchMetric(nodes[leaderIdx].clientPort,
|
||||||
|
"baradb_raft_snapshot_index")
|
||||||
|
echo "leader after writes: log_entries=", leaderLogLen,
|
||||||
|
" snapshot_index=", leaderSnapIdx
|
||||||
|
if leaderLogLen < 0 or leaderLogLen > 64:
|
||||||
|
echo "leader raft log not bounded: baradb_raft_log_entries=",
|
||||||
|
leaderLogLen, " (want <= 64)"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
if leaderSnapIdx <= 0:
|
||||||
|
echo "leader never compacted: baradb_raft_snapshot_index=",
|
||||||
|
leaderSnapIdx, " (want > 0)"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "leader log stayed bounded and compaction advanced"
|
||||||
|
|
||||||
|
# Sanity: leader and surviving follower agree on the row count.
|
||||||
|
let wantCount = rowCountOn(nodes[leaderIdx].clientPort)
|
||||||
|
if wantCount != RowCount:
|
||||||
|
echo "leader count=", wantCount, " want ", RowCount
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
|
||||||
|
# ---- Scenario A: cold node returns with its intact data dir. ----
|
||||||
|
nodes[coldIdx].output.setLen(0) # fresh log for the snapshot marker scan
|
||||||
|
restartNode(nodes[coldIdx], wipe = false)
|
||||||
|
if not waitReady(nodes[coldIdx], 10):
|
||||||
|
echo "cold node ", nodes[coldIdx].id, " never became ready after restart"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "scenario A: ", nodes[coldIdx].id, " restarted with intact data dir"
|
||||||
|
|
||||||
|
# Within 15s: snapshot evidence (metric on the cold node, or its restore
|
||||||
|
# log line) AND data convergence with the leader.
|
||||||
|
block:
|
||||||
|
let start = getTime()
|
||||||
|
var snapSeen = false
|
||||||
|
var converged = false
|
||||||
|
while getTime() - start < initDuration(seconds = 15):
|
||||||
|
nodes.drainAll()
|
||||||
|
if not snapSeen:
|
||||||
|
snapSeen = fetchMetric(nodes[coldIdx].clientPort,
|
||||||
|
"baradb_raft_snapshot_index") > 0 or
|
||||||
|
nodes[coldIdx].output.contains(SnapshotMarker)
|
||||||
|
if not converged:
|
||||||
|
try:
|
||||||
|
converged = rowCountOn(nodes[coldIdx].clientPort) == wantCount
|
||||||
|
except CatchableError:
|
||||||
|
discard
|
||||||
|
if snapSeen and converged: break
|
||||||
|
sleep(200)
|
||||||
|
if not snapSeen:
|
||||||
|
echo "scenario A: no snapshot evidence on ", nodes[coldIdx].id,
|
||||||
|
" (baradb_raft_snapshot_index stayed 0, no restore log line)"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
if not converged:
|
||||||
|
echo "scenario A: ", nodes[coldIdx].id,
|
||||||
|
" count did not converge to ", wantCount, " within 15s"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "scenario A: snapshot installed, count converged to ", wantCount
|
||||||
|
|
||||||
|
# Spot-check a few ids survived the snapshot round-trip.
|
||||||
|
block:
|
||||||
|
let db = openClient(nodes[coldIdx].clientPort)
|
||||||
|
defer: db.close()
|
||||||
|
for i in [1, 42, RowCount]:
|
||||||
|
let v = db.getValue(sql("SELECT name FROM " & TableName &
|
||||||
|
" WHERE id = " & $i))
|
||||||
|
if v != "row-" & $i:
|
||||||
|
echo "scenario A: spot-check id=", i, " got '", v,
|
||||||
|
"' want 'row-", i, "'"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "scenario A: spot-checks passed"
|
||||||
|
|
||||||
|
# ---- Scenario B: wiped node rejoins with the same node id. ----
|
||||||
|
nodes[coldIdx].output.setLen(0)
|
||||||
|
restartNode(nodes[coldIdx], wipe = true)
|
||||||
|
if not waitReady(nodes[coldIdx], 10):
|
||||||
|
echo "wiped node ", nodes[coldIdx].id, " never became ready after restart"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "scenario B: ", nodes[coldIdx].id, " restarted with a wiped data dir"
|
||||||
|
|
||||||
|
# Within 20s: snapshot → catch-up, then the full row set.
|
||||||
|
block:
|
||||||
|
let start = getTime()
|
||||||
|
var snapSeen = false
|
||||||
|
var converged = false
|
||||||
|
while getTime() - start < initDuration(seconds = 20):
|
||||||
|
nodes.drainAll()
|
||||||
|
if not snapSeen:
|
||||||
|
snapSeen = fetchMetric(nodes[coldIdx].clientPort,
|
||||||
|
"baradb_raft_snapshot_index") > 0 or
|
||||||
|
nodes[coldIdx].output.contains(SnapshotMarker)
|
||||||
|
if not converged:
|
||||||
|
try:
|
||||||
|
converged = rowCountOn(nodes[coldIdx].clientPort) == wantCount
|
||||||
|
except CatchableError:
|
||||||
|
discard
|
||||||
|
if snapSeen and converged: break
|
||||||
|
sleep(200)
|
||||||
|
if not snapSeen:
|
||||||
|
echo "scenario B: no snapshot evidence on wiped ", nodes[coldIdx].id
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
if not converged:
|
||||||
|
echo "scenario B: wiped ", nodes[coldIdx].id,
|
||||||
|
" count did not converge to ", wantCount, " within 20s"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "scenario B: wiped node converged to ", wantCount, " rows"
|
||||||
|
|
||||||
|
# Spot-check again on the wiped node.
|
||||||
|
block:
|
||||||
|
let db = openClient(nodes[coldIdx].clientPort)
|
||||||
|
defer: db.close()
|
||||||
|
for i in [1, 42, RowCount]:
|
||||||
|
let v = db.getValue(sql("SELECT name FROM " & TableName &
|
||||||
|
" WHERE id = " & $i))
|
||||||
|
if v != "row-" & $i:
|
||||||
|
echo "scenario B: spot-check id=", i, " got '", v,
|
||||||
|
"' want 'row-", i, "'"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "scenario B: spot-checks passed"
|
||||||
|
finally:
|
||||||
|
for n in nodes.mitems:
|
||||||
|
n.killNode()
|
||||||
|
if n.p != nil: n.p.close()
|
||||||
|
removeDir(n.dataDir)
|
||||||
|
|
||||||
|
suite "Raft cold-node E2E":
|
||||||
|
test "compacted-away node and wiped node rejoin and converge":
|
||||||
|
if not fileExists(BinaryPath):
|
||||||
|
if getEnv("CI").len > 0:
|
||||||
|
echo "[FAIL] ", BinaryPath, " missing under CI — build step broken?"
|
||||||
|
fail()
|
||||||
|
else:
|
||||||
|
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
|
||||||
|
skip()
|
||||||
|
else:
|
||||||
|
runColdNodeScenario()
|
||||||
@@ -208,6 +208,10 @@ proc runClusterScenario() =
|
|||||||
suite "Raft E2E cluster":
|
suite "Raft E2E cluster":
|
||||||
test "3-node election and failover":
|
test "3-node election and failover":
|
||||||
if not fileExists(BinaryPath):
|
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)"
|
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
|
||||||
skip()
|
skip()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
## Raft failover-under-load E2E — real 3-node cluster over the TCP transport.
|
||||||
|
## A writer thread hammers the current leader with INSERTs; once 50 writes
|
||||||
|
## have been acknowledged the leader is killed mid-load. Asserts:
|
||||||
|
## A (availability): a survivor accepts an INSERT within 10s of the kill.
|
||||||
|
## B (durability): every acknowledged id is present on BOTH survivors
|
||||||
|
## once the new leader is stable and the remaining
|
||||||
|
## follower has caught up.
|
||||||
|
## Process-management conventions follow tests/raft_writes_e2e_test.nim;
|
||||||
|
## client access follows tests/nimforum_smoke_test.nim.
|
||||||
|
import std/unittest
|
||||||
|
import std/osproc
|
||||||
|
import std/os
|
||||||
|
import std/strtabs
|
||||||
|
import std/strutils
|
||||||
|
import std/sequtils
|
||||||
|
import std/times
|
||||||
|
import std/net
|
||||||
|
import std/posix
|
||||||
|
import std/sets
|
||||||
|
import std/locks
|
||||||
|
import std/typedthreads
|
||||||
|
|
||||||
|
import ../adaptors/nim/baradb_sqlite as sqlite
|
||||||
|
|
||||||
|
const
|
||||||
|
BinaryPath = "./build/baradadb"
|
||||||
|
LeaderMarker = "became leader"
|
||||||
|
AckTarget = 50 # kill the leader once this many writes are acked
|
||||||
|
ProbeId = 1000001 # availability-probe row id (clear of writer's 1..N)
|
||||||
|
|
||||||
|
type
|
||||||
|
NodeProc = object
|
||||||
|
id: string
|
||||||
|
clientPort: int
|
||||||
|
p: Process
|
||||||
|
dataDir: string
|
||||||
|
output: string
|
||||||
|
alive: bool
|
||||||
|
|
||||||
|
# Shared writer-thread state, passed by pointer into the thread (same
|
||||||
|
# convention as tests/test_storage_hardening.nim); all access goes through
|
||||||
|
# the lock.
|
||||||
|
type
|
||||||
|
WriterArgs = object
|
||||||
|
lock: ptr Lock
|
||||||
|
acked: ptr seq[int] # ids whose INSERT was acknowledged by the cluster
|
||||||
|
stop: ptr bool # main thread sets this to end the writer loop
|
||||||
|
ports: array[3, int] # candidate client ports (leader first, then survivors)
|
||||||
|
|
||||||
|
proc drainOutput(n: var NodeProc) =
|
||||||
|
## Reads whatever the child has written so far. The pipe was set O_NONBLOCK
|
||||||
|
## at start, so this never blocks — a hung read is impossible here.
|
||||||
|
var tmp: array[8192, char]
|
||||||
|
while true:
|
||||||
|
let count = posix.read(n.p.outputHandle.cint, tmp[0].addr, tmp.len)
|
||||||
|
if count <= 0: break
|
||||||
|
for i in 0 ..< count: n.output.add tmp[i]
|
||||||
|
|
||||||
|
proc drainAll(nodes: var seq[NodeProc]) =
|
||||||
|
for n in nodes.mitems:
|
||||||
|
if n.p != nil: n.drainOutput()
|
||||||
|
|
||||||
|
proc dumpAll(nodes: var seq[NodeProc]) =
|
||||||
|
## Debuggability: on failure, everything the nodes said.
|
||||||
|
nodes.drainAll()
|
||||||
|
for n in nodes:
|
||||||
|
echo "===== output of ", n.id, " (port ", n.clientPort, ") ====="
|
||||||
|
echo n.output
|
||||||
|
|
||||||
|
proc portOpen(port: int): bool =
|
||||||
|
var s: Socket
|
||||||
|
try:
|
||||||
|
s = newSocket()
|
||||||
|
s.connect("127.0.0.1", Port(port), timeout = 250)
|
||||||
|
s.close()
|
||||||
|
result = true
|
||||||
|
except CatchableError:
|
||||||
|
if s != nil: s.close()
|
||||||
|
result = false
|
||||||
|
|
||||||
|
proc killNode(n: var NodeProc) =
|
||||||
|
if n.p != nil and n.alive:
|
||||||
|
try:
|
||||||
|
n.p.terminate()
|
||||||
|
discard n.p.waitForExit()
|
||||||
|
except CatchableError:
|
||||||
|
discard
|
||||||
|
n.alive = false
|
||||||
|
|
||||||
|
proc leaderTerms(output: string): seq[int] =
|
||||||
|
## All terms this node logged leadership for ("became leader for term T").
|
||||||
|
var pos = 0
|
||||||
|
while true:
|
||||||
|
let idx = output.find(LeaderMarker, pos)
|
||||||
|
if idx < 0: break
|
||||||
|
let tIdx = output.find("term ", idx)
|
||||||
|
if tIdx < 0: break
|
||||||
|
let numStart = tIdx + 5
|
||||||
|
var numEnd = numStart
|
||||||
|
while numEnd < output.len and output[numEnd] in Digits: inc numEnd
|
||||||
|
if numEnd > numStart:
|
||||||
|
result.add(parseInt(output[numStart ..< numEnd]))
|
||||||
|
pos = numEnd
|
||||||
|
|
||||||
|
proc maxLeader(nodes: seq[NodeProc]): tuple[idx, term: int] =
|
||||||
|
## Node that logged leadership for the highest term seen so far.
|
||||||
|
result = (-1, 0)
|
||||||
|
for i in 0 ..< nodes.len:
|
||||||
|
for t in leaderTerms(nodes[i].output):
|
||||||
|
if t > result.term: result = (i, t)
|
||||||
|
|
||||||
|
proc drainFor(nodes: var seq[NodeProc], ms: int) =
|
||||||
|
let start = getTime()
|
||||||
|
while getTime() - start < initDuration(milliseconds = ms):
|
||||||
|
nodes.drainAll()
|
||||||
|
sleep(50)
|
||||||
|
|
||||||
|
proc openClient(port: int): DbConn =
|
||||||
|
## Connect with retries — the port may accept TCP before the DB is usable.
|
||||||
|
for i in 0 ..< 50:
|
||||||
|
try:
|
||||||
|
return open("127.0.0.1:" & $port, "", "", "default")
|
||||||
|
except CatchableError:
|
||||||
|
sleep(100)
|
||||||
|
raise newException(IOError, "cannot connect to port " & $port)
|
||||||
|
|
||||||
|
proc countRows(port: int): int =
|
||||||
|
## Row count of load_test on `port`, or -1 on any error (not ready yet).
|
||||||
|
result = -1
|
||||||
|
try:
|
||||||
|
let db = openClient(port)
|
||||||
|
try:
|
||||||
|
result = parseInt(db.getValue(sql"SELECT count(*) FROM load_test"))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except CatchableError:
|
||||||
|
discard
|
||||||
|
|
||||||
|
proc allIds(port: int): HashSet[int] =
|
||||||
|
## Every id in load_test on `port`. Raises on error.
|
||||||
|
let db = openClient(port)
|
||||||
|
try:
|
||||||
|
for row in db.getAllRows(sql"SELECT id FROM load_test"):
|
||||||
|
if row.len >= 1 and row[0].len > 0:
|
||||||
|
result.incl(parseInt(row[0]))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
proc writerLoop(args: WriterArgs) {.thread.} =
|
||||||
|
## INSERTs n = 1, 2, ... against the cluster. Every acknowledged n is
|
||||||
|
## appended to args.acked. On any error the client is dropped and reopened
|
||||||
|
## against the next candidate port — the documented client retry contract.
|
||||||
|
## Writes against followers are forwarded to the leader by the server.
|
||||||
|
var n = 1
|
||||||
|
var portIdx = 0
|
||||||
|
var db: DbConn
|
||||||
|
while true:
|
||||||
|
withLock args.lock[]:
|
||||||
|
if args.stop[]: break
|
||||||
|
if cast[pointer](db) == nil:
|
||||||
|
let port = args.ports[portIdx]
|
||||||
|
try:
|
||||||
|
db = open("127.0.0.1:" & $port, "", "", "default")
|
||||||
|
except CatchableError:
|
||||||
|
portIdx = (portIdx + 1) mod args.ports.len
|
||||||
|
sleep(50)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
db.exec(sql("INSERT INTO load_test (id) VALUES (" & $n & ")"))
|
||||||
|
withLock args.lock[]:
|
||||||
|
args.acked[].add n
|
||||||
|
inc n
|
||||||
|
except CatchableError:
|
||||||
|
if cast[pointer](db) != nil:
|
||||||
|
try: db.close()
|
||||||
|
except CatchableError: discard
|
||||||
|
db = default(DbConn)
|
||||||
|
portIdx = (portIdx + 1) mod args.ports.len
|
||||||
|
sleep(50)
|
||||||
|
if cast[pointer](db) != nil:
|
||||||
|
try: db.close()
|
||||||
|
except CatchableError: discard
|
||||||
|
|
||||||
|
proc runFailoverLoadScenario() =
|
||||||
|
## Fatal phase failures dump all captured node output, record a test
|
||||||
|
## failure, and return; cleanup happens in the finally below either way.
|
||||||
|
let tstamp = getTime().toUnix.int
|
||||||
|
# Port bases: distinct from nimforum_smoke_test (35000+mod10000),
|
||||||
|
# raft_e2e_test (41000+mod5000) and raft_writes_e2e_test (46000+mod4000).
|
||||||
|
# Client ports are spaced by 10 because the server derives HTTP (port+440),
|
||||||
|
# WS (port+441) and gossip (raftPort+100) ports — consecutive client ports
|
||||||
|
# collide.
|
||||||
|
let cbase = 50000 + (tstamp mod 4000)
|
||||||
|
let rbase = cbase + 100
|
||||||
|
let peers = "n1@127.0.0.1:" & $(rbase + 1) &
|
||||||
|
",n2@127.0.0.1:" & $(rbase + 2) &
|
||||||
|
",n3@127.0.0.1:" & $(rbase + 3)
|
||||||
|
# SQL client ports for transparent leader write forwarding.
|
||||||
|
let clientPeers = "n1@127.0.0.1:" & $(cbase + 10) &
|
||||||
|
",n2@127.0.0.1:" & $(cbase + 20) &
|
||||||
|
",n3@127.0.0.1:" & $(cbase + 30)
|
||||||
|
|
||||||
|
var nodes: seq[NodeProc]
|
||||||
|
for i in 1 .. 3:
|
||||||
|
let id = "n" & $i
|
||||||
|
let dataDir = getTempDir() / "baradb_raft_failover_load_e2e_" & $tstamp & "_" & id
|
||||||
|
createDir(dataDir)
|
||||||
|
var env = newStringTable()
|
||||||
|
for key, val in envPairs():
|
||||||
|
env[key] = val
|
||||||
|
env["BARADB_PORT"] = $(cbase + i * 10)
|
||||||
|
env["BARADB_RAFT_ENABLED"] = "true"
|
||||||
|
env["BARADB_RAFT_PORT"] = $(rbase + i)
|
||||||
|
env["BARADB_RAFT_NODE_ID"] = id
|
||||||
|
env["BARADB_RAFT_PEERS"] = peers
|
||||||
|
env["BARADB_RAFT_CLIENT_PEERS"] = clientPeers
|
||||||
|
env["BARADB_DATA_DIR"] = dataDir
|
||||||
|
env["BARADB_LOG_LEVEL"] = "info"
|
||||||
|
let p = startProcess(BinaryPath, env = env,
|
||||||
|
options = {poStdErrToStdOut, poDaemon})
|
||||||
|
discard fcntl(p.outputHandle.cint, F_SETFL,
|
||||||
|
fcntl(p.outputHandle.cint, F_GETFL) or O_NONBLOCK)
|
||||||
|
nodes.add NodeProc(id: id, clientPort: cbase + i * 10, p: p,
|
||||||
|
dataDir: dataDir, alive: true)
|
||||||
|
|
||||||
|
var
|
||||||
|
writer: Thread[WriterArgs]
|
||||||
|
writerStarted = false
|
||||||
|
wLock: Lock
|
||||||
|
wAcked: seq[int]
|
||||||
|
wStop = false
|
||||||
|
initLock(wLock)
|
||||||
|
try:
|
||||||
|
# Readiness: all three client ports accept TCP connections (10s each).
|
||||||
|
for i in 0 ..< nodes.len:
|
||||||
|
let readyStart = getTime()
|
||||||
|
var ok = false
|
||||||
|
while getTime() - readyStart < initDuration(seconds = 10):
|
||||||
|
if portOpen(nodes[i].clientPort):
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
sleep(100)
|
||||||
|
if not ok:
|
||||||
|
echo "node ", nodes[i].id, " never became ready"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Election: timeouts are 150-300ms, heartbeat 50ms — a leader should
|
||||||
|
# emerge within ~2s; 10s deadline for margin.
|
||||||
|
var elected = false
|
||||||
|
let electStart = getTime()
|
||||||
|
while getTime() - electStart < initDuration(seconds = 10):
|
||||||
|
nodes.drainAll()
|
||||||
|
if maxLeader(nodes).idx >= 0:
|
||||||
|
elected = true
|
||||||
|
break
|
||||||
|
sleep(50)
|
||||||
|
if not elected:
|
||||||
|
echo "no leader elected within 10s"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Settle and require stability, same as raft_writes_e2e_test.
|
||||||
|
nodes.drainFor(2000)
|
||||||
|
let (leaderIdx, leaderTerm) = maxLeader(nodes)
|
||||||
|
nodes.drainFor(1000)
|
||||||
|
let (stableIdx, stableTerm) = maxLeader(nodes)
|
||||||
|
if stableIdx != leaderIdx or stableTerm != leaderTerm:
|
||||||
|
echo "cluster unstable: leadership moved from ", nodes[leaderIdx].id,
|
||||||
|
" (term ", leaderTerm, ") to ", nodes[stableIdx].id,
|
||||||
|
" (term ", stableTerm, ")"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "leader elected: ", nodes[leaderIdx].id, " (term ", leaderTerm, ")"
|
||||||
|
|
||||||
|
# Schema goes through the raft ddl log on the leader (C3c).
|
||||||
|
block:
|
||||||
|
let db = openClient(nodes[leaderIdx].clientPort)
|
||||||
|
try:
|
||||||
|
db.exec(sql"CREATE TABLE load_test (id INT PRIMARY KEY)")
|
||||||
|
except CatchableError as e:
|
||||||
|
echo "leader CREATE TABLE failed: ", e.msg
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
db.close()
|
||||||
|
echo "leader schema committed via raft ddl"
|
||||||
|
|
||||||
|
# Load phase: sustained INSERTs from a background writer thread.
|
||||||
|
let wArgs = WriterArgs(
|
||||||
|
lock: addr wLock, acked: addr wAcked, stop: addr wStop,
|
||||||
|
ports: [nodes[leaderIdx].clientPort,
|
||||||
|
nodes[(leaderIdx + 1) mod 3].clientPort,
|
||||||
|
nodes[(leaderIdx + 2) mod 3].clientPort])
|
||||||
|
createThread(writer, writerLoop, wArgs)
|
||||||
|
writerStarted = true
|
||||||
|
|
||||||
|
# Wait until AckTarget writes are acknowledged (30s deadline).
|
||||||
|
var ackedAtKill = 0
|
||||||
|
let loadStart = getTime()
|
||||||
|
while getTime() - loadStart < initDuration(seconds = 30):
|
||||||
|
withLock wLock:
|
||||||
|
ackedAtKill = wAcked.len
|
||||||
|
if ackedAtKill >= AckTarget: break
|
||||||
|
sleep(20)
|
||||||
|
if ackedAtKill < AckTarget:
|
||||||
|
echo "only ", ackedAtKill, " writes acked within 30s (need ", AckTarget, ")"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "load phase: ", ackedAtKill, " writes acknowledged, killing leader ",
|
||||||
|
nodes[leaderIdx].id
|
||||||
|
|
||||||
|
# Kill the leader mid-load.
|
||||||
|
killNode(nodes[leaderIdx])
|
||||||
|
let killTime = getTime()
|
||||||
|
|
||||||
|
# Assert A (availability): a survivor accepts an INSERT within 10s of
|
||||||
|
# the kill. Probe both survivors; keep the one that answers. Each attempt
|
||||||
|
# uses a fresh id: an attempt may commit but lose its response during the
|
||||||
|
# failover, and retrying the same id would then loop on UNIQUE violations.
|
||||||
|
var writerSurvivor = -1
|
||||||
|
var writeErr = ""
|
||||||
|
var probeAttempt = 0
|
||||||
|
while getTime() - killTime < initDuration(seconds = 10):
|
||||||
|
for i in 0 ..< nodes.len:
|
||||||
|
if i == leaderIdx: continue
|
||||||
|
inc probeAttempt
|
||||||
|
let probeId = ProbeId + probeAttempt
|
||||||
|
try:
|
||||||
|
let db = openClient(nodes[i].clientPort)
|
||||||
|
try:
|
||||||
|
db.exec(sql("INSERT INTO load_test (id) VALUES (" & $probeId & ")"))
|
||||||
|
writerSurvivor = i
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
if writerSurvivor >= 0: break
|
||||||
|
except CatchableError as e:
|
||||||
|
writeErr = e.msg
|
||||||
|
# "not leader" / commit timeout / connection blips — keep probing.
|
||||||
|
if writerSurvivor >= 0: break
|
||||||
|
sleep(100)
|
||||||
|
if writerSurvivor < 0:
|
||||||
|
echo "ASSERT A FAILED: no survivor accepted a write within 10s of the kill",
|
||||||
|
(if writeErr.len > 0: " (last error: " & writeErr & ")" else: "")
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
let availMs = inMilliseconds(getTime() - killTime)
|
||||||
|
echo "availability: ", nodes[writerSurvivor].id,
|
||||||
|
" accepted a write ", availMs, "ms after the kill"
|
||||||
|
|
||||||
|
# Stop the writer thread and snapshot what was acknowledged.
|
||||||
|
withLock wLock:
|
||||||
|
wStop = true
|
||||||
|
joinThreads(writer)
|
||||||
|
writerStarted = false
|
||||||
|
withLock wLock:
|
||||||
|
ackedAtKill = wAcked.len
|
||||||
|
echo "writer stopped; ", ackedAtKill, " total acknowledged writes"
|
||||||
|
|
||||||
|
# Let the new leader stabilize and the remaining follower catch up:
|
||||||
|
# poll until both survivors agree on a row count that covers every
|
||||||
|
# acknowledged write (10s deadline). The >= ackedAtKill guard prevents
|
||||||
|
# a trivial 0 == 0 pass before any raft entries have been applied.
|
||||||
|
let survivorIdx = [0, 1, 2].filterIt(it != leaderIdx)
|
||||||
|
var caughtUp = false
|
||||||
|
let cuStart = getTime()
|
||||||
|
while getTime() - cuStart < initDuration(seconds = 10):
|
||||||
|
let c0 = countRows(nodes[survivorIdx[0]].clientPort)
|
||||||
|
let c1 = countRows(nodes[survivorIdx[1]].clientPort)
|
||||||
|
if c0 >= ackedAtKill and c0 == c1:
|
||||||
|
caughtUp = true
|
||||||
|
break
|
||||||
|
sleep(100)
|
||||||
|
if not caughtUp:
|
||||||
|
echo "survivors never reached equal row counts within 10s (",
|
||||||
|
countRows(nodes[survivorIdx[0]].clientPort), " vs ",
|
||||||
|
countRows(nodes[survivorIdx[1]].clientPort), ")"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "survivors caught up: equal row counts"
|
||||||
|
|
||||||
|
# Assert B (durability): every acknowledged id must be present on BOTH
|
||||||
|
# survivors.
|
||||||
|
var acked: HashSet[int]
|
||||||
|
withLock wLock:
|
||||||
|
acked = toHashSet(wAcked)
|
||||||
|
for i in survivorIdx:
|
||||||
|
var ids: HashSet[int]
|
||||||
|
try:
|
||||||
|
ids = allIds(nodes[i].clientPort)
|
||||||
|
except CatchableError as e:
|
||||||
|
echo "ASSERT B FAILED: could not read ids from ", nodes[i].id,
|
||||||
|
": ", e.msg
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
let missing = acked - ids
|
||||||
|
if missing.len > 0:
|
||||||
|
echo "ASSERT B FAILED: ", nodes[i].id, " is missing ",
|
||||||
|
missing.len, " acknowledged ids"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "durability: ", nodes[i].id, " contains all ",
|
||||||
|
acked.len, " acknowledged ids"
|
||||||
|
|
||||||
|
check acked.len >= AckTarget
|
||||||
|
finally:
|
||||||
|
if writerStarted:
|
||||||
|
withLock wLock:
|
||||||
|
wStop = true
|
||||||
|
joinThreads(writer)
|
||||||
|
deinitLock(wLock)
|
||||||
|
for n in nodes.mitems:
|
||||||
|
n.killNode()
|
||||||
|
if n.p != nil: n.p.close()
|
||||||
|
removeDir(n.dataDir)
|
||||||
|
|
||||||
|
suite "Raft failover under load E2E":
|
||||||
|
test "committed writes survive a leader kill under sustained write load":
|
||||||
|
if not fileExists(BinaryPath):
|
||||||
|
if getEnv("CI").len > 0:
|
||||||
|
echo "[FAIL] ", BinaryPath, " missing under CI — build step broken?"
|
||||||
|
fail()
|
||||||
|
else:
|
||||||
|
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
|
||||||
|
skip()
|
||||||
|
else:
|
||||||
|
runFailoverLoadScenario()
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
## Raft TLS E2E — real 3-node cluster with TLS on the raft transport.
|
||||||
|
## Starts three actual build/baradadb processes with per-node self-signed
|
||||||
|
## certs (BARADB_RAFT_TLS_ENABLED + CERT/KEY_FILE), asserts election and
|
||||||
|
## replicated writes work over the encrypted transport, then starts a 4th
|
||||||
|
## plaintext node pointed at the same peers and asserts it never becomes
|
||||||
|
## leader (its frames are undecryptable) while the TLS cluster keeps
|
||||||
|
## operating among its 3 members.
|
||||||
|
## Process-management conventions follow tests/raft_writes_e2e_test.nim
|
||||||
|
## (copying is the repo's e2e convention — do not factor out).
|
||||||
|
##
|
||||||
|
## NOTE: only the raft port is TLS here. The SQL client port stays
|
||||||
|
## plaintext (BARADB_TLS_ENABLED is the client wire port — a different
|
||||||
|
## feature), so the baradb_sqlite adaptor connects as usual.
|
||||||
|
import std/unittest
|
||||||
|
import std/osproc
|
||||||
|
import std/os
|
||||||
|
import std/strtabs
|
||||||
|
import std/strutils
|
||||||
|
import std/times
|
||||||
|
import std/net
|
||||||
|
import std/posix
|
||||||
|
|
||||||
|
import ../adaptors/nim/baradb_sqlite as sqlite
|
||||||
|
import barabadb/protocol/ssl
|
||||||
|
|
||||||
|
const
|
||||||
|
BinaryPath = "./build/baradadb"
|
||||||
|
LeaderMarker = "became leader"
|
||||||
|
|
||||||
|
type
|
||||||
|
NodeProc = object
|
||||||
|
id: string
|
||||||
|
clientPort: int
|
||||||
|
raftPort: int
|
||||||
|
p: Process
|
||||||
|
dataDir: string
|
||||||
|
output: string
|
||||||
|
alive: bool
|
||||||
|
|
||||||
|
proc drainOutput(n: var NodeProc) =
|
||||||
|
## Reads whatever the child has written so far. The pipe was set O_NONBLOCK
|
||||||
|
## at start, so this never blocks — a hung read is impossible here.
|
||||||
|
var tmp: array[8192, char]
|
||||||
|
while true:
|
||||||
|
let count = posix.read(n.p.outputHandle.cint, tmp[0].addr, tmp.len)
|
||||||
|
if count <= 0: break
|
||||||
|
for i in 0 ..< count: n.output.add tmp[i]
|
||||||
|
|
||||||
|
proc drainAll(nodes: var seq[NodeProc]) =
|
||||||
|
for n in nodes.mitems:
|
||||||
|
if n.p != nil: n.drainOutput()
|
||||||
|
|
||||||
|
proc dumpAll(nodes: var seq[NodeProc]) =
|
||||||
|
## Debuggability: on failure, everything the nodes said.
|
||||||
|
nodes.drainAll()
|
||||||
|
for n in nodes:
|
||||||
|
echo "===== output of ", n.id, " (port ", n.clientPort, ") ====="
|
||||||
|
echo n.output
|
||||||
|
|
||||||
|
proc portOpen(port: int): bool =
|
||||||
|
var s: Socket
|
||||||
|
try:
|
||||||
|
s = newSocket()
|
||||||
|
s.connect("127.0.0.1", Port(port), timeout = 250)
|
||||||
|
s.close()
|
||||||
|
result = true
|
||||||
|
except CatchableError:
|
||||||
|
if s != nil: s.close()
|
||||||
|
result = false
|
||||||
|
|
||||||
|
proc tlsHandshake(port: int): bool =
|
||||||
|
## True when a real TLS client handshake completes against `port`.
|
||||||
|
## Wire-level discriminator: against a plaintext peer it fails — either
|
||||||
|
## immediately (SSL error on the garbage reply) or, when the peer swallows
|
||||||
|
## our ClientHello and waits for more bytes, via the 3s recv timeout.
|
||||||
|
var ctx: SslContext
|
||||||
|
var s: Socket
|
||||||
|
try:
|
||||||
|
ctx = newContext(verifyMode = CVerifyNone)
|
||||||
|
s = newSocket()
|
||||||
|
var tv = Timeval(tvSec: posix.Time(3), tvUsec: Suseconds(0))
|
||||||
|
discard setsockopt(s.getFd(), SOL_SOCKET, SO_RCVTIMEO,
|
||||||
|
addr tv, SockLen(sizeof(tv)))
|
||||||
|
s.connect("127.0.0.1", Port(port), timeout = 3000)
|
||||||
|
ctx.wrapConnectedSocket(s, handshakeAsClient)
|
||||||
|
result = true
|
||||||
|
except CatchableError:
|
||||||
|
result = false
|
||||||
|
finally:
|
||||||
|
if s != nil: s.close()
|
||||||
|
if ctx != nil: ctx.destroyContext()
|
||||||
|
|
||||||
|
proc killNode(n: var NodeProc) =
|
||||||
|
if n.p != nil and n.alive:
|
||||||
|
try:
|
||||||
|
n.p.terminate()
|
||||||
|
discard n.p.waitForExit()
|
||||||
|
except CatchableError:
|
||||||
|
discard
|
||||||
|
n.alive = false
|
||||||
|
|
||||||
|
proc leaderTerms(output: string): seq[int] =
|
||||||
|
## All terms this node logged leadership for ("became leader for term T").
|
||||||
|
var pos = 0
|
||||||
|
while true:
|
||||||
|
let idx = output.find(LeaderMarker, pos)
|
||||||
|
if idx < 0: break
|
||||||
|
let tIdx = output.find("term ", idx)
|
||||||
|
if tIdx < 0: break
|
||||||
|
let numStart = tIdx + 5
|
||||||
|
var numEnd = numStart
|
||||||
|
while numEnd < output.len and output[numEnd] in Digits: inc numEnd
|
||||||
|
if numEnd > numStart:
|
||||||
|
result.add(parseInt(output[numStart ..< numEnd]))
|
||||||
|
pos = numEnd
|
||||||
|
|
||||||
|
proc maxLeader(nodes: seq[NodeProc]): tuple[idx, term: int] =
|
||||||
|
## Node that logged leadership for the highest term seen so far.
|
||||||
|
result = (-1, 0)
|
||||||
|
for i in 0 ..< nodes.len:
|
||||||
|
for t in leaderTerms(nodes[i].output):
|
||||||
|
if t > result.term: result = (i, t)
|
||||||
|
|
||||||
|
proc drainFor(nodes: var seq[NodeProc], ms: int) =
|
||||||
|
let start = getTime()
|
||||||
|
while getTime() - start < initDuration(milliseconds = ms):
|
||||||
|
nodes.drainAll()
|
||||||
|
sleep(50)
|
||||||
|
|
||||||
|
proc openClient(port: int): DbConn =
|
||||||
|
## Connect with retries — the port may accept TCP before the DB is usable.
|
||||||
|
for i in 0 ..< 50:
|
||||||
|
try:
|
||||||
|
return open("127.0.0.1:" & $port, "", "", "default")
|
||||||
|
except CatchableError:
|
||||||
|
sleep(100)
|
||||||
|
raise newException(IOError, "cannot connect to port " & $port)
|
||||||
|
|
||||||
|
proc waitForRow(port: int, table, name: string, deadlineSec: int): bool =
|
||||||
|
## Poll SELECT on `port` until a row with `name` appears. Tolerates errors
|
||||||
|
## (e.g. "unknown table" while schema has not been created yet) by retrying.
|
||||||
|
let db = openClient(port)
|
||||||
|
defer: db.close()
|
||||||
|
let start = getTime()
|
||||||
|
while getTime() - start < initDuration(seconds = deadlineSec):
|
||||||
|
try:
|
||||||
|
let rows = db.getAllRows(sql("SELECT * FROM " & table))
|
||||||
|
for row in rows:
|
||||||
|
if row.len >= 2 and row[1] == name:
|
||||||
|
return true
|
||||||
|
except CatchableError:
|
||||||
|
discard
|
||||||
|
sleep(100)
|
||||||
|
return false
|
||||||
|
|
||||||
|
proc startNode(id: string, clientPort, raftPort: int, peers, clientPeers: string,
|
||||||
|
tlsEnabled: bool): NodeProc =
|
||||||
|
## Boots one build/baradadb process. When tlsEnabled, a self-signed cert
|
||||||
|
## (CN = node id) is generated into the node's data dir first.
|
||||||
|
let tstamp = getTime().toUnix.int
|
||||||
|
let dataDir = getTempDir() / "baradb_raft_tls_e2e_" & $tstamp & "_" & id
|
||||||
|
createDir(dataDir)
|
||||||
|
var env = newStringTable()
|
||||||
|
for key, val in envPairs():
|
||||||
|
env[key] = val
|
||||||
|
env["BARADB_PORT"] = $clientPort
|
||||||
|
env["BARADB_RAFT_ENABLED"] = "true"
|
||||||
|
env["BARADB_RAFT_PORT"] = $raftPort
|
||||||
|
env["BARADB_RAFT_NODE_ID"] = id
|
||||||
|
env["BARADB_RAFT_PEERS"] = peers
|
||||||
|
env["BARADB_RAFT_CLIENT_PEERS"] = clientPeers
|
||||||
|
env["BARADB_DATA_DIR"] = dataDir
|
||||||
|
env["BARADB_LOG_LEVEL"] = "info"
|
||||||
|
if tlsEnabled:
|
||||||
|
let (certFile, keyFile) = generateSelfSignedCert(dataDir, id)
|
||||||
|
doAssert certFile.len > 0 and keyFile.len > 0,
|
||||||
|
"openssl cert generation failed for " & id
|
||||||
|
env["BARADB_RAFT_TLS_ENABLED"] = "true"
|
||||||
|
env["BARADB_RAFT_TLS_CERT_FILE"] = certFile
|
||||||
|
env["BARADB_RAFT_TLS_KEY_FILE"] = keyFile
|
||||||
|
let p = startProcess(BinaryPath, env = env,
|
||||||
|
options = {poStdErrToStdOut, poDaemon})
|
||||||
|
discard fcntl(p.outputHandle.cint, F_SETFL,
|
||||||
|
fcntl(p.outputHandle.cint, F_GETFL) or O_NONBLOCK)
|
||||||
|
NodeProc(id: id, clientPort: clientPort, raftPort: raftPort, p: p,
|
||||||
|
dataDir: dataDir, alive: true)
|
||||||
|
|
||||||
|
proc runTlsScenario() =
|
||||||
|
## Fatal phase failures dump all captured node output, record a test
|
||||||
|
## failure, and return; cleanup happens in the finally below either way.
|
||||||
|
let tstamp = getTime().toUnix.int
|
||||||
|
# Port base per the T6 brief: distinct from raft_writes_e2e_test
|
||||||
|
# (46000+mod4000). Client ports are spaced by 10 because the server
|
||||||
|
# derives HTTP (port+440), WS (port+441) and gossip (raftPort+100)
|
||||||
|
# ports — consecutive client ports collide.
|
||||||
|
let cbase = 54000 + (tstamp mod 4000)
|
||||||
|
let rbase = cbase + 100
|
||||||
|
let peers = "n1@127.0.0.1:" & $(rbase + 1) &
|
||||||
|
",n2@127.0.0.1:" & $(rbase + 2) &
|
||||||
|
",n3@127.0.0.1:" & $(rbase + 3)
|
||||||
|
# SQL client ports for transparent leader write forwarding.
|
||||||
|
let clientPeers = "n1@127.0.0.1:" & $(cbase + 10) &
|
||||||
|
",n2@127.0.0.1:" & $(cbase + 20) &
|
||||||
|
",n3@127.0.0.1:" & $(cbase + 30)
|
||||||
|
|
||||||
|
var nodes: seq[NodeProc]
|
||||||
|
# Negative-case node: same peers, raft TLS DISABLED, own dir and ports
|
||||||
|
# (4th port in each base range). Started later, after the TLS cluster is
|
||||||
|
# up, so the positive assertions are not polluted by its noise.
|
||||||
|
var rogue: NodeProc
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Node starts live inside the try so a raise from start #2/#3 still
|
||||||
|
# reaches the cleanup in the finally below.
|
||||||
|
for i in 1 .. 3:
|
||||||
|
nodes.add startNode("n" & $i, cbase + i * 10, rbase + i,
|
||||||
|
peers, clientPeers, tlsEnabled = true)
|
||||||
|
|
||||||
|
# Readiness: all three client ports accept TCP connections (10s each).
|
||||||
|
for i in 0 ..< nodes.len:
|
||||||
|
let readyStart = getTime()
|
||||||
|
var ok = false
|
||||||
|
while getTime() - readyStart < initDuration(seconds = 10):
|
||||||
|
if portOpen(nodes[i].clientPort):
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
sleep(100)
|
||||||
|
if not ok:
|
||||||
|
echo "node ", nodes[i].id, " never became ready"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Election over TLS: timeouts are 150-300ms, heartbeat 50ms — a leader
|
||||||
|
# should emerge within ~2s; 10s deadline for margin.
|
||||||
|
var elected = false
|
||||||
|
let electStart = getTime()
|
||||||
|
while getTime() - electStart < initDuration(seconds = 10):
|
||||||
|
nodes.drainAll()
|
||||||
|
if maxLeader(nodes).idx >= 0:
|
||||||
|
elected = true
|
||||||
|
break
|
||||||
|
sleep(50)
|
||||||
|
if not elected:
|
||||||
|
echo "no leader elected within 10s over TLS"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Settle and require stability, same as raft_writes_e2e_test.
|
||||||
|
nodes.drainFor(2000)
|
||||||
|
let (leaderIdx, leaderTerm) = maxLeader(nodes)
|
||||||
|
nodes.drainFor(1000)
|
||||||
|
let (stableIdx, stableTerm) = maxLeader(nodes)
|
||||||
|
if stableIdx != leaderIdx or stableTerm != leaderTerm:
|
||||||
|
echo "TLS cluster unstable: leadership moved from ", nodes[leaderIdx].id,
|
||||||
|
" (term ", leaderTerm, ") to ", nodes[stableIdx].id,
|
||||||
|
" (term ", stableTerm, ")"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "TLS leader elected: ", nodes[leaderIdx].id, " (term ", leaderTerm, ")"
|
||||||
|
|
||||||
|
# Wire-level TLS assertion: a real client handshake must complete
|
||||||
|
# against every cluster node's raft port. Without this, the negative
|
||||||
|
# "plaintext node never becomes leader" check alone cannot distinguish
|
||||||
|
# TLS rejection from "can't win an election anyway" — if TLS wrapping
|
||||||
|
# were silently dropped from the transport, these handshakes raise and
|
||||||
|
# the suite fails.
|
||||||
|
for n in nodes.items:
|
||||||
|
if not tlsHandshake(n.raftPort):
|
||||||
|
echo "TLS handshake failed against raft port of ", n.id,
|
||||||
|
" (port ", n.raftPort, ") — raft transport not encrypted?"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "TLS handshake verified against all 3 raft ports"
|
||||||
|
|
||||||
|
let followerIdx = (if leaderIdx == 0: 1 else: 0)
|
||||||
|
|
||||||
|
# Schema: CREATE TABLE goes through the raft "ddl" log (C3c) — this
|
||||||
|
# proves raft DDL replication works over the TLS transport.
|
||||||
|
block:
|
||||||
|
let db = openClient(nodes[leaderIdx].clientPort)
|
||||||
|
try:
|
||||||
|
db.exec(sql"CREATE TABLE tls_test (id INT PRIMARY KEY, name STRING)")
|
||||||
|
except CatchableError as e:
|
||||||
|
echo "leader CREATE TABLE failed: ", e.msg
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
db.close()
|
||||||
|
echo "leader schema committed via raft ddl over TLS"
|
||||||
|
|
||||||
|
# Wait until the follower has applied CREATE TABLE (SELECT no longer
|
||||||
|
# errors with unknown table). Deadline 5s.
|
||||||
|
block:
|
||||||
|
let db = openClient(nodes[followerIdx].clientPort)
|
||||||
|
defer: db.close()
|
||||||
|
let start = getTime()
|
||||||
|
var ready = false
|
||||||
|
while getTime() - start < initDuration(seconds = 5):
|
||||||
|
try:
|
||||||
|
discard db.getAllRows(sql"SELECT * FROM tls_test")
|
||||||
|
ready = true
|
||||||
|
break
|
||||||
|
except CatchableError:
|
||||||
|
sleep(100)
|
||||||
|
if not ready:
|
||||||
|
echo "follower ", nodes[followerIdx].id,
|
||||||
|
" never applied CREATE TABLE within 5s"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "schema replicated to follower ", nodes[followerIdx].id, " over TLS"
|
||||||
|
|
||||||
|
# Leader write: INSERT goes through the raft log and waits for majority
|
||||||
|
# commit before responding — expect success over TLS.
|
||||||
|
block:
|
||||||
|
let db = openClient(nodes[leaderIdx].clientPort)
|
||||||
|
try:
|
||||||
|
db.exec(sql"INSERT INTO tls_test (id, name) VALUES (1, 'tls-row')")
|
||||||
|
except CatchableError as e:
|
||||||
|
echo "leader INSERT failed: ", e.msg
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
db.close()
|
||||||
|
echo "leader INSERT committed over TLS"
|
||||||
|
|
||||||
|
# Follower visibility: poll until the row shows up (5s deadline).
|
||||||
|
if not waitForRow(nodes[followerIdx].clientPort, "tls_test", "tls-row", 5):
|
||||||
|
echo "follower ", nodes[followerIdx].id,
|
||||||
|
" never saw the replicated row within 5s"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "row replicated to follower ", nodes[followerIdx].id, " over TLS"
|
||||||
|
|
||||||
|
# Negative scenario: start the plaintext node pointed at the same peers.
|
||||||
|
# Its TLS-less frames are undecryptable to the cluster, so it can never
|
||||||
|
# collect votes and must never become leader.
|
||||||
|
rogue = startNode("n4", cbase + 40, rbase + 4, peers, clientPeers,
|
||||||
|
tlsEnabled = false)
|
||||||
|
block:
|
||||||
|
let readyStart = getTime()
|
||||||
|
var ok = false
|
||||||
|
while getTime() - readyStart < initDuration(seconds = 10):
|
||||||
|
if portOpen(rogue.clientPort):
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
sleep(100)
|
||||||
|
if not ok:
|
||||||
|
echo "plaintext node never became ready"
|
||||||
|
rogue.drainOutput()
|
||||||
|
echo "===== output of ", rogue.id, " (port ", rogue.clientPort, ") ====="
|
||||||
|
echo rogue.output
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "plaintext node n4 started against the TLS peers"
|
||||||
|
|
||||||
|
# Complementary wire-level negative: the rogue node's raft port speaks
|
||||||
|
# plaintext, so a TLS handshake against it must FAIL.
|
||||||
|
if tlsHandshake(rogue.raftPort):
|
||||||
|
echo "TLS handshake unexpectedly succeeded against plaintext n4 ",
|
||||||
|
"raft port ", rogue.raftPort
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "TLS handshake correctly fails against plaintext n4 raft port"
|
||||||
|
|
||||||
|
# Give n4 many election cycles (timeouts 150-300ms) to try its luck.
|
||||||
|
# The TLS cluster must keep operating among its 3 members meanwhile.
|
||||||
|
# Drain n4's pipe alongside the others — its connection-refused spam
|
||||||
|
# must not fill the pipe and block the child.
|
||||||
|
block:
|
||||||
|
let negStart = getTime()
|
||||||
|
while getTime() - negStart < initDuration(seconds = 6):
|
||||||
|
nodes.drainAll()
|
||||||
|
rogue.drainOutput()
|
||||||
|
sleep(50)
|
||||||
|
|
||||||
|
# Cluster still elects/operates: leader among the 3 TLS nodes accepts a
|
||||||
|
# write and it replicates to a follower, with n4 running.
|
||||||
|
nodes.drainAll()
|
||||||
|
let (leaderIdx2, leaderTerm2) = maxLeader(nodes)
|
||||||
|
if leaderIdx2 < 0:
|
||||||
|
echo "TLS cluster lost its leader after plaintext node joined"
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
let followerIdx2 = (if leaderIdx2 == 0: 1 else: 0)
|
||||||
|
block:
|
||||||
|
let db = openClient(nodes[leaderIdx2].clientPort)
|
||||||
|
try:
|
||||||
|
db.exec(sql"INSERT INTO tls_test (id, name) VALUES (2, 'still-tls')")
|
||||||
|
except CatchableError as e:
|
||||||
|
echo "post-plaintext INSERT failed on ", nodes[leaderIdx2].id,
|
||||||
|
" (term ", leaderTerm2, "): ", e.msg
|
||||||
|
rogue.drainOutput()
|
||||||
|
echo "===== output of ", rogue.id, " (port ", rogue.clientPort, ") ====="
|
||||||
|
echo rogue.output
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
db.close()
|
||||||
|
if not waitForRow(nodes[followerIdx2].clientPort, "tls_test", "still-tls", 5):
|
||||||
|
echo "post-plaintext row never replicated to ", nodes[followerIdx2].id
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "TLS cluster kept operating with plaintext node present"
|
||||||
|
|
||||||
|
# Final negative assertion: n4 never won an election.
|
||||||
|
rogue.drainOutput()
|
||||||
|
if rogue.output.contains(LeaderMarker):
|
||||||
|
echo "plaintext node became leader — TLS isolation broken"
|
||||||
|
echo "===== output of ", rogue.id, " (port ", rogue.clientPort, ") ====="
|
||||||
|
echo rogue.output
|
||||||
|
dumpAll(nodes)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
echo "plaintext node never became leader (TLS rejection confirmed)"
|
||||||
|
finally:
|
||||||
|
for n in nodes.mitems:
|
||||||
|
n.killNode()
|
||||||
|
if n.p != nil: n.p.close()
|
||||||
|
removeDir(n.dataDir)
|
||||||
|
if rogue.p != nil:
|
||||||
|
rogue.killNode()
|
||||||
|
rogue.p.close()
|
||||||
|
removeDir(rogue.dataDir)
|
||||||
|
|
||||||
|
suite "Raft TLS E2E":
|
||||||
|
test "3-node TLS cluster elects and replicates; plaintext node rejected":
|
||||||
|
if not fileExists(BinaryPath):
|
||||||
|
if getEnv("CI").len > 0:
|
||||||
|
echo "[FAIL] ", BinaryPath, " missing under CI — build step broken?"
|
||||||
|
fail()
|
||||||
|
else:
|
||||||
|
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
|
||||||
|
skip()
|
||||||
|
else:
|
||||||
|
runTlsScenario()
|
||||||
@@ -412,6 +412,10 @@ proc runWritesScenario() =
|
|||||||
suite "Raft replicated writes E2E":
|
suite "Raft replicated writes E2E":
|
||||||
test "writes replicate, followers reject, failover resumes writes":
|
test "writes replicate, followers reject, failover resumes writes":
|
||||||
if not fileExists(BinaryPath):
|
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)"
|
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
|
||||||
skip()
|
skip()
|
||||||
else:
|
else:
|
||||||
|
|||||||
+731
-8
@@ -1,6 +1,7 @@
|
|||||||
## BaraDB — Test Suite
|
## BaraDB — Test Suite
|
||||||
import std/unittest
|
import std/unittest
|
||||||
import std/tables
|
import std/tables
|
||||||
|
import std/sets
|
||||||
import std/strutils
|
import std/strutils
|
||||||
import std/os
|
import std/os
|
||||||
import std/asyncdispatch
|
import std/asyncdispatch
|
||||||
@@ -8,11 +9,13 @@ import std/asyncnet
|
|||||||
import std/monotimes
|
import std/monotimes
|
||||||
import std/base64
|
import std/base64
|
||||||
import std/json
|
import std/json
|
||||||
|
import std/streams
|
||||||
|
|
||||||
import barabadb/core/types
|
import barabadb/core/types
|
||||||
import barabadb/core/mvcc
|
import barabadb/core/mvcc
|
||||||
import barabadb/core/deadlock
|
import barabadb/core/deadlock
|
||||||
import barabadb/core/config
|
import barabadb/core/config
|
||||||
|
import barabadb/core/backup
|
||||||
import barabadb/core/server
|
import barabadb/core/server
|
||||||
import barabadb/core/columnar
|
import barabadb/core/columnar
|
||||||
import barabadb/core/raft
|
import barabadb/core/raft
|
||||||
@@ -1656,14 +1659,28 @@ suite "Replication":
|
|||||||
rm.connectReplica("r2")
|
rm.connectReplica("r2")
|
||||||
rm.connectReplica("r3")
|
rm.connectReplica("r3")
|
||||||
|
|
||||||
|
# Unreachable replicas cannot ack — semi-sync must fail closed (return 0)
|
||||||
let lsn = rm.writeLsn(@[1'u8])
|
let lsn = rm.writeLsn(@[1'u8])
|
||||||
check not rm.isFullyAcked(lsn) # needs 2 acks
|
check lsn == 0
|
||||||
|
|
||||||
rm.ackLsn("r1", lsn)
|
# No connected replicas → nothing to wait for; write succeeds
|
||||||
check not rm.isFullyAcked(lsn) # still needs 1 more
|
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)
|
# ackLsn bookkeeping still clears pendingAcks at the required quorum
|
||||||
check rm.isFullyAcked(lsn) # 2 acks received
|
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":
|
test "Replica status":
|
||||||
var rm = newReplicationManager(rmAsync)
|
var rm = newReplicationManager(rmAsync)
|
||||||
@@ -2607,6 +2624,613 @@ suite "Raft Network Transport":
|
|||||||
check replyMsg.kind == rmkRequestVoteReply
|
check replyMsg.kind == rmkRequestVoteReply
|
||||||
check replyMsg.success
|
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":
|
suite "Raft SQL Write Path":
|
||||||
test "leader append+commit wait round-trips through applyCommand":
|
test "leader append+commit wait round-trips through applyCommand":
|
||||||
proc scenario() =
|
proc scenario() =
|
||||||
@@ -2670,7 +3294,7 @@ suite "Raft SQL Write Path":
|
|||||||
|
|
||||||
# Server-side leader write path: append + wait for majority commit
|
# Server-side leader write path: append + wait for majority commit
|
||||||
let (ok, errMsg) = waitFor appendWriteToRaft(leader,
|
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
|
check ok
|
||||||
if not ok: echo "appendWriteToRaft failed: ", errMsg
|
if not ok: echo "appendWriteToRaft failed: ", errMsg
|
||||||
|
|
||||||
@@ -2760,6 +3384,53 @@ suite "Raft SQL Write Path":
|
|||||||
check n.lastSnapshotIndex == 0
|
check n.lastSnapshotIndex == 0
|
||||||
check n.log.len == 15
|
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":
|
test "appendDdlToRaft fails when node is not leader":
|
||||||
var n = newRaftNode("n1", @["n2"], raftPort = 29113)
|
var n = newRaftNode("n1", @["n2"], raftPort = 29113)
|
||||||
let (ok, err) = waitFor appendDdlToRaft(n,
|
let (ok, err) = waitFor appendDdlToRaft(n,
|
||||||
@@ -2782,7 +3453,7 @@ suite "Raft SQL Write Path":
|
|||||||
var n = newRaftNode("n1", @["n2"], raftPort = 29111)
|
var n = newRaftNode("n1", @["n2"], raftPort = 29111)
|
||||||
# Still a follower — appendLog returns index 0.
|
# Still a follower — appendLog returns index 0.
|
||||||
let (ok, err) = waitFor appendWriteToRaft(n,
|
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 not ok
|
||||||
check "lost leadership" in err
|
check "lost leadership" in err
|
||||||
|
|
||||||
@@ -2791,7 +3462,7 @@ suite "Raft SQL Write Path":
|
|||||||
var n = newRaftNode("n1", @["n2", "n3"], raftPort = 29112)
|
var n = newRaftNode("n1", @["n2", "n3"], raftPort = 29112)
|
||||||
n.becomeLeader()
|
n.becomeLeader()
|
||||||
let (ok, err) = waitFor appendWriteToRaft(n,
|
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 not ok
|
||||||
check "raft commit timeout" in err
|
check "raft commit timeout" in err
|
||||||
|
|
||||||
@@ -2816,6 +3487,43 @@ suite "Raft SQL Write Path":
|
|||||||
let (found, _) = db.get("t.id=1")
|
let (found, _) = db.get("t.id=1")
|
||||||
check not found
|
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":
|
test "applyReplicatedPut updates in-memory graphs":
|
||||||
var testDir = getTempDir() / "baradb_raft_apply_g_" & $getCurrentProcessId() & "_" & $getMonoTime().ticks
|
var testDir = getTempDir() / "baradb_raft_apply_g_" & $getCurrentProcessId() & "_" & $getMonoTime().ticks
|
||||||
createDir(testDir)
|
createDir(testDir)
|
||||||
@@ -3590,6 +4298,21 @@ suite "Window Functions":
|
|||||||
if row["name"] == "Bob":
|
if row["name"] == "Bob":
|
||||||
check row["last_sal"] == "90000"
|
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":
|
suite "GROUP BY Aggregates":
|
||||||
var db: LSMTree
|
var db: LSMTree
|
||||||
var ctx: qexec.ExecutionContext
|
var ctx: qexec.ExecutionContext
|
||||||
|
|||||||
Reference in New Issue
Block a user