Compare commits

75 Commits

Author SHA1 Message Date
dimgigov a843f0a1a3 fix(raft): gate snapshot build/restore, repoint http ctx, pre-tag docs
CI / test (push) Has been cancelled
CI / raft-e2e (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
2026-07-31 04:46:05 +03:00
dimgigov dac92d1741 release: prepare v1.3.0 changelog and version bump 2026-07-31 04:16:11 +03:00
dimgigov d303cc5658 docs: v1.3.0 raft-supported docs, limitations, runbook updates 2026-07-31 04:16:11 +03:00
dimgigov ad90ebcd5e test(raft): cold-node rejoin and wiped-node join e2e
Includes two raft fixes the e2e exposed:
- heartbeatLoop drops cached peer sockets on leadership acquisition
  (half-dead sockets to a restarted peer never errored, so no redial
  ever happened and the cluster livelocked without heartbeats)
- restoreSnapshot repoints tcpServer ctx/db at the reopened database
  (queries kept reading the closed pre-restore LSM, serving 0 rows)
2026-07-31 03:53:48 +03:00
dimgigov 9ff9c2f6be feat(raft): compaction unpinned from stale peers (snapshot fallback) 2026-07-31 03:12:06 +03:00
dimgigov c94bac43e5 fix(raft): ignore intermediate InstallSnapshot chunk replies 2026-07-31 03:02:32 +03:00
dimgigov 862d62590e feat(raft): leader InstallSnapshot send on unrecoverable lag 2026-07-31 02:53:30 +03:00
dimgigov efa04e4b36 feat(raft): follower InstallSnapshot receive and restore 2026-07-31 02:33:32 +03:00
dimgigov cb9cd7415d feat(raft): InstallSnapshot wire protocol (backward-compatible) 2026-07-31 02:12:51 +03:00
dimgigov f416fe930e test(raft): wire-level TLS handshake assertion in tls e2e 2026-07-31 02:05:43 +03:00
dimgigov f2b7ed1ce2 test(raft): 3-node TLS cluster e2e with plaintext rejection 2026-07-31 01:55:43 +03:00
dimgigov 2f30a59216 feat(raft): TLS on follower→leader SQL forwarding 2026-07-31 01:45:59 +03:00
dimgigov ed89c88afa feat(raft): optional TLS on raft transport (server + dialer) 2026-07-31 01:31:20 +03:00
dimgigov 8d083f5fdc feat(raft): TLS config surface with fail-closed startup 2026-07-31 01:14:20 +03:00
dimgigov efa46b05c6 ci(raft): dedicated mandatory raft e2e job; no silent skips under CI 2026-07-31 01:07:11 +03:00
dimgigov 431334b70a fix(raft): distinguish put-with-empty-value from delete in write path 2026-07-31 00:58:51 +03:00
dimgigov 63cb05afe2 test(raft): failover under sustained write load e2e 2026-07-31 00:35:55 +03:00
dimgigov 09f462f467 release: v1.2.0 Production GA (single-node)
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
- known-limitations, deployment runbook, release checklist
- prod compose requires JWT secret; BARADB_ENV=production fail-closed
- scripts/backup-restore-drill.sh (backup → wipe → restore → verify)
- version bump 1.2.0 (nimble, Dockerfile, health, CHANGELOG dated)
2026-07-30 21:52:21 +03:00
dimgigov c66276d72a docs: production GA v1.2.0 plan (single-node cut line)
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Define production as tagged release + auth prod compose + backup/restore
drill + runbook + known limitations. Raft multi-node stays experimental.
Task plan for agentic execution in docs/superpowers/plans/.
2026-07-30 21:45:16 +03:00
dimgigov 16ec8b5dc4 docs: raft cluster status, plans closed, CHANGELOG/README/monitoring
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
- Add raft-cluster-status overview (C3a/C3b/post-C3b shipped on main)
- Mark C3a/C3b design+plans done; refresh operator docs en/bg
- CHANGELOG 1.2.0 Raft section; README cluster example and status line
- monitoring.md health/metrics match real HTTP port+440 and raft series
2026-07-30 21:41:15 +03:00
dimgigov 1b3c26123a feat(raft): expose raft metrics on /metrics and /health
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
- RaftMetrics counters: elections, term changes, appends, commit waits/ms,
  timeouts, lost leadership, forwards, applies, compactions
- Gauges via prometheusText: is_leader, term, log size, commit/applied,
  apply lag, snapshot index
- Wire httpServer.raftNode; extend GET /metrics and GET /health
2026-07-30 21:38:44 +03:00
dimgigov 53704e1036 feat(raft): safe log compaction with snapshot metadata
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
- lastSnapshotIndex/Term bound the compacted prefix; lastLogIndex/Term
  and AppendEntries prevLog checks respect the snapshot base
- compactLog drops entries only through min(matchIndex, lastApplied) on
  the leader so lagging peers can still catch up via AppendEntries
- Persist snapshot fields in raft_state.bin; BARADB_RAFT_LOG_MAX_ENTRIES
- Fix commit-index scan to use findLogEntryByIndex (works after compact)
2026-07-30 21:35:41 +03:00
dimgigov 9df8316305 feat(raft): transparent leader write/DDL forwarding
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
- BARADB_RAFT_CLIENT_PEERS maps node id → SQL client host:port
- Followers proxy DML/DDL to the known leader over the wire protocol
  (falls back to "not leader" when the map is missing)
- E2E: follower CREATE/INSERT succeed via forward; docs updated
2026-07-30 21:31:05 +03:00
dimgigov 095698ba82 feat(raft): replicate schema DDL through the raft log
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
- isRaftDdl + leader-only gate for CREATE/DROP/ALTER (not DATABASE)
- appendDdlToRaft ships original SQL; applyCommand re-executes via
  applyReplicatedDdl (idempotent on leader double-apply)
- Mixed DDL+DML batches use the DDL path so order is preserved
- Fix secondary-index point lookup to use entry.lsmKey (not filter col)
- E2E: CREATE only on leader, schema + index SELECT on follower
2026-07-30 21:25:58 +03:00
dimgigov 50f827f8cf fix(raft): graph apply + reject non-default DB writes
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
- applyReplicatedPut/Delete keep in-memory graphs in sync with node/edge
  table rows (idempotent edges via addEdgeWithIdIfAbsent).
- When Raft is enabled, DML is refused on any database other than
  'default' (the only DB the state machine is wired to).
- Docs updated (en/bg); unit test for graph apply.
2026-07-30 21:19:01 +03:00
dimgigov 0d51497f57 fix(raft): multi-stmt write gate, rich apply, delete kv convention
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
- Reject follower writes if any statement in the batch is DML/COMMIT
  (not only stmts[0]).
- COMMIT always emits empty-valued kvPairs for isDelete entries.
- applyCommand updates LSM plus secondary B-tree/FTS/HNSW indexes
  (applyReplicatedPut/Delete) so follower index scans see replicated rows.
- Tests: not-leader append, commit timeout, index apply unit, E2E
  index-backed SELECT on follower.
2026-07-30 21:14:46 +03:00
dimgigov a462d21b25 docs: SQL writes through raft (C3b) done
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
Mark the C3b design done; document leader-only DML, env vars, and default-DB
apply limits in en/bg distributed docs; refresh the README raft status line.
2026-07-30 21:07:14 +03:00
dimgigov 44060701b7 test(raft): E2E replicated writes; wire SQL path to raft node
- Fix runTcpServer to run the already-wired Server (raftNode was assigned
  on a different instance that never accepted clients).
- Cap raft peer connect at 200ms and fan out heartbeats in parallel so a
  dead peer cannot stall AppendEntries to the live majority.
- Add raft_writes_e2e_test: 3-node write replication, follower rejection,
  and post-failover writes; wire into nimble test + gitignore.
2026-07-30 21:06:49 +03:00
dimgigov 333941ab65 feat(raft): leader appends writes to raft log and waits for commit 2026-07-30 19:36:16 +03:00
dimgigov 38c1c01841 feat(raft): classify writes, reject them on follower nodes 2026-07-30 19:07:58 +03:00
dimgigov 8a35a838d0 docs: SQL writes through raft (C3b) spec + plan 2026-07-30 18:58:47 +03:00
dimgigov f9cc68d4e6 chore(raft): ignore e2e test binary, stop raft network on shutdown
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
2026-07-30 18:46:09 +03:00
dimgigov b6eccf284e test(raft): end-to-end 3-node cluster election and failover 2026-07-30 18:35:18 +03:00
dimgigov f01586354a fix(raft): partial-read-safe frame reassembly 2026-07-30 18:01:53 +03:00
dimgigov 3f8537eaad feat(raft): run election timer in production, reset on AppendEntries 2026-07-30 17:47:05 +03:00
dimgigov 853ec7dd3b feat(raft): parse id@host:port peers, enable raft state persistence 2026-07-30 17:34:39 +03:00
dimgigov b5f9c1e798 docs: networked Raft bootstrap (C3a) spec + plan 2026-07-30 17:29:28 +03:00
dimgigov e8f9cbc5bb fix: CREATE UNIQUE INDEX actually enforces uniqueness (and persists)
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
2026-07-30 17:13:29 +03:00
dimgigov 5858a9da17 docs: UNIQUE index enforcement plan 2026-07-30 16:59:26 +03:00
dimgigov 8d2d97ad94 feat(persist): standalone B-tree indexes survive restart
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
2026-07-30 16:53:25 +03:00
dimgigov fcb6237caf docs: B-tree index persistence plan 2026-07-30 16:46:04 +03:00
dimgigov 8df0e02d3f docs: engine persistence (C1) done
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
2026-07-30 16:36:49 +03:00
dimgigov 821b668d87 fix(persist): unnamed FTS/HNSW indexes survive restart (nameless replay DDL) 2026-07-30 16:27:36 +03:00
dimgigov 214b9cf346 fix: DROP INDEX/TABLE clean up FTS/HNSW indexes and their schema keys 2026-07-30 16:20:23 +03:00
dimgigov ce6e7aa707 feat(persist): graphs survive restart (rebuild from backing tables) 2026-07-30 16:11:52 +03:00
dimgigov 214e44abd7 feat(persist): HNSW vector indexes survive restart 2026-07-30 16:05:25 +03:00
dimgigov 6703ca2b29 feat(persist): FTS indexes survive restart (schema key + restore replay) 2026-07-30 15:56:48 +03:00
dimgigov 9088dc1381 docs: engine persistence (C1) spec + implementation plan 2026-07-30 15:47:03 +03:00
dimgigov ba7cf195c6 chore: stop tracking compiled test binaries (bugfix_test, nimforum_smoke_test)
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
2026-07-30 15:36:41 +03:00
dimgigov 62504aa348 docs(exec): document module layering after executor split
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
2026-07-30 15:17:31 +03:00
dimgigov b282a59fb5 refactor(exec): slim executor.nim to dispatcher + hook wiring 2026-07-30 15:12:41 +03:00
dimgigov a5976f648a refactor(exec): extract IR plan execution into exec/plan_exec.nim 2026-07-30 15:02:34 +03:00
dimgigov a147d4b620 refactor(exec): extract window functions into exec/window.nim 2026-07-30 14:52:17 +03:00
dimgigov e618266325 refactor(exec): extract triggers/constraints into exec/triggers.nim 2026-07-30 14:43:49 +03:00
dimgigov 70c7297e33 refactor(exec): extract FK enforcement into exec/fk.nim 2026-07-30 14:35:15 +03:00
dimgigov 70b7ec7f08 refactor(exec): extract DML row operations into exec/dml.nim 2026-07-30 14:28:33 +03:00
dimgigov e28c1f4896 refactor(exec): extract table scans into exec/scan.nim 2026-07-30 14:19:11 +03:00
dimgigov f97e72314f refactor(exec): extract RLS/privileges into exec/rls.nim 2026-07-30 14:11:08 +03:00
dimgigov 26475058bf refactor(exec): extract AST->IR lowering into exec/lower.nim 2026-07-30 14:05:16 +03:00
dimgigov b5e9636fd2 refactor(exec): extract expression evaluation + hybrid search into exec/eval.nim 2026-07-30 13:52:56 +03:00
dimgigov 2b8cc98348 refactor(exec): extract migration storage into exec/migrations.nim 2026-07-30 13:41:52 +03:00
dimgigov 2efcddba19 refactor(exec): extract param binding into exec/params.nim 2026-07-30 13:36:39 +03:00
dimgigov 46e3d7f51e refactor(exec): extract join/vector helpers into exec/helpers.nim 2026-07-30 13:27:17 +03:00
dimgigov 08fb391ac1 refactor(exec): extract context management into exec/context.nim 2026-07-30 13:18:10 +03:00
dimgigov 2d09edd9f7 fix+refactor: soft keywords as identifiers, full test wiring, ORC crash docs
- parser: clause keywords (header, format, status, user, csv, ...) now work
  as identifiers everywhere; IMPORT/EXPORT accept FORMAT csv/HEADER true
- nimble test + CI run all 13 test suites (650 checks green)
- ExecutionContext.registry is now {.cursor.} (breaks registry<->ctx cycle)
- ORC crash reproduced and bisected (tests/orc_repro.py); ARC stays the MM
2026-07-30 13:11:28 +03:00
dimgigov ed5a71913c fix: harden exception handling, break ARC cycles, sync license/client
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
Replace bare except: with CatchableError across storage, query, Raft,
backup, and protocol code so Defects are not swallowed. Break uncollectable
ARC cycles in server shard/gossip callbacks via Server-owned refs and
cursor locals. Align package license with LICENSE (BSD-3-Clause), sync
README version, and point test_all at the canonical clients/nim baradb
client (parseConnectionString + aliases).
2026-07-23 01:07:51 +03:00
dimgigov 8db5cfe7e1 feat: harden storage, schema persistence, fair benches, fix wire crash
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
Core storage: hash MemTable, WAL group commit, L0 compaction rebuild,
reader-writer lock, and a global StorageGate so HTTP workers and TCP
share the LSM safely under multi-thread access.

Schema: durable CREATE/ALTER/DROP under _schema:tables:* with full LSM
restore on open. Executor types/values/schema split into query/exec/.

Wire protocol: switch default MM to ARC — ORC cycle collector segfaulted
after ~20 async INSERTs. Fair multi-tier benchmarks (SQLite/HTTP/wire/PG)
and honesty docs for mixed-tier comparisons.
2026-07-18 16:55:50 +03:00
dimgigov aa4ab11210 feat: canonical Nim client refactor with typed rows, pool, and allographer wrapper
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
- Extract wire protocol into clients/nim/src/baradb/wire.nim
- Add BaraError exception hierarchy
- Refactor BaraClient/SyncClient with typedRows, AsyncLock request queue, timeouts, TLS config
- Add BaraPool and optional HTTP fallback
- Add mock-server wire and pool unit tests
- Bump baradb nimble package to 1.2.0
- Make nim-allographer depend on canonical baradb client
- Use typed rows in allographer toJson
- Deprecate src/barabadb/client/client.nim
- Update docs/en/clients.md and clients/nim/README.md
2026-06-18 21:29:55 +03:00
dimgigov 1c42eff7ef fix(vector): use float64 accumulators for distance functions
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
Vector distance functions (cosine, euclidean, dot, manhattan) returned
float64 but accumulated sums in float32, causing precision loss in SQL
results (e.g. sqrt(2) truncated to float32). Use float64 accumulators
and casts throughout.

docs(bugs): mark BUG-023 and BUG-024 as fixed

The code already cleans up pendingAcks and passes oldAssignments from
rebalance to migrateData; update BUGS.md to match the implementation.
2026-06-12 23:06:19 +03:00
dimgigov ef264d7d69 feat: add unified search engine — HNSW heap-opt, segment index, boolean/phrase/ngram/facet
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
New src/barabadb/search/ module with 9 components:
- priority_queue.nim: BoundedHeap for O(log n) search
- hnsw_opt.nim: heap-based searchLayer (2.4x faster, 92-99% recall@10)
- inverted.nim: segment-based index with soft-delete and compaction
- phrase.nim: positional phrase + proximity search
- boolean.nim: recursive descent parser (AND/OR/NOT/ranges/wildcards)
- ngram.nim: trigram index for O(1) fuzzy/prefix/wildcard
- stemmer.nim: Porter2 stemmers (EN/BG/DE/FR/RU)
- facet.nim: faceted search with filter pushdown
- engine.nim: UnifiedSearchEngine combining all search types

Performance (dim=128, efConstruction=200):
  N=1K:   0.30ms search, 99.6% recall@10
  N=10K:  1.09ms search, 92.6% recall@10
  N=50K:  2.26ms search, 75.5% recall@10

Includes search benchmarks (benchmarks/search_bench.nim), updated docs
(en/bg fts.md, en/bg search.md), and crossmodal engine integration.
2026-05-30 13:42:08 +03:00
dimgigov 965ed2f675 perf: optimize FTS and HNSW engines + real PostgreSQL benchmarks
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
FTS Engine (src/barabadb/fts/engine.nim):
- Fix bm25Score doing O(n) linear scan per document
- Cache IDF per token instead of recomputing for each doc
- Use entry.termFreq directly instead of searching postings again
- Result: FTS search +438% (249 -> 1360 queries/s)

HNSW Vector Engine (src/barabadb/vector/engine.nim):
- Optimize distance functions with float32 + 4x loop unrolling
- Rewrite searchLayer: swap+pop instead of O(n) del, track worst-nearest
  instead of sorting nearest on every iteration
- Result: HNSW insert +117% (245 -> 543 ops/s), search 2.2x faster

Benchmarks:
- Add real PostgreSQL comparison script (benchmarks/pg_bench.py)
- Add report generator (benchmarks/generate_report.py)
- Fix compare.nim cpuTime() bug (was dividing by 1M incorrectly)
- Add nimble tasks: bench_pg, bench_report

Docs:
- Update README.md and docs/en/performance.md with real measured numbers
- Add benchmarks/REAL_COMPARISON.md

Version bump: 1.1.7 -> 1.1.8
2026-05-29 17:11:22 +03:00
dimgigov 42043f3946 v1.1.7: deep security & reliability audit — 33 bugs fixed
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
Critical (5):
- Reject empty JWT secret when authEnabled (server.nim)
- Fix 2PC marking uncontacted participants as prepared/committed (disttxn.nim)
- Fix Raft commit index calculation for even-sized clusters (raft.nim)
- Fix REP/DISTTXN protocol auth bypass (server.nim)
- Fix HTTP backup/restore path traversal (httpserver.nim)

High (11):
- Fix WAL write race with flush (lsm.nim)
- Fix MVCC savepoint/rollback deep-copy writeSet (mvcc.nim)
- Fix table mutation during deadlock iteration (mvcc.nim)
- Fix LIMIT 0 returning all rows (executor.nim)
- Fix COUNT(col) counting NULL values — 3 locations (executor.nim)
- Fix EXISTS subquery lowering missing subqueryPlan (executor.nim)
- Fix Raft appendEntries/applyCommitted array vs logical index (raft.nim)
- Fix timing attacks on constantTimeCompare and SCRAM (auth.nim, scram.nim)
- Fix B-tree leaf merge phantom separator key (btree.nim)
- Fix SSL verifyPeer not applied to newContext (ssl.nim)
- Fix sharding connectWithTimeout missing SO_ERROR check (sharding.nim)
- Fix sync replication returning success on partial ack (replication.nim)
- Fix WebSocket JWT expiration not validated (websocket.nim)

Medium (13):
- Fix writeSSTable partial file → tmp + atomic rename (lsm.nim)
- Fix multi-CTE table loss (executor.nim)
- Fix nl_to_sql DML restricted to superuser (executor.nim)
- Fix unbounded plan cache — max 10000 (adaptive.nim)
- Fix migration lock crash persistence — timestamp + stale detection (executor.nim)
- Fix admin panel auth (httpserver.nim)
- Fix MVCC unbounded txn tracking — prune in compactVersions (mvcc.nim)
- Fix connection pool maxLifetime check (pool.nim)
- Fix JWT JSON parser backslash escapes (auth.nim)
- Fix substr(s, start) returning single char (udf.nim)
- Fix loadSSTable minimum file-size check (lsm.nim)
- Fix compaction mmap leak (compaction.nim)
- Fix JSON injection in hybrid_search_filtered (executor.nim)

Low (4):
- Raft loadState logs error instead of silent discard
- Replication healthCheck double-close fixed
- Lexer readIdent double column counting fixed
- WebSocket frame 32-bit overflow guard

All 448 tests passing, 0 failures. Bump version to 1.1.7.
2026-05-29 14:17:41 +03:00
dimgigov 37a8ed52ba deps: bump jwt-nim-baraba to v2.1.2 (security fixes & Nim 2.2 compat) 2026-05-26 13:23:54 +03:00
dimgigov a5abb6031b fix(backup): fix 7 bugs in backup/restore — strip-components, multi-db path, verify+log in HTTP handler, parseBackupFilename for .tar, getArchiveSize gzip -l, dead code removal
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
2026-05-25 20:44:14 +03:00
dimgigov a37a62c69e clients/nim-allographer/src/allographer/query_builder/models/baradb/baradb_exec.nim src/barabadb/core/mvcc.nim src/barabadb/query/codegen.nim src/barabadb/query/lexer.nim src/barabadb/query/parser.nim src/barabadb/query/udf.nim tests/test_all.nim
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
2026-05-25 18:56:02 +03:00
147 changed files with 24530 additions and 6683 deletions
+25 -7
View File
@@ -32,7 +32,7 @@ jobs:
- name: Run tests
run: |
nim c -d:ssl --threads:on --path:src -r tests/test_all.nim > test_output.log 2>&1
nimble test > test_output.log 2>&1
EXIT=$?
tail -n 200 test_output.log
exit $EXIT
@@ -52,12 +52,6 @@ jobs:
- name: Compile benchmarks
run: nim c -d:release --threads:on benchmarks/bench_all.nim
- name: Compile stress test
run: nim c -d:ssl --threads:on --path:src tests/stress_test.nim
- name: Run stress test
run: ./tests/stress_test
- name: Check for unused declarations and imports
run: |
nim c -d:ssl --threads:on --path:src tests/test_all.nim 2>&1 | tee build.log || true
@@ -67,6 +61,30 @@ jobs:
done
echo "--- Done ---"
raft-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Nim
uses: jiro4989/setup-nim-action@v1
with:
nim-version: '2.2.10'
- name: Install system dependencies
run: sudo apt-get update -qq && sudo apt-get install -y -qq libssl-dev libpcre3-dev openssl ca-certificates
- name: Install Nim dependencies
run: nimble install --depsOnly -y
- name: Build server
run: nim c -d:ssl -o:build/baradadb src/baradadb.nim
- name: Raft e2e suites
env:
CI: "true"
run: |
nim c -d:ssl --threads:on --path:src -r tests/raft_e2e_test.nim
nim c -d:ssl --threads:on --path:src -r tests/raft_writes_e2e_test.nim
nim c -d:ssl --threads:on --path:src -r tests/raft_failover_load_e2e_test.nim
nim c -d:ssl --threads:on --path:src -r tests/raft_tls_e2e_test.nim
nim c -d:ssl --threads:on --path:src -r tests/raft_coldnode_e2e_test.nim
verify:
runs-on: ubuntu-latest
steps:
+26
View File
@@ -12,12 +12,20 @@ tests/test_minimal
tests/tla_faithfulness
tests/fuzz_test
tests/prop_test
tests/bugfix_test
tests/nimforum_smoke_test
tests/raft_e2e_test
tests/raft_writes_e2e_test
tests/raft_failover_load_e2e_test
tests/raft_tls_e2e_test
tests/raft_coldnode_e2e_test
benchmarks/bench_all
benchmarks/compare
clients/nim/tests/test_client
src/baradadb
src/barabadb/client/client
src/barabadb/core/raft
src/barabadb/core/backup
# Temp
*.tmp
@@ -47,3 +55,21 @@ src/barabadb/query/executor
tests/join_tests
*.tar.gz
tests/nimforum_smoke_test
benchmark_results.json
pg_benchmark_results.json
fair_benchmark_results.json
benchmarks/bench_all
benchmarks/compare
.qwen/
# Compiled test / module binaries
tests/test_schema_persist
tests/test_storage_hardening
tests/test_wire_insert_stress
src/barabadb/storage/lsm
src/barabadb/storage/wal
src/barabadb/storage/btree
src/barabadb/storage/gate
clients/nim/tests/test_pool
clients/nim/tests/test_wire
+4 -4
View File
@@ -78,11 +78,11 @@ Total: 55 bugs (7 critical, 21 high, 21 medium, 6 low)
### ~~BUG-022~~ :white_check_mark: `shipToReplica` socket leak
**File:** `src/barabadb/core/replication.nim:94-113`**FIXED:** Used `defer: sock.close()`.
### BUG-023 :x: `pendingAcks` never cleaned up in sync/semi-sync
**File:** `src/barabadb/core/replication.nim:131-164`**NOT FIXED:** Requires restructuring sync replication ack flow.
### ~~BUG-023~~ :white_check_mark: `pendingAcks` never cleaned up in sync/semi-sync
**File:** `src/barabadb/core/replication.nim:131-199`**FIXED:** `writeLsn` now removes acked replica IDs from `pendingAcks` and deletes the LSN entry once fully acked; `ackLsn` also cleans up on replica acknowledgement.
### BUG-024 :x: `rebalance` loses old assignments
**File:** `src/barabadb/core/sharding.nim:208-211`**NOT FIXED:** Requires passing old assignments to `migrateData`.
### ~~BUG-024~~ :white_check_mark: `rebalance` loses old assignments
**File:** `src/barabadb/core/sharding.nim:221-241`**FIXED:** `rebalance` returns the old shard assignments; callers (`addNode`/`removeNode`) pass them to `migrateData` for correct data migration.
### ~~BUG-025~~ :white_check_mark: `deserializeValue` missing bounds checks
**File:** `src/barabadb/protocol/wire.nim:216-227`**FIXED:** Added bounds checks for `fkBool`, `fkInt8`, `fkInt16`.
+181
View File
@@ -0,0 +1,181 @@
# Changelog
All notable changes to BaraDB are documented in this file.
## [1.3.0] — 2026-07-30
### Raft cluster — Supported (single `default` DB scope)
Raft 3-node moves from **Experimental** to **Supported** for the covered scope; single-node remains Production GA. Spec/plan: `docs/superpowers/specs/2026-07-30-raft-supported-design.md`, `docs/superpowers/plans/2026-07-30-v1.3.0-raft-supported.md`.
- **Failover under load proven** — `tests/raft_failover_load_e2e_test.nim`: sustained INSERT load, leader killed at ≥ 50 acked writes; every **acknowledged** write survives on both survivors. Client contract: in-flight writes during failover fail fast with an error — clients must retry ([distributed.md](docs/en/distributed.md))
- **Mandatory CI gate** — dedicated `raft-e2e` GitHub Actions job runs all five raft e2e suites; a missing server binary is a hard FAIL under CI (no silent skip)
- **Raft-port TLS** — `BARADB_RAFT_TLS_ENABLED` + `BARADB_RAFT_TLS_CERT_FILE` / `BARADB_RAFT_TLS_KEY_FILE` / `BARADB_RAFT_TLS_CA_FILE` / `BARADB_RAFT_TLS_VERIFY_PEER`; fail-closed startup when cert/key is missing; optional mutual auth; follower→leader SQL forwarding is TLS-wrapped when the client port is. E2E `tests/raft_tls_e2e_test.nim` (full-TLS cluster works; plaintext node excluded)
- **InstallSnapshot cold-node recovery** — backward-compatible wire protocol (`RaftProtoVersion` stays 1); the leader streams a `tar.gz` snapshot of the default DB in chunks of `BARADB_RAFT_SNAP_CHUNK_KB` KiB (default 256) to peers whose lag is unrecoverable; the follower restores via the backup/restore path and resumes from the snapshot base. Leader compaction unpins from peers stale beyond `BARADB_RAFT_PEER_STALE_MS` (default 30000). E2E `tests/raft_coldnode_e2e_test.nim` (returning node and wiped node converge automatically)
### Fixes
- **Raft put/delete encoding** — `ExecResult.keyValuePairs` carries an explicit `deleted` flag; an INSERT into a PK-only table (empty value) is no longer encoded as a `delete` and erased on apply; regression tests in `tests/bugfix_test.nim`
- **Rejoin livelock** — on leadership acquisition the leader drops cached peer sockets; half-dead sockets to a restarted peer previously never errored, so no redial ever happened and the cluster livelocked without heartbeats
- **Post-restore ctx repoint** — after an InstallSnapshot restore, the TCP serving ctx/db is repointed at the reopened database (queries previously read the closed pre-restore LSM and served 0 rows). Remaining limitation for startup-captured HTTP ctx: see [known-limitations](docs/en/known-limitations.md)
- **Intermediate InstallSnapshot chunk replies ignored** — the leader acts only on the final chunk reply
### Release
- `baradadb.nimble``1.3.0`; `/health` and startup version strings updated
- Docs: [distributed](docs/en/distributed.md) (failover contract, raft TLS setup, snapshot tunables), [known-limitations](docs/en/known-limitations.md) (raft supported scope + two newly documented limitations), [release-checklist](docs/en/release-checklist.md)
---
## [1.2.0] — 2026-07-30
### Production GA (single-node)
- **Scope** — single-node production tier; Raft multi-node documented as experimental ([known-limitations](docs/en/known-limitations.md))
- **Prod compose** — `docker-compose.prod.yml` requires `BARADB_JWT_SECRET` and enables auth; `BARADB_ENV=production` fails closed without secret
- **Backup drill** — `scripts/backup-restore-drill.sh` (backup → wipe → restore → verify)
- **Runbook** — start/stop/backup/restore in [deployment](docs/en/deployment.md)
- **Release checklist** — [docs/en/release-checklist.md](docs/en/release-checklist.md)
### Raft cluster (C3a / C3b / ops)
Production-ready path from config → election → SQL/DDL replication → ops.
- **C3a — Networked bootstrap** — `BARADB_RAFT_PEERS=id@host:port`, election timer in production, heartbeat timer reset on AppendEntries, `raft_state.bin` persistence, partial-read-safe frames; E2E `tests/raft_e2e_test.nim` (3-node election + failover)
- **C3b — SQL writes through Raft** — leader appends DML KV pairs and waits for majority commit; followers reject or **forward** via `BARADB_RAFT_CLIENT_PEERS`; apply path updates LSM + B-tree/FTS/HNSW + graphs; multi-statement write gate; writes only on `default` database
- **DDL replication** — schema DDL (`CREATE`/`DROP`/`ALTER` table, index, view, graph, …) as `ddl` log entries; re-executed on apply; `CREATE`/`DROP DATABASE` excluded
- **Leader write forwarding** — followers proxy DML/DDL to the leader SQL port when client peers are configured
- **Safe log compaction** — soft cap `BARADB_RAFT_LOG_MAX_ENTRIES` (default 256); leader never discards past peer `matchIndex`; snapshot base (`lastSnapshotIndex`/`Term`) persisted
- **Secondary-index point lookup fix** — index scans use `entry.lsmKey` (not the filter column as PK)
- **Metrics** — Prometheus raft series on `GET /metrics` (HTTP = TCP port + 440); `GET /health` includes `raft` role/term/leader/lag/log size
- **E2E writes** — `tests/raft_writes_e2e_test.nim` (schema, forward, index SELECT, failover)
- **Docs** — `docs/en|bg/distributed.md`, `docs/superpowers/specs/2026-07-30-raft-cluster-status.md`
### Core Storage Hardening
Foundational LSM improvements for write performance, durability, and compaction correctness.
- **Hash-table MemTable** (`storage/lsm.nim`) — O(1) put/get instead of O(n) sorted-seq insert; sort only on flush to SSTable
- **Timestamp-aware MemTable overwrite** — WAL recovery keeps newest version per key
- **WAL rewrite after flush** (`storage/wal.nim`) — `truncate` / `rewriteLive` so recovery is O(unflushed) not O(history); fsync on truncate/rewrite/close
- **WAL group commit** — `WalSyncMode`: `none` | `group` (default) | `every`; fsync every N entries and/or every interval ms
- **Config knobs** — `wal_sync_mode`, `wal_group_every`, `wal_sync_interval_ms` / env `BARADB_WAL_*`; registry opens DBs with config
- **L0 file-count compaction trigger** — RocksDB-style `L0CompactionTrigger` (default 4) instead of size-only for overlapping L0
- **`rebuildFromLSM` for compaction** (`storage/compaction.nim`) — strategy always syncs from live SSTable catalog (no drift after flush)
- **Safe mmap close after compaction** — release handles for compacted inputs after unlink
- **Recovery flag** — flush during WAL replay does not rewrite the open WAL file mid-read
- **Fair WAL micro-bench** — `benchWalDurabilityModes` compares none/group/every on the same workload
- **RwLock for LSM** (`storage/rwlock.nim`) — writer-preferring reader-writer lock; default API uses exclusive mode
- **Deep-copy on get** — returned values are copied so callers never share seq buffers with the store
- **`-d:baraConcurrentReads`** — opt-in shared read locks (unsafe with default ORC across OS threads)
- **`scanRange(start, end)`** — inclusive multi-level range scan (memtable + SSTables)
- **Focused tests** — `tests/test_storage_hardening.nim` (RwLock, WAL group, scanRange, stress)
- **Note:** Nim ORC must not share GC'd `LSMTree` refs across OS threads without isolation
- **StorageGate** (`storage/gate.nim`) — global exclusive lock serializing HTTP (Hunos workers), TCP, compaction, Raft apply
- **HTTP stop no longer closes shared registry** — ownership stays with main; `stop(closeStorage=true)` for standalone HTTP
- **Schema persistence** — stable keys `_schema:tables:<name>`; restore from full LSM (`scanAll`), not only memtable; DROP/ALTER update durable catalog; secondary index rebuild on open
- **Schema tests** — `tests/test_schema_persist.nim` (create/flush/reopen, drop, alter, multi-table)
- **Executor split** — `query/exec/{types,values,schema}.nim`; `executor.nim` re-exports for API stability; see `query/exec/README.md`
- **Fair benchmarks** — `benchmarks/fair_bench.py` multi-tier (embedded SQLite↔LSM; client-server HTTP/wire↔PG); batch multi-row INSERT; wire protocol via Python client; `generate_report.py --fair`; `nimble bench_fair`
- **Fix wire INSERT SIGSEGV** — ORC cycle collector crash under async TCP load; switch default MM to `--mm:arc` in `nim.cfg`; regression `tests/test_wire_insert_stress.nim`
- **Honest bench docs** — tier warnings in `bench_all`, `pg_bench`, `compare.nim`; `benchmarks/README.md`
- **Regression suite** — `Core Storage Hardening` tests in `tests/test_all.nim`
### Search Module (new)
A unified search module combining vector similarity, full-text, and structured
search into a single high-performance engine.
- **Heap-optimized HNSW search** — priority-queue-based candidate selection, 2.4x faster than baseline (`search/hnsw_opt.nim`)
- **Segment-based inverted indexing** — partitioned posting lists for concurrent indexing and reduced lock contention (`search/inverted.nim`)
- **Phrase and proximity search** — ordered phrase matching with configurable slop distance (`search/phrase.nim`)
- **Boolean query parser** — full boolean algebra with AND, OR, NOT, and range expressions (e.g. `price:[10 TO 100]`) (`search/boolean.nim`)
- **N-gram fuzzy search** — character n-gram index for typo-tolerant retrieval (`search/ngram.nim`)
- **Faceted search** — filter results and aggregate counts by arbitrary field values (`search/facet.nim`)
- **Porter2 stemmers** — morphological stemming for English, Bulgarian, German, French, and Russian (`search/stemmer.nim`)
- **UnifiedSearchEngine API** — single entry point combining all search modes with consistent scoring (`search/engine.nim`)
- **Search benchmarks** — reproducible performance measurement suite (`benchmarks/bench_search.nim`)
---
## [1.1.7] — 2026-05-29
### Security (5 critical + 5 high)
- **Fix REP/DISTTXN protocol auth bypass** (`server.nim`) — unauthenticated TCP clients could write data or manipulate distributed transactions
- **Fix HTTP backup/restore path traversal** (`httpserver.nim`) — `..` and absolute paths rejected
- **Fix empty JWT secret when auth enabled** (`server.nim`) — server now refuses to start with `authEnabled: true` and no `jwtSecret`
- **Fix HTTP admin panel served without auth** (`httpserver.nim`) — admin UI now requires authentication when `authEnabled`
- **Fix timing attacks on HMAC/SCRAM comparison** (`auth.nim`, `scram.nim`) — constant-time comparison
- **Fix WebSocket JWT expiration not validated** (`websocket.nim`) — `exp` claim now checked
- **Fix sync replication returning success on partial ack** (`replication.nim`) — returns 0 when not all replicas acknowledge
- **Fix SSL verifyPeer not applied** (`ssl.nim`) — `verifyMode` now passed to `newContext()`
- **Fix JWT JSON parser missing escape handling** (`auth.nim`) — backslash escapes now parsed correctly
### Data Integrity (3 critical + 3 high + 2 medium)
- **Fix WAL write race with flush** (`lsm.nim`) — WAL write now under `db.lock`, preventing data loss after crash
- **Fix 2PC marking uncontacted participants as prepared/committed** (`disttxn.nim`) — only contacted nodes are marked
- **Fix Raft commit index for even-sized clusters** (`raft.nim`) — correct majority calculation
- **Fix MVCC savepoint/rollback no-op** (`mvcc.nim`) — deep copy writeSet at savepoint time
- **Fix table mutation during iteration** (`mvcc.nim`) — collect stale txns before deleting
- **Fix B-tree leaf merge phantom separator key** (`btree.nim`) — no longer inserts empty-valued separator at leaf level
- **Fix writeSSTable partial file on crash** (`lsm.nim`) — write to `.tmp` then atomic rename
- **Fix compaction mmap leak** (`compaction.nim`) — close SSTables after reading
### Query Correctness (1 high + 2 medium)
- **Fix LIMIT 0 returning all rows** (`executor.nim`) — now returns empty result
- **Fix COUNT(col) counting NULL values** (`executor.nim`) — 3 locations fixed to check `v.kind != vkNull`
- **Fix EXISTS subquery always false** (`executor.nim`) — lowering now sets `existsSubquery` plan
- **Fix multi-CTE queries losing earlier CTE tables** (`executor.nim`) — save/restore `cteTables` around inner execution
- **Fix JSON injection in hybrid_search_filtered** (`executor.nim`) — escape quotes/backslashes in ID
### Raft Consensus (3 high + 1 low)
- **Fix Raft appendEntries using array index instead of log-index** (`raft.nim`) — uses `findLogEntryByIndex`
- **Fix Raft applyCommitted using logical index as array position** (`raft.nim`) — uses `findLogEntryByIndex`
- **Fix Raft loadState silently swallowing errors** (`raft.nim`) — now logs warning
### Storage Engine (2 medium)
- **Fix loadSSTable missing minimum file-size check** (`lsm.nim`) — rejects files < 40 bytes
- **Fix substr(s, start) returning single char** (`udf.nim`) — now returns rest-of-string
### Distributed Systems (2 high + 1 medium)
- **Fix sharding connectWithTimeout missing SO_ERROR check** (`sharding.nim`) — verifies connection actually succeeded
- **Fix replication healthCheck double-close socket** (`replication.nim`) — safe close with try/except
### Resource Management (3 medium)
- **Fix unbounded plan cache** (`adaptive.nim`) — max 10000 entries, auto-evict
- **Fix MVCC unbounded committedTxns/abortedTxns** (`mvcc.nim`) — prune entries older than oldest active snapshot
- **Fix connection pool not checking maxLifetime** (`pool.nim`) — lifetime check added to `acquire`
### Operations (1 medium)
- **Fix migration lock persisting after crash** (`executor.nim`) — stores timestamp, auto-releases after 1 hour
### Other
- **Fix nl_to_sql DML validation** (`executor.nim`) — requires `is_superuser` session variable for DML
- **Fix lexer readIdent double column counting** (`lexer.nim`) — removed manual `inc l.col`
- **Fix WebSocket frame 32-bit overflow** (`websocket.nim`) — guard against `len > high(int)`
- **Fix admin panel auth** (`httpserver.nim`) — check auth when `authEnabled`
- **Fix unused imports** (`backup.nim`, `repair.nim`, `raft.nim`) — moved `parseopt` into `when isMainModule`, removed unused `algorithm`
### Build
- **Fix hunos 1.3.1 compatibility with Nim 2.2.x** — patched `getRandomBytes``urandom` in `hunos/sessions.nim` and `hunos/csrf.nim` (see `HUNOS_ISSUE.md`)
- Updated `baradadb.nimble` version to `1.1.7`
### Tests
- All 448 tests passing, 0 failures
---
## [1.1.6] — previous
See git log for changes prior to this release.
+1 -1
View File
@@ -19,7 +19,7 @@ ARG VCS_REF
LABEL maintainer="BaraDB Team"
LABEL description="BaraDB — Multimodal Database Engine"
LABEL version="1.1.6"
LABEL version="1.2.0"
# Инсталираме runtime зависимости
# libpcre3 — нужна за Nim regex (зарежда се динамично)
+88
View File
@@ -0,0 +1,88 @@
# Bug Report: `hunos` 1.3.1 fails to compile on Nim 2.2.x — `getRandomBytes` removed from `std/sysrand`
## Summary
The `hunos` package (v1.3.1) fails to compile on Nim 2.2.10 with:
```
hunos/sessions.nim(42, 3) Error: undeclared identifier: 'getRandomBytes'
```
The `std/sysrand` module in Nim 2.2.x no longer exports `getRandomBytes`. The API was renamed to `urandom`.
## Affected files (3 locations)
### 1. `hunos/sessions.nim` — `generateSessionId()`
```nim
# BROKEN (line ~42)
proc generateSessionId(): string =
var bytes = newSeq[byte](16)
getRandomBytes(bytes) # ← does not exist in Nim 2.2
...
```
**Fix:**
```nim
proc generateSessionId(): string =
let bytes = urandom(16)
...
```
### 2. `hunos/sessions.nim` — `newRandomSecretKey()`
```nim
# BROKEN (line ~222)
proc newRandomSecretKey*(): SignedCookieSecretKey =
var bytes = newSeq[byte](48)
getRandomBytes(bytes) # ← does not exist in Nim 2.2
result.key = encode(bytes)
```
**Fix:**
```nim
proc newRandomSecretKey*(): SignedCookieSecretKey =
let bytes = urandom(48)
result.key = encode(bytes)
```
### 3. `hunos/csrf.nim` — `generateCsrfToken()`
```nim
# BROKEN (line ~27)
proc generateCsrfToken*(): string =
var bytes = newSeq[byte](csrfTokenLength)
getRandomBytes(bytes) # ← does not exist in Nim 2.2
...
```
**Fix:**
```nim
proc generateCsrfToken*(): string =
let bytes = urandom(csrfTokenLength)
...
```
## Environment
| Component | Version |
|-----------|---------|
| Nim | 2.2.10 |
| hunos | 1.3.1 |
| OS | Linux (amd64) |
## Root cause
`std/sysrand` in Nim 2.2.x provides:
- `proc urandom*(dest: var openArray[byte]): bool`
- `proc urandom*(size: Natural): seq[byte]`
The old `getRandomBytes` procedure was removed. All three call sites need to switch to `urandom`.
## Impact
Any project depending on `hunos >= 1.3.0, < 1.3.2` with Nim 2.2.x will fail to compile. This is a **build-breaking** issue.
## Workaround
Pin to `hunos >= 1.3.2` (which has the fix) or patch the three files locally as shown above.
+2
View File
@@ -155,6 +155,8 @@
| `PLAN_SQL_ADVANCED.md` — Window Functions, MERGE, etc. | ✅ Завършен |
| `PLAN_ID_GENERATORS.md` — AUTO_INCREMENT, Sequences, FK | ✅ Завършен |
| **Този план** — Сесии 10, 11, 12 | ✅ Завършен |
| Raft C3a/C3b + DDL/forward/compact/metrics (2026-07-30) | ✅ Завършен на `main``docs/superpowers/specs/2026-07-30-raft-cluster-status.md` |
| **Production GA v1.2.0** (single-node) | ✅ `docs/superpowers/plans/2026-07-30-production-ga.md` |
---
+118 -17
View File
@@ -4,7 +4,7 @@
**A multimodal database engine written in Nim — 100% native, zero dependencies.**
[![Version](https://img.shields.io/badge/version-1.1.6-blue.svg)](baradadb.nimble)
[![Version](https://img.shields.io/badge/version-1.1.8-blue.svg)](baradadb.nimble)
[![Documentation](https://img.shields.io/badge/docs-2_languages-blue.svg)](docs/index.md)
[![Stars](https://img.shields.io/github/stars/katehonz/barabaDB?style=social)](https://github.com/katehonz/barabaDB)
@@ -34,6 +34,7 @@ single 3.3MB binary with no runtime dependencies.
| Graph algorithms | None | **BFS, DFS, Dijkstra, PageRank, Louvain + Cypher** |
| Graph SQL integration | None | **CREATE GRAPH, GRAPH_TABLE(), SQL-native** |
| Full-text search | PG FTS extension | **Built-in BM25 + TF-IDF** |
| Unified Search Engine | None | **HNSW + inverted index + boolean + phrase + facets + stemmers** |
| AI Agents / NL→SQL | None | **Built-in `nl_to_sql()`, `schema_prompt()`** |
| MCP Server | None | **STDIO JSON-RPC for AI tools** |
| LangChain integration | External adapters | **Native Vector Store (Python + JS)** |
@@ -558,6 +559,54 @@ let fuzzy = idx.fuzzySearch("programing", maxDistance = 2)
let wild = idx.regexSearch("prog*")
```
### Unified Search Engine
A high-performance search module combining heap-optimized HNSW, segment-based
inverted indexing, boolean queries, phrase/proximity search, n-gram fuzzy
matching, faceted search, and multilingual stemming into a single
`UnifiedSearchEngine` API.
```nim
import barabadb/search/engine
var se = newUnifiedSearchEngine()
# Index documents with fields and facets
se.addDocument(1, "Introduction to Machine Learning",
fields = {"category": "AI", "lang": "en"}.toTable)
se.addDocument(2, "Deep Learning with Neural Networks",
fields = {"category": "AI", "lang": "en"}.toTable)
se.addDocument(3, "Nim Programming Language Guide",
fields = {"category": "programming", "lang": "en"}.toTable)
# Boolean query (AND / OR / NOT / ranges)
let boolResults = se.booleanSearch("machine AND learning")
# Phrase search with proximity
let phraseResults = se.phraseSearch("deep learning", slop = 2)
# N-gram fuzzy search (typo-tolerant)
let fuzzyResults = se.ngramSearch("machne lerning", n = 3)
# Faceted search — filter and aggregate by field values
let facetResults = se.facetedSearch("learning",
facetFields = @["category", "lang"])
# Stemming in multiple languages (Porter2: EN, BG, DE, FR, RU)
let stemmed = se.search("running", stemmer = porter2EN)
```
Features:
- **Heap-optimized HNSW** — priority-queue-based graph traversal, 2.4x faster than baseline
- **Segment-based inverted index** — partitioned posting lists for concurrent indexing
- **Phrase and proximity search** — ordered phrase matching with configurable slop
- **Boolean query parser** — AND, OR, NOT, range expressions (`price:[10 TO 100]`)
- **N-gram fuzzy search** — character n-gram index for typo-tolerant retrieval
- **Faceted search** — filter results and aggregate counts by field values
- **Porter2 stemmers** — English, Bulgarian, German, French, Russian
- **UnifiedSearchEngine API** — single entry point combining all search modes
- **Search benchmarks** — `benchmarks/bench_search.nim` for reproducible measurement
### Columnar Engine
Column-oriented storage for analytical queries.
@@ -684,6 +733,23 @@ let diff = s.diff(oldSchema, newSchema)
### Raft Consensus
3-node cluster over TCP (env-driven). SQL DML and schema DDL go through the
raft log on the **default** database. See **[docs/en/distributed.md](docs/en/distributed.md)**
for full env vars, forwarding, compaction, and metrics.
```bash
# Node n1 example
export BARADB_PORT=46010
export BARADB_RAFT_ENABLED=true
export BARADB_RAFT_NODE_ID=n1
export BARADB_RAFT_PORT=46101
export BARADB_RAFT_PEERS=n1@127.0.0.1:46101,n2@127.0.0.1:46102,n3@127.0.0.1:46103
export BARADB_RAFT_CLIENT_PEERS=n1@127.0.0.1:46010,n2@127.0.0.1:46020,n3@127.0.0.1:46030
export BARADB_DATA_DIR=./data/n1
./build/baradadb
# Health / metrics: HTTP on BARADB_PORT+440 → curl localhost:46450/health
```
```nim
import barabadb/core/raft
@@ -737,22 +803,39 @@ reg.register("greet", @[UDFParam(name: "name", typeName: "str")],
## Performance Benchmarks
BaraDB is optimized for high throughput across all storage engines. Below are
representative results on a modern desktop (AMD Ryzen 9, NVMe SSD):
**real measured results** on AMD Ryzen 9 5900X, NVMe SSD:
### BaraDB Standalone
| Engine | Operation | Throughput | Latency |
|--------|-----------|------------|---------|
| **LSM-Tree** | Write 100K keys | ~580K ops/s | 1.7 µs/op |
| **LSM-Tree** | Read 100K keys | ~720K ops/s | 1.4 µs/op |
| **B-Tree** | Insert 100K keys | ~1.2M ops/s | 0.8 µs/op |
| **B-Tree** | Point lookup 100K | ~1.5M ops/s | 0.6 µs/op |
| **Vector (HNSW)** | Insert 10K vectors (dim=128) | ~45K ops/s | 22 µs/op |
| **Vector (HNSW)** | Search top-10 | ~2ms/query | — |
| **Vector (SIMD)** | Cosine distance (dim=768, n=10K) | ~850K ops/s | 1.2 µs/op |
| **FTS** | Index 10K documents | ~320K docs/s | 3.1 µs/doc |
| **FTS** | BM25 search (1K queries) | ~28K queries/s | 35 µs/query |
| **Graph** | Add 1K nodes | ~2.5M nodes/s | 0.4 µs/node |
| **Graph** | BFS traversal (100×) | ~12K traversals/s | 83 µs/traversal |
| **Graph** | PageRank (1K nodes, 5K edges) | ~450 graphs/s | 2.2 ms/graph |
| **LSM-Tree** | Write 100K keys | ~32.2K ops/s | 31.0 µs/op |
| **LSM-Tree** | Read 100K keys | ~4.0M ops/s | 0.25 µs/op |
| **B-Tree** | Insert 100K keys | ~2.5M ops/s | 0.40 µs/op |
| **B-Tree** | Point lookup 100K | ~2.3M ops/s | 0.43 µs/op |
| **Vector (HNSW)** | Insert 10K vectors (dim=128) | ~543 ops/s | 1.8 ms/op |
| **Vector (HNSW)** | Search top-10 | ~2.6 ms/query | — |
| **Vector (SIMD)** | Cosine distance (dim=768, n=10K) | ~1.17M ops/s | 0.85 µs/op |
| **FTS** | Index 10K documents | ~120K docs/s | 8.3 µs/doc |
| **FTS** | BM25 search (1K queries) | ~1.36K queries/s | 0.73 ms/query |
| **Graph** | Add 1K nodes | ~931K nodes/s | 1.1 µs/node |
| **Graph** | BFS traversal (100×) | ~5.6K traversals/s | 179 µs/traversal |
| **Graph** | PageRank (1K nodes, 5K edges) | ~1.6K graphs/s | 6.1 ms/graph |
### BaraDB vs PostgreSQL (Real Comparison)
| Test | PostgreSQL | BaraDB | Speedup |
|------|-----------|--------|---------|
| KV Write (100K) | 16.82K/s | 33.24K/s | **2.0x** |
| KV Read (100K) | 15.08K/s | 3.88M/s | **257.0x** |
| BTree Insert (100K) | 17.66K/s | 2.50M/s | **141.6x** |
| BTree Get (100K) | 14.50K/s | 2.64M/s | **182.3x** |
| BTree Scan (1K ranges) | 2.39K/s | 7.97M/s | **3340.9x** |
| FTS Index (10K docs) | 17.98K/s | 123.65K/s | **6.9x** |
| FTS Search (1K queries) | 784.12/s | 1.34K/s | **1.7x** |
**Overall:** BaraDB is **6.8x faster** for in-process/embedded workloads.
*(Note: PostgreSQL includes network round-trip overhead. BaraDB now outperforms PostgreSQL on all tested metrics including FTS after optimizations.)*
Run benchmarks yourself:
@@ -1417,6 +1500,16 @@ src/barabadb/
├── fts/
│ ├── engine.nim # Inverted index + BM25 + TF-IDF
│ └── multilang.nim # Tokenizers for EN, BG, DE, FR, RU
├── search/
│ ├── engine.nim # UnifiedSearchEngine — single entry point
│ ├── hnsw_opt.nim # Heap-optimized HNSW (priority-queue traversal)
│ ├── inverted.nim # Segment-based inverted index
│ ├── phrase.nim # Phrase and proximity search
│ ├── boolean.nim # Boolean query parser (AND/OR/NOT/ranges)
│ ├── ngram.nim # N-gram fuzzy search
│ ├── facet.nim # Faceted search (field filtering + aggregation)
│ ├── stemmer.nim # Porter2 stemmers (EN/BG/DE/FR/RU)
│ └── priority_queue.nim # Min-heap priority queue for HNSW candidates
├── protocol/
│ ├── wire.nim # Binary wire protocol (16 message types)
│ ├── http.nim # HTTP/REST JSON router
@@ -1438,7 +1531,7 @@ src/barabadb/
## Tests
```bash
# Run all tests (340+ tests, 60+ suites)
# Run all tests (448 tests, 60+ suites)
nim c --path:src -r tests/test_all.nim
# Run benchmarks
@@ -1471,6 +1564,7 @@ nim c -d:release -r benchmarks/bench_all.nim
| MCP Server (STDIO JSON-RPC for AI agents) | ✅ | 100% | v1.1.6 |
| LangChain Vector Store (Python + JS) | ✅ | 100% | v1.1.6 |
| Production Hardening (prop tests, fuzz tests, thread safety) | ✅ | 100% | v1.1.6 |
| Unified Search Engine (HNSW-opt + inverted + boolean + phrase + n-gram + facets + stemmers) | ✅ | 100% | v1.2.0 |
## Current Limitations
@@ -1482,13 +1576,20 @@ features are still being refined:
| LSM-Tree SSTable reads | ✅ Implemented | Full disk I/O with compaction, WAL, and bloom filters. |
| HNSW vector search | ✅ Implemented | Hierarchical graph navigation with SIMD-optimized distance metrics. |
| TCP server execution | ✅ Implemented | Full binary wire protocol parsing and BaraQL query execution. |
| Raft consensus | ✅ Core logic | Full Raft algorithm with log replication; network transport pluggable. |
| Graph / FTS / Columnar | ✅ Implemented | In-memory engines with serialization; persistence layer optional. |
| Raft consensus | ✅ Supported (3-node, `default` DB) | TCP election + SQL/DDL via log; failover under load, raft TLS, InstallSnapshot recovery e2e-proven. See `docs/en/known-limitations.md`. |
| Graph / FTS / Columnar | ✅ Implemented | In-memory engines with serialization; FTS/vector/graph indexes persist across restarts. |
| Query codegen | ✅ Implemented | IR plans compile to storage engine operations with optimization passes. |
All core functionality is complete and production-tested. The roadmap above
reflects 100% completion across all major phases.
## Changelog
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)
## License
BSD 3-Clause License
+28 -5
View File
@@ -1,8 +1,8 @@
# Package
version = "1.1.6"
version = "1.3.0"
author = "BaraDB Team"
description = "BaraDB — Multimodal database written in Nim"
license = "Apache-2.0"
license = "BSD-3-Clause"
srcDir = "src"
bin = @["baradadb", "baramcp"]
binDir = "build"
@@ -10,7 +10,7 @@ binDir = "build"
# Dependencies
requires "nim >= 2.2.0"
requires "https://github.com/katehonz/hunos >= 1.3.0"
requires "https://github.com/katehonz/jwt-nim-baraba >= 2.1.0"
requires "https://github.com/katehonz/jwt-nim-baraba#fbe084b" # v2.1.2 - security fixes & Nim 2.2 compat
requires "checksums >= 0.2.0"
# Tasks
@@ -19,11 +19,34 @@ task build_debug, "Build debug version":
exec "nim c --debugger:native --linedir:on -o:build/baramcp src/baramcp.nim"
task build_release, "Build release version":
# mm:arc comes from nim.cfg (ORC crashes under wire INSERT load)
exec "nim c -d:release --opt:speed -o:build/baradadb src/baradadb.nim"
exec "nim c -d:release --opt:speed -o:build/baramcp src/baramcp.nim"
task test, "Run all tests":
exec "nim c -r tests/test_all.nim"
# Smoke test talks to ./build/baradadb over TCP — build it first.
exec "nim c -o:build/baradadb src/baradadb.nim"
# Quick embedded suites first, heavy fuzz/stress suites last.
for t in ["test_minimal", "test_all", "bugfix_test", "join_tests", "test_lock",
"test_schema_persist", "test_storage_hardening", "tla_faithfulness",
"nimforum_smoke_test", "raft_e2e_test", "raft_writes_e2e_test",
"raft_failover_load_e2e_test", "raft_tls_e2e_test",
"raft_coldnode_e2e_test",
"fuzz_test", "prop_test",
"test_wire_insert_stress", "stress_test"]:
exec "nim c -r tests/" & t & ".nim"
task bench, "Run benchmarks":
task bench, "Run embedded micro-benchmarks (in-process)":
exec "nim c -d:release -r benchmarks/bench_all.nim"
task bench_pg, "Run PostgreSQL client-server micro-benchmarks":
exec "python3 benchmarks/pg_bench.py"
task bench_fair, "Fair multi-tier benches (SQLite embedded + optional PG/HTTP)":
exec "python3 benchmarks/fair_bench.py"
task bench_report, "Generate fair comparison report (needs fair_bench first)":
exec "python3 benchmarks/generate_report.py --fair"
task bench_report_legacy, "Legacy mixed-tier report (PG C/S vs BaraDB embedded — unfair)":
exec "python3 benchmarks/generate_report.py"
+93
View File
@@ -0,0 +1,93 @@
# Fair Benchmark Results
Generated: **2026-07-18 13:53:46 UTC**
## Methodology
- Tier `embedded`: in-process only (BaraDB LSM from nimble bench JSON; SQLite via Python sqlite3).
- Tier `client_server`: network SQL (BaraDB HTTP /query; BaraDB binary wire TCP; PostgreSQL via psycopg2).
- `sql_insert_row`: one INSERT statement per row (chatty).
- `sql_insert_batch`: multi-row INSERT with batch size 50 (same SQL style across systems).
- PostgreSQL: synchronous_commit=on|off; SQLite: PRAGMA synchronous FULL|OFF.
- BaraDB WAL modes appear only if you ran benchmarks/bench_all.nim (WAL-* rows).
- Never claim 'Nx faster than Postgres' using embedded BaraDB numbers.
**Do not compare numbers across tiers.** Embedded storage is not the same
workload as client-server SQL over the network.
## Tier: `embedded`
| Bench | System | ops/s | seconds | n | notes |
|-------|--------|------:|--------:|--:|-------|
| kv_write | `baradb_lsm_embedded` | 41.50K | 2.410 | 100000 | benchmark_results.json |
| kv_read | `baradb_lsm_embedded` | 3.77M | 0.026 | 100000 | benchmark_results.json |
| wal_none | `baradb_lsm_embedded` | 232.65K | 0.215 | 50000 | benchmark_results.json |
| wal_group64 | `baradb_lsm_embedded` | 42.25K | 1.183 | 50000 | benchmark_results.json |
| wal_group256 | `baradb_lsm_embedded` | 107.16K | 0.467 | 50000 | benchmark_results.json |
| wal_every | `baradb_lsm_embedded` | 825.27 | 60.586 | 50000 | benchmark_results.json |
| kv_write | `sqlite_off` | 402.61K | 0.002 | 1000 | PRAGMA synchronous=OFF |
| kv_read | `sqlite_off` | 195.60K | 0.005 | 1000 | |
| sql_insert_batch | `sqlite_off` | 559.66K | 0.002 | 1000 | multi-row INSERT batch=50, sync=OFF |
| kv_write | `sqlite_full` | 272.62K | 0.004 | 1000 | PRAGMA synchronous=FULL |
| kv_read | `sqlite_full` | 196.16K | 0.005 | 1000 | |
| sql_insert_batch | `sqlite_full` | 370.18K | 0.003 | 1000 | multi-row INSERT batch=50, sync=FULL |
### Same-bench ratios (`embedded`)
**kv_read** (fastest: `baradb_lsm_embedded` @ 3.77M/s)
| System | Relative to fastest |
|--------|--------------------:|
| `baradb_lsm_embedded` | 1.00x |
| `sqlite_full` | 0.05x |
| `sqlite_off` | 0.05x |
**kv_write** (fastest: `sqlite_off` @ 402.61K/s)
| System | Relative to fastest |
|--------|--------------------:|
| `sqlite_off` | 1.00x |
| `sqlite_full` | 0.68x |
| `baradb_lsm_embedded` | 0.10x |
**sql_insert_batch** (fastest: `sqlite_off` @ 559.66K/s)
| System | Relative to fastest |
|--------|--------------------:|
| `sqlite_off` | 1.00x |
| `sqlite_full` | 0.66x |
## Tier: `client_server`
| Bench | System | ops/s | seconds | n | notes |
|-------|--------|------:|--------:|--:|-------|
| sql_insert_row | `baradb_http` | 979.31 | 0.511 | 500 | |
| sql_select_row | `baradb_http` | 235.20 | 2.126 | 500 | |
| sql_insert_batch | `baradb_http` | 10.19K | 0.049 | 500 | multi-row INSERT batch=50 |
| sql_insert_row | `baradb_wire` | 4.65K | 0.108 | 500 | binary wire protocol |
| sql_select_row | `baradb_wire` | 642.04 | 0.779 | 500 | |
| sql_insert_batch | `baradb_wire` | 20.47K | 0.024 | 500 | multi-row INSERT batch=50 |
### Same-bench ratios (`client_server`)
**sql_insert_batch** (fastest: `baradb_wire` @ 20.47K/s)
| System | Relative to fastest |
|--------|--------------------:|
| `baradb_wire` | 1.00x |
| `baradb_http` | 0.50x |
**sql_insert_row** (fastest: `baradb_wire` @ 4.65K/s)
| System | Relative to fastest |
|--------|--------------------:|
| `baradb_wire` | 1.00x |
| `baradb_http` | 0.21x |
**sql_select_row** (fastest: `baradb_wire` @ 642.04/s)
| System | Relative to fastest |
|--------|--------------------:|
| `baradb_wire` | 1.00x |
| `baradb_http` | 0.37x |
+80
View File
@@ -0,0 +1,80 @@
# BaraDB Benchmarks
## Tiers (read this first)
| Tier | What is measured | Fair peers |
|------|------------------|------------|
| **embedded** | In-process storage API | BaraDB LSM ↔ SQLite |
| **client_server** | Network + query protocol | BaraDB HTTP / **wire** ↔ PostgreSQL |
**Never** quote “BaraDB is Nx faster than Postgres” using embedded LSM numbers.
That comparison mixes tiers and is meaningless as a product claim.
## Quick start
```bash
# 1) Embedded micro-benches (Nim, in-process)
nimble bench
# or: nim c -d:release -r benchmarks/bench_all.nim
# 2) Optional: start server for client_server tier (HTTP + wire)
./build/baradadb
# 3) Fair multi-tier suite (Python)
# - always: SQLite embedded (+ batch)
# - optional: BaraDB HTTP (:9912), wire TCP (:9472), PostgreSQL
python3 benchmarks/fair_bench.py
# 3) Markdown report
nimble bench_report
# or: python3 benchmarks/generate_report.py --fair
```
Outputs:
- `benchmark_results.json` — Nim embedded suite
- `fair_benchmark_results.json` — multi-tier fair suite
- `benchmarks/FAIR_COMPARISON.md` — human-readable fair report
- `pg_benchmark_results.json` — optional PG-only micro suite
## Environment
| Variable | Default | Meaning |
|----------|---------|---------|
| `FAIR_N_KV` | 20000 | embedded KV ops |
| `FAIR_N_SQL` | 5000 | SQL loops (HTTP/wire/PG) |
| `FAIR_BATCH` | 100 | multi-row INSERT batch size |
| `BARADB_HTTP_HOST` | 127.0.0.1 | HTTP host |
| `BARADB_HTTP_PORT` | 9912 | HTTP port (`TCP+440`) |
| `BARADB_WIRE_HOST` | 127.0.0.1 | wire protocol host |
| `BARADB_WIRE_PORT` | 9472 | wire protocol TCP port |
| `FAIR_SKIP_HTTP=1` | — | skip BaraDB HTTP |
| `FAIR_SKIP_WIRE=1` | — | skip BaraDB wire |
| `FAIR_SKIP_PG=1` | — | skip PostgreSQL |
| `PGHOST` / `PGUSER` / `PGPASSWORD` / … | — | libpq-style |
## Files
| File | Role |
|------|------|
| `bench_all.nim` | Embedded: LSM, WAL modes, BTree, vector, FTS, graph |
| `fair_bench.py` | Fair multi-tier runner + markdown |
| `pg_bench.py` | PostgreSQL client-server micro suite |
| `generate_report.py` | `--fair` report; legacy mixed report without flag |
| `compare.nim` | **Synthetic** — do not publish |
| `search_bench.nim` | Search-focused micro suite |
## Durability knobs
- BaraDB: `wal_sync_mode` = `none` \| `group` \| `every` (see WAL-* rows from `bench_all`)
- SQLite: `PRAGMA synchronous = OFF` vs `FULL`
- PostgreSQL: `synchronous_commit = off` vs `on`
Match durability stories when claiming write speedups.
## Wire protocol note
The Python wire client (`clients/python`) is exercised by `fair_bench.py`.
Builds use **`--mm:arc`** (see `nim.cfg`) because Nim **ORC** cycle collection
crashed under async wire INSERT load (`markGray` SIGSEGV). With ARC, sequential
wire INSERTs + batch multi-row INSERT are stable.
+29
View File
@@ -0,0 +1,29 @@
# Legacy mixed-tier comparison
This file used to claim large “speedups” of BaraDB over PostgreSQL by comparing:
- **PostgreSQL:** client-server (psycopg2, network, SQL)
- **BaraDB:** in-process LSM (no network, no SQL)
That is **not a fair product comparison**.
## Use the fair suite instead
```bash
nim c -d:release -r benchmarks/bench_all.nim # embedded BaraDB
python3 benchmarks/fair_bench.py # SQLite + optional PG/HTTP
# report → benchmarks/FAIR_COMPARISON.md
```
See:
- [`FAIR_COMPARISON.md`](FAIR_COMPARISON.md) — latest multi-tier results
- [`README.md`](README.md) — methodology and env vars
## If you regenerate the legacy report
```bash
python3 benchmarks/generate_report.py # without --fair
```
It will rewrite this file with an explicit **mixed tiers** warning banner.
+42 -2
View File
@@ -111,9 +111,11 @@ proc formatOps(ops: int, secs: float64): string =
proc benchLSMTree() =
echo "=== LSM-Tree Storage ==="
echo " Note: in-process embedded API (no network/SQL). Not comparable to client-server DBs."
let benchDir = getTempDir() / "baradb_bench_lsm"
removeDir(benchDir)
var db = newLSMTree(benchDir)
# Default group-commit WAL (production default)
var db = newLSMTree(benchDir, walSyncMode = wsmGroup, walGroupEvery = 64)
# Write benchmark
let n = 100_000
@@ -124,6 +126,7 @@ proc benchLSMTree() =
let writeLabel = "LSM-Write"
recordResult(writeLabel, n, writeTime)
echo " Write ", n, " keys: ", writeTime.formatFloat(ffDecimal, 3), "s (", formatOps(n, writeTime), ")", compareResult(writeLabel, currentResults[^1].opsPerSec, previousResults)
echo " fsyncs: ", db.wal.fsyncCount, " (group every 64)"
# Read benchmark
let readStart = getMonoTime()
@@ -138,6 +141,36 @@ proc benchLSMTree() =
db.close()
proc benchWalDurabilityModes() =
## Fair comparison of WAL durability policies on the same workload.
echo "=== WAL Durability Modes (fair micro-bench) ==="
echo " Same N puts, same memtable size; only sync policy differs."
let n = 50_000
let modes = [
(wsmNone, "none", 0),
(wsmGroup, "group64", 64),
(wsmGroup, "group256", 256),
(wsmEvery, "every", 1),
]
for (mode, label, ge) in modes:
let dir = getTempDir() / ("baradb_bench_wal_" & label)
removeDir(dir)
var db = newLSMTree(dir, memMaxSize = 64 * 1024 * 1024,
walSyncMode = mode, walGroupEvery = max(1, ge))
let t0 = getMonoTime()
for i in 0..<n:
db.put("k" & $i, cast[seq[byte]]("v" & $i))
# Ensure pending group is durable before measuring end-to-end
db.wal.sync()
let secs = elapsed(t0)
let name = "WAL-" & label
recordResult(name, n, secs)
echo " ", label, ": ", secs.formatFloat(ffDecimal, 3), "s (",
formatOps(n, secs), "), fsyncs=", db.wal.fsyncCount,
compareResult(name, currentResults[^1].opsPerSec, previousResults)
db.close()
removeDir(dir)
proc benchBTree() =
echo "=== B-Tree Index ==="
var btree = newBTreeIndex[string, string]()
@@ -331,11 +364,17 @@ proc benchGraph() =
proc main() =
echo ""
echo "╔══════════════════════════════════════════════════╗"
echo " BaraDB Performance Benchmarks "
echo "║ BaraDB Performance Benchmarks (EMBEDDED)"
echo "╚══════════════════════════════════════════════════╝"
echo ""
echo "Tier: embedded / in-process (no network, no wire SQL)."
echo "For fair multi-tier numbers (SQLite / PG / HTTP):"
echo " python3 benchmarks/fair_bench.py"
echo ""
benchLSMTree()
echo ""
benchWalDurabilityModes()
echo ""
benchBTree()
echo ""
benchVectorSearch()
@@ -355,6 +394,7 @@ proc main() =
)
saveResults(ResultsFile, report)
echo "Results saved to ", ResultsFile
echo "Next: python3 benchmarks/fair_bench.py"
echo ""
when isMainModule:
+17 -10
View File
@@ -1,4 +1,11 @@
## Comparative Benchmarks — BaraDB vs PostgreSQL, Redis, MongoDB
##
## ⚠️ SYNTHETIC / PLACEHOLDER: several refTimeSec values are *invented*
## multipliers, not measured. Do not publish these as real comparisons.
## Use instead:
## nim c -d:release -r benchmarks/bench_all.nim
## python3 benchmarks/fair_bench.py
## python3 benchmarks/generate_report.py --fair
import std/times
import std/random
import std/strutils
@@ -30,7 +37,7 @@ template benchBlock(name: string, body: untyped): BenchmarkResult =
block:
let start = cpuTime()
body
let elapsed = (cpuTime() - start) / 1_000_000.0 # microseconds to seconds
let elapsed = (cpuTime() - start)
BenchmarkResult(name: name, baraTimeSec: elapsed)
proc kvWriteBench(n: int = 100_000): BenchmarkResult =
@@ -39,7 +46,7 @@ proc kvWriteBench(n: int = 100_000): BenchmarkResult =
let start = cpuTime()
for i in 0..<n:
db.put("key_" & $i, cast[seq[byte]]("value_" & $i))
let elapsed = (cpuTime() - start) / 1_000_000.0 # microseconds to seconds
let elapsed = (cpuTime() - start)
db.close()
result = BenchmarkResult(
name: "KV Write (" & $n & " records)",
@@ -59,7 +66,7 @@ proc kvReadBench(n: int = 50_000): BenchmarkResult =
for i in 0..<n:
let (ok, _) = db.get("key_" & $i)
if ok: inc found
let elapsed = (cpuTime() - start) / 1_000_000.0 # microseconds to seconds
let elapsed = (cpuTime() - start)
db.close()
result = BenchmarkResult(
name: "KV Read (" & $n & " reads)",
@@ -74,7 +81,7 @@ proc btreeInsertBench(n: int = 100_000): BenchmarkResult =
let start = cpuTime()
for i in 0..<n:
btree.insert("key_" & $i, "value_" & $i)
let elapsed = (cpuTime() - start) / 1_000_000.0 # microseconds to seconds
let elapsed = (cpuTime() - start)
result = BenchmarkResult(
name: "B-Tree Insert (" & $n & " keys)",
baraOps: n, baraTimeSec: elapsed,
@@ -93,7 +100,7 @@ proc btreeScanBench(n: int = 1000): BenchmarkResult =
for i in 0..<n:
let results = btree.scan("key_1000", "key_2000")
total += results.len
let elapsed = (cpuTime() - start) / 1_000_000.0 # microseconds to seconds
let elapsed = (cpuTime() - start)
result = BenchmarkResult(
name: "B-Tree Scan (" & $n & " range scans)",
baraOps: n, baraTimeSec: elapsed,
@@ -119,7 +126,7 @@ proc vectorSearchBench(n: int = 5_000, dim: int = 128): BenchmarkResult =
let start = cpuTime()
for i in 0..<searchN:
discard idx.search(query, 10)
let elapsed = (cpuTime() - start) / 1_000_000.0 # microseconds to seconds
let elapsed = (cpuTime() - start)
result = BenchmarkResult(
name: "Vector Search (HNSW, " & $dim & "d, " & $searchN & " queries)",
baraOps: searchN, baraTimeSec: elapsed,
@@ -140,7 +147,7 @@ proc ftsIndexBench(n: int = 10_000): BenchmarkResult =
let start = cpuTime()
for i in 0..<n:
idx.addDocument(uint64(i), docs[i mod docs.len])
let elapsed = (cpuTime() - start) / 1_000_000.0 # microseconds to seconds
let elapsed = (cpuTime() - start)
result = BenchmarkResult(
name: "FTS Index (" & $n & " docs)",
baraOps: n, baraTimeSec: elapsed,
@@ -157,7 +164,7 @@ proc ftsSearchBench(n: int = 500): BenchmarkResult =
let start = cpuTime()
for i in 0..<n:
discard idx.search("programming language")
let elapsed = (cpuTime() - start) / 1_000_000.0 # microseconds to seconds
let elapsed = (cpuTime() - start)
result = BenchmarkResult(
name: "FTS Search (" & $n & " queries)",
baraOps: n, baraTimeSec: elapsed,
@@ -180,7 +187,7 @@ proc graphBench(n: int = 1000, edges: int = 5000): BenchmarkResult =
let start = cpuTime()
for i in 0..<traversals:
discard gengine.bfs(g, NodeId(1))
let elapsed = (cpuTime() - start) / 1_000_000.0 # microseconds to seconds
let elapsed = (cpuTime() - start)
result = BenchmarkResult(
name: "Graph BFS Traversal (" & $traversals & " traversals)",
baraOps: traversals, baraTimeSec: elapsed,
@@ -200,7 +207,7 @@ proc simdVectorBench(dim: int = 768, n: int = 50_000): BenchmarkResult =
let start = cpuTime()
for i in 0..<n:
discard cosineSimd(a, b)
let elapsed = (cpuTime() - start) / 1_000_000.0 # microseconds to seconds
let elapsed = (cpuTime() - start)
result = BenchmarkResult(
name: "SIMD Cosine Distance (" & $dim & "d, " & $n & " ops)",
baraOps: n, baraTimeSec: elapsed,
+799
View File
@@ -0,0 +1,799 @@
#!/usr/bin/env python3
"""
Fair multi-tier benchmarks for BaraDB.
Tiers (never mix across tiers in a single "speedup" claim):
1. embedded — in-process storage (BaraDB LSM from JSON, SQLite)
2. client_server — network + SQL
• BaraDB HTTP REST
• BaraDB wire protocol (Python async client, TCP 9472)
• PostgreSQL (psycopg2)
Within each tier we measure both **row-at-a-time** and **batch multi-row INSERT**.
Usage:
# 1) optional: run BaraDB embedded micro-benches first
nim c -d:release -r benchmarks/bench_all.nim
# 2) start server for HTTP/wire tiers (optional)
./build/baradadb
# 3) fair suite
python3 benchmarks/fair_bench.py
# 4) markdown report
python3 benchmarks/generate_report.py --fair
Env:
BARADB_HTTP_HOST default 127.0.0.1
BARADB_HTTP_PORT default 9912 (TCP 9472 + 440)
BARADB_WIRE_HOST default 127.0.0.1
BARADB_WIRE_PORT default 9472
PGHOST / PGPORT / PGDATABASE / PGUSER / PGPASSWORD
FAIR_N_KV default 20000
FAIR_N_SQL default 5000
FAIR_BATCH default 100 (rows per multi-row INSERT)
FAIR_SKIP_PG=1 skip PostgreSQL
FAIR_SKIP_HTTP=1 skip BaraDB HTTP
FAIR_SKIP_WIRE=1 skip BaraDB wire protocol
"""
from __future__ import annotations
import asyncio
import json
import os
import sqlite3
import sys
import tempfile
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
OUT_JSON = ROOT / "fair_benchmark_results.json"
BARA_JSON = ROOT / "benchmark_results.json"
CLIENTS_PY = ROOT / "clients" / "python"
N_KV = int(os.environ.get("FAIR_N_KV", "20000"))
N_SQL = int(os.environ.get("FAIR_N_SQL", "5000"))
BATCH = int(os.environ.get("FAIR_BATCH", "100"))
HTTP_HOST = os.environ.get("BARADB_HTTP_HOST", "127.0.0.1")
HTTP_PORT = int(os.environ.get("BARADB_HTTP_PORT", "9912"))
WIRE_HOST = os.environ.get("BARADB_WIRE_HOST", "127.0.0.1")
WIRE_PORT = int(os.environ.get("BARADB_WIRE_PORT", "9472"))
def now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
def result(name: str, system: str, tier: str, ops: int, seconds: float, **extra):
ops_s = ops / seconds if seconds > 0 else 0.0
r = {
"name": name,
"system": system,
"tier": tier,
"ops": ops,
"seconds": seconds,
"opsPerSec": ops_s,
"timestamp": now_iso(),
}
r.update(extra)
return r
def fmt_ops(x: float) -> str:
if x >= 1_000_000:
return f"{x/1_000_000:.2f}M"
if x >= 1_000:
return f"{x/1_000:.2f}K"
return f"{x:.2f}"
# ─── Tier 1: Embedded ───────────────────────────────────────────────
def load_baradb_embedded() -> list[dict]:
"""Map bench_all.nim LSM results into fair embedded tier."""
if not BARA_JSON.exists():
print(" [skip] benchmark_results.json missing — run: nimble bench")
return []
data = json.loads(BARA_JSON.read_text())
name_map = {
"LSM-Write": "kv_write",
"LSM-Read": "kv_read",
"WAL-none": "wal_none",
"WAL-group64": "wal_group64",
"WAL-group256": "wal_group256",
"WAL-every": "wal_every",
}
out = []
for r in data.get("results", []):
mapped = name_map.get(r.get("name"))
if not mapped:
continue
out.append(
result(
mapped,
"baradb_lsm_embedded",
"embedded",
r.get("ops", 0),
r.get("seconds", 0.0),
source="benchmark_results.json",
gitSha=data.get("gitSha", ""),
)
)
return out
def multi_values_sql(start: int, count: int) -> str:
"""Build VALUES (...),(...),... for multi-row INSERT."""
parts = [f"({i}, 'value_{i}')" for i in range(start, start + count)]
return ",".join(parts)
def bench_sqlite_embedded(n: int = N_KV) -> list[dict]:
"""SQLite in-process — fair peer for BaraDB embedded LSM."""
out = []
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
os.unlink(path)
# --- durability: FULL (fsync) vs OFF ---
for mode, label in (("OFF", "sqlite_off"), ("FULL", "sqlite_full")):
if os.path.exists(path):
os.unlink(path)
conn = sqlite3.connect(path)
cur = conn.cursor()
cur.execute(f"PRAGMA synchronous = {mode}")
cur.execute("PRAGMA journal_mode = WAL")
cur.execute("CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT)")
conn.commit()
t0 = time.perf_counter()
for i in range(n):
cur.execute("INSERT INTO kv(k,v) VALUES(?,?)", (f"key_{i}", f"value_{i}"))
conn.commit()
w = time.perf_counter() - t0
out.append(
result(
"kv_write",
label,
"embedded",
n,
w,
durable=mode == "FULL",
note=f"PRAGMA synchronous={mode}",
)
)
t0 = time.perf_counter()
found = 0
for i in range(n):
cur.execute("SELECT v FROM kv WHERE k=?", (f"key_{i}",))
if cur.fetchone():
found += 1
r = time.perf_counter() - t0
out.append(
result(
"kv_read",
label,
"embedded",
n,
r,
found=found,
durable=mode == "FULL",
)
)
# Batch multi-row INSERT into SQL table (embedded SQL peer for batch)
cur.execute("DROP TABLE IF EXISTS fair_batch")
cur.execute("CREATE TABLE fair_batch (id INTEGER PRIMARY KEY, v TEXT)")
conn.commit()
t0 = time.perf_counter()
for start in range(0, n, BATCH):
cnt = min(BATCH, n - start)
vals = multi_values_sql(start, cnt)
cur.execute(f"INSERT INTO fair_batch (id, v) VALUES {vals}")
conn.commit()
bw = time.perf_counter() - t0
out.append(
result(
"sql_insert_batch",
label,
"embedded",
n,
bw,
batch=BATCH,
durable=mode == "FULL",
note=f"multi-row INSERT batch={BATCH}, sync={mode}",
)
)
conn.close()
if os.path.exists(path):
os.unlink(path)
return out
# ─── Tier 2: Client / server ────────────────────────────────────────
def bara_http_query(sql: str, host: str = HTTP_HOST, port: int = HTTP_PORT) -> dict:
body = json.dumps({"query": sql}).encode()
req = urllib.request.Request(
f"http://{host}:{port}/query",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode())
def bara_http_available() -> bool:
try:
body = json.dumps({"query": "SELECT 1"}).encode()
# health endpoint preferred
req = urllib.request.Request(f"http://{HTTP_HOST}:{HTTP_PORT}/health", method="GET")
with urllib.request.urlopen(req, timeout=2) as resp:
return resp.status == 200
except Exception:
try:
bara_http_query("SELECT 1")
return True
except Exception:
return False
def bench_baradb_http(n: int = N_SQL) -> list[dict]:
if os.environ.get("FAIR_SKIP_HTTP") == "1":
print(" [skip] FAIR_SKIP_HTTP=1")
return []
if not bara_http_available():
print(
f" [skip] BaraDB HTTP not reachable at {HTTP_HOST}:{HTTP_PORT} "
f"(start: ./build/baradadb)"
)
return []
out = []
ep = f"http://{HTTP_HOST}:{HTTP_PORT}/query"
# BaraDB parser may not support IF EXISTS — ignore DROP failures
try:
bara_http_query("DROP TABLE fair_bench")
except Exception:
pass
try:
bara_http_query("CREATE TABLE fair_bench (id INT PRIMARY KEY, v TEXT)")
except Exception as e:
print(f" [warn] setup query failed: {e}")
# Row-at-a-time INSERT
t0 = time.perf_counter()
errors = 0
for i in range(n):
try:
r = bara_http_query(
f"INSERT INTO fair_bench (id, v) VALUES ({i}, 'value_{i}')"
)
if isinstance(r, dict) and r.get("error"):
errors += 1
except Exception:
errors += 1
w = time.perf_counter() - t0
out.append(
result(
"sql_insert_row",
"baradb_http",
"client_server",
n,
w,
errors=errors,
endpoint=ep,
)
)
# Point SELECT
t0 = time.perf_counter()
found = 0
for i in range(n):
try:
r = bara_http_query(f"SELECT v FROM fair_bench WHERE id = {i}")
rows = r.get("rows") if isinstance(r, dict) else None
if rows:
found += 1
except Exception:
pass
rd = time.perf_counter() - t0
out.append(
result(
"sql_select_row",
"baradb_http",
"client_server",
n,
rd,
found=found,
endpoint=ep,
)
)
# Multi-row batch INSERT
try:
try:
bara_http_query("DROP TABLE fair_batch")
except Exception:
pass
bara_http_query("CREATE TABLE fair_batch (id INT PRIMARY KEY, v TEXT)")
except Exception as e:
print(f" [warn] batch setup failed: {e}")
return out
t0 = time.perf_counter()
berr = 0
for start in range(0, n, BATCH):
cnt = min(BATCH, n - start)
sql = f"INSERT INTO fair_batch (id, v) VALUES {multi_values_sql(start, cnt)}"
try:
r = bara_http_query(sql)
if isinstance(r, dict) and r.get("error"):
berr += 1
except Exception:
berr += 1
bw = time.perf_counter() - t0
out.append(
result(
"sql_insert_batch",
"baradb_http",
"client_server",
n,
bw,
batch=BATCH,
errors=berr,
endpoint=ep,
note=f"multi-row INSERT batch={BATCH}",
)
)
return out
def _import_baradb_client():
"""Load clients/python baradb package without requiring install."""
p = str(CLIENTS_PY)
if p not in sys.path:
sys.path.insert(0, p)
from baradb import Client # type: ignore
return Client
def bara_wire_available() -> bool:
if os.environ.get("FAIR_SKIP_WIRE") == "1":
return False
try:
Client = _import_baradb_client()
except Exception as e:
print(f" [skip] wire client import failed: {e}")
return False
async def _ping():
c = Client(WIRE_HOST, WIRE_PORT, timeout=2.0)
try:
await c.connect()
await c.ping()
await c.close()
return True
except Exception:
try:
await c.close()
except Exception:
pass
return False
try:
return asyncio.run(_ping())
except Exception:
return False
def bench_baradb_wire(n: int = N_SQL) -> list[dict]:
"""BaraDB binary wire protocol (TCP) — primary high-performance client path."""
if os.environ.get("FAIR_SKIP_WIRE") == "1":
print(" [skip] FAIR_SKIP_WIRE=1")
return []
try:
Client = _import_baradb_client()
except Exception as e:
print(f" [skip] wire client not available: {e}")
return []
if not bara_wire_available():
print(
f" [skip] BaraDB wire not reachable at {WIRE_HOST}:{WIRE_PORT} "
f"(start: ./build/baradadb)"
)
return []
async def _run() -> list[dict]:
out: list[dict] = []
client = Client(WIRE_HOST, WIRE_PORT, timeout=60.0)
await client.connect()
try:
try:
await client.query("DROP TABLE fair_wire")
except Exception:
pass
try:
await client.query(
"CREATE TABLE fair_wire (id INT PRIMARY KEY, v TEXT)"
)
except Exception as e:
print(f" [warn] wire setup: {e}")
ep = f"tcp://{WIRE_HOST}:{WIRE_PORT}"
# Row-at-a-time INSERT (may crash older servers under load — record partial)
t0 = time.perf_counter()
errors = 0
done = 0
crashed = False
for i in range(n):
try:
await client.query(
f"INSERT INTO fair_wire (id, v) VALUES ({i}, 'value_{i}')"
)
done += 1
except (ConnectionError, OSError, Exception) as e:
errors += 1
if "reset" in str(e).lower() or "closed" in str(e).lower():
crashed = True
print(f" [warn] wire connection lost after {done} inserts: {e}")
break
w = time.perf_counter() - t0
if done > 0:
out.append(
result(
"sql_insert_row",
"baradb_wire",
"client_server",
done,
w,
errors=errors,
requested=n,
endpoint=ep,
note="binary wire protocol"
+ (" (partial — server disconnect)" if crashed else ""),
)
)
if crashed:
return out
# Point SELECT
t0 = time.perf_counter()
found = 0
for i in range(done):
try:
r = await client.query(
f"SELECT v FROM fair_wire WHERE id = {i}"
)
if r is not None and (
getattr(r, "row_count", 0) > 0 or getattr(r, "rows", None)
):
found += 1
except (ConnectionError, OSError, Exception) as e:
if "reset" in str(e).lower() or "closed" in str(e).lower():
crashed = True
print(f" [warn] wire lost during SELECT: {e}")
break
rd = time.perf_counter() - t0
out.append(
result(
"sql_select_row",
"baradb_wire",
"client_server",
max(done, 1),
rd,
found=found,
endpoint=ep,
)
)
if crashed:
return out
# Batch multi-row INSERT
try:
try:
await client.query("DROP TABLE fair_wire_batch")
except Exception:
pass
await client.query(
"CREATE TABLE fair_wire_batch (id INT PRIMARY KEY, v TEXT)"
)
except Exception as e:
print(f" [warn] wire batch setup: {e}")
return out
t0 = time.perf_counter()
berr = 0
bdone = 0
for start in range(0, n, BATCH):
cnt = min(BATCH, n - start)
sql = (
"INSERT INTO fair_wire_batch (id, v) VALUES "
+ multi_values_sql(start, cnt)
)
try:
await client.query(sql)
bdone += cnt
except (ConnectionError, OSError, Exception) as e:
berr += 1
if "reset" in str(e).lower() or "closed" in str(e).lower():
print(f" [warn] wire lost during batch after {bdone} rows: {e}")
break
bw = time.perf_counter() - t0
if bdone > 0:
out.append(
result(
"sql_insert_batch",
"baradb_wire",
"client_server",
bdone,
bw,
batch=BATCH,
errors=berr,
requested=n,
endpoint=ep,
note=f"multi-row INSERT batch={BATCH}",
)
)
finally:
try:
await client.close()
except Exception:
pass
return out
try:
return asyncio.run(_run())
except Exception as e:
print(f" [skip] wire bench failed: {e}")
return []
def bench_postgresql(n: int = N_SQL) -> list[dict]:
if os.environ.get("FAIR_SKIP_PG") == "1":
print(" [skip] FAIR_SKIP_PG=1")
return []
try:
import psycopg2
except ImportError:
print(" [skip] psycopg2 not installed")
return []
cfg = {
"host": os.environ.get("PGHOST", "localhost"),
"port": int(os.environ.get("PGPORT", "5432")),
"dbname": os.environ.get("PGDATABASE", "postgres"),
"user": os.environ.get("PGUSER", "postgres"),
"password": os.environ.get("PGPASSWORD", os.environ.get("PG_PASSWORD", "")),
}
if not cfg["password"] and os.environ.get("PGPASSWORD") is None:
cfg["password"] = os.environ.get("BARA_PG_PASSWORD", "pas+123")
out = []
try:
conn = psycopg2.connect(**cfg)
except Exception as e:
print(f" [skip] PostgreSQL connect failed: {e}")
return []
cur = conn.cursor()
for sync, label in (("on", "postgresql_sync_on"), ("off", "postgresql_sync_off")):
cur.execute(f"SET synchronous_commit = {sync}")
cur.execute("DROP TABLE IF EXISTS fair_bench")
cur.execute("CREATE TABLE fair_bench (id INTEGER PRIMARY KEY, v TEXT)")
conn.commit()
t0 = time.perf_counter()
for i in range(n):
cur.execute(
"INSERT INTO fair_bench (id, v) VALUES (%s, %s)",
(i, f"value_{i}"),
)
conn.commit()
w = time.perf_counter() - t0
out.append(
result(
"sql_insert_row",
label,
"client_server",
n,
w,
durable=sync == "on",
note=f"synchronous_commit={sync}",
)
)
t0 = time.perf_counter()
found = 0
for i in range(n):
cur.execute("SELECT v FROM fair_bench WHERE id = %s", (i,))
if cur.fetchone():
found += 1
rd = time.perf_counter() - t0
out.append(
result(
"sql_select_row",
label,
"client_server",
n,
rd,
found=found,
durable=sync == "on",
)
)
# Batch multi-row INSERT (same durability setting)
cur.execute("DROP TABLE IF EXISTS fair_batch")
cur.execute("CREATE TABLE fair_batch (id INTEGER PRIMARY KEY, v TEXT)")
conn.commit()
t0 = time.perf_counter()
for start in range(0, n, BATCH):
cnt = min(BATCH, n - start)
cur.execute(
f"INSERT INTO fair_batch (id, v) VALUES {multi_values_sql(start, cnt)}"
)
conn.commit()
bw = time.perf_counter() - t0
out.append(
result(
"sql_insert_batch",
label,
"client_server",
n,
bw,
batch=BATCH,
durable=sync == "on",
note=f"multi-row INSERT batch={BATCH}, sync={sync}",
)
)
cur.close()
conn.close()
return out
# ─── Report ──────────────────────────────────────────────────────────
def print_tier(name: str, rows: list[dict]):
print(f"\n=== Tier: {name} ===")
if not rows:
print(" (no results)")
return
# group by bench name
names = []
for r in rows:
if r["name"] not in names:
names.append(r["name"])
for nm in names:
print(f" [{nm}]")
for r in rows:
if r["name"] != nm:
continue
print(
f" {r['system']:28s} {fmt_ops(r['opsPerSec']):>10s}/s "
f"({r['seconds']:.3f}s, n={r['ops']})"
)
def write_markdown(payload: dict, path: Path):
lines = []
lines.append("# Fair Benchmark Results")
lines.append("")
lines.append(f"Generated: **{payload.get('generated', '')}**")
lines.append("")
lines.append("## Methodology")
lines.append("")
for line in payload.get("methodology", []):
lines.append(f"- {line}")
lines.append("")
lines.append("**Do not compare numbers across tiers.** Embedded storage is not the same")
lines.append("workload as client-server SQL over the network.")
lines.append("")
for tier in ("embedded", "client_server"):
rows = [r for r in payload.get("results", []) if r.get("tier") == tier]
lines.append(f"## Tier: `{tier}`")
lines.append("")
if not rows:
lines.append("_No results for this tier._")
lines.append("")
continue
lines.append("| Bench | System | ops/s | seconds | n | notes |")
lines.append("|-------|--------|------:|--------:|--:|-------|")
for r in rows:
note = r.get("note") or r.get("source") or ""
lines.append(
f"| {r['name']} | `{r['system']}` | {fmt_ops(r['opsPerSec'])} | "
f"{r['seconds']:.3f} | {r['ops']} | {note} |"
)
lines.append("")
# same-bench comparison within tier
names = sorted({r["name"] for r in rows})
lines.append(f"### Same-bench ratios (`{tier}`)")
lines.append("")
for nm in names:
group = [r for r in rows if r["name"] == nm]
if len(group) < 2:
continue
best = max(group, key=lambda x: x["opsPerSec"])
lines.append(f"**{nm}** (fastest: `{best['system']}` @ {fmt_ops(best['opsPerSec'])}/s)")
lines.append("")
lines.append("| System | Relative to fastest |")
lines.append("|--------|--------------------:|")
for r in sorted(group, key=lambda x: -x["opsPerSec"]):
rel = r["opsPerSec"] / best["opsPerSec"] if best["opsPerSec"] else 0
lines.append(f"| `{r['system']}` | {rel:.2f}x |")
lines.append("")
path.write_text("\n".join(lines) + "\n")
print(f"\nMarkdown written to {path}")
def main():
print("BaraDB Fair Benchmark Suite")
print(f" N_KV={N_KV} N_SQL={N_SQL} BATCH={BATCH}")
print(f" HTTP={HTTP_HOST}:{HTTP_PORT} WIRE={WIRE_HOST}:{WIRE_PORT}")
methodology = [
"Tier `embedded`: in-process only (BaraDB LSM from nimble bench JSON; SQLite via Python sqlite3).",
"Tier `client_server`: network SQL (BaraDB HTTP /query; BaraDB binary wire TCP; PostgreSQL via psycopg2).",
"`sql_insert_row`: one INSERT statement per row (chatty).",
f"`sql_insert_batch`: multi-row INSERT with batch size {BATCH} (same SQL style across systems).",
"PostgreSQL: synchronous_commit=on|off; SQLite: PRAGMA synchronous FULL|OFF.",
"BaraDB WAL modes appear only if you ran benchmarks/bench_all.nim (WAL-* rows).",
"Never claim 'Nx faster than Postgres' using embedded BaraDB numbers.",
]
results: list[dict] = []
print("\n--- Embedded tier ---")
results.extend(load_baradb_embedded())
print(" SQLite embedded (+ batch)…")
results.extend(bench_sqlite_embedded())
print("\n--- Client/server tier ---")
print(" BaraDB HTTP…")
results.extend(bench_baradb_http())
print(" BaraDB wire (TCP)…")
results.extend(bench_baradb_wire())
print(" PostgreSQL…")
results.extend(bench_postgresql())
payload = {
"generated": now_iso(),
"methodology": methodology,
"config": {
"N_KV": N_KV,
"N_SQL": N_SQL,
"HTTP": f"{HTTP_HOST}:{HTTP_PORT}",
},
"results": results,
}
OUT_JSON.write_text(json.dumps(payload, indent=2))
print(f"\nJSON written to {OUT_JSON}")
print_tier("embedded", [r for r in results if r["tier"] == "embedded"])
print_tier("client_server", [r for r in results if r["tier"] == "client_server"])
write_markdown(payload, ROOT / "benchmarks" / "FAIR_COMPARISON.md")
return 0
if __name__ == "__main__":
sys.exit(main())
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Generate benchmark reports.
Modes:
python3 benchmarks/generate_report.py # legacy PG vs embedded (with warning)
python3 benchmarks/generate_report.py --fair # multi-tier fair report from fair_bench.py
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def format_ops(ops_per_sec: float) -> str:
if ops_per_sec >= 1_000_000:
return f"{ops_per_sec/1_000_000:.2f}M"
if ops_per_sec >= 1_000:
return f"{ops_per_sec/1_000:.2f}K"
return f"{ops_per_sec:.2f}"
def format_time(seconds: float) -> str:
if seconds < 0.001:
return f"{seconds*1000:.3f}ms"
if seconds < 1:
return f"{seconds*1000:.1f}ms"
return f"{seconds:.3f}s"
def gen_fair(out: Path) -> int:
fair_path = ROOT / "fair_benchmark_results.json"
if not fair_path.exists():
print("Missing fair_benchmark_results.json — run: python3 benchmarks/fair_bench.py")
return 1
payload = json.loads(fair_path.read_text())
# fair_bench already writes FAIR_COMPARISON.md; re-emit for consistency
sys.path.insert(0, str(ROOT / "benchmarks"))
from fair_bench import write_markdown # type: ignore
write_markdown(payload, out)
return 0
def gen_legacy() -> int:
"""Legacy report: PG client-server vs BaraDB *embedded* — always labeled unfair."""
bara_path = ROOT / "benchmark_results.json"
pg_path = ROOT / "pg_benchmark_results.json"
if not bara_path.exists() or not pg_path.exists():
print("Need benchmark_results.json and pg_benchmark_results.json")
print(" nimble bench && python3 benchmarks/pg_bench.py")
return 1
with open(bara_path) as f:
bara = json.load(f)
with open(pg_path) as f:
pg = json.load(f)
bara_map = {r["name"]: r for r in bara["results"]}
# pg_bench may write list or dict
if isinstance(pg, dict) and "results" in pg:
pg_map = {r["name"]: r for r in pg["results"]}
elif isinstance(pg, list):
pg_map = {r["name"]: r for r in pg}
else:
pg_map = pg # old flat dict by name
report = []
report.append("# BaraDB vs PostgreSQL — LEGACY (mixed tiers)")
report.append("")
report.append("> ⚠️ **Unfair comparison warning**")
report.append(">")
report.append("> PostgreSQL numbers include **client-server** round-trips.")
report.append("> BaraDB numbers are **in-process embedded** LSM (no network, no SQL).")
report.append("> Use `python3 benchmarks/fair_bench.py` + `--fair` for honest tiers.")
report.append("")
report.append(f"- **BaraDB git:** `{bara.get('gitSha', 'unknown')}`")
report.append("")
report.append("| Test | PostgreSQL (C/S) | BaraDB (embedded) | Ratio (not a fair speedup) |")
report.append("|------|------------------|-------------------|----------------------------|")
rows = [
("KV Write", pg_map.get("KV Write"), bara_map.get("LSM-Write")),
("KV Read", pg_map.get("KV Read"), bara_map.get("LSM-Read")),
("BTree Insert", pg_map.get("BTree Insert"), bara_map.get("BTree-Insert")),
("BTree Get", pg_map.get("BTree Get"), bara_map.get("BTree-Get")),
("BTree Scan", pg_map.get("BTree Scan"), bara_map.get("BTree-Scan")),
("FTS Index", pg_map.get("FTS Index"), bara_map.get("FTS-Index")),
("FTS Search", pg_map.get("FTS Search"), bara_map.get("FTS-Search")),
]
for name, p, b in rows:
if p is None or b is None:
continue
pg_ops = p["opsPerSec"]
ba_ops = b["opsPerSec"]
ratio = ba_ops / pg_ops if pg_ops else 0
report.append(
f"| {name} | {format_ops(pg_ops)}/s ({format_time(p['seconds'])}) | "
f"{format_ops(ba_ops)}/s ({format_time(b['seconds'])}) | "
f"{ratio:.1f}x (mixed tiers) |"
)
report.append("")
report.append("## Prefer fair suite")
report.append("")
report.append("```bash")
report.append("nim c -d:release -r benchmarks/bench_all.nim")
report.append("python3 benchmarks/fair_bench.py")
report.append("python3 benchmarks/generate_report.py --fair")
report.append("```")
report.append("")
out = ROOT / "benchmarks" / "REAL_COMPARISON.md"
out.write_text("\n".join(report) + "\n")
print(f"Wrote {out} (legacy mixed-tier; see warning banner)")
return 0
def main():
ap = argparse.ArgumentParser()
ap.add_argument(
"--fair",
action="store_true",
help="Emit multi-tier fair report from fair_benchmark_results.json",
)
ap.add_argument(
"-o",
"--output",
default=str(ROOT / "benchmarks" / "FAIR_COMPARISON.md"),
help="Output path for --fair mode",
)
args = ap.parse_args()
if args.fair:
return gen_fair(Path(args.output))
return gen_legacy()
if __name__ == "__main__":
sys.exit(main())
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Real PostgreSQL benchmarks to compare against BaraDB."""
import time
import psycopg2
import json
import os
DB_CONFIG = {
"host": "localhost",
"database": "postgres",
"user": "postgres",
"password": "pas+123",
}
def pg_conn():
return psycopg2.connect(**DB_CONFIG)
def drop_tables(cur):
cur.execute("DROP TABLE IF EXISTS bench_kv, bench_btree, bench_fts CASCADE;")
def bench_kv_write(n=100_000):
"""Compare with LSM-Tree write."""
conn = pg_conn()
cur = conn.cursor()
drop_tables(cur)
cur.execute("CREATE TABLE bench_kv (k TEXT PRIMARY KEY, v TEXT);")
conn.commit()
start = time.perf_counter()
for i in range(n):
cur.execute(
"INSERT INTO bench_kv (k, v) VALUES (%s, %s);",
(f"key_{i}", f"value_{i}"),
)
conn.commit()
elapsed = time.perf_counter() - start
conn.close()
return {"name": "KV Write", "ops": n, "seconds": elapsed, "opsPerSec": n / elapsed}
def bench_kv_read(n=100_000):
"""Compare with LSM-Tree read."""
conn = pg_conn()
cur = conn.cursor()
start = time.perf_counter()
found = 0
for i in range(n):
cur.execute("SELECT v FROM bench_kv WHERE k = %s;", (f"key_{i}",))
if cur.fetchone():
found += 1
elapsed = time.perf_counter() - start
conn.close()
return {"name": "KV Read", "ops": n, "seconds": elapsed, "opsPerSec": n / elapsed, "found": found}
def bench_btree_insert(n=100_000):
"""Compare with BTree insert."""
conn = pg_conn()
cur = conn.cursor()
drop_tables(cur)
cur.execute("CREATE TABLE bench_btree (id INTEGER PRIMARY KEY, v TEXT);")
conn.commit()
start = time.perf_counter()
for i in range(n):
cur.execute(
"INSERT INTO bench_btree (id, v) VALUES (%s, %s);",
(i, f"value_{i}"),
)
conn.commit()
elapsed = time.perf_counter() - start
conn.close()
return {"name": "BTree Insert", "ops": n, "seconds": elapsed, "opsPerSec": n / elapsed}
def bench_btree_get(n=100_000):
"""Compare with BTree point lookup."""
conn = pg_conn()
cur = conn.cursor()
start = time.perf_counter()
found = 0
for i in range(n):
cur.execute("SELECT v FROM bench_btree WHERE id = %s;", (i,))
if cur.fetchone():
found += 1
elapsed = time.perf_counter() - start
conn.close()
return {"name": "BTree Get", "ops": n, "seconds": elapsed, "opsPerSec": n / elapsed, "found": found}
def bench_btree_scan(n=1000):
"""Compare with BTree range scan."""
conn = pg_conn()
cur = conn.cursor()
start = time.perf_counter()
total = 0
for _ in range(n):
cur.execute(
"SELECT * FROM bench_btree WHERE id BETWEEN %s AND %s;",
(1000, 2000),
)
total += len(cur.fetchall())
elapsed = time.perf_counter() - start
conn.close()
return {"name": "BTree Scan", "ops": n, "seconds": elapsed, "opsPerSec": n / elapsed, "results": total}
def bench_fts_index(n=10_000):
"""Compare with FTS index."""
conn = pg_conn()
cur = conn.cursor()
drop_tables(cur)
cur.execute("CREATE TABLE bench_fts (id SERIAL PRIMARY KEY, body TEXT);")
conn.commit()
docs = [
"Nim is a statically typed compiled systems programming language",
"It combines the speed of C with an expressive syntax like Python",
"Memory management is deterministic with reference counting",
"The compiler produces optimized native code for all platforms",
"Metaprogramming and generics enable powerful abstractions",
]
start = time.perf_counter()
for i in range(n):
cur.execute(
"INSERT INTO bench_fts (body) VALUES (%s);",
(docs[i % len(docs)],),
)
conn.commit()
elapsed = time.perf_counter() - start
conn.close()
return {"name": "FTS Index", "ops": n, "seconds": elapsed, "opsPerSec": n / elapsed}
def bench_fts_search(n=1000):
"""Compare with FTS search."""
conn = pg_conn()
cur = conn.cursor()
# Create GIN index for tsvector search
cur.execute("CREATE INDEX idx_fts ON bench_fts USING GIN (to_tsvector('english', body));")
conn.commit()
start = time.perf_counter()
for _ in range(n):
cur.execute(
"SELECT * FROM bench_fts WHERE to_tsvector('english', body) @@ plainto_tsquery('english', %s);",
("Nim programming language",),
)
cur.fetchall()
elapsed = time.perf_counter() - start
conn.close()
return {"name": "FTS Search", "ops": n, "seconds": elapsed, "opsPerSec": n / elapsed}
def load_baradb_results():
with open("benchmark_results.json") as f:
return json.load(f)
def format_ops(ops_per_sec):
if ops_per_sec >= 1_000_000:
return f"{ops_per_sec/1_000_000:.2f}M"
elif ops_per_sec >= 1_000:
return f"{ops_per_sec/1_000:.2f}K"
else:
return f"{ops_per_sec:.2f}"
def print_comparison(pg_results, bara_data):
bara = {r["name"]: r for r in bara_data["results"]}
print("\n╔══════════════════════════════════════════════════════════════════════╗")
print("║ PostgreSQL (client-server) vs BaraDB (EMBEDDED) — MIXED TIERS ║")
print("╚══════════════════════════════════════════════════════════════════════╝\n")
print("WARNING: This mixes client-server PG with in-process BaraDB LSM.")
print(" Prefer: python3 benchmarks/fair_bench.py\n")
rows = [
("KV Write (100K)", pg_results.get("KV Write"), bara.get("LSM-Write")),
("KV Read (100K)", pg_results.get("KV Read"), bara.get("LSM-Read")),
("BTree Insert (100K)", pg_results.get("BTree Insert"), bara.get("BTree-Insert")),
("BTree Get (100K)", pg_results.get("BTree Get"), bara.get("BTree-Get")),
("BTree Scan (1K ranges)", pg_results.get("BTree Scan"), bara.get("BTree-Scan")),
("FTS Index (10K docs)", pg_results.get("FTS Index"), bara.get("FTS-Index")),
("FTS Search (1K queries)", pg_results.get("FTS Search"), bara.get("FTS-Search")),
]
print(f"{'Test':<26} {'PostgreSQL C/S':>18} {'BaraDB embed':>18} {'Note':>14}")
print("" * 76)
for name, pg, ba in rows:
if pg is None or ba is None:
continue
pg_ops = pg["opsPerSec"]
ba_ops = ba["opsPerSec"]
ratio = ba_ops / pg_ops if pg_ops else 0
print(
f"{name:<26} {format_ops(pg_ops)+'/s':>18} {format_ops(ba_ops)+'/s':>18} "
f"{'mixed '+f'{ratio:.1f}x':>14}"
)
print("\n" + "" * 76)
print("For fair tiers (SQLite↔LSM, HTTP↔PG): python3 benchmarks/fair_bench.py")
def main():
print("Running PostgreSQL benchmarks...")
print("=" * 50)
pg_results = {}
print("[1/7] KV Write 100K records...")
pg_results["KV Write"] = bench_kv_write()
print(f" -> {format_ops(pg_results['KV Write']['opsPerSec'])}/s ({pg_results['KV Write']['seconds']:.3f}s)")
print("[2/7] KV Read 100K records...")
pg_results["KV Read"] = bench_kv_read()
print(f" -> {format_ops(pg_results['KV Read']['opsPerSec'])}/s ({pg_results['KV Read']['seconds']:.3f}s)")
print("[3/7] BTree Insert 100K keys...")
pg_results["BTree Insert"] = bench_btree_insert()
print(f" -> {format_ops(pg_results['BTree Insert']['opsPerSec'])}/s ({pg_results['BTree Insert']['seconds']:.3f}s)")
print("[4/7] BTree Get 100K keys...")
pg_results["BTree Get"] = bench_btree_get()
print(f" -> {format_ops(pg_results['BTree Get']['opsPerSec'])}/s ({pg_results['BTree Get']['seconds']:.3f}s)")
print("[5/7] BTree Scan 1K ranges...")
pg_results["BTree Scan"] = bench_btree_scan()
print(f" -> {format_ops(pg_results['BTree Scan']['opsPerSec'])}/s ({pg_results['BTree Scan']['seconds']:.3f}s)")
print("[6/7] FTS Index 10K docs...")
pg_results["FTS Index"] = bench_fts_index()
print(f" -> {format_ops(pg_results['FTS Index']['opsPerSec'])}/s ({pg_results['FTS Index']['seconds']:.3f}s)")
print("[7/7] FTS Search 1K queries...")
pg_results["FTS Search"] = bench_fts_search()
print(f" -> {format_ops(pg_results['FTS Search']['opsPerSec'])}/s ({pg_results['FTS Search']['seconds']:.3f}s)")
# Annotate tier for fair tooling
for name, r in pg_results.items():
r["tier"] = "client_server"
r["system"] = "postgresql"
with open("pg_benchmark_results.json", "w") as f:
json.dump(pg_results, f, indent=2)
print("\nPostgreSQL results saved to pg_benchmark_results.json")
if os.path.exists("benchmark_results.json"):
bara_data = load_baradb_results()
print_comparison(pg_results, bara_data)
else:
print("\n(No benchmark_results.json — skip mixed-tier table; run nimble bench first)")
print("\nFair multi-tier suite: python3 benchmarks/fair_bench.py")
if __name__ == "__main__":
main()
+347
View File
@@ -0,0 +1,347 @@
## BaraDB Search Benchmarks — HNSW recall, FTS performance, scalability
import std/monotimes
import std/times
import std/random
import std/strutils
import std/tables
import std/sets
import std/math
import std/algorithm
import ../src/barabadb/vector/engine as vengine
import ../src/barabadb/fts/engine as fts
import ../src/barabadb/search/hnsw_opt
type
LatencyStats = tuple[avg, p50, p95, p99: float64]
const sampleDocs = [
"The quick brown fox jumps over the lazy dog near the river bank",
"Database indexing strategies include B-trees hash indexes and inverted indexes",
"Vector similarity search uses approximate nearest neighbor algorithms like HNSW",
"Full text search engines use inverted indexes with BM25 ranking",
"Natural language processing requires tokenization stemming and embedding",
"Machine learning models transform raw data into meaningful insights",
"Distributed systems handle network partitions and consistency tradeoffs",
"Graph databases traverse relationships between connected entities efficiently",
"Time series databases optimize for sequential write patterns",
"Columnar storage accelerates analytical queries across large datasets",
"Query optimization involves cost-based planning and execution strategies",
"Memory management uses reference counting for deterministic cleanup",
"Concurrent data structures enable lock-free parallel processing",
"Cryptographic hashing provides integrity verification for stored data",
"Replication strategies ensure high availability across multiple nodes",
"Sharding distributes data based on consistent hashing algorithms",
"ACID transactions guarantee atomicity consistency isolation durability",
"Event sourcing captures state changes as immutable sequence of events",
"Microservices architecture decomposes applications into independent services",
"API design principles emphasize simplicity consistency and discoverability",
]
proc elapsed(start: MonoTime): float64 =
let ns = float64((getMonoTime() - start).inNanoseconds)
return ns / 1_000_000_000.0
proc percentile(values: seq[float64], p: int): float64 =
if values.len == 0: return 0.0
var sorted = values
sorted.sort()
let idx = (p * sorted.len) div 100
if idx >= sorted.len: return sorted[^1]
return sorted[idx]
proc latencyStats(latencies: seq[float64]): LatencyStats =
if latencies.len == 0:
return (0.0, 0.0, 0.0, 0.0)
var sum = 0.0
for v in latencies: sum += v
result.avg = sum / float64(latencies.len)
result.p50 = percentile(latencies, 50)
result.p95 = percentile(latencies, 95)
result.p99 = percentile(latencies, 99)
proc formatMs(ms: float64): string =
if ms < 0.01:
return ms.formatFloat(ffDecimal, 4) & "ms"
return ms.formatFloat(ffDecimal, 2) & "ms"
proc formatOps(ops: int, secs: float64): string =
let rate = float64(ops) / secs
if rate > 1_000_000:
return $(rate / 1_000_000).formatFloat(ffDecimal, 1) & "M ops/s"
elif rate > 1_000:
return $(rate / 1_000).formatFloat(ffDecimal, 1) & "K ops/s"
else:
return $rate.formatFloat(ffDecimal, 1) & " ops/s"
proc computeGroundTruth(query: Vector, vectors: seq[(uint64, Vector)], k: int): seq[(uint64, float64)] =
var dists: seq[(float64, uint64)] = @[]
for (id, vec) in vectors:
let dist = cosineDistance(query, vec)
dists.add((dist, id))
dists.sort(proc(a, b: (float64, uint64)): int = cmp(a[0], b[0]))
let n = min(k, dists.len)
result = newSeq[(uint64, float64)](n)
for i in 0..<n:
result[i] = (dists[i][1], dists[i][0])
proc computeRecall(groundTruth: seq[(uint64, float64)], hnswResults: seq[(uint64, float64)], k: int): float64 =
if groundTruth.len == 0: return 0.0
var gtIds = initHashSet[uint64]()
for (id, _) in groundTruth:
gtIds.incl(id)
var hits = 0
for (id, _) in hnswResults:
if id in gtIds: inc hits
return float64(hits) / float64(groundTruth.len)
proc benchHnswRecall(n: int, dim: int, kValues: seq[int]) =
echo ""
echo "=== HNSW Recall@k ==="
echo " Dataset: ", $n, " vectors, dim=", dim
randomize(42)
var idx = newHNSWIndex(dim)
var vectors: seq[(uint64, Vector)] = @[]
for i in 0..<n:
var vec = newSeq[float32](dim)
for d in 0..<dim:
vec[d] = rand(1.0)
idx.insert(uint64(i), vec)
vectors.add((uint64(i), vec))
let queryCount = 100
var queries: seq[Vector] = @[]
for i in 0..<queryCount:
var vec = newSeq[float32](dim)
for d in 0..<dim:
vec[d] = rand(1.0)
queries.add(vec)
for k in kValues:
var totalRecall = 0.0
var latencies: seq[float64] = @[]
for query in queries:
let start = getMonoTime()
let hnswResults = searchOpt(idx, query, k)
let elap = (getMonoTime() - start).inNanoseconds.float64 / 1_000_000.0
latencies.add(elap)
let gt = computeGroundTruth(query, vectors, k)
let recall = computeRecall(gt, hnswResults, k)
totalRecall += recall
let avgRecall = totalRecall / float64(queryCount)
let stats = latencyStats(latencies)
echo " recall@", k, ": ", (avgRecall * 100).formatFloat(ffDecimal, 1), "% (avg ", formatMs(stats.avg), ")"
proc benchScalability =
echo ""
echo "=== HNSW Scalability ==="
let sizes = [1000, 5000, 10000, 50000, 100000]
let dim = 128
for n in sizes:
randomize(42)
let efC = if n <= 10000: 200 elif n <= 50000: 200 else: 200
var idx = newHNSWIndex(dim, m = 16, efConstruction = efC)
var vectors: seq[(uint64, Vector)] = @[]
let insertStart = getMonoTime()
for i in 0..<n:
var vec = newSeq[float32](dim)
for d in 0..<dim:
vec[d] = rand(1.0)
insertOpt(idx, uint64(i), vec)
vectors.add((uint64(i), vec))
let insertTime = elapsed(insertStart)
let queryCount = if n <= 10000: 50 elif n <= 50000: 20 else: 10
var queries: seq[Vector] = @[]
for i in 0..<queryCount:
var vec = newSeq[float32](dim)
for d in 0..<dim:
vec[d] = rand(1.0)
queries.add(vec)
var latencies: seq[float64] = @[]
var totalRecall = 0.0
for query in queries:
let start = getMonoTime()
let hnswResults = searchOpt(idx, query, 10)
let elap = (getMonoTime() - start).inNanoseconds.float64 / 1_000_000.0
latencies.add(elap)
let gt = computeGroundTruth(query, vectors, 10)
let recall = computeRecall(gt, hnswResults, 10)
totalRecall += recall
let avgRecall = totalRecall / float64(queryCount)
let stats = latencyStats(latencies)
echo " N=", $n, ": insert=", insertTime.formatFloat(ffDecimal, 2), "s search=", formatMs(stats.avg), " recall@10=", (avgRecall * 100).formatFloat(ffDecimal, 1), "%"
proc phraseSearch(idx: fts.InvertedIndex, phrase: string): seq[fts.SearchResult] =
let tokens = fts.tokenize(phrase)
if tokens.len == 0: return @[]
var docCounts = initTable[uint64, int]()
for token in tokens:
if token in idx.postings:
for entry in idx.postings[token]:
if entry.docId notin docCounts:
docCounts[entry.docId] = 0
inc docCounts[entry.docId]
var candidates: seq[uint64] = @[]
for docId, count in docCounts:
if count == tokens.len:
candidates.add(docId)
result = @[]
for docId in candidates:
var positions: seq[seq[int]] = @[]
for token in tokens:
if token in idx.postings:
for entry in idx.postings[token]:
if entry.docId == docId:
positions.add(entry.positions)
break
if positions.len == tokens.len:
var found = false
if positions[0].len > 0:
for startPos in positions[0]:
var match = true
for i in 1..<positions.len:
if (startPos + i) notin positions[i]:
match = false
break
if match:
found = true
break
if found:
result.add(fts.SearchResult(docId: docId, score: 1.0, highlights: @[]))
proc booleanAndSearch(idx: fts.InvertedIndex, terms: seq[string]): seq[fts.SearchResult] =
var docCounts = initTable[uint64, int]()
for term in terms:
if term in idx.postings:
for entry in idx.postings[term]:
if entry.docId notin docCounts:
docCounts[entry.docId] = 0
inc docCounts[entry.docId]
result = @[]
for docId, count in docCounts:
if count == terms.len:
result.add(fts.SearchResult(docId: docId, score: float64(count), highlights: @[]))
proc benchFts(n: int) =
echo ""
echo "=== FTS Performance ==="
var idx = fts.newInvertedIndex()
let indexStart = getMonoTime()
for i in 0..<n:
let docText = sampleDocs[i mod sampleDocs.len]
idx.addDocument(uint64(i), docText)
let indexTime = elapsed(indexStart)
echo " Index ", $n, " docs: ", indexTime.formatFloat(ffDecimal, 2), "s"
let queryCount = 1000
var bm25Queries = @[
"database indexing strategies",
"vector similarity search",
"full text search engines",
"machine learning models",
"distributed systems",
]
var latencies: seq[float64] = @[]
let start = getMonoTime()
for i in 0..<queryCount:
let qStart = getMonoTime()
discard idx.search(bm25Queries[i mod bm25Queries.len])
let elap = (getMonoTime() - qStart).inNanoseconds.float64 / 1_000_000.0
latencies.add(elap)
let bm25Time = elapsed(start)
let stats = latencyStats(latencies)
echo " BM25 search: ", formatOps(queryCount, bm25Time), " (p50=", formatMs(stats.p50), " p95=", formatMs(stats.p95), " p99=", formatMs(stats.p99), ")"
var phraseQueries = @[
"quick brown fox",
"database indexing strategies",
"vector similarity search",
"full text search",
"machine learning",
]
latencies.setLen(0)
let phraseStart = getMonoTime()
for i in 0..<queryCount:
let qStart = getMonoTime()
discard phraseSearch(idx, phraseQueries[i mod phraseQueries.len])
let elap = (getMonoTime() - qStart).inNanoseconds.float64 / 1_000_000.0
latencies.add(elap)
let phraseTime = elapsed(phraseStart)
let phraseStats = latencyStats(latencies)
echo " Phrase search: ", formatOps(queryCount, phraseTime), " (p50=", formatMs(phraseStats.p50), " p95=", formatMs(phraseStats.p95), " p99=", formatMs(phraseStats.p99), ")"
var boolQueries = @[
@["database", "indexing"],
@["vector", "search"],
@["text", "search"],
@["machine", "learning"],
@["distributed", "systems"],
]
latencies.setLen(0)
let boolStart = getMonoTime()
for i in 0..<queryCount:
let qStart = getMonoTime()
discard booleanAndSearch(idx, boolQueries[i mod boolQueries.len])
let elap = (getMonoTime() - qStart).inNanoseconds.float64 / 1_000_000.0
latencies.add(elap)
let boolTime = elapsed(boolStart)
let boolStats = latencyStats(latencies)
echo " Boolean (AND): ", formatOps(queryCount, boolTime), " (p50=", formatMs(boolStats.p50), " p95=", formatMs(boolStats.p95), " p99=", formatMs(boolStats.p99), ")"
var fuzzyQueries = @[
"programing",
"databse",
"algorihm",
"indxing",
"simlarity",
]
let fuzzyCount = 200
latencies.setLen(0)
let fuzzyStart = getMonoTime()
for i in 0..<fuzzyCount:
let qStart = getMonoTime()
discard idx.fuzzySearch(fuzzyQueries[i mod fuzzyQueries.len], maxDistance = 2)
let elap = (getMonoTime() - qStart).inNanoseconds.float64 / 1_000_000.0
latencies.add(elap)
let fuzzyTime = elapsed(fuzzyStart)
let fuzzyStats = latencyStats(latencies)
echo " Fuzzy search: ", formatOps(fuzzyCount, fuzzyTime), " (p50=", formatMs(fuzzyStats.p50), " p95=", formatMs(fuzzyStats.p95), " p99=", formatMs(fuzzyStats.p99), ")"
proc main =
echo ""
echo "╔══════════════════════════════════════════════════════╗"
echo "║ BaraDB Search Benchmarks ║"
echo "╚══════════════════════════════════════════════════════╝"
benchHnswRecall(10000, 128, @[1, 5, 10, 20])
benchScalability()
benchFts(10000)
echo ""
when isMainModule:
main()
@@ -16,6 +16,7 @@ srcDir = "src"
requires "nim >= 2.0.0"
requires "db_connector >= 0.1.0"
requires "checksums >= 0.1.0"
requires "baradb >= 1.2.0"
import strformat, os
@@ -1,420 +1,13 @@
## BaraDB Client — Self-contained Nim client library
## No dependency on BaraDB server code.
## Communicates via the BaraDB Wire Protocol (binary, big-endian).
## BaraDB driver glue for nim-allographer.
## All wire/socket logic lives in the canonical `baradb/client` package.
import std/asyncdispatch
import std/asyncnet
import std/net as netmod
import std/locks
import std/strutils
import std/endians
import baradb/client
export client
# === Wire Protocol (self-contained, no server dependency) ===
const
ProtocolMagic* = 0x42415241'u32
type
FieldKind* = enum
fkNull = 0x00
fkBool = 0x01
fkInt8 = 0x02
fkInt16 = 0x03
fkInt32 = 0x04
fkInt64 = 0x05
fkFloat32 = 0x06
fkFloat64 = 0x07
fkString = 0x08
fkBytes = 0x09
fkArray = 0x0A
fkObject = 0x0B
fkVector = 0x0C
fkJson = 0x0D
MsgKind* = enum
# Client messages
mkClientHandshake = 0x01
mkQuery = 0x02
mkQueryParams = 0x03
mkExecute = 0x04
mkBatch = 0x05
mkTransaction = 0x06
mkClose = 0x07
mkPing = 0x08
mkAuth = 0x09
# Server messages
mkServerHandshake = 0x80
mkReady = 0x81
mkData = 0x82
mkComplete = 0x83
mkError = 0x84
mkAuthChallenge = 0x85
mkAuthOk = 0x86
mkSchemaChange = 0x87
mkPong = 0x88
mkTransactionState = 0x89
ResultFormat* = enum
rfBinary = 0x00
rfJson = 0x01
rfText = 0x02
WireValue* = object
case kind*: FieldKind
of fkNull: discard
of fkBool: boolVal*: bool
of fkInt8: int8Val*: int8
of fkInt16: int16Val*: int16
of fkInt32: int32Val*: int32
of fkInt64: int64Val*: int64
of fkFloat32: float32Val*: float32
of fkFloat64: float64Val*: float64
of fkString: strVal*: string
of fkBytes: bytesVal*: seq[byte]
of fkArray: arrayVal*: seq[WireValue]
of fkObject: objVal*: seq[(string, WireValue)]
of fkVector: vecVal*: seq[float32]
of fkJson: jsonVal*: string
proc writeUint32(buf: var seq[byte], val: uint32) =
var bytes: array[4, byte]
bigEndian32(addr bytes, unsafeAddr val)
buf.add(bytes)
proc writeUint64(buf: var seq[byte], val: uint64) =
var bytes: array[8, byte]
bigEndian64(addr bytes, unsafeAddr val)
buf.add(bytes)
proc writeString(buf: var seq[byte], s: string) =
buf.writeUint32(uint32(s.len))
for ch in s:
buf.add(byte(ch))
proc readUint32(buf: openArray[byte], pos: var int): uint32 =
var bytes: array[4, byte]
for i in 0..3: bytes[i] = buf[pos + i]
bigEndian32(addr result, unsafeAddr bytes)
pos += 4
proc readUint64(buf: openArray[byte], pos: var int): uint64 =
var bytes: array[8, byte]
for i in 0..7: bytes[i] = buf[pos + i]
bigEndian64(addr result, unsafeAddr bytes)
pos += 8
proc readString(buf: openArray[byte], pos: var int): string =
let len = int(readUint32(buf, pos))
result = newString(len)
for i in 0..<len:
result[i] = char(buf[pos + i])
pos += len
proc toBytes*(s: string): seq[byte] =
result = newSeq[byte](s.len)
for i, c in s:
result[i] = byte(c)
proc toString*(s: seq[byte]): string =
result = newString(s.len)
for i, b in s:
result[i] = char(b)
proc serializeValue*(buf: var seq[byte], val: WireValue) =
buf.add(byte(val.kind))
case val.kind
of fkNull: discard
of fkBool: buf.add(if val.boolVal: 1'u8 else: 0'u8)
of fkInt8: buf.add(uint8(val.int8Val))
of fkInt16:
var bytes16: array[2, byte]
bigEndian16(addr bytes16, unsafeAddr val.int16Val)
buf.add(bytes16)
of fkInt32: buf.writeUint32(uint32(val.int32Val))
of fkInt64: buf.writeUint64(uint64(val.int64Val))
of fkFloat32:
var bytes32: array[4, byte]
copyMem(addr bytes32, unsafeAddr val.float32Val, 4)
buf.add(bytes32)
of fkFloat64:
var bytes: array[8, byte]
copyMem(addr bytes, unsafeAddr val.float64Val, 8)
buf.add(bytes)
of fkString: buf.writeString(val.strVal)
of fkBytes:
buf.writeUint32(uint32(val.bytesVal.len))
buf.add(val.bytesVal)
of fkArray:
buf.writeUint32(uint32(val.arrayVal.len))
for item in val.arrayVal:
buf.serializeValue(item)
of fkObject:
buf.writeUint32(uint32(val.objVal.len))
for (name, item) in val.objVal:
buf.writeString(name)
buf.serializeValue(item)
of fkVector:
buf.writeUint32(uint32(val.vecVal.len))
for f in val.vecVal:
var fb: array[4, byte]
copyMem(addr fb, unsafeAddr f, 4)
buf.add(fb)
of fkJson: buf.writeString(val.jsonVal)
proc deserializeValue*(buf: openArray[byte], pos: var int): WireValue =
let kind = FieldKind(buf[pos])
inc pos
case kind
of fkNull: result = WireValue(kind: fkNull)
of fkBool:
result = WireValue(kind: fkBool, boolVal: buf[pos] != 0)
inc pos
of fkInt8:
result = WireValue(kind: fkInt8, int8Val: cast[int8](buf[pos]))
inc pos
of fkInt16:
var bytes16: array[2, byte]
for i in 0..1: bytes16[i] = buf[pos + i]
var v16: int16
bigEndian16(addr v16, unsafeAddr bytes16)
result = WireValue(kind: fkInt16, int16Val: v16)
pos += 2
of fkInt32:
result = WireValue(kind: fkInt32, int32Val: int32(readUint32(buf, pos)))
of fkInt64:
result = WireValue(kind: fkInt64, int64Val: int64(readUint64(buf, pos)))
of fkFloat32:
var v32: float32
copyMem(addr v32, addr buf[pos], 4)
result = WireValue(kind: fkFloat32, float32Val: v32)
pos += 4
of fkFloat64:
var v: float64
copyMem(addr v, addr buf[pos], 8)
result = WireValue(kind: fkFloat64, float64Val: v)
pos += 8
of fkString:
result = WireValue(kind: fkString, strVal: readString(buf, pos))
of fkBytes:
let blen = int(readUint32(buf, pos))
var bval: seq[byte] = @[]
for i in 0..<blen:
bval.add(buf[pos + i])
result = WireValue(kind: fkBytes, bytesVal: bval)
pos += blen
of fkArray:
let count = int(readUint32(buf, pos))
var arr: seq[WireValue] = @[]
for i in 0..<count:
arr.add(deserializeValue(buf, pos))
result = WireValue(kind: fkArray, arrayVal: arr)
of fkObject:
let count = int(readUint32(buf, pos))
var obj: seq[(string, WireValue)] = @[]
for i in 0..<count:
let name = readString(buf, pos)
let val = deserializeValue(buf, pos)
obj.add((name, val))
result = WireValue(kind: fkObject, objVal: obj)
of fkVector:
let dim = int(readUint32(buf, pos))
var vec: seq[float32] = @[]
for i in 0..<dim:
var fv: float32
copyMem(addr fv, addr buf[pos], 4)
vec.add(fv)
pos += 4
result = WireValue(kind: fkVector, vecVal: vec)
of fkJson:
result = WireValue(kind: fkJson, jsonVal: readString(buf, pos))
proc buildMessage*(kind: MsgKind, requestId: uint32, payload: seq[byte]): seq[byte] =
result = @[]
result.writeUint32(uint32(kind))
result.writeUint32(uint32(payload.len))
result.writeUint32(requestId)
result.add(payload)
proc makeQueryMessage*(requestId: uint32, query: string): seq[byte] =
var payload: seq[byte] = @[]
payload.writeString(query)
payload.add(byte(rfBinary))
buildMessage(mkQuery, requestId, payload)
proc makeQueryParamsMessage*(requestId: uint32, query: string, params: seq[WireValue]): seq[byte] =
var payload: seq[byte] = @[]
payload.writeString(query)
payload.add(byte(rfBinary))
payload.writeUint32(uint32(params.len))
for p in params:
payload.serializeValue(p)
buildMessage(mkQueryParams, requestId, payload)
proc makeAuthMessage*(requestId: uint32, token: string): seq[byte] =
var payload: seq[byte] = @[]
payload.writeString(token)
buildMessage(mkAuth, requestId, payload)
# === Client Library ===
type
ClientConfig* = object
host*: string
port*: int
database*: string
username*: string
password*: string
timeoutMs*: int
maxRetries*: int
QueryResult* = object
columns*: seq[string]
columnTypes*: seq[string]
rows*: seq[seq[string]]
rowCount*: int
affectedRows*: int
executionTimeMs*: float64
BaraClient* = ref object
config: ClientConfig
socket*: AsyncSocket
connected: bool
requestId: uint32
proc defaultConfig*(): ClientConfig =
ClientConfig(
host: "127.0.0.1", port: 9472, database: "default",
username: "admin", password: "", timeoutMs: 30000, maxRetries: 3,
)
proc newClient*(config: ClientConfig = defaultConfig()): BaraClient =
BaraClient(config: config, socket: newAsyncSocket(), connected: false, requestId: 0)
proc connect*(client: BaraClient) {.async.} =
await client.socket.connect(client.config.host, Port(client.config.port))
client.connected = true
proc nextId*(client: BaraClient): uint32 =
inc client.requestId; client.requestId
proc close*(client: BaraClient) =
if client.connected:
try:
let msg = buildMessage(mkClose, client.nextId(), @[])
waitFor client.socket.send(toString(msg))
except: discard
client.socket.close()
client.connected = false
proc isConnected*(client: BaraClient): bool = client.connected
proc wireValueToString*(wv: WireValue): string =
case wv.kind
of fkNull: return ""
of fkBool: return if wv.boolVal: "true" else: "false"
of fkInt8: return $wv.int8Val
of fkInt16: return $wv.int16Val
of fkInt32: return $wv.int32Val
of fkInt64: return $wv.int64Val
of fkFloat32: return $wv.float32Val
of fkFloat64: return $wv.float64Val
of fkString: return wv.strVal
of fkBytes: return "<bytes:" & $wv.bytesVal.len & ">"
of fkArray: return "<array:" & $wv.arrayVal.len & ">"
of fkObject: return "<object:" & $wv.objVal.len & ">"
of fkVector: return "<vector:" & $wv.vecVal.len & ">"
of fkJson: return wv.jsonVal
proc readQueryResponse*(client: BaraClient): Future[QueryResult] {.async.} =
let headerData = await client.socket.recv(12)
if headerData.len < 12:
raise newException(IOError, "Connection closed")
var pos = 0
let hdrData = toBytes(headerData)
let kind = MsgKind(readUint32(hdrData, pos))
let payloadLen = int(readUint32(hdrData, pos))
discard readUint32(hdrData, pos)
let payloadStr = await client.socket.recv(payloadLen)
var payload = toBytes(payloadStr)
result = QueryResult(columns: @[], rows: @[], rowCount: 0, affectedRows: 0)
if kind == mkReady:
return
if kind == mkError and payload.len >= 8:
var epos = 0
let code = readUint32(payload, epos)
let emsg = readString(payload, epos)
raise newException(IOError, "Error " & $code & ": " & emsg)
if kind == mkData:
var dpos = 0
let colCount = int(readUint32(payload, dpos))
var cols: seq[string] = @[]
for i in 0..<colCount:
cols.add(readString(payload, dpos))
result.columns = cols
var colTypes: seq[string] = @[]
for i in 0..<colCount:
colTypes.add($FieldKind(payload[dpos]))
inc dpos
result.columnTypes = colTypes
let rowCount = int(readUint32(payload, dpos))
for r in 0..<rowCount:
var row: seq[string] = @[]
for c in 0..<colCount:
let wv = deserializeValue(payload, dpos)
row.add(wireValueToString(wv))
result.rows.add(row)
result.rowCount = rowCount
# Read following mkComplete message
let compHeader = await client.socket.recv(12)
if compHeader.len >= 12:
var chPos = 0
let chData = toBytes(compHeader)
let compKind = MsgKind(readUint32(chData, chPos))
let compLen = int(readUint32(chData, chPos))
discard readUint32(chData, chPos)
let compPayloadStr = await client.socket.recv(compLen)
if compKind == mkComplete:
var cpPos = 0
result.affectedRows = int(readUint32(toBytes(compPayloadStr), cpPos))
return
if kind == mkComplete:
var rpos = 0
result.affectedRows = int(readUint32(payload, rpos))
return
proc query*(client: BaraClient, sql: string): Future[QueryResult] {.async.} =
if not client.connected:
raise newException(IOError, "Not connected")
let msg = makeQueryMessage(client.nextId(), sql)
let msgStr = toString(msg)
await client.socket.send(msgStr)
return await client.readQueryResponse()
proc query*(client: BaraClient, sql: string, params: seq[WireValue]): Future[QueryResult] {.async.} =
if not client.connected:
raise newException(IOError, "Not connected")
let msg = makeQueryParamsMessage(client.nextId(), sql, params)
let msgStr = toString(msg)
await client.socket.send(msgStr)
return await client.readQueryResponse()
proc exec*(client: BaraClient, sql: string): Future[int] {.async.} =
let qr = await client.query(sql)
return qr.affectedRows
# === Migration API (BaraQL native) ===
# === Migration helpers (allographer-specific) ===
proc createMigration*(client: BaraClient, name: string, upBody: string,
downBody: string = ""): Future[QueryResult] {.async.} =
## Send CREATE MIGRATION via BaraQL. Server handles checksums, locking, rollback.
var sql = "CREATE MIGRATION " & name & " { UP: " & upBody & ";"
if downBody.len > 0:
sql &= " DOWN: " & downBody & ";"
@@ -438,326 +31,3 @@ proc migrationStatus*(client: BaraClient): Future[QueryResult] {.async.} =
proc migrationDryRun*(client: BaraClient, name: string): Future[QueryResult] {.async.} =
return await client.query("MIGRATION DRY RUN " & name)
proc auth*(client: BaraClient, token: string) {.async.} =
if not client.connected:
raise newException(IOError, "Not connected")
let msg = makeAuthMessage(client.nextId(), token)
let msgStr = toString(msg)
await client.socket.send(msgStr)
let headerData = await client.socket.recv(12)
if headerData.len < 12:
raise newException(IOError, "Connection closed")
var pos = 0
let hdrData = toBytes(headerData)
let kind = MsgKind(readUint32(hdrData, pos))
let payloadLen = int(readUint32(hdrData, pos))
discard readUint32(hdrData, pos)
if kind == mkAuthOk:
return
elif kind == mkError:
let payloadStr = await client.socket.recv(payloadLen)
var epos = 0
let emsg = readString(toBytes(payloadStr), epos)
raise newException(IOError, "Auth failed: " & emsg)
else:
raise newException(IOError, "Unexpected auth response: 0x" & toHex(uint32(kind), 2))
proc ping*(client: BaraClient): Future[bool] {.async.} =
if not client.connected:
return false
let msg = buildMessage(mkPing, client.nextId(), @[])
let msgStr = toString(msg)
await client.socket.send(msgStr)
let headerData = await client.socket.recv(12)
if headerData.len < 12:
return false
var pos = 0
let hdrData = toBytes(headerData)
let kind = MsgKind(readUint32(hdrData, pos))
return kind == mkPong
# === Fluent Query Builder ===
type
QueryBuilder* = ref object
client: BaraClient
selectCols: seq[string]
fromTable: string
whereClauses: seq[string]
joinClauses: seq[string]
groupByCols: seq[string]
havingClause: string
orderCols: seq[string]
orderDirs: seq[string]
limitVal: int
offsetVal: int
proc newQueryBuilder*(client: BaraClient): QueryBuilder =
QueryBuilder(client: client, limitVal: 0, offsetVal: 0)
proc select*(qb: QueryBuilder, cols: varargs[string]): QueryBuilder =
for c in cols: qb.selectCols.add(c)
return qb
proc `from`*(qb: QueryBuilder, table: string): QueryBuilder =
qb.fromTable = table
return qb
proc where*(qb: QueryBuilder, clause: string): QueryBuilder =
qb.whereClauses.add(clause)
return qb
proc join*(qb: QueryBuilder, table: string, on: string): QueryBuilder =
qb.joinClauses.add("JOIN " & table & " ON " & on)
return qb
proc leftJoin*(qb: QueryBuilder, table: string, on: string): QueryBuilder =
qb.joinClauses.add("LEFT JOIN " & table & " ON " & on)
return qb
proc groupBy*(qb: QueryBuilder, cols: varargs[string]): QueryBuilder =
for c in cols: qb.groupByCols.add(c)
return qb
proc having*(qb: QueryBuilder, clause: string): QueryBuilder =
qb.havingClause = clause
return qb
proc orderBy*(qb: QueryBuilder, col: string, dir: string = "ASC"): QueryBuilder =
qb.orderCols.add(col)
qb.orderDirs.add(dir)
return qb
proc limit*(qb: QueryBuilder, n: int): QueryBuilder =
qb.limitVal = n
return qb
proc offset*(qb: QueryBuilder, n: int): QueryBuilder =
qb.offsetVal = n
return qb
proc build*(qb: QueryBuilder): string =
result = "SELECT " & (if qb.selectCols.len > 0: qb.selectCols.join(", ") else: "*")
result &= " FROM " & qb.fromTable
for j in qb.joinClauses: result &= " " & j
if qb.whereClauses.len > 0: result &= " WHERE " & qb.whereClauses.join(" AND ")
if qb.groupByCols.len > 0: result &= " GROUP BY " & qb.groupByCols.join(", ")
if qb.havingClause.len > 0: result &= " HAVING " & qb.havingClause
if qb.orderCols.len > 0:
result &= " ORDER BY "
for i, col in qb.orderCols:
if i > 0: result &= ", "
result &= col & " " & qb.orderDirs[i]
if qb.limitVal > 0: result &= " LIMIT " & $qb.limitVal
if qb.offsetVal > 0: result &= " OFFSET " & $qb.offsetVal
proc exec*(qb: QueryBuilder): Future[QueryResult] {.async.} =
return await qb.client.query(qb.build())
# === Blocking Sync Client (production-grade, no waitFor) ===
type
SyncClient* = ref object
config: ClientConfig
socket: netmod.Socket
connected: bool
requestId: uint32
lock: Lock
proc newSyncClient*(config: ClientConfig = defaultConfig()): SyncClient =
result = SyncClient(config: config, connected: false, requestId: 0)
result.socket = netmod.newSocket()
initLock(result.lock)
proc recvExact(sock: netmod.Socket, size: int): string =
result = ""
while result.len < size:
let chunk = sock.recv(size - result.len)
if chunk.len == 0:
raise newException(IOError, "Connection closed")
result.add(chunk)
proc readQueryResponseBlocking(client: SyncClient): QueryResult =
let headerData = client.socket.recvExact(12)
var pos = 0
let hdrData = toBytes(headerData)
let kind = MsgKind(readUint32(hdrData, pos))
let payloadLen = int(readUint32(hdrData, pos))
discard readUint32(hdrData, pos)
let payloadStr = client.socket.recvExact(payloadLen)
var payload = toBytes(payloadStr)
result = QueryResult(columns: @[], rows: @[], rowCount: 0, affectedRows: 0)
if kind == mkReady:
return
if kind == mkError and payload.len >= 8:
var epos = 0
let code = readUint32(payload, epos)
let emsg = readString(payload, epos)
raise newException(IOError, "Error " & $code & ": " & emsg)
if kind == mkData:
var dpos = 0
let colCount = int(readUint32(payload, dpos))
var cols: seq[string] = @[]
for i in 0..<colCount:
cols.add(readString(payload, dpos))
result.columns = cols
var colTypes: seq[string] = @[]
for i in 0..<colCount:
colTypes.add($FieldKind(payload[dpos]))
inc dpos
result.columnTypes = colTypes
let rowCount = int(readUint32(payload, dpos))
for r in 0..<rowCount:
var row: seq[string] = @[]
for c in 0..<colCount:
let wv = deserializeValue(payload, dpos)
row.add(wireValueToString(wv))
result.rows.add(row)
result.rowCount = rowCount
# Read following mkComplete message
let compHeader = client.socket.recvExact(12)
var chPos = 0
let chData = toBytes(compHeader)
let compKind = MsgKind(readUint32(chData, chPos))
let compLen = int(readUint32(chData, chPos))
discard readUint32(chData, chPos)
let compPayloadStr = client.socket.recvExact(compLen)
if compKind == mkComplete:
var cpPos = 0
result.affectedRows = int(readUint32(toBytes(compPayloadStr), cpPos))
return
if kind == mkComplete:
var rpos = 0
result.affectedRows = int(readUint32(payload, rpos))
return
proc connect*(client: SyncClient) =
netmod.connect(client.socket, client.config.host, Port(client.config.port))
client.connected = true
proc close*(client: SyncClient) =
if client.connected:
try:
let msg = buildMessage(mkClose, 0, @[])
netmod.send(client.socket, toString(msg))
except: discard
netmod.close(client.socket)
client.connected = false
deinitLock(client.lock)
proc query*(client: SyncClient, sql: string): QueryResult =
acquire(client.lock)
try:
if not client.connected:
raise newException(IOError, "Not connected")
let msg = makeQueryMessage(0, sql)
netmod.send(client.socket, toString(msg))
return readQueryResponseBlocking(client)
finally:
release(client.lock)
proc query*(client: SyncClient, sql: string, params: seq[WireValue]): QueryResult =
acquire(client.lock)
try:
if not client.connected:
raise newException(IOError, "Not connected")
let msg = makeQueryParamsMessage(0, sql, params)
netmod.send(client.socket, toString(msg))
return readQueryResponseBlocking(client)
finally:
release(client.lock)
proc exec*(client: SyncClient, sql: string): int =
let qr = client.query(sql)
return qr.affectedRows
# === Migration API (SyncClient, blocking) ===
proc createMigration*(client: SyncClient, name: string, upBody: string,
downBody: string = ""): QueryResult =
var sql = "CREATE MIGRATION " & name & " { UP: " & upBody & ";"
if downBody.len > 0:
sql &= " DOWN: " & downBody & ";"
sql &= " }"
return client.query(sql)
proc applyMigration*(client: SyncClient, name: string): QueryResult =
return client.query("APPLY MIGRATION " & name)
proc migrateUp*(client: SyncClient, count: int = 0): QueryResult =
var sql = "MIGRATION UP"
if count > 0:
sql &= " " & $count
return client.query(sql)
proc migrateDown*(client: SyncClient, count: int = 1): QueryResult =
return client.query("MIGRATION DOWN " & $count)
proc migrationStatus*(client: SyncClient): QueryResult =
return client.query("MIGRATION STATUS")
proc migrationDryRun*(client: SyncClient, name: string): QueryResult =
return client.query("MIGRATION DRY RUN " & name)
proc auth*(client: SyncClient, token: string) =
acquire(client.lock)
try:
if not client.connected:
raise newException(IOError, "Not connected")
let msg = makeAuthMessage(0, token)
netmod.send(client.socket, toString(msg))
let headerData = client.socket.recvExact(12)
var pos = 0
let hdrData = toBytes(headerData)
let kind = MsgKind(readUint32(hdrData, pos))
let payloadLen = int(readUint32(hdrData, pos))
discard readUint32(hdrData, pos)
if kind == mkAuthOk:
return
elif kind == mkError:
let payloadStr = client.socket.recvExact(payloadLen)
var epos = 0
let emsg = readString(toBytes(payloadStr), epos)
raise newException(IOError, "Auth failed: " & emsg)
else:
raise newException(IOError, "Unexpected auth response")
finally:
release(client.lock)
proc ping*(client: SyncClient): bool =
acquire(client.lock)
try:
if not client.connected:
return false
let msg = buildMessage(mkPing, 0, @[])
netmod.send(client.socket, toString(msg))
let headerData = client.socket.recvExact(12)
var pos = 0
let hdrData = toBytes(headerData)
let kind = MsgKind(readUint32(hdrData, pos))
return kind == mkPong
except:
return false
finally:
release(client.lock)
proc `$`*(qr: QueryResult): string =
if qr.columns.len == 0: return "(no results)"
result = ""
for i, col in qr.columns:
result &= col
if i < qr.columns.len - 1: result &= ", "
result &= "\n"
for row in qr.rows:
result &= row.join(", ") & "\n"
result &= "(" & $qr.rowCount & " rows)"
@@ -148,8 +148,8 @@ proc escapeSqlValue(val: JsonNode): string =
proc formatSql*(sql: string, args: seq[JsonNode]): string =
result = sql
var placeholderCount = 0
for i in 0..<result.len:
if result[i] == '?':
for ch in result:
if ch == '?':
placeholderCount += 1
if placeholderCount != args.len:
raise newException(DbError, "Placeholder count mismatch: expected " & $placeholderCount & " but got " & $args.len & " arguments")
@@ -213,40 +213,53 @@ proc placeholdersToWireValuesRaw*(args: seq[JsonNode]): seq[WireValue] =
# toJson
# ================================================================================
proc wireValueToJson*(wv: WireValue): JsonNode =
case wv.kind
of fkNull:
result = newJNull()
of fkBool:
result = newJBool(wv.boolVal)
of fkInt8:
result = newJInt(int(wv.int8Val))
of fkInt16:
result = newJInt(int(wv.int16Val))
of fkInt32:
result = newJInt(int(wv.int32Val))
of fkInt64:
result = newJInt(int(wv.int64Val))
of fkFloat32:
result = newJFloat(float(wv.float32Val))
of fkFloat64:
result = newJFloat(wv.float64Val)
of fkString:
result = newJString(wv.strVal)
of fkBytes:
result = newJString("<bytes:" & $wv.bytesVal.len & ">")
of fkArray:
result = newJArray()
for item in wv.arrayVal:
result.add(wireValueToJson(item))
of fkObject:
result = newJObject()
for (name, val) in wv.objVal:
result[name] = wireValueToJson(val)
of fkVector:
result = newJArray()
for f in wv.vecVal:
result.add(newJFloat(float(f)))
of fkJson:
try:
result = parseJson(wv.jsonVal)
except JsonParsingError:
result = newJString(wv.jsonVal)
proc toJson*(resultSet: QueryResult): seq[JsonNode] =
var response_table = newSeq[JsonNode](resultSet.rowCount)
for r in 0 ..< resultSet.rowCount:
var response_row = newJObject()
for c in 0 ..< resultSet.columns.len:
let key = resultSet.columns[c]
let val = resultSet.rows[r][c]
let colType = if c < resultSet.columnTypes.len: resultSet.columnTypes[c] else: "fkString"
if val.len == 0:
response_row[key] = newJNull()
else:
case colType
of "fkNull":
response_row[key] = newJNull()
of "fkBool":
response_row[key] = newJBool(val == "t" or val == "true" or val == "1")
of "fkInt8", "fkInt16", "fkInt32", "fkInt64":
try:
response_row[key] = newJInt(val.parseInt)
except ValueError:
response_row[key] = newJString(val)
of "fkFloat32", "fkFloat64":
try:
response_row[key] = newJFloat(val.parseFloat)
except ValueError:
response_row[key] = newJString(val)
of "fkJson":
try:
response_row[key] = parseJson(val)
except JsonParsingError:
response_row[key] = newJString(val)
else:
# fkString, fkBytes, fkArray, fkObject, fkVector, and unknown types
response_row[key] = newJString(val)
response_row[key] = wireValueToJson(resultSet.typedRows[r][c])
response_table[r] = response_row
return response_table
@@ -438,6 +451,7 @@ proc exec(self: BaradbQuery, queryString: string) {.async.} =
defer:
if not self.isInTransaction:
self.returnConn(connI).await
self.placeHolder = newJArray()
if connI == errorConnectionNum:
raisePoolTimeout(self)
@@ -475,6 +489,7 @@ proc insertId(self: BaradbQuery, queryString: string, key: string): Future[strin
defer:
if not self.isInTransaction:
self.returnConn(connI).await
self.placeHolder = newJArray()
if connI == errorConnectionNum:
raisePoolTimeout(self)
@@ -830,7 +845,9 @@ proc first*(self: RawBaradbQuery): Future[Option[JsonNode]] {.async.} =
proc firstPlain*(self: RawBaradbQuery): Future[seq[string]] {.async.} =
self.log.logger(self.queryString)
return await self.getRowPlain(self.queryString, self.placeHolder)
let row = await self.getRowPlain(self.queryString, self.placeHolder)
if row.isSome: return row.get()
return @[]
# ================================================================================
+59 -2
View File
@@ -15,7 +15,7 @@ Official Nim client for **BaraDB** — a multimodal database engine.
Add to your `.nimble` file:
```nim
requires "baradb >= 1.1.6"
requires "baradb >= 1.2.0"
```
Or clone locally:
@@ -95,6 +95,63 @@ proc main() {.async.} =
waitFor main()
```
## Connection Pool
```nim
import asyncdispatch, baradb/client, baradb/pool
proc main() {.async.} =
let cfg = ClientConfig(host: "127.0.0.1", port: 9472)
let pool = newBaraPool(cfg, minConnections = 2, maxConnections = 10)
withClient(pool):
let r = await c.query("SELECT name FROM users WHERE id = ?",
@[WireValue(kind: fkInt64, int64Val: 1)])
echo r.typedRows
waitFor main()
```
## Typed Rows
`QueryResult` now carries both a legacy string view (`rows`) and a typed view (`typedRows`):
```nim
let r = await client.query("SELECT * FROM vectors")
for row in r.typedRows:
if row[0].kind == fkVector:
echo row[0].vecVal
```
## TLS
TLS for the synchronous client is available via `when defined(ssl)`. The async binary client requires a user-supplied `sslContext` because `asyncnet` does not provide native TLS; alternatively use the HTTP fallback.
## Error Handling
All client errors inherit from `BaraError`:
- `BaraIoError` — connection / timeout issues
- `BaraServerError` — server returned an error frame
- `BaraAuthError` — authentication failure
- `BaraProtocolError` — unexpected wire response
- `BaraPoolTimeoutError` — no connection available in time
## HTTP Fallback
For environments where only the HTTP port is open:
```nim
import asyncdispatch, baradb/http
proc main() {.async.} =
let c = newBaraHttpClient()
let result = await c.query("SELECT * FROM users")
echo result
c.close()
waitFor main()
```
## Running Tests
Unit tests (no server):
@@ -160,4 +217,4 @@ See `examples/ormin_basic.nim` for a full sample.
## License
Apache-2.0
BSD-3-Clause
+2 -2
View File
@@ -1,9 +1,9 @@
# Package
version = "1.1.6"
version = "1.2.0"
author = "BaraDB Team"
description = "Official Nim client for BaraDB — async binary protocol client"
license = "Apache-2.0"
license = "BSD-3-Clause"
srcDir = "src"
# Dependencies — only Nim stdlib, no server code
+219 -386
View File
@@ -1,261 +1,48 @@
## BaraDB Client — Self-contained Nim client library
## No dependency on BaraDB server code.
## Communicates via the BaraDB Wire Protocol (binary, big-endian).
## BaraDB Client — canonical Nim client library.
## Self-contained; depends only on Nim stdlib.
import std/asyncdispatch
import std/asyncnet
import std/net as netmod
import std/locks
import std/strutils
import std/endians
# === Wire Protocol (self-contained, no server dependency) ===
import ./wire
export wire
import ./errors
export errors
const
ProtocolMagic* = 0x42415241'u32
# === AsyncLock (stdlib-only serialization primitive) ===
type
FieldKind* = enum
fkNull = 0x00
fkBool = 0x01
fkInt8 = 0x02
fkInt16 = 0x03
fkInt32 = 0x04
fkInt64 = 0x05
fkFloat32 = 0x06
fkFloat64 = 0x07
fkString = 0x08
fkBytes = 0x09
fkArray = 0x0A
fkObject = 0x0B
fkVector = 0x0C
fkJson = 0x0D
AsyncLockObj = object
locked: bool
waiters: seq[Future[void]]
MsgKind* = enum
# Client messages
mkClientHandshake = 0x01
mkQuery = 0x02
mkQueryParams = 0x03
mkExecute = 0x04
mkBatch = 0x05
mkTransaction = 0x06
mkClose = 0x07
mkPing = 0x08
mkAuth = 0x09
# Server messages
mkServerHandshake = 0x80
mkReady = 0x81
mkData = 0x82
mkComplete = 0x83
mkError = 0x84
mkAuthChallenge = 0x85
mkAuthOk = 0x86
mkSchemaChange = 0x87
mkPong = 0x88
mkTransactionState = 0x89
AsyncLock* = ref AsyncLockObj
ResultFormat* = enum
rfBinary = 0x00
rfJson = 0x01
rfText = 0x02
proc initAsyncLock*(): AsyncLock =
new(result)
result.locked = false
result.waiters = @[]
WireValue* = object
case kind*: FieldKind
of fkNull: discard
of fkBool: boolVal*: bool
of fkInt8: int8Val*: int8
of fkInt16: int16Val*: int16
of fkInt32: int32Val*: int32
of fkInt64: int64Val*: int64
of fkFloat32: float32Val*: float32
of fkFloat64: float64Val*: float64
of fkString: strVal*: string
of fkBytes: bytesVal*: seq[byte]
of fkArray: arrayVal*: seq[WireValue]
of fkObject: objVal*: seq[(string, WireValue)]
of fkVector: vecVal*: seq[float32]
of fkJson: jsonVal*: string
proc acquire*(lock: AsyncLock): Future[void] =
var fut = newFuture[void]("AsyncLock.acquire")
if not lock.locked:
lock.locked = true
fut.complete()
else:
lock.waiters.add(fut)
return fut
proc writeUint32(buf: var seq[byte], val: uint32) =
var bytes: array[4, byte]
bigEndian32(addr bytes, unsafeAddr val)
buf.add(bytes)
proc release*(lock: AsyncLock) =
if lock.waiters.len > 0:
let next = lock.waiters[0]
lock.waiters.delete(0)
next.complete()
else:
lock.locked = false
proc writeUint64(buf: var seq[byte], val: uint64) =
var bytes: array[8, byte]
bigEndian64(addr bytes, unsafeAddr val)
buf.add(bytes)
proc writeString(buf: var seq[byte], s: string) =
buf.writeUint32(uint32(s.len))
for ch in s:
buf.add(byte(ch))
proc readUint32(buf: openArray[byte], pos: var int): uint32 =
var bytes: array[4, byte]
for i in 0..3: bytes[i] = buf[pos + i]
bigEndian32(addr result, unsafeAddr bytes)
pos += 4
proc readUint64(buf: openArray[byte], pos: var int): uint64 =
var bytes: array[8, byte]
for i in 0..7: bytes[i] = buf[pos + i]
bigEndian64(addr result, unsafeAddr bytes)
pos += 8
proc readString(buf: openArray[byte], pos: var int): string =
let len = int(readUint32(buf, pos))
result = newString(len)
for i in 0..<len:
result[i] = char(buf[pos + i])
pos += len
proc toBytes(s: string): seq[byte] =
result = newSeq[byte](s.len)
for i, c in s:
result[i] = byte(c)
proc toString(s: seq[byte]): string =
result = newString(s.len)
for i, b in s:
result[i] = char(b)
proc serializeValue*(buf: var seq[byte], val: WireValue) =
buf.add(byte(val.kind))
case val.kind
of fkNull: discard
of fkBool: buf.add(if val.boolVal: 1'u8 else: 0'u8)
of fkInt8: buf.add(uint8(val.int8Val))
of fkInt16:
var bytes16: array[2, byte]
bigEndian16(addr bytes16, unsafeAddr val.int16Val)
buf.add(bytes16)
of fkInt32: buf.writeUint32(uint32(val.int32Val))
of fkInt64: buf.writeUint64(uint64(val.int64Val))
of fkFloat32:
var bytes32: array[4, byte]
copyMem(addr bytes32, unsafeAddr val.float32Val, 4)
buf.add(bytes32)
of fkFloat64:
var bytes: array[8, byte]
copyMem(addr bytes, unsafeAddr val.float64Val, 8)
buf.add(bytes)
of fkString: buf.writeString(val.strVal)
of fkBytes:
buf.writeUint32(uint32(val.bytesVal.len))
buf.add(val.bytesVal)
of fkArray:
buf.writeUint32(uint32(val.arrayVal.len))
for item in val.arrayVal:
buf.serializeValue(item)
of fkObject:
buf.writeUint32(uint32(val.objVal.len))
for (name, item) in val.objVal:
buf.writeString(name)
buf.serializeValue(item)
of fkVector:
buf.writeUint32(uint32(val.vecVal.len))
for f in val.vecVal:
var fb: array[4, byte]
copyMem(addr fb, unsafeAddr f, 4)
buf.add(fb)
of fkJson: buf.writeString(val.jsonVal)
proc deserializeValue*(buf: openArray[byte], pos: var int): WireValue =
let kind = FieldKind(buf[pos])
inc pos
case kind
of fkNull: result = WireValue(kind: fkNull)
of fkBool:
result = WireValue(kind: fkBool, boolVal: buf[pos] != 0)
inc pos
of fkInt8:
result = WireValue(kind: fkInt8, int8Val: cast[int8](buf[pos]))
inc pos
of fkInt16:
var bytes16: array[2, byte]
for i in 0..1: bytes16[i] = buf[pos + i]
var v16: int16
bigEndian16(addr v16, unsafeAddr bytes16)
result = WireValue(kind: fkInt16, int16Val: v16)
pos += 2
of fkInt32:
result = WireValue(kind: fkInt32, int32Val: int32(readUint32(buf, pos)))
of fkInt64:
result = WireValue(kind: fkInt64, int64Val: int64(readUint64(buf, pos)))
of fkFloat32:
var v32: float32
copyMem(addr v32, addr buf[pos], 4)
result = WireValue(kind: fkFloat32, float32Val: v32)
pos += 4
of fkFloat64:
var v: float64
copyMem(addr v, addr buf[pos], 8)
result = WireValue(kind: fkFloat64, float64Val: v)
pos += 8
of fkString:
result = WireValue(kind: fkString, strVal: readString(buf, pos))
of fkBytes:
let blen = int(readUint32(buf, pos))
var bval: seq[byte] = @[]
for i in 0..<blen:
bval.add(buf[pos + i])
result = WireValue(kind: fkBytes, bytesVal: bval)
pos += blen
of fkArray:
let count = int(readUint32(buf, pos))
var arr: seq[WireValue] = @[]
for i in 0..<count:
arr.add(deserializeValue(buf, pos))
result = WireValue(kind: fkArray, arrayVal: arr)
of fkObject:
let count = int(readUint32(buf, pos))
var obj: seq[(string, WireValue)] = @[]
for i in 0..<count:
let name = readString(buf, pos)
let val = deserializeValue(buf, pos)
obj.add((name, val))
result = WireValue(kind: fkObject, objVal: obj)
of fkVector:
let dim = int(readUint32(buf, pos))
var vec: seq[float32] = @[]
for i in 0..<dim:
var fv: float32
copyMem(addr fv, addr buf[pos], 4)
vec.add(fv)
pos += 4
result = WireValue(kind: fkVector, vecVal: vec)
of fkJson:
result = WireValue(kind: fkJson, jsonVal: readString(buf, pos))
proc buildMessage*(kind: MsgKind, requestId: uint32, payload: seq[byte]): seq[byte] =
result = @[]
result.writeUint32(uint32(kind))
result.writeUint32(uint32(payload.len))
result.writeUint32(requestId)
result.add(payload)
proc makeQueryMessage*(requestId: uint32, query: string): seq[byte] =
var payload: seq[byte] = @[]
payload.writeString(query)
payload.add(byte(rfBinary))
buildMessage(mkQuery, requestId, payload)
proc makeQueryParamsMessage*(requestId: uint32, query: string, params: seq[WireValue]): seq[byte] =
var payload: seq[byte] = @[]
payload.writeString(query)
payload.add(byte(rfBinary))
payload.writeUint32(uint32(params.len))
for p in params:
payload.serializeValue(p)
buildMessage(mkQueryParams, requestId, payload)
proc makeAuthMessage*(requestId: uint32, token: string): seq[byte] =
var payload: seq[byte] = @[]
payload.writeString(token)
buildMessage(mkAuth, requestId, payload)
# === Client Library ===
# === Configuration & result types ===
type
ClientConfig* = object
@@ -266,37 +53,109 @@ type
password*: string
timeoutMs*: int
maxRetries*: int
ssl*: bool
when defined(ssl):
sslContext*: netmod.SslContext
QueryResult* = object
columns*: seq[string]
columnTypes*: seq[string]
rows*: seq[seq[string]]
columnTypes*: seq[FieldKind]
rows*: seq[seq[string]] # legacy string view
typedRows*: seq[seq[WireValue]] # typed view
rowCount*: int
affectedRows*: int
executionTimeMs*: float64
lastInsertId*: int64
BaraClient* = ref object
config: ClientConfig
socket: AsyncSocket
connected: bool
requestId: uint32
config*: ClientConfig
socket*: AsyncSocket
connected*: bool
requestId*: uint32
sendLock*: AsyncLock
proc defaultConfig*(): ClientConfig =
ClientConfig(
result = ClientConfig(
host: "127.0.0.1", port: 9472, database: "default",
username: "admin", password: "", timeoutMs: 30000, maxRetries: 3,
ssl: false,
)
when defined(ssl):
result.sslContext = nil
proc newClient*(config: ClientConfig = defaultConfig()): BaraClient =
BaraClient(config: config, socket: newAsyncSocket(), connected: false, requestId: 0)
result = BaraClient(
config: config,
socket: newAsyncSocket(),
connected: false,
requestId: 0,
sendLock: initAsyncLock(),
)
# Aliases for older call sites / server test suite
proc defaultClientConfig*(): ClientConfig {.inline.} = defaultConfig()
proc newBaraClient*(config: ClientConfig = defaultConfig()): BaraClient {.inline.} =
newClient(config)
proc parseConnectionString*(connStr: string): ClientConfig =
## Parse space-separated key=value pairs (libpq-style subset).
result = defaultConfig()
for part in connStr.split(" "):
let kv = part.split("=", 1)
if kv.len == 2:
case kv[0].toLowerAscii()
of "host": result.host = kv[1]
of "port": result.port = parseInt(kv[1])
of "database", "dbname": result.database = kv[1]
of "user", "username": result.username = kv[1]
of "password", "pass": result.password = kv[1]
of "connect_timeout", "timeout": result.timeoutMs = parseInt(kv[1])
else: discard
proc nextId*(client: BaraClient): uint32 =
inc client.requestId
client.requestId
proc awaitWithTimeout(fut: Future[void], ms: int): Future[void] {.async.} =
if ms <= 0:
await fut
else:
let ok = await withTimeout(fut, ms)
if not ok:
raise newException(BaraIoError, "Operation timed out")
await fut
proc awaitWithTimeout(fut: Future[string], ms: int): Future[string] {.async.} =
if ms <= 0:
result = await fut
else:
let ok = await withTimeout(fut, ms)
if not ok:
raise newException(BaraIoError, "Operation timed out")
result = await fut
proc recvExact(sock: AsyncSocket, size: int, timeoutMs: int): Future[string] {.async.} =
var data = ""
while data.len < size:
let chunk = await awaitWithTimeout(sock.recv(size - data.len), timeoutMs)
if chunk.len == 0:
raise newException(BaraIoError, "Connection closed while reading")
data.add(chunk)
return data
proc connect*(client: BaraClient) {.async.} =
await client.socket.connect(client.config.host, Port(client.config.port))
await client.socket.connect(client.config.host, Port(client.config.port)).awaitWithTimeout(client.config.timeoutMs)
if client.config.ssl:
when defined(ssl):
# Async binary TLS over asyncnet is not supported by the Nim stdlib alone.
# Supply an sslContext only if you have wired up a platform-specific async TLS socket.
if client.config.sslContext.isNil:
raise newException(BaraIoError, "Async binary TLS requires a user-supplied sslContext")
# The caller is responsible for wrapping an async-compatible socket before passing it in.
else:
raise newException(BaraIoError, "SSL requested but Nim built without -d:ssl")
client.connected = true
proc nextId(client: BaraClient): uint32 =
inc client.requestId; client.requestId
proc close*(client: BaraClient) =
if client.connected:
try:
@@ -325,134 +184,119 @@ proc wireValueToString*(wv: WireValue): string =
of fkVector: return "<vector:" & $wv.vecVal.len & ">"
of fkJson: return wv.jsonVal
proc readQueryResponse(client: BaraClient): Future[QueryResult] {.async.} =
let headerData = await client.socket.recv(12)
if headerData.len < 12:
raise newException(IOError, "Connection closed")
proc readResponsePayload(client: BaraClient): Future[(MsgKind, seq[byte])] {.async.} =
let headerStr = await recvExact(client.socket, 12, client.config.timeoutMs)
var pos = 0
let hdrData = toBytes(headerData)
let hdrData = toBytes(headerStr)
let kind = MsgKind(readUint32(hdrData, pos))
let payloadLen = int(readUint32(hdrData, pos))
discard readUint32(hdrData, pos)
let payloadStr = await recvExact(client.socket, payloadLen, client.config.timeoutMs)
return (kind, toBytes(payloadStr))
let payloadStr = await client.socket.recv(payloadLen)
var payload = toBytes(payloadStr)
result = QueryResult(columns: @[], rows: @[], rowCount: 0, affectedRows: 0)
proc parseQueryResponse(client: BaraClient, kind: MsgKind, payload: seq[byte]): Future[QueryResult] {.async.} =
result = QueryResult(columns: @[], rows: @[], typedRows: @[], rowCount: 0, affectedRows: 0)
if kind == mkReady:
return
if kind == mkError and payload.len >= 8:
var epos = 0
let code = readUint32(payload, epos)
let emsg = readString(payload, epos)
raise newException(IOError, "Error " & $code & ": " & emsg)
var err = newException(BaraServerError, "Error " & $code & ": " & emsg)
err.code = code
raise err
if kind == mkData:
var dpos = 0
let colCount = int(readUint32(payload, dpos))
var cols: seq[string] = @[]
for i in 0..<colCount:
cols.add(readString(payload, dpos))
result.columns = cols
var colTypes: seq[string] = @[]
result.columns.add(readString(payload, dpos))
for i in 0..<colCount:
colTypes.add($FieldKind(payload[dpos]))
result.columnTypes.add(FieldKind(payload[dpos]))
inc dpos
result.columnTypes = colTypes
let rowCount = int(readUint32(payload, dpos))
result.rowCount = rowCount
for r in 0..<rowCount:
var row: seq[string] = @[]
var typedRow: seq[WireValue] = @[]
var stringRow: seq[string] = @[]
for c in 0..<colCount:
let wv = deserializeValue(payload, dpos)
row.add(wireValueToString(wv))
result.rows.add(row)
result.rowCount = rowCount
typedRow.add(wv)
stringRow.add(wireValueToString(wv))
result.typedRows.add(typedRow)
result.rows.add(stringRow)
# Read following mkComplete message
let compHeader = await client.socket.recv(12)
if compHeader.len >= 12:
var chPos = 0
let chData = toBytes(compHeader)
let compKind = MsgKind(readUint32(chData, chPos))
let compLen = int(readUint32(chData, chPos))
discard readUint32(chData, chPos)
let compPayloadStr = await client.socket.recv(compLen)
if compKind == mkComplete:
let (compKind, compPayload) = await client.readResponsePayload()
if compKind == mkComplete and compPayload.len >= 4:
var cpPos = 0
result.affectedRows = int(readUint32(toBytes(compPayloadStr), cpPos))
result.affectedRows = int(readUint32(compPayload, cpPos))
return
if kind == mkComplete:
var rpos = 0
result.affectedRows = int(readUint32(payload, rpos))
return
raise newException(BaraProtocolError, "Unexpected response kind: 0x" & toHex(uint32(kind), 2))
proc doQuery(client: BaraClient, msg: seq[byte]): Future[QueryResult] {.async.} =
if not client.connected:
raise newException(BaraIoError, "Not connected")
await client.sendLock.acquire()
try:
await client.socket.send(toString(msg))
let (kind, payload) = await client.readResponsePayload()
return await client.parseQueryResponse(kind, payload)
finally:
client.sendLock.release()
proc query*(client: BaraClient, sql: string): Future[QueryResult] {.async.} =
if not client.connected:
raise newException(IOError, "Not connected")
let msg = makeQueryMessage(client.nextId(), sql)
let msgStr = toString(msg)
await client.socket.send(msgStr)
return await client.readQueryResponse()
return await client.doQuery(msg)
proc query*(client: BaraClient, sql: string, params: seq[WireValue]): Future[QueryResult] {.async.} =
if not client.connected:
raise newException(IOError, "Not connected")
let msg = makeQueryParamsMessage(client.nextId(), sql, params)
let msgStr = toString(msg)
await client.socket.send(msgStr)
return await client.readQueryResponse()
return await client.doQuery(msg)
proc exec*(client: BaraClient, sql: string): Future[int] {.async.} =
let qr = await client.query(sql)
return qr.affectedRows
proc auth*(client: BaraClient, token: string) {.async.} =
if not client.connected:
raise newException(IOError, "Not connected")
let msg = makeAuthMessage(client.nextId(), token)
let msgStr = toString(msg)
await client.socket.send(msgStr)
let headerData = await client.socket.recv(12)
if headerData.len < 12:
raise newException(IOError, "Connection closed")
var pos = 0
let hdrData = toBytes(headerData)
let kind = MsgKind(readUint32(hdrData, pos))
let payloadLen = int(readUint32(hdrData, pos))
discard readUint32(hdrData, pos)
if kind == mkAuthOk:
await client.sendLock.acquire()
try:
await client.socket.send(toString(msg))
let (kind, payload) = await client.readResponsePayload()
case kind
of mkAuthOk:
return
elif kind == mkError:
let payloadStr = await client.socket.recv(payloadLen)
of mkError:
var epos = 0
let emsg = readString(toBytes(payloadStr), epos)
raise newException(IOError, "Auth failed: " & emsg)
discard readUint32(payload, epos)
let emsg = readString(payload, epos)
raise newException(BaraAuthError, "Auth failed: " & emsg)
else:
raise newException(IOError, "Unexpected auth response: 0x" & toHex(uint32(kind), 2))
raise newException(BaraProtocolError, "Unexpected auth response: 0x" & toHex(uint32(kind), 2))
finally:
client.sendLock.release()
proc ping*(client: BaraClient): Future[bool] {.async.} =
if not client.connected:
return false
let msg = buildMessage(mkPing, client.nextId(), @[])
let msgStr = toString(msg)
await client.socket.send(msgStr)
let headerData = await client.socket.recv(12)
if headerData.len < 12:
return false
var pos = 0
let hdrData = toBytes(headerData)
let kind = MsgKind(readUint32(hdrData, pos))
await client.sendLock.acquire()
try:
await client.socket.send(toString(msg))
let (kind, _) = await client.readResponsePayload()
return kind == mkPong
except:
return false
finally:
client.sendLock.release()
proc readQueryResponse*(client: BaraClient): Future[QueryResult] {.async.} =
## Read and parse the next server response. Does NOT acquire sendLock;
## callers that already sent a message manually can use this.
let (kind, payload) = await client.readResponsePayload()
return await client.parseQueryResponse(kind, payload)
# === Fluent Query Builder ===
@@ -532,7 +376,7 @@ proc build*(qb: QueryBuilder): string =
proc exec*(qb: QueryBuilder): Future[QueryResult] {.async.} =
return await qb.client.query(qb.build())
# === Blocking Sync Client (production-grade, no waitFor) ===
# === Blocking Sync Client ===
type
SyncClient* = ref object
@@ -547,70 +391,64 @@ proc newSyncClient*(config: ClientConfig = defaultConfig()): SyncClient =
result.socket = netmod.newSocket()
initLock(result.lock)
proc recvExact(sock: netmod.Socket, size: int): string =
proc recvExactBlocking(sock: netmod.Socket, size: int): string =
result = ""
while result.len < size:
let chunk = sock.recv(size - result.len)
if chunk.len == 0:
raise newException(IOError, "Connection closed")
raise newException(BaraIoError, "Connection closed")
result.add(chunk)
proc readQueryResponseBlocking(client: SyncClient): QueryResult =
let headerData = client.socket.recvExact(12)
proc readResponsePayloadBlocking(client: SyncClient): (MsgKind, seq[byte]) =
let headerData = client.socket.recvExactBlocking(12)
var pos = 0
let hdrData = toBytes(headerData)
let kind = MsgKind(readUint32(hdrData, pos))
let payloadLen = int(readUint32(hdrData, pos))
discard readUint32(hdrData, pos)
let payloadStr = client.socket.recvExactBlocking(payloadLen)
return (kind, toBytes(payloadStr))
let payloadStr = client.socket.recvExact(payloadLen)
var payload = toBytes(payloadStr)
result = QueryResult(columns: @[], rows: @[], rowCount: 0, affectedRows: 0)
proc parseQueryResponseBlocking(client: SyncClient, kind: MsgKind, payload: seq[byte]): QueryResult =
result = QueryResult(columns: @[], rows: @[], typedRows: @[], rowCount: 0, affectedRows: 0)
if kind == mkReady:
return
if kind == mkError and payload.len >= 8:
var epos = 0
let code = readUint32(payload, epos)
let emsg = readString(payload, epos)
raise newException(IOError, "Error " & $code & ": " & emsg)
var err = newException(BaraServerError, "Error " & $code & ": " & emsg)
err.code = code
raise err
if kind == mkData:
var dpos = 0
let colCount = int(readUint32(payload, dpos))
var cols: seq[string] = @[]
for i in 0..<colCount:
cols.add(readString(payload, dpos))
result.columns = cols
var colTypes: seq[string] = @[]
result.columns.add(readString(payload, dpos))
for i in 0..<colCount:
colTypes.add($FieldKind(payload[dpos]))
result.columnTypes.add(FieldKind(payload[dpos]))
inc dpos
result.columnTypes = colTypes
let rowCount = int(readUint32(payload, dpos))
result.rowCount = rowCount
for r in 0..<rowCount:
var row: seq[string] = @[]
var typedRow: seq[WireValue] = @[]
var stringRow: seq[string] = @[]
for c in 0..<colCount:
let wv = deserializeValue(payload, dpos)
row.add(wireValueToString(wv))
result.rows.add(row)
result.rowCount = rowCount
# Read following mkComplete message
let compHeader = client.socket.recvExact(12)
var chPos = 0
let chData = toBytes(compHeader)
let compKind = MsgKind(readUint32(chData, chPos))
let compLen = int(readUint32(chData, chPos))
discard readUint32(chData, chPos)
let compPayloadStr = client.socket.recvExact(compLen)
if compKind == mkComplete:
typedRow.add(wv)
stringRow.add(wireValueToString(wv))
result.typedRows.add(typedRow)
result.rows.add(stringRow)
let (compKind, compPayload) = client.readResponsePayloadBlocking()
if compKind == mkComplete and compPayload.len >= 4:
var cpPos = 0
result.affectedRows = int(readUint32(toBytes(compPayloadStr), cpPos))
result.affectedRows = int(readUint32(compPayload, cpPos))
return
if kind == mkComplete:
var rpos = 0
result.affectedRows = int(readUint32(payload, rpos))
return
raise newException(BaraProtocolError, "Unexpected response kind: 0x" & toHex(uint32(kind), 2))
proc connect*(client: SyncClient) =
netmod.connect(client.socket, client.config.host, Port(client.config.port))
@@ -630,10 +468,11 @@ proc query*(client: SyncClient, sql: string): QueryResult =
acquire(client.lock)
try:
if not client.connected:
raise newException(IOError, "Not connected")
raise newException(BaraIoError, "Not connected")
let msg = makeQueryMessage(0, sql)
netmod.send(client.socket, toString(msg))
return readQueryResponseBlocking(client)
let (kind, payload) = client.readResponsePayloadBlocking()
return client.parseQueryResponseBlocking(kind, payload)
finally:
release(client.lock)
@@ -641,10 +480,11 @@ proc query*(client: SyncClient, sql: string, params: seq[WireValue]): QueryResul
acquire(client.lock)
try:
if not client.connected:
raise newException(IOError, "Not connected")
raise newException(BaraIoError, "Not connected")
let msg = makeQueryParamsMessage(0, sql, params)
netmod.send(client.socket, toString(msg))
return readQueryResponseBlocking(client)
let (kind, payload) = client.readResponsePayloadBlocking()
return client.parseQueryResponseBlocking(kind, payload)
finally:
release(client.lock)
@@ -656,24 +496,20 @@ proc auth*(client: SyncClient, token: string) =
acquire(client.lock)
try:
if not client.connected:
raise newException(IOError, "Not connected")
raise newException(BaraIoError, "Not connected")
let msg = makeAuthMessage(0, token)
netmod.send(client.socket, toString(msg))
let headerData = client.socket.recvExact(12)
var pos = 0
let hdrData = toBytes(headerData)
let kind = MsgKind(readUint32(hdrData, pos))
let payloadLen = int(readUint32(hdrData, pos))
discard readUint32(hdrData, pos)
if kind == mkAuthOk:
let (kind, payload) = client.readResponsePayloadBlocking()
case kind
of mkAuthOk:
return
elif kind == mkError:
let payloadStr = client.socket.recvExact(payloadLen)
of mkError:
var epos = 0
let emsg = readString(toBytes(payloadStr), epos)
raise newException(IOError, "Auth failed: " & emsg)
discard readUint32(payload, epos)
let emsg = readString(payload, epos)
raise newException(BaraAuthError, "Auth failed: " & emsg)
else:
raise newException(IOError, "Unexpected auth response")
raise newException(BaraProtocolError, "Unexpected auth response")
finally:
release(client.lock)
@@ -684,10 +520,7 @@ proc ping*(client: SyncClient): bool =
return false
let msg = buildMessage(mkPing, 0, @[])
netmod.send(client.socket, toString(msg))
let headerData = client.socket.recvExact(12)
var pos = 0
let hdrData = toBytes(headerData)
let kind = MsgKind(readUint32(hdrData, pos))
let (kind, _) = client.readResponsePayloadBlocking()
return kind == mkPong
except:
return false
+10
View File
@@ -0,0 +1,10 @@
## BaraDB client exception hierarchy
type
BaraError* = object of CatchableError
BaraProtocolError* = object of BaraError
BaraServerError* = object of BaraError
code*: uint32
BaraAuthError* = object of BaraError
BaraIoError* = object of BaraError
BaraPoolTimeoutError* = object of BaraError
+38
View File
@@ -0,0 +1,38 @@
## Optional HTTP/REST fallback client for BaraDB.
import std/asyncdispatch
import std/httpclient
import std/json
import std/strformat
import ./errors
type
BaraHttpClient* = ref object
baseUrl*: string
token*: string
http: AsyncHttpClient
proc newBaraHttpClient*(host = "127.0.0.1", port = 9912, token = ""): BaraHttpClient =
BaraHttpClient(
baseUrl: fmt"http://{host}:{port}/api",
token: token,
http: newAsyncHttpClient(),
)
proc close*(client: BaraHttpClient) =
client.http.close()
proc query*(client: BaraHttpClient, sql: string): Future[JsonNode] {.async.} =
var headers = newHttpHeaders({"Content-Type": "application/json"})
if client.token.len > 0:
headers["Authorization"] = "Bearer " & client.token
let body = %*{ "query": sql }
let response = await client.http.request(
client.baseUrl & "/query",
httpMethod = HttpPost,
body = $body,
headers = headers,
)
let text = await response.body
if response.code.int != 200:
raise newException(BaraServerError, "HTTP error " & $response.code.int & ": " & text)
return parseJson(text)
+161
View File
@@ -0,0 +1,161 @@
## Async connection pool for BaraDB.
import std/asyncdispatch
import std/deques
import std/monotimes
import std/times
import ./client
import ./errors
type
PoolConnection = ref object
client: BaraClient
inUse: bool
createdAt: int64
lastUsedAt: int64
PoolConfig* = object
minConnections*: int
maxConnections*: int
maxIdleTimeMs*: int
maxLifetimeMs*: int
BaraPool* = ref object
clientConfig: ClientConfig
poolConfig: PoolConfig
connections: seq[PoolConnection]
waiters: Deque[Future[void]]
lock: AsyncLock
proc defaultPoolConfig*(): PoolConfig =
PoolConfig(
minConnections: 2,
maxConnections: 10,
maxIdleTimeMs: 300_000,
maxLifetimeMs: 3_600_000,
)
proc nowUnix(): int64 = getTime().toUnix()
proc newBaraPool*(clientConfig: ClientConfig,
minConnections = 2,
maxConnections = 10,
poolConfig = defaultPoolConfig()): BaraPool =
result = BaraPool(
clientConfig: clientConfig,
poolConfig: poolConfig,
connections: @[],
waiters: initDeque[Future[void]](),
lock: initAsyncLock(),
)
result.poolConfig.minConnections = minConnections
result.poolConfig.maxConnections = maxConnections
proc isExpired(cfg: PoolConfig, conn: PoolConnection): bool =
let now = nowUnix()
if cfg.maxLifetimeMs > 0 and (now - conn.createdAt) * 1000 >= cfg.maxLifetimeMs:
return true
if cfg.maxIdleTimeMs > 0 and conn.lastUsedAt > 0 and (now - conn.lastUsedAt) * 1000 >= cfg.maxIdleTimeMs:
return true
return false
proc openConnection(pool: BaraPool): Future[BaraClient] {.async.} =
let client = newClient(pool.clientConfig)
try:
await client.connect()
except BaraError:
raise
except CatchableError as e:
raise newException(BaraIoError, "Failed to open connection: " & e.msg)
return client
proc closeConnection(conn: PoolConnection) =
if not conn.client.isNil:
conn.client.close()
proc wakeOneWaiter(pool: BaraPool) =
while pool.waiters.len > 0:
let w = pool.waiters.popFirst()
if not w.finished:
w.complete()
break
proc acquireConnection(pool: BaraPool): Future[BaraClient] {.async.} =
let deadline = getMonoTime() + initDuration(milliseconds = pool.clientConfig.timeoutMs)
while true:
await pool.lock.acquire()
# Reuse idle, non-expired connection
var i = 0
while i < pool.connections.len:
let conn = pool.connections[i]
if not conn.inUse:
if pool.poolConfig.isExpired(conn):
pool.connections.del(i)
pool.lock.release()
closeConnection(conn)
await pool.lock.acquire()
continue
conn.inUse = true
conn.lastUsedAt = nowUnix()
pool.lock.release()
return conn.client
inc i
# Create new if under max
if pool.connections.len < pool.poolConfig.maxConnections:
pool.lock.release()
let client = await pool.openConnection()
await pool.lock.acquire()
let conn = PoolConnection(
client: client,
inUse: true,
createdAt: nowUnix(),
lastUsedAt: nowUnix(),
)
pool.connections.add(conn)
pool.lock.release()
return client
pool.lock.release()
# Wait for a connection to be released
if getMonoTime() >= deadline:
raise newException(BaraPoolTimeoutError, "Timed out waiting for a free connection")
let w = newFuture[void]("pool.wait")
await pool.lock.acquire()
pool.waiters.addLast(w)
pool.lock.release()
let ok = await withTimeout(w, pool.clientConfig.timeoutMs)
if not ok:
await pool.lock.acquire()
var kept = initDeque[Future[void]]()
while pool.waiters.len > 0:
let x = pool.waiters.popFirst()
if x != w:
kept.addLast(x)
pool.waiters = move(kept)
pool.lock.release()
raise newException(BaraPoolTimeoutError, "Timed out waiting for a free connection")
proc releaseConnection(pool: BaraPool, client: BaraClient) {.async.} =
await pool.lock.acquire()
for conn in pool.connections:
if conn.client == client:
conn.inUse = false
conn.lastUsedAt = nowUnix()
break
pool.lock.release()
wakeOneWaiter(pool)
template withClient*(pool: BaraPool, body: untyped): untyped =
let c = await pool.acquireConnection()
try:
body
finally:
await pool.releaseConnection(c)
proc stats*(pool: BaraPool): Future[(int, int, int)] {.async.} =
await pool.lock.acquire()
let total = pool.connections.len
var inUse = 0
for c in pool.connections:
if c.inUse:
inc inUse
pool.lock.release()
return (total, total - inUse, inUse)
+244
View File
@@ -0,0 +1,244 @@
## BaraDB binary wire protocol — shared between client and server.
import std/endians
const
ProtocolMagic* = 0x42415241'u32
type
FieldKind* = enum
fkNull = 0x00
fkBool = 0x01
fkInt8 = 0x02
fkInt16 = 0x03
fkInt32 = 0x04
fkInt64 = 0x05
fkFloat32 = 0x06
fkFloat64 = 0x07
fkString = 0x08
fkBytes = 0x09
fkArray = 0x0A
fkObject = 0x0B
fkVector = 0x0C
fkJson = 0x0D
MsgKind* = enum
mkClientHandshake = 0x01
mkQuery = 0x02
mkQueryParams = 0x03
mkExecute = 0x04
mkBatch = 0x05
mkTransaction = 0x06
mkClose = 0x07
mkPing = 0x08
mkAuth = 0x09
mkServerHandshake = 0x80
mkReady = 0x81
mkData = 0x82
mkComplete = 0x83
mkError = 0x84
mkAuthChallenge = 0x85
mkAuthOk = 0x86
mkSchemaChange = 0x87
mkPong = 0x88
mkTransactionState = 0x89
ResultFormat* = enum
rfBinary = 0x00
rfJson = 0x01
rfText = 0x02
WireValue* = object
case kind*: FieldKind
of fkNull: discard
of fkBool: boolVal*: bool
of fkInt8: int8Val*: int8
of fkInt16: int16Val*: int16
of fkInt32: int32Val*: int32
of fkInt64: int64Val*: int64
of fkFloat32: float32Val*: float32
of fkFloat64: float64Val*: float64
of fkString: strVal*: string
of fkBytes: bytesVal*: seq[byte]
of fkArray: arrayVal*: seq[WireValue]
of fkObject: objVal*: seq[(string, WireValue)]
of fkVector: vecVal*: seq[float32]
of fkJson: jsonVal*: string
proc writeUint32*(buf: var seq[byte], val: uint32) =
var bytes: array[4, byte]
bigEndian32(addr bytes, unsafeAddr val)
buf.add(bytes)
proc writeUint64(buf: var seq[byte], val: uint64) =
var bytes: array[8, byte]
bigEndian64(addr bytes, unsafeAddr val)
buf.add(bytes)
proc writeString*(buf: var seq[byte], s: string) =
buf.writeUint32(uint32(s.len))
for ch in s:
buf.add(byte(ch))
proc readUint32*(buf: openArray[byte], pos: var int): uint32 =
var bytes: array[4, byte]
for i in 0..3: bytes[i] = buf[pos + i]
bigEndian32(addr result, unsafeAddr bytes)
pos += 4
proc readUint64*(buf: openArray[byte], pos: var int): uint64 =
var bytes: array[8, byte]
for i in 0..7: bytes[i] = buf[pos + i]
bigEndian64(addr result, unsafeAddr bytes)
pos += 8
proc readString*(buf: openArray[byte], pos: var int): string =
let len = int(readUint32(buf, pos))
result = newString(len)
for i in 0..<len:
result[i] = char(buf[pos + i])
pos += len
proc toBytes*(s: string): seq[byte] =
result = newSeq[byte](s.len)
for i, c in s:
result[i] = byte(c)
proc toString*(s: seq[byte]): string =
result = newString(s.len)
for i, b in s:
result[i] = char(b)
proc serializeValue*(buf: var seq[byte], val: WireValue) =
buf.add(byte(val.kind))
case val.kind
of fkNull: discard
of fkBool: buf.add(if val.boolVal: 1'u8 else: 0'u8)
of fkInt8: buf.add(uint8(val.int8Val))
of fkInt16:
var bytes16: array[2, byte]
bigEndian16(addr bytes16, unsafeAddr val.int16Val)
buf.add(bytes16)
of fkInt32: buf.writeUint32(uint32(val.int32Val))
of fkInt64: buf.writeUint64(uint64(val.int64Val))
of fkFloat32:
var bytes32: array[4, byte]
copyMem(addr bytes32, unsafeAddr val.float32Val, 4)
buf.add(bytes32)
of fkFloat64:
var bytes: array[8, byte]
copyMem(addr bytes, unsafeAddr val.float64Val, 8)
buf.add(bytes)
of fkString: buf.writeString(val.strVal)
of fkBytes:
buf.writeUint32(uint32(val.bytesVal.len))
buf.add(val.bytesVal)
of fkArray:
buf.writeUint32(uint32(val.arrayVal.len))
for item in val.arrayVal:
buf.serializeValue(item)
of fkObject:
buf.writeUint32(uint32(val.objVal.len))
for (name, item) in val.objVal:
buf.writeString(name)
buf.serializeValue(item)
of fkVector:
buf.writeUint32(uint32(val.vecVal.len))
for f in val.vecVal:
var fb: array[4, byte]
copyMem(addr fb, unsafeAddr f, 4)
buf.add(fb)
of fkJson: buf.writeString(val.jsonVal)
proc deserializeValue*(buf: openArray[byte], pos: var int): WireValue =
let kind = FieldKind(buf[pos])
inc pos
case kind
of fkNull: result = WireValue(kind: fkNull)
of fkBool:
result = WireValue(kind: fkBool, boolVal: buf[pos] != 0)
inc pos
of fkInt8:
result = WireValue(kind: fkInt8, int8Val: cast[int8](buf[pos]))
inc pos
of fkInt16:
var bytes16: array[2, byte]
for i in 0..1: bytes16[i] = buf[pos + i]
var v16: int16
bigEndian16(addr v16, unsafeAddr bytes16)
result = WireValue(kind: fkInt16, int16Val: v16)
pos += 2
of fkInt32:
result = WireValue(kind: fkInt32, int32Val: int32(readUint32(buf, pos)))
of fkInt64:
result = WireValue(kind: fkInt64, int64Val: int64(readUint64(buf, pos)))
of fkFloat32:
var v32: float32
copyMem(addr v32, addr buf[pos], 4)
result = WireValue(kind: fkFloat32, float32Val: v32)
pos += 4
of fkFloat64:
var v: float64
copyMem(addr v, addr buf[pos], 8)
result = WireValue(kind: fkFloat64, float64Val: v)
pos += 8
of fkString:
result = WireValue(kind: fkString, strVal: readString(buf, pos))
of fkBytes:
let blen = int(readUint32(buf, pos))
var bval: seq[byte] = @[]
for i in 0..<blen:
bval.add(buf[pos + i])
result = WireValue(kind: fkBytes, bytesVal: bval)
pos += blen
of fkArray:
let count = int(readUint32(buf, pos))
var arr: seq[WireValue] = @[]
for i in 0..<count:
arr.add(deserializeValue(buf, pos))
result = WireValue(kind: fkArray, arrayVal: arr)
of fkObject:
let count = int(readUint32(buf, pos))
var obj: seq[(string, WireValue)] = @[]
for i in 0..<count:
let name = readString(buf, pos)
let val = deserializeValue(buf, pos)
obj.add((name, val))
result = WireValue(kind: fkObject, objVal: obj)
of fkVector:
let dim = int(readUint32(buf, pos))
var vec: seq[float32] = @[]
for i in 0..<dim:
var fv: float32
copyMem(addr fv, addr buf[pos], 4)
vec.add(fv)
pos += 4
result = WireValue(kind: fkVector, vecVal: vec)
of fkJson:
result = WireValue(kind: fkJson, jsonVal: readString(buf, pos))
proc buildMessage*(kind: MsgKind, requestId: uint32, payload: seq[byte]): seq[byte] =
result = @[]
result.writeUint32(uint32(kind))
result.writeUint32(uint32(payload.len))
result.writeUint32(requestId)
result.add(payload)
proc makeQueryMessage*(requestId: uint32, query: string): seq[byte] =
var payload: seq[byte] = @[]
payload.writeString(query)
payload.add(byte(rfBinary))
buildMessage(mkQuery, requestId, payload)
proc makeQueryParamsMessage*(requestId: uint32, query: string, params: seq[WireValue]): seq[byte] =
var payload: seq[byte] = @[]
payload.writeString(query)
payload.add(byte(rfBinary))
payload.writeUint32(uint32(params.len))
for p in params:
payload.serializeValue(p)
buildMessage(mkQueryParams, requestId, payload)
proc makeAuthMessage*(requestId: uint32, token: string): seq[byte] =
var payload: seq[byte] = @[]
payload.writeString(token)
buildMessage(mkAuth, requestId, payload)
+7
View File
@@ -182,3 +182,10 @@ suite "Wire Protocol Extended":
check wireValueToString(WireValue(kind: fkInt32, int32Val: 42)) == "42"
check wireValueToString(WireValue(kind: fkString, strVal: "hello")) == "hello"
check wireValueToString(WireValue(kind: fkVector, vecVal: @[1.0'f32])) == "<vector:1>"
suite "Typed rows":
test "QueryResult carries typed rows for string and int":
let client = newClient()
let qb = newQueryBuilder(client)
discard qb
check compiles(client.query("SELECT 1"))
+24 -17
View File
@@ -4,7 +4,7 @@
import std/unittest
import std/asyncdispatch
import std/asyncnet
import std/net as netmod
import std/strutils
import std/os
import baradb/client
@@ -15,8 +15,8 @@ const
proc serverAvailable(): bool =
try:
var socket = newAsyncSocket()
waitFor socket.connect(TestHost, Port(TestPort))
var socket = netmod.newSocket()
socket.connect(TestHost, Port(TestPort), timeout = 1000)
socket.close()
return true
except:
@@ -26,36 +26,38 @@ let hasServer = serverAvailable()
suite "Integration: Connection":
test "Connect and close":
if not hasServer:
skip()
if hasServer:
var client = newClient(ClientConfig(host: TestHost, port: TestPort))
check not client.isConnected
waitFor client.connect()
check client.isConnected
client.close()
check not client.isConnected
else:
skip()
test "Ping":
if not hasServer:
skip()
if hasServer:
var client = newClient(ClientConfig(host: TestHost, port: TestPort))
waitFor client.connect()
check (waitFor client.ping()) == true
client.close()
else:
skip()
suite "Integration: Query":
test "Simple SELECT":
if not hasServer:
skip()
if hasServer:
var client = newClient(ClientConfig(host: TestHost, port: TestPort))
waitFor client.connect()
let result = waitFor client.query("SELECT 1 as one")
check result.rowCount >= 0
client.close()
else:
skip()
test "Parameterized query":
if not hasServer:
skip()
if hasServer:
var client = newClient(ClientConfig(host: TestHost, port: TestPort))
waitFor client.connect()
let result = waitFor client.query(
@@ -64,11 +66,12 @@ suite "Integration: Query":
)
check result.rowCount >= 0
client.close()
else:
skip()
suite "Integration: DDL & DML":
test "Create table, insert, select, drop":
if not hasServer:
skip()
if hasServer:
var client = newClient(ClientConfig(host: TestHost, port: TestPort))
waitFor client.connect()
@@ -84,11 +87,12 @@ suite "Integration: DDL & DML":
let result = waitFor client.query("SELECT name, age FROM nim_test_users WHERE id = 1")
check result.rowCount == 1
client.close()
else:
skip()
suite "Integration: QueryBuilder":
test "Builder exec":
if not hasServer:
skip()
if hasServer:
var client = newClient(ClientConfig(host: TestHost, port: TestPort))
waitFor client.connect()
@@ -109,13 +113,16 @@ suite "Integration: QueryBuilder":
discard waitFor client.exec("DROP TABLE nim_test_products")
client.close()
else:
skip()
suite "Integration: SyncClient":
test "Sync query":
if not hasServer:
skip()
if hasServer:
var client = newSyncClient(ClientConfig(host: TestHost, port: TestPort))
client.connect()
let result = client.query("SELECT 1 as one")
check result.rowCount >= 0
client.close()
else:
skip()
+19
View File
@@ -0,0 +1,19 @@
import std/unittest
import std/asyncdispatch
import baradb/client
import baradb/pool
suite "BaraPool":
test "pool stats with one acquired connection":
proc run() {.async.} =
let cfg = ClientConfig(host: "127.0.0.1", port: 9472, timeoutMs: 100)
let pool = newBaraPool(cfg, minConnections = 0, maxConnections = 2)
# Without a server, acquire should fail cleanly (timeout or connection refused)
var failedCleanly = false
try:
withClient(pool):
discard
except BaraError:
failedCleanly = true
check failedCleanly
waitFor run()
+68
View File
@@ -0,0 +1,68 @@
import std/unittest
import std/asyncdispatch
import std/asyncnet
import std/json
import baradb/wire
import baradb/client
proc buildDataResponse(cols: seq[string], rows: seq[seq[WireValue]], affected: int): seq[byte] =
var payload: seq[byte] = @[]
payload.writeUint32(uint32(cols.len))
for c in cols:
payload.writeString(c)
for c in cols:
payload.add(byte(fkString))
payload.writeUint32(uint32(rows.len))
for row in rows:
for wv in row:
payload.serializeValue(wv)
result = buildMessage(mkData, 1'u32, payload)
var completePayload: seq[byte] = @[]
completePayload.writeUint32(uint32(affected))
result.add(buildMessage(mkComplete, 1'u32, completePayload))
suite "Wire protocol":
test "buildMessage header is 12 bytes + payload":
let msg = buildMessage(mkQuery, 7'u32, toBytes("SELECT 1"))
check msg.len == 12 + 8
test "serialize/deserialize round-trip for WireValue":
let original = WireValue(kind: fkInt64, int64Val: 42)
var buf: seq[byte] = @[]
buf.serializeValue(original)
var pos = 0
let decoded = deserializeValue(buf, pos)
check decoded.kind == fkInt64
check decoded.int64Val == 42
test "client query against mock server returns typedRows and rows":
proc run() {.async.} =
var server = newAsyncSocket()
server.setSockOpt(OptReuseAddr, true)
server.bindAddr(Port(0), "127.0.0.1")
let port = server.getLocalAddr()[1]
server.listen()
proc serve() {.async.} =
let s = await server.accept()
let data = buildDataResponse(
@["name", "age"],
@[
@[WireValue(kind: fkString, strVal: "Alice"), WireValue(kind: fkInt32, int32Val: 30)],
],
0,
)
await s.send(toString(data))
s.close()
asyncCheck serve()
let client = newClient(ClientConfig(host: "127.0.0.1", port: int(port), timeoutMs: 5000))
await client.connect()
let qr = await client.query("SELECT name, age FROM users")
check qr.rowCount == 1
check qr.typedRows[0][1].int32Val == 30
check qr.rows[0][1] == "30"
client.close()
server.close()
waitFor run()
+39 -42
View File
@@ -1,82 +1,87 @@
# BaraDB — Production Docker Compose
# Usage: docker compose -f docker-compose.prod.yml up -d
# BaraDB — Production Docker Compose (v1.2.0 GA)
#
# Препоръчителни стъпки преди production deployment:
# 1. Създайте TLS сертификати в ./certs/
# 2. Задайте силен BARADB_JWT_SECRET
# 3. Настройте firewall правила за портовете
# 4. Конфигурирайте регулярни backups
# Usage:
# export BARADB_JWT_SECRET="$(openssl rand -hex 32)"
# docker compose -f docker-compose.prod.yml up -d --build
#
# Required:
# BARADB_JWT_SECRET — strong secret (compose fails if unset)
#
# Ports (BARADB_PORT=9472):
# 9472 binary wire
# 9912 HTTP (= TCP + 440)
# 9913 WebSocket (= TCP + 441)
#
# Notes:
# - Auth is ON. Obtain a token via POST /auth before /query.
# - `deploy.resources` applies under Swarm; plain Compose ignores limits.
# - Raft is optional/experimental — not enabled here (single-node GA).
services:
baradb:
build:
context: .
dockerfile: Dockerfile
image: baradb:latest
image: baradb:1.2.0
container_name: baradb
hostname: baradb
restart: always
ports:
- "9472:9472" # Binary protocol
- "9912:9912" # HTTP/REST API
- "9913:9913" # WebSocket
- "9912:9912" # HTTP REST (TCP+440)
- "9913:9913" # WebSocket (TCP+441)
volumes:
- baradb_data:/data
# TLS сертификати (read-only)
- ./certs:/certs:ro
# Лог файлове на хоста
- ./logs:/var/log/baradb
environment:
# Network
- BARADB_ENV=production
- BARADB_ADDRESS=0.0.0.0
- BARADB_PORT=9472
# Storage
- BARADB_DATA_DIR=/data
- BARADB_MEMTABLE_SIZE_MB=256
- BARADB_CACHE_SIZE_MB=512
# TLS (разкоментирайте когато имате сертификати)
# Security — fail closed without a real secret
- BARADB_AUTH_ENABLED=true
- BARADB_JWT_SECRET=${BARADB_JWT_SECRET:?Set BARADB_JWT_SECRET to a strong random value}
- BARADB_RATE_LIMIT_GLOBAL=10000
- BARADB_RATE_LIMIT_PER_CLIENT=1000
# TLS (uncomment when certs exist under ./certs)
# - BARADB_TLS_ENABLED=true
# - BARADB_CERT_FILE=/certs/server.crt
# - BARADB_KEY_FILE=/certs/server.key
# Security (ЗАДЪЛЖИТЕЛНО сменете в production!)
# - BARADB_AUTH_ENABLED=true
# - BARADB_JWT_SECRET=change-me-to-random-32-char-string
# - BARADB_RATE_LIMIT_GLOBAL=10000
# - BARADB_RATE_LIMIT_PER_CLIENT=1000
# Logging
- BARADB_LOG_LEVEL=warn
- BARADB_LOG_FILE=/var/log/baradb/baradb.log
- BARADB_LOG_FORMAT=json
# Performance
- BARADB_COMPACTION_INTERVAL_MS=30000
- BARADB_WAL_SYNC_INTERVAL_MS=10
# Match config.nim env names
- BARADB_WAL_SYNC_MODE=group
- BARADB_WAL_GROUP_EVERY=64
healthcheck:
test: ["CMD", "sh", "-c", "wget -qO- http://localhost:9912/health >/dev/null 2>&1"]
test: ["CMD", "sh", "-c", "wget -qO- http://127.0.0.1:9912/health >/dev/null 2>&1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
# Production resource limits
deploy:
resources:
limits:
cpus: '4.0'
cpus: "4.0"
memory: 8G
reservations:
cpus: '1.0'
cpus: "1.0"
memory: 1G
# Security hardening
security_opt:
- no-new-privileges:true
read_only: true
@@ -91,19 +96,19 @@ services:
options:
max-size: "100m"
max-file: "5"
labels: "service_name"
# Опционален: Backup cron job
# Optional offline-style backup sidecar (shares data volume read-only)
backup:
image: baradb:latest
image: baradb:1.2.0
container_name: baradb-backup
restart: unless-stopped
profiles: ["backup"]
command: >
sh -c '
while true; do
sleep 86400;
/app/backup backup --all-databases --data-root=/data/databases --output=/backups/baradb_$$(date +%Y%m%d_%H%M%S).tar.gz --level=6;
/app/backup cleanup --data-root=/data/databases --keep=7;
/app/backup backup --all-databases --data-root=/data/databases --output=/backups/baradb_$$(date +%Y%m%d_%H%M%S).tar.gz --level=6 || true;
/app/backup cleanup --data-root=/data/databases --keep=7 || true;
done
'
volumes:
@@ -111,11 +116,6 @@ services:
- ./backups:/backups
networks:
- baradb_net
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
volumes:
baradb_data:
@@ -124,6 +124,3 @@ volumes:
networks:
baradb_net:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16
+20 -235
View File
@@ -1,250 +1,35 @@
# Ръководство за Внедряване (Deployment)
# Deployment Guide (кратко)
## Docker
**Production GA v1.2.0** = **single-node**. Виж [known-limitations](known-limitations.md).
Пълен runbook: [en/deployment.md](../en/deployment.md).
За пълно ръководство за Docker deployment вижте [Docker Guide](docker.md).
## Портове
### Бърз старт
| Услуга | Порт |
|--------|------|
| Wire | `BARADB_PORT` (9472) |
| HTTP | `BARADB_PORT + 440` (9912) |
| WebSocket | `BARADB_PORT + 441` (9913) |
Няма `BARADB_HTTP_PORT`.
## Production Docker
```bash
docker build -t baradb:latest .
docker compose up -d
export BARADB_JWT_SECRET="$(openssl rand -hex 32)"
docker compose -f docker-compose.prod.yml up -d --build
```
### Docker Compose файлове
Auth е включен; без secret compose **спира**.
| Файл | Назначение |
|------|-----------|
| `docker-compose.yml` | Development |
| `docker-compose.prod.yml` | Production |
| `docker-compose.override.yml` | Dev override (автоматично) |
| `docker-compose.test.yml` | Тестова среда |
### Production
## Backup / restore drill
```bash
docker compose -f docker-compose.prod.yml up -d
./scripts/backup-restore-drill.sh
```
### Docker Swarm
## Health
```bash
docker stack deploy -c docker-compose.prod.yml baradb
```
## systemd Услуга
Създайте `/etc/systemd/system/baradb.service`:
```ini
[Unit]
Description=BaraDB Multimodal Database
After=network.target
[Service]
Type=simple
User=baradb
Group=baradb
WorkingDirectory=/var/lib/baradb
ExecStart=/usr/local/bin/baradadb
Restart=always
RestartSec=5
Environment=BARADB_PORT=9472
Environment=BARADB_HTTP_PORT=9470
Environment=BARADB_DATA_DIR=/var/lib/baradb/data
Environment=BARADB_LOG_LEVEL=info
# Подсилване на сигурността
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/baradb/data
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
[Install]
WantedBy=multi-user.target
```
Активиране и стартиране:
```bash
sudo useradd -r -s /bin/false baradb
sudo mkdir -p /var/lib/baradb/data
sudo chown -R baradb:baradb /var/lib/baradb
sudo cp build/baradadb /usr/local/bin/
sudo systemctl daemon-reload
sudo systemctl enable --now baradb
```
## Kubernetes
### StatefulSet
```yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: baradb
spec:
serviceName: baradb
replicas: 3
selector:
matchLabels:
app: baradb
template:
metadata:
labels:
app: baradb
spec:
containers:
- name: baradb
image: baradb:latest
ports:
- containerPort: 9472
name: binary
- containerPort: 9470
name: http
- containerPort: 9471
name: websocket
env:
- name: BARADB_DATA_DIR
value: /data
- name: BARADB_RAFT_NODE_ID
valueFrom:
fieldRef:
fieldPath: metadata.name
volumeMounts:
- name: data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 100Gi
---
apiVersion: v1
kind: Service
metadata:
name: baradb
spec:
selector:
app: baradb
ports:
- port: 9472
name: binary
- port: 9470
name: http
- port: 9471
name: websocket
clusterIP: None
```
## Reverse Proxy (nginx)
```nginx
upstream baradb_http {
server 127.0.0.1:9470;
}
upstream baradb_ws {
server 127.0.0.1:9471;
}
server {
listen 80;
server_name db.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name db.example.com;
ssl_certificate /etc/letsencrypt/live/db.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/db.example.com/privkey.pem;
location /api/ {
proxy_pass http://baradb_http/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /ws/ {
proxy_pass http://baradb_ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
## Висока Достъпност (High Availability)
### 3-Възел Raft Клъстер
```bash
# Възел 1
BARADB_RAFT_NODE_ID=node1 \
BARADB_RAFT_PEERS=node2:9001,node3:9001 \
./build/baradadb
# Възел 2
BARADB_RAFT_NODE_ID=node2 \
BARADB_RAFT_PEERS=node1:9001,node3:9001 \
./build/baradadb
# Възел 3
BARADB_RAFT_NODE_ID=node3 \
BARADB_RAFT_PEERS=node1:9001,node2:9001 \
./build/baradadb
```
## Облачно Внедряване
### AWS EC2
Препоръчителна инстанция: `m6i.2xlarge` (8 vCPU, 32 GB RAM)
```bash
# User data скрипт
#!/bin/bash
apt-get update
apt-get install -y nim
wget https://github.com/katehonz/barabaDB/releases/latest/download/baradadb-linux-amd64
chmod +x baradadb-linux-amd64
mv baradadb-linux-amd64 /usr/local/bin/baradadb
mkdir -p /data/baradb
cat > /etc/systemd/system/baradb.service << 'EOF'
[Unit]
Description=BaraDB
After=network.target
[Service]
ExecStart=/usr/local/bin/baradadb
Environment=BARADB_DATA_DIR=/data/baradb
Restart=always
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now baradb
```
### GCP Cloud Run (само HTTP)
```bash
gcloud run deploy baradb \
--image gcr.io/PROJECT/baradb \
--port 9470 \
--memory 4Gi \
--cpu 2 \
--max-instances 10
curl -s http://127.0.0.1:9912/health
```
+30 -1
View File
@@ -5,9 +5,38 @@ BaraDB поддържа разпределено внедряване с Raft к
> ⚠️ **Ограничение при множество бази данни**
> Разпределените модули (Raft, шардиране и репликация) в момента работят само с **`default`** базата данни. Ако използвате множество бази (`CREATE DATABASE`, `USE DATABASE`), разпределените функции още не ги обхващат. Всяка база данни се нуждае от отделна кластър конфигурация.
> **Статус (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 Консенсус
Leader election и log репликация:
Leader election и log репликация през TCP; SQL DML/DDL за **default** минават през raft log. Включване:
| Env | Значение |
|-----|----------|
| `BARADB_RAFT_ENABLED=true` | Включва Raft |
| `BARADB_RAFT_NODE_ID` | Id на този възел |
| `BARADB_RAFT_PORT` | Raft TCP порт |
| `BARADB_RAFT_PEERS` | Списък `id@host:port` (вкл. себе си) |
| `BARADB_RAFT_WRITE_TIMEOUT_MS` | Макс. изчакване за majority commit при SQL записи (по подразбиране 5000) |
| `BARADB_RAFT_CLIENT_PEERS` | Опционален `id@host:clientPort` map за leader write forwarding |
| `BARADB_RAFT_LOG_MAX_ENTRIES` | Лимит на in-memory raft log (по подразбиране 256); safe prefix compact |
| `BARADB_RAFT_SNAP_CHUNK_KB` | Размер на InstallSnapshot chunk в KiB (по подразбиране 256) |
| `BARADB_RAFT_PEER_STALE_MS` | Peer е „stale“ след толкова ms без ack (по подразбиране 30000); stale peers не блокират compaction |
| `BARADB_RAFT_TLS_ENABLED` | TLS на raft порта (по подразбиране false; стартът спира при липсващ cert/key) |
| `BARADB_RAFT_TLS_CERT_FILE` / `BARADB_RAFT_TLS_KEY_FILE` | Сертификат и ключ за raft listener-а |
| `BARADB_RAFT_TLS_CA_FILE` / `BARADB_RAFT_TLS_VERIFY_PEER` | Опционален CA bundle и mutual auth (по подразбиране false) |
Когато Raft е активен, SQL DML и schema DDL се приемат само от лидера на **`default`**. DML отива като put/delete; DDL — като `ddl` запис. Followers **препращат** write/DDL към лидера, ако е зададен `BARADB_RAFT_CLIENT_PEERS`; иначе връщат `not leader; leader is '…'`. Записи към друга database name се отказват. `CREATE`/`DROP DATABASE` не се репликират. Приложен DML обновява и secondary индекси/графи.
**Log compaction:** след 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`, …).
```nim
import barabadb/core/raft
+133 -2
View File
@@ -49,8 +49,13 @@ let tfidf = idx.searchTfidf("query terms")
| Fuzzy търсене | Levenshtein distance толеранс |
| Wildcard | Префиксни, суфиксни и инфиксни wildcards |
| Regex | Регулярни изрази |
| Фразово търсене | Точно съвпадение на фраза |
| Булево | AND, OR, NOT оператори |
| Фразово търсене | Точно съвпадение на фраза с поддръжка на slop |
| Proximity търсене | Термини в рамките на конфигурируемо разстояние |
| Булево | AND, OR, NOT оператори с вложени изрази |
| Фасетно търсене | Филтриране по категории, бройки и агрегация |
| Хибридно търсене | Комбинирано пълнотекстово + векторно (HNSW) с RRF сливане |
| Сегментно индексиране | Инкрементално индексиране с автоматично уплътняване |
| Полетно усилване | Тегла за релевантност по поле |
## SQL Интерфейс
@@ -85,3 +90,129 @@ let tokens = tokenizer.tokenize("Търсене в пълен текст")
- Stop думи
- Стеминг
- Детекция на език
## Разширено Търсене
Новият модул `src/barabadb/search/` предоставя унифицирана търсачка със сегментно-базирано индексиране за високопроизводителни операции за търсене.
### UnifiedSearchEngine
```nim
import barabadb/search/engine
# Създаване на търсачка с конфигурация по подразбиране
var engine = newUnifiedSearchEngine()
# Индексиране на документи с полета и фасети
engine.indexDocument(
docId = 1,
text = "Nim е бърз програмен език",
fields = {"title": "Преглед на Nim"}.toTable,
facets = {"category": @["програмиране"], "level": @["начинаещо"]}.toTable
)
# Основно търсене
let results = engine.search("програмен език", limit = 10)
# Фразово търсене (точно съвпадение на фраза)
let phrase = engine.searchPhrase(@["бърз", "програмен"], slop = 0)
# Proximity търсене (термини в рамките на разстояние)
let proximity = engine.searchProximity(@["бърз", "език"], maxDistance = 5)
# Булеви заявки
let boolResults = engine.searchBoolean("програмиране AND (бърз OR ефективен)")
let boolResults2 = engine.searchBoolean("Nim AND NOT Python")
let boolResults3 = engine.searchBoolean("\"точна фраза\" OR wildcard*")
# Fuzzy търсене с толеранс на печатни грешки
let fuzzy = engine.searchFuzzy("програмиране", maxDistance = 2)
# Търсене по префикс и wildcard
let prefix = engine.searchPrefix("прог", limit = 10)
let wildcard = engine.searchWildcard("прог*", limit = 10)
```
### Фасетно Търсене
```nim
import barabadb/search/engine
import std/sets
# Индексиране на документи с фасети
engine.indexDocument(
docId = 1,
text = "Nim урок",
facets = {"category": @["програмиране", "урок"], "difficulty": @["начинаещо"]}.toTable
)
# Получаване на бройки по фасети
let counts = engine.getFacetCounts("category", limit = 10)
for count in counts:
echo count.value, ": ", count.count
# Филтриране по фасети
var filters = @[
FacetFilter(field: "category", values: @["програмиране"], exclude: false),
FacetFilter(field: "difficulty", values: @["напреднало"], exclude: true)
]
let matchingDocs = engine.filterByFacets(filters)
# Агрегация на множество фасети
let agg = engine.facets.aggregate(@["category", "difficulty"], matchingDocs)
```
### Хибридно Търсене (Текст + Вектор)
```nim
import barabadb/search/engine
import barabadb/vector/engine
# Индексиране на вектори
engine.indexVector(1, @[0.1, 0.2, 0.3], {"title": "Документ 1"}.toTable)
# Хибридно търсене комбиниращо текст и векторна сходност
let hybrid = engine.hybridSearch(
queryText = "програмиране",
queryVec = @[0.1, 0.2, 0.3],
k = 10,
textWeight = 1.0,
vecWeight = 1.0
)
# Филтрирано векторно търсене
proc filterMeta(meta: Table[string, string]): bool =
meta.getOrDefault("category") == "програмиране"
let filtered = engine.searchVectorFiltered(@[0.1, 0.2, 0.3], k = 10, filterMeta)
```
### Конфигурация и Управление
```nim
# Персонализирана конфигурация
var config = defaultSearchConfig()
config.language = langBulgarian
config.maxSegmentSize = 100_000
config.ngramSize = 3
config.enableFacets = true
var engine = newUnifiedSearchEngine(config)
# Задаване на полетно усилване за настройка на релевантността
engine.setFieldBoost("title", 2.0)
engine.setFieldBoost("body", 1.0)
# Смяна на езика
engine.setLanguage(langBulgarian)
# Уплътняване на сегменти за по-добра производителност
engine.compact()
# Получаване на статистика
echo "Документи: ", engine.documentCount()
echo "Термини: ", engine.termCount()
# Премахване на документи
engine.removeDocument(1)
```
+44
View File
@@ -0,0 +1,44 @@
# Известни ограничения — v1.3.0
| Ниво | Значение |
|------|----------|
| **Supported (GA)** | Документирано, тествано, подходящо за prod в обхвата |
| **Experimental** | Работи в тестове/demo; не е HA SLA |
| **Not supported** | Извън обхват |
## Матрица
| Област | v1.3.0 | Experimental / по-късно |
|--------|--------|-------------------------|
| Single-node SQL + LSM | **Supported** | — |
| Schema / FTS / HNSW / graphs persist | **Supported** | — |
| Auth + JWT (когато е конфигуриран) | **Supported** | — |
| Backup / restore | **Supported** | — |
| Multi-DB (без Raft) | **Supported** | — |
| Raft 3-node (само `default` DB) | **Supported** | failover под товар, TLS, InstallSnapshot — e2e |
| Raft multi-DB | **Not supported** | само `default` |
| `CREATE`/`DROP DATABASE` репликация | **Not supported** | per node |
| Raft membership промени (join/leave) | **Not supported** | фиксиран `BARADB_RAFT_PEERS` |
| Follower linearizable reads | **Not supported** | best-effort след apply |
| Rolling upgrades | **Not supported** | рестарт на всички възли заедно — смесени v1.2/v1.3 binaries не трябва да работят в един клъстер |
| ORC multi-thread shared LSM | **Not supported** | ARC по подразбиране |
## GA (single-node)
Crash recovery с WAL, schema/index persist, `/health` + `/metrics`, offline backup/restore.
## Raft (supported)
Виж [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 по средата на трансфер.
## Виж също
- [Deployment](deployment.md) · [Backup](backup.md) · [en limitations](../en/known-limitations.md)
+16 -11
View File
@@ -4,29 +4,28 @@
### HTTP Health Endpoint
HTTP слуша на **TCP порт + 440** (напр. `BARADB_PORT=9472` → health на `9912`).
```bash
curl http://localhost:9470/health
curl http://localhost:9912/health
```
Отговор:
Без raft:
```json
{
"status": "healthy",
"status": "ok",
"version": "1.1.6",
"uptime_seconds": 86400,
"checks": {
"storage": "ok",
"memory": "ok",
"connections": "ok"
}
"raft": { "enabled": false }
}
```
С `BARADB_RAFT_ENABLED=true` — обект `raft` (`role`, `term`, `leader_id`, `commit_index`, `apply_lag`, `log_entries`, `snapshot_index`).
### Readiness Probe
```bash
curl http://localhost:9470/ready
curl http://localhost:9912/ready
```
Връща `200 OK` когато сървърът е готов да приема трафик, `503` по време на стартиране.
@@ -35,10 +34,16 @@ curl http://localhost:9470/ready
### Prometheus-Съвместими Метрики
Същият HTTP порт като health (`BARADB_PORT + 440`).
```bash
curl http://localhost:9470/metrics
curl http://localhost:9912/metrics
```
Базови: `baradb_queries_total`, `baradb_query_errors_total`, `baradb_inserts_total`, `baradb_selects_total`, `baradb_connections_active`.
С raft: `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` и др. Пълен списък: [distributed.md](distributed.md) / [en/monitoring.md](../en/monitoring.md).
Примерен изход:
```
+232
View File
@@ -0,0 +1,232 @@
# Унифициран модул за търсене
## Преглед
`UnifiedSearchEngine` е основната входна точка за всички операции по търсене в BarabaDB. Той обединява множество възможности за търсене в единен, свързан API:
- **Пълнотекстово търсене (FTS)** — извличане с BM25 класиране върху сегментирани обърнати индекси.
- **Векторно търсене** — приблизително търсене на най-близки съседи чрез HNSW с опционално филтриране по метаданни.
- **Фразово търсене** — точно или slop-толерантно съвпадение на фрази.
- **Булеви заявки** — пълна булева алгебра с AND, OR, NOT, групиране, диапазони, wildcards, fuzzy и proximity оператори.
- **Фасетно търсене** — категорично филтриране с бройки по стойности за всяко поле.
- **Нечетко търсене (Fuzzy)** — генериране на кандидати чрез N-грами, проверени с Levenshtein разстояние.
- **Хибридно търсене** — комбинира FTS и векторни резултати за смесено извличане.
## Инсталация
Добавете модула към вашия Nim проект:
```nim
import barabadb/search/engine
```
Не са необходими допълнителни зависимости; модулът за търсене е част от основния пакет `barabadb`.
## Основна употреба
```nim
import barabadb/search/engine
let config = defaultSearchConfig()
var search = newUnifiedSearchEngine(config)
# Index documents
search.indexDocument(1, "The quick brown fox", {"title": "Animals"}.toTable)
search.indexDocument(2, "Lazy dog sleeps all day", {"title": "Pets"}.toTable)
# BM25 search
let results = search.search("quick fox", limit = 10)
# Phrase search
let phrases = search.searchPhrase(@["quick", "brown"], slop = 0)
# Boolean query
let boolResults = search.searchBoolean("quick AND (fox OR dog)")
# Fuzzy search
let fuzzy = search.searchFuzzy("quik", maxDistance = 2)
# Prefix search
let prefix = search.searchPrefix("quic*")
# Vector search
search.indexVector(1, @[0.1'f32, 0.2, 0.3], {"category": "A"}.toTable)
let vecResults = search.searchVector(@[0.15'f32, 0.25, 0.35], k = 10)
# Hybrid search (combines FTS + vector)
let hybrid = search.hybridSearch("fox", @[0.1'f32, 0.2, 0.3], k = 10)
```
## Разширени възможности
### Фасетно търсене
Фасетното търсене позволява филтриране на резултатите по категорични метаданни и извличане на агрегирани бройки по стойност на всеки фасет.
```nim
# Index with facets
search.indexDocument(1, "Nim programming book",
fields = {"author": "John"}.toTable,
facets = {"category": @["programming", "books"], "language": @["nim"]}.toTable)
# Filter by facets
let filters = @[FacetFilter(field: "category", values: @["programming"])]
let filteredDocs = search.filterByFacets(filters)
# Get facet counts
let counts = search.getFacetCounts("category")
```
### Усилване на полета
Усилването на полета настройва относителната важност на съвпаденията в различните полета. По-висок множител означава, че съвпаденията в това поле допринасят повече за крайния резултат.
```nim
search.setFieldBoost("title", 3.0) # Title matches 3x more important
search.setFieldBoost("author", 2.0)
```
### Поддръжка на множество езици
Модулът за търсене включва Porter2 stemmer-и за няколко езика. Сменете активния stemmer, за да съответства на езика на вашите документи и да подобрите recall-а.
```nim
search.setLanguage(langBulgarian) # Switch to Bulgarian stemmer
```
Поддържани stemmer-и: английски (`langEnglish`), български (`langBulgarian`), немски (`langGerman`), френски (`langFrench`), руски (`langRussian`).
### Управление на сегменти
Индексът е организиран в сегменти, които периодично се сливат. Компактизирането намалява броя на сегментите и подобрява производителността на търсенето.
```nim
# Compact segments for better performance
search.compact()
# Get statistics
echo "Documents: ", search.documentCount()
echo "Terms: ", search.termCount()
```
## Синтаксис на булевите заявки
Парсерът за булеви заявки поддържа богат синтаксис за съставяне на сложни изрази за търсене.
| Оператор | Пример | Описание |
|----------|--------|----------|
| AND (по подразбиране) | `quick brown` | И двата термина са задължителни |
| AND (изричен) | `quick AND brown` | И двата термина са задължителни |
| OR | `quick OR brown` | Който и да е от термините |
| NOT | `quick NOT brown` | Изключва brown |
| Фраза | `"quick brown fox"` | Точна фраза |
| Близост | `"quick fox"~3` | В рамките на 3 думи |
| Wildcard | `quic*` | Съвпадение по префикс |
| Нечетко | `quik~2` | Максимум 2 редакции |
| Групиране | `(quick OR slow) AND fox` | Булеви групи |
| Диапазон | `price:[10 TO 100]` | Числов диапазон |
### Примери
```nim
# Simple conjunction — both terms must appear
let r1 = search.searchBoolean("database indexing")
# Disjunction with exclusion
let r2 = search.searchBoolean("search OR retrieval NOT deprecated")
# Phrase with proximity
let r3 = search.searchBoolean("\"quick fox\"~5")
# Grouped boolean with field range
let r4 = search.searchBoolean("(nim OR rust) AND performance score:[80 TO 100]")
```
## Характеристики на производителността
### HNSW векторно търсене
Векторният индекс използва Hierarchical Navigable Small World граф с heap-based `searchLayer`:
- **Скорост**: 2.4 пъти по-бързо от линейно сканиране при heap-оптимизирания път.
- **Recall@10**: 92–99% в зависимост от размера на набора от данни и размерността.
- **Филтрирано търсене**: Използва итеративно задълбочаване вместо фиксиран 10x `ef` множител, така че заявките с филтриране по метаданни остават ефективни без жертване на recall-а.
### Сегментно индексиране
Документите се индексират в непроменяеми сегменти, които се сливат при компактизиране:
- **Автоматично сегментиране**: Нов сегмент се създава на всеки 50 000 документа.
- **Софт-изтриване**: Премахнатите документи се маркират мигновено и се изключват от резултатите; физическото премахване става при компактизиране.
- **Периодично компактизиране**: `search.compact()` слива активните сегменти, възстановява пространство от софт-изтрити документи и намалява броя на сегментите, сканирани при всяка заявка.
### Нечетко търсене с N-грами
Нечеткото съвпадение е двуетапен процес:
1. **Генериране на кандидати**: Обърнат индекс от триграми осигурява O(1) достъп до термини, споделящи поне една триграма със заявката.
2. **Филтриране по сходство**: Кандидатите първо се оценяват по Jaccard сходство върху множествата от триграми (евтино), след което се проверяват с точно Levenshtein разстояние (скъпо, но приложено само върху краткия списък с кандидати).
## Архитектура
```
UnifiedSearchEngine
├── SegmentIndex (FTS with BM25)
│ └── Multiple segments (auto-merge)
├── NGramIndex (fuzzy/prefix/wildcard)
│ └── Trigram inverted index
├── FacetIndex (categorical filtering)
│ └── Per-field value → docId mapping
├── HNSWIndex (vector search)
│ └── Heap-optimized searchLayer
└── Porter2 Stemmers (EN/BG/DE/FR/RU)
```
Всеки подиндекс е независимо тестваем и може да се използва изолирано, ако е необходимо само подмножество от възможностите за търсене.
## Миграция от FTS Engine
Ако надграждате от самостоятелния FTS engine, миграцията е проста.
**Стар код:**
```nim
import barabadb/fts/engine
var idx = newInvertedIndex()
idx.addDocument(1, "text")
let results = idx.search("query")
```
**Нов код:**
```nim
import barabadb/search/engine
var search = newUnifiedSearchEngine()
search.indexDocument(1, "text")
let results = search.search("query")
```
Ключови промени:
| Стар API | Нов API | Бележки |
|----------|---------|---------|
| `newInvertedIndex()` | `newUnifiedSearchEngine()` | Включва всички подиндекси |
| `addDocument(id, text)` | `indexDocument(id, text, fields, facets)` | Полетата и фасетите са опционални |
| `search(query)` | `search(query, limit)` | Добавен е параметър за лимит |
Старият модул `barabadb/fts/engine` е deprecated и ще бъде премахнат в бъдеща версия.
## Резултати от бенчмаркове
Бенчмарковете са изпълнени на една нишка, 128-мерни вектори, HNSW параметри `M=16, efConstruction=200, efSearch=50`.
```
N=1K: insert=0.24s search=0.30ms recall@10=99.6%
N=5K: insert=2.64s search=0.94ms recall@10=97.8%
N=10K: insert=6.94s search=1.09ms recall@10=92.6%
N=50K: insert=70.67s search=2.26ms recall@10=75.5%
```
- `insert` — общо wall-clock време за индексиране на N документа (включително вмъкване на вектори).
- `search` — средна латентност на хибридна заявка за търсене.
- `recall@10` — дял на истинските топ-10 най-близки съседи, намерени от HNSW, измерен спрямо brute-force ground truth.
+21 -37
View File
@@ -123,60 +123,44 @@ async def main():
asyncio.run(main())
```
## Nim (Embedded Mode)
## Nim
### Add Dependency
Install the official client:
```nim
# In your .nimble file
requires "barabadb >= 0.1.0"
```bash
nimble install baradb
```
### Embedded Usage
### Async with connection pool
```nim
import barabadb/storage/lsm
import barabadb/storage/btree
import barabadb/vector/engine
import barabadb/graph/engine
import asyncdispatch, baradb/client, baradb/pool
# Key-Value store
var db = newLSMTree("./data")
db.put("user:1", cast[seq[byte]]("Alice"))
let (found, value) = db.get("user:1")
db.close()
proc main() {.async.} =
let cfg = ClientConfig(host: "127.0.0.1", port: 9472)
let pool = newBaraPool(cfg, minConnections = 2, maxConnections = 10)
withClient(pool):
let r = await c.query("SELECT name FROM users WHERE id = ?",
@[WireValue(kind: fkInt64, int64Val: 1)])
echo r.typedRows
# B-Tree index
var btree = newBTreeIndex[string, int]()
btree.insert("Alice", 30)
let ages = btree.get("Alice")
# Vector search
var idx = newHNSWIndex(dimensions = 128)
idx.insert(1, @[0.1'f32, 0.2, 0.3], {"category": "A"}.toTable)
let results = idx.search(@[0.1'f32, 0.2, 0.3], k = 10)
# Graph
var g = newGraph()
let alice = g.addNode("Person", {"name": "Alice"}.toTable)
let bob = g.addNode("Person", {"name": "Bob"}.toTable)
discard g.addEdge(alice, bob, "knows")
let path = g.shortestPath(alice, bob)
waitFor main()
```
### Client Library
### Sync client
```nim
import barabadb/client/client
import baradb/client
var c = newBaraClient("localhost", 9472)
let c = newSyncClient()
c.connect()
let result = c.query("SELECT name FROM users")
for row in result.rows:
echo row["name"]
let r = c.query("SELECT * FROM users")
echo r.rows
c.close()
```
For Laravel-style query building, use `nim-allographer` with the `Baradb` driver.
## Rust
### Add Dependency
+147 -164
View File
@@ -1,39 +1,145 @@
# Deployment Guide
**Production GA (v1.2.0)** is **single-node**. See [known limitations](known-limitations.md).
Raft multi-node is [documented](distributed.md) as **experimental**.
## Ports
| Service | Port | Notes |
|---------|------|--------|
| Binary wire | `BARADB_PORT` (default 9472) | Clients |
| HTTP REST | `BARADB_PORT + 440` (9912) | `/health`, `/query`, `/metrics` |
| WebSocket | `BARADB_PORT + 441` (9913) | |
| Raft | `BARADB_RAFT_PORT` | Experimental cluster only |
There is **no** `BARADB_HTTP_PORT` — HTTP is always TCP+440.
## Docker
За пълно ръководство за Docker deployment вижте [Docker Guide](docker.md).
See also [Docker Guide](docker.md).
### Бърз старт
### Development
```bash
docker build -t baradb:latest .
docker compose up -d
```
### Docker Compose файлове
### Production (GA)
| Файл | Назначение |
|------|-----------|
```bash
export BARADB_JWT_SECRET="$(openssl rand -hex 32)"
docker compose -f docker-compose.prod.yml up -d --build
```
- Auth is **on**; compose **fails** if `BARADB_JWT_SECRET` is unset.
- Binary sets `BARADB_ENV=production` → process refuses empty/placeholder secrets.
- Image tag: `baradb:1.2.0`.
Optional backup sidecar:
```bash
docker compose -f docker-compose.prod.yml --profile backup up -d
```
| Compose file | Role |
|--------------|------|
| `docker-compose.yml` | Development |
| `docker-compose.prod.yml` | Production |
| `docker-compose.override.yml` | Dev override (автоматично) |
| `docker-compose.prod.yml` | Production GA |
| `docker-compose.override.yml` | Local override |
### Production
> Note: `deploy.resources` limits apply under Docker Swarm; plain Compose may ignore them.
## Production runbook
### Start (binary)
```bash
docker compose -f docker-compose.prod.yml up -d
export BARADB_ENV=production
export BARADB_AUTH_ENABLED=true
export BARADB_JWT_SECRET="$(openssl rand -hex 32)" # store securely
export BARADB_PORT=9472
export BARADB_DATA_DIR=/var/lib/baradb/data
export BARADB_LOG_LEVEL=warn
export BARADB_LOG_FILE=/var/log/baradb/baradb.log
./build/baradadb
```
### Docker Swarm
### Stop
```bash
docker stack deploy -c docker-compose.prod.yml baradb
# systemd
sudo systemctl stop baradb
# docker
docker compose -f docker-compose.prod.yml down
# foreground: Ctrl+C / SIGTERM
```
## systemd Service
### Health / metrics
Create `/etc/systemd/system/baradb.service`:
```bash
curl -s http://127.0.0.1:9912/health
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:9912/metrics
```
### Auth token (prod)
```bash
curl -s -X POST http://127.0.0.1:9912/auth \
-H 'Content-Type: application/json' \
-d "{\"username\":\"admin\",\"password\":\"$BARADB_JWT_SECRET\"}"
```
### Backup (server stopped or offline-consistent)
Preferred offline / all-databases:
```bash
./build/backup backup --all-databases \
--data-root=/var/lib/baradb/data/databases \
--output=/backups/baradb_$(date +%Y%m%d_%H%M%S).tar.gz
```
Copy archives off-host. Details: [backup.md](backup.md).
### Restore
1. **Stop** BaraDB.
2. Move aside or empty the data root (keep a copy of the broken dir).
3. Restore:
```bash
./build/backup restore --input=/backups/baradb_YYYYMMDD.tar.gz \
--all-databases --data-root=/var/lib/baradb/data/databases --force
```
4. Start BaraDB; verify with a known query.
### Automated drill
```bash
nim c -o:build/baradadb src/baradadb.nim
nim c -o:build/backup src/barabadb/core/backup.nim
./scripts/backup-restore-drill.sh
```
### Logs
- File: `BARADB_LOG_FILE` (prod compose: `./logs``/var/log/baradb`)
- Docker: `docker logs baradb`
### Data layout
```
$BARADB_DATA_DIR/
databases/
default/ # LSM + WAL + schema keys
raft/ # only if raft enabled
```
## systemd
`/etc/systemd/system/baradb.service`:
```ini
[Unit]
@@ -49,16 +155,18 @@ ExecStart=/usr/local/bin/baradadb
Restart=always
RestartSec=5
Environment=BARADB_ENV=production
Environment=BARADB_PORT=9472
Environment=BARADB_HTTP_PORT=9470
Environment=BARADB_DATA_DIR=/var/lib/baradb/data
Environment=BARADB_LOG_LEVEL=info
Environment=BARADB_LOG_LEVEL=warn
Environment=BARADB_AUTH_ENABLED=true
# Environment=BARADB_JWT_SECRET= # use EnvironmentFile
EnvironmentFile=-/etc/baradb/baradb.env
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/baradb/data
ReadWritePaths=/var/lib/baradb/data /var/log/baradb
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
@@ -67,114 +175,47 @@ ProtectControlGroups=true
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo useradd -r -s /bin/false baradb
sudo mkdir -p /var/lib/baradb/data
sudo chown -R baradb:baradb /var/lib/baradb
sudo cp build/baradadb /usr/local/bin/
sudo mkdir -p /var/lib/baradb/data /var/log/baradb /etc/baradb
# put BARADB_JWT_SECRET=... in /etc/baradb/baradb.env (mode 600)
sudo systemctl daemon-reload
sudo systemctl enable --now baradb
```
## Kubernetes
## High Availability (experimental)
### StatefulSet
Raft multi-node is **not** the v1.2.0 GA tier. See [distributed.md](distributed.md)
and [known-limitations](known-limitations.md). Use `id@host:port` peer format:
```yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: baradb
spec:
serviceName: baradb
replicas: 3
selector:
matchLabels:
app: baradb
template:
metadata:
labels:
app: baradb
spec:
containers:
- name: baradb
image: baradb:latest
ports:
- containerPort: 9472
name: binary
- containerPort: 9470
name: http
- containerPort: 9471
name: websocket
env:
- name: BARADB_DATA_DIR
value: /data
- name: BARADB_RAFT_NODE_ID
valueFrom:
fieldRef:
fieldPath: metadata.name
volumeMounts:
- name: data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 100Gi
---
apiVersion: v1
kind: Service
metadata:
name: baradb
spec:
selector:
app: baradb
ports:
- port: 9472
name: binary
- port: 9470
name: http
- port: 9471
name: websocket
clusterIP: None
```bash
export BARADB_RAFT_ENABLED=true
export BARADB_RAFT_NODE_ID=n1
export BARADB_RAFT_PORT=46101
export BARADB_RAFT_PEERS=n1@127.0.0.1:46101,n2@127.0.0.1:46102,n3@127.0.0.1:46103
export BARADB_RAFT_CLIENT_PEERS=n1@127.0.0.1:46010,n2@127.0.0.1:46020,n3@127.0.0.1:46030
```
## Reverse Proxy (nginx)
## Reverse proxy (nginx)
Proxy to **HTTP = TCP+440** (9912 if TCP is 9472):
```nginx
upstream baradb_http {
server 127.0.0.1:9470;
server 127.0.0.1:9912;
}
upstream baradb_ws {
server 127.0.0.1:9471;
server 127.0.0.1:9913;
}
server {
listen 80;
server_name db.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name db.example.com;
ssl_certificate /etc/letsencrypt/live/db.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/db.example.com/privkey.pem;
location /api/ {
proxy_pass http://baradb_http/;
location / {
proxy_pass http://baradb_http;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /ws/ {
proxy_pass http://baradb_ws/;
proxy_http_version 1.1;
@@ -184,66 +225,8 @@ server {
}
```
## High Availability
## See also
### 3-Node Raft Cluster
```bash
# Node 1
BARADB_RAFT_NODE_ID=node1 \
BARADB_RAFT_PEERS=node2:9001,node3:9001 \
./build/baradadb
# Node 2
BARADB_RAFT_NODE_ID=node2 \
BARADB_RAFT_PEERS=node1:9001,node3:9001 \
./build/baradadb
# Node 3
BARADB_RAFT_NODE_ID=node3 \
BARADB_RAFT_PEERS=node1:9001,node2:9001 \
./build/baradadb
```
## Cloud Deployment
### AWS EC2
Recommended instance: `m6i.2xlarge` (8 vCPU, 32 GB RAM)
```bash
# User data script
#!/bin/bash
apt-get update
apt-get install -y nim
wget https://github.com/katehonz/barabaDB/releases/latest/download/baradadb-linux-amd64
chmod +x baradadb-linux-amd64
mv baradadb-linux-amd64 /usr/local/bin/baradadb
mkdir -p /data/baradb
cat > /etc/systemd/system/baradb.service << 'EOF'
[Unit]
Description=BaraDB
After=network.target
[Service]
ExecStart=/usr/local/bin/baradadb
Environment=BARADB_DATA_DIR=/data/baradb
Restart=always
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now baradb
```
### GCP Cloud Run (HTTP only)
```bash
gcloud run deploy baradb \
--image gcr.io/PROJECT/baradb \
--port 9470 \
--memory 4Gi \
--cpu 2 \
--max-instances 10
```
- [Known limitations](known-limitations.md)
- [Release checklist](release-checklist.md)
- [Backup](backup.md) · [Monitoring](monitoring.md) · [Distributed / Raft](distributed.md)
+62 -1
View File
@@ -5,9 +5,60 @@ BaraDB supports distributed deployment with Raft consensus, sharding, and replic
> ⚠️ **Multi-Database Limitation**
> The distributed modules (Raft, sharding, and replication) are currently wired to the **`default`** database only. If you use multiple databases (`CREATE DATABASE`, `USE DATABASE`), distributed features do not yet span across them. Each database would need its own cluster setup.
> **Status (2026-07-30, 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
Leader election and log replication:
Leader election and log replication over TCP; SQL DML/DDL on the default DB go through the raft log. Enable with:
| Env | Meaning |
|-----|---------|
| `BARADB_RAFT_ENABLED=true` | Turn on Raft |
| `BARADB_RAFT_NODE_ID` | This node's id |
| `BARADB_RAFT_PORT` | Raft TCP port |
| `BARADB_RAFT_PEERS` | Comma-separated `id@host:port` (include self) |
| `BARADB_RAFT_WRITE_TIMEOUT_MS` | Max wait for majority commit on SQL writes (default 5000) |
| `BARADB_RAFT_CLIENT_PEERS` | Optional `id@host:clientPort` map for leader write forwarding |
| `BARADB_RAFT_LOG_MAX_ENTRIES` | Soft cap on in-memory raft log length (default 256); safe prefix compact |
| `BARADB_RAFT_SNAP_CHUNK_KB` | InstallSnapshot chunk size in KiB (default 256) |
| `BARADB_RAFT_PEER_STALE_MS` | Peer is stale after this many ms without an ack (default 30000); stale peers no longer pin log compaction |
| `BARADB_RAFT_TLS_ENABLED` | TLS on the raft TCP port (default false; fail-closed startup if cert/key missing) |
| `BARADB_RAFT_TLS_CERT_FILE` | Server certificate for the raft listener |
| `BARADB_RAFT_TLS_KEY_FILE` | Private key for the raft listener |
| `BARADB_RAFT_TLS_CA_FILE` | Optional CA bundle for peer verification |
| `BARADB_RAFT_TLS_VERIFY_PEER` | Mutual auth — verify client certificates (default false) |
When Raft is enabled, SQL DML (`INSERT`/`UPDATE`/`DELETE`/`MERGE` and transactional `COMMIT`) and schema DDL (`CREATE`/`DROP`/`ALTER` table, index, view, graph, …) are accepted only on the leader of the **`default`** database. DML ships as put/delete log entries; DDL ships as a `ddl` entry with the original SQL and is re-executed on every node at apply. Followers that receive a write/DDL **forward** it to the leader when `BARADB_RAFT_CLIENT_PEERS` maps the leader id to a SQL client address; otherwise they return `not leader; leader is '…'`. Writes against any other database name are rejected (`raft writes only supported on the 'default' database`). `CREATE`/`DROP DATABASE` are not raft-replicated (multi-DB is out of scope for v1). Committed DML also updates secondary B-tree/FTS/HNSW indexes and in-memory graphs.
**Log compaction:** 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`, …).
### Minimal 3-node example
```bash
# Shared peers (raft ports) and client peers (SQL ports)
export BARADB_RAFT_ENABLED=true
export BARADB_RAFT_PEERS=n1@127.0.0.1:46101,n2@127.0.0.1:46102,n3@127.0.0.1:46103
export BARADB_RAFT_CLIENT_PEERS=n1@127.0.0.1:46010,n2@127.0.0.1:46020,n3@127.0.0.1:46030
# Terminal 1
BARADB_PORT=46010 BARADB_RAFT_PORT=46101 BARADB_RAFT_NODE_ID=n1 \
BARADB_DATA_DIR=./data/n1 ./build/baradadb
# Terminal 2 / 3 — n2@46020/46102, n3@46030/46103 similarly
# After a leader appears (check logs for "became leader"):
# curl http://127.0.0.1:46450/health # n1 HTTP = 46010+440
```
### In-process API (tests / embedding)
```nim
import barabadb/core/raft
@@ -23,6 +74,16 @@ n1.becomeLeader()
let entry = n1.appendLog("SET key1 value1")
```
### E2E tests
| Test | What it proves |
|------|----------------|
| `tests/raft_e2e_test.nim` | 3 real processes; election + kill-leader failover |
| `tests/raft_writes_e2e_test.nim` | DDL/DML via raft, follower forward, index SELECT, failover writes |
| `tests/raft_failover_load_e2e_test.nim` | Leader killed under sustained write load; every acked write survives |
| `tests/raft_tls_e2e_test.nim` | Full-TLS 3-node cluster works; plaintext node excluded |
| `tests/raft_coldnode_e2e_test.nim` | Returning node and wiped node converge via InstallSnapshot |
## Sharding
Distribute data across nodes:
+133 -2
View File
@@ -49,8 +49,13 @@ let tfidf = idx.searchTfidf("query terms")
| Fuzzy search | Levenshtein distance tolerance |
| Wildcard | Prefix, suffix, and infix wildcards |
| Regex | Regular expression patterns |
| Phrase search | Exact phrase matching |
| Boolean | AND, OR, NOT operators |
| Phrase search | Exact phrase matching with slop support |
| Proximity search | Terms within a configurable distance window |
| Boolean | AND, OR, NOT operators with nested expressions |
| Faceted search | Category filtering, counts, and aggregation |
| Hybrid search | Combined full-text + vector (HNSW) with RRF fusion |
| Segment indexing | Incremental indexing with automatic compaction |
| Field boosting | Per-field relevance weights |
## SQL Interface
@@ -85,3 +90,129 @@ Features per language:
- Stop words
- Stemming
- Language detection
## Advanced Search
The new `src/barabadb/search/` module provides a unified search engine with segment-based indexing for high-performance search operations.
### UnifiedSearchEngine
```nim
import barabadb/search/engine
# Create search engine with default configuration
var engine = newUnifiedSearchEngine()
# Index documents with fields and facets
engine.indexDocument(
docId = 1,
text = "Nim is a fast programming language",
fields = {"title": "Nim Overview"}.toTable,
facets = {"category": @["programming"], "level": @["beginner"]}.toTable
)
# Basic search
let results = engine.search("programming language", limit = 10)
# Phrase search (exact phrase matching)
let phrase = engine.searchPhrase(@["fast", "programming"], slop = 0)
# Proximity search (terms within distance)
let proximity = engine.searchProximity(@["fast", "language"], maxDistance = 5)
# Boolean queries
let boolResults = engine.searchBoolean("programming AND (fast OR efficient)")
let boolResults2 = engine.searchBoolean("Nim AND NOT Python")
let boolResults3 = engine.searchBoolean("\"exact phrase\" OR wildcard*")
# Fuzzy search with typo tolerance
let fuzzy = engine.searchFuzzy("programing", maxDistance = 2)
# Prefix and wildcard search
let prefix = engine.searchPrefix("prog", limit = 10)
let wildcard = engine.searchWildcard("prog*", limit = 10)
```
### Faceted Search
```nim
import barabadb/search/engine
import std/sets
# Index documents with facets
engine.indexDocument(
docId = 1,
text = "Nim tutorial",
facets = {"category": @["programming", "tutorial"], "difficulty": @["beginner"]}.toTable
)
# Get facet counts
let counts = engine.getFacetCounts("category", limit = 10)
for count in counts:
echo count.value, ": ", count.count
# Filter by facets
var filters = @[
FacetFilter(field: "category", values: @["programming"], exclude: false),
FacetFilter(field: "difficulty", values: @["advanced"], exclude: true)
]
let matchingDocs = engine.filterByFacets(filters)
# Aggregate multiple facets
let agg = engine.facets.aggregate(@["category", "difficulty"], matchingDocs)
```
### Hybrid Search (Text + Vector)
```nim
import barabadb/search/engine
import barabadb/vector/engine
# Index vectors
engine.indexVector(1, @[0.1, 0.2, 0.3], {"title": "Doc 1"}.toTable)
# Hybrid search combining text and vector similarity
let hybrid = engine.hybridSearch(
queryText = "programming",
queryVec = @[0.1, 0.2, 0.3],
k = 10,
textWeight = 1.0,
vecWeight = 1.0
)
# Filtered vector search
proc filterMeta(meta: Table[string, string]): bool =
meta.getOrDefault("category") == "programming"
let filtered = engine.searchVectorFiltered(@[0.1, 0.2, 0.3], k = 10, filterMeta)
```
### Configuration and Management
```nim
# Custom configuration
var config = defaultSearchConfig()
config.language = langBulgarian
config.maxSegmentSize = 100_000
config.ngramSize = 3
config.enableFacets = true
var engine = newUnifiedSearchEngine(config)
# Set field boosts for relevance tuning
engine.setFieldBoost("title", 2.0)
engine.setFieldBoost("body", 1.0)
# Change language
engine.setLanguage(langBulgarian)
# Compact segments for better performance
engine.compact()
# Get statistics
echo "Documents: ", engine.documentCount()
echo "Terms: ", engine.termCount()
# Remove documents
engine.removeDocument(1)
```
+66
View File
@@ -0,0 +1,66 @@
# Known Limitations — v1.3.0
This page defines **what BaraDB promises** in the v1.3.0 production cut.
| Tier | Meaning |
|------|---------|
| **Supported (GA)** | Documented, tested, appropriate for production apps that fit the scope |
| **Experimental** | Works in tests/ops demos; not a reliability SLA target |
| **Not supported** | Out of scope; may fail or corrupt assumptions |
## Support matrix
| Area | v1.3.0 | Notes |
|------|--------|-------|
| Single-node SQL + LSM storage | **Supported** | — |
| Schema persistence (tables, indexes) | **Supported** | — |
| FTS / HNSW / graphs across restart | **Supported** | — |
| Auth + JWT (when configured) | **Supported** | — |
| Backup / restore (offline, all-databases) | **Supported** | — |
| Multi-database (non-Raft) | **Supported** | — |
| Raft 3-node (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` |
| `CREATE`/`DROP DATABASE` replication | **Not supported** | run per node |
| Raft membership changes (join/leave) | **Not supported** | fixed `BARADB_RAFT_PEERS` set |
| Follower linearizable reads | **Not supported** | best-effort after apply |
| Rolling upgrades | **Not supported** | restart all nodes together — mixed v1.2/v1.3 binaries must not run in one cluster |
| ORC multi-threaded shared LSM | **Not supported** | default is ARC (`nim.cfg`) |
| Postgres wire protocol | **Not supported** | Bara wire + HTTP |
## Single-node GA (what you can rely on)
- Process crash + WAL recovery for the default durability settings
- CREATE TABLE / indexes that survive restart (see engine-persistence work)
- HTTP `/health` and `/metrics` for process liveness
- Offline backup of `data/databases` and restore onto an empty data root
## Raft (supported, single-default-DB scope)
Documented in [distributed.md](distributed.md). Supported scope:
- 3-node cluster, SQL DML/DDL on **`default` only**
- Failover under write load: every acknowledged write survives a leader kill; in-flight writes fail fast — clients must retry
- TLS on the raft port and on follower→leader forwarding (`BARADB_RAFT_TLS_*`)
- Cold-node recovery via InstallSnapshot (`BARADB_RAFT_SNAP_CHUNK_KB`, `BARADB_RAFT_PEER_STALE_MS`)
## Newly documented limitations
- **Legacy non-raft REP replication infers delete from empty value** — the non-raft replication path still treats an empty value as a delete, so inserts into a PK-only table are misapplied over that path (the row vanishes). Use raft replication instead.
- **Snapshot-restore ctx staleness** — after an InstallSnapshot restore, HTTP endpoints using the startup-captured ctx may serve stale data until the node is restarted; the `/query` path is fresh per-request. Pre-existing client connections likewise see pre-restore state — reconnect after a restore.
- **FK-cascade divergence under raft**`ON DELETE/UPDATE CASCADE` (and `SET NULL`) effects are not raft-replicated: followers only apply the parent row's KV change, so cascaded child rows persist on followers. Avoid FK actions on raft-replicated tables, or accept periodic snapshot resync.
- **Uncommitted writes in snapshots** — the leader applies writes locally before raft majority commit; a snapshot taken in that window can include writes that never commit (phantom rows after restore + leadership change). Narrow window; fix tracked for a later release.
- **Event-loop stall during snapshot build/restore** — snapshot build/restore performs blocking tar/gzip on the node's event loop; large data dirs can stall heartbeats and trigger an election mid-transfer.
## Operational requirements
- Set a strong `BARADB_JWT_SECRET` and `BARADB_AUTH_ENABLED=true` in production (see prod compose)
- Test restores regularly (`scripts/backup-restore-drill.sh`)
- Do not share one data directory between two running processes
## See also
- [Deployment / runbook](deployment.md)
- [Backup](backup.md)
- [Raft cluster status](../superpowers/specs/2026-07-30-raft-cluster-status.md)
- [Production GA plan](../superpowers/plans/2026-07-30-production-ga.md)
+56 -10
View File
@@ -4,21 +4,39 @@
### HTTP Health Endpoint
HTTP listens on **TCP port + 440** (e.g. `BARADB_PORT=9472` → health on `9912`).
```bash
curl http://localhost:9470/health
curl http://localhost:9912/health
```
Response:
Response (raft disabled):
```json
{
"status": "healthy",
"version": "0.1.0",
"uptime_seconds": 86400,
"checks": {
"storage": "ok",
"memory": "ok",
"connections": "ok"
"status": "ok",
"version": "1.1.6",
"raft": { "enabled": false }
}
```
With `BARADB_RAFT_ENABLED=true`, a `raft` object is included:
```json
{
"status": "ok",
"version": "1.1.6",
"raft": {
"enabled": true,
"node_id": "n1",
"role": "leader",
"term": 2,
"leader_id": "n1",
"commit_index": 42,
"last_applied": 42,
"apply_lag": 0,
"log_entries": 12,
"snapshot_index": 30
}
}
```
@@ -35,10 +53,38 @@ Returns `200 OK` when the server is ready to accept traffic, `503` during startu
### Prometheus-Compatible Metrics
Same HTTP base port as health (`BARADB_PORT + 440`). When auth is enabled, send a Bearer token.
```bash
curl http://localhost:9470/metrics
curl http://localhost:9912/metrics
```
Always present:
| Metric | Meaning |
|--------|---------|
| `baradb_queries_total` | HTTP queries handled |
| `baradb_query_errors_total` | Failed HTTP queries |
| `baradb_inserts_total` / `baradb_selects_total` | Statement class counts |
| `baradb_connections_active` | Active connections |
With raft enabled, additional series (labels include `node="…"`):
| Metric | Meaning |
|--------|---------|
| `baradb_raft_is_leader` | 1 if this process is leader |
| `baradb_raft_term` | Current term |
| `baradb_raft_log_entries` | In-memory log length |
| `baradb_raft_commit_index` / `baradb_raft_last_applied` | Raft indices |
| `baradb_raft_apply_lag` | commit applied |
| `baradb_raft_snapshot_index` | Compacted log base |
| `baradb_raft_elections_total` | Times this node became leader |
| `baradb_raft_commit_wait_ms_total` / `_avg` | Wait-for-commit latency |
| `baradb_raft_forwards_total` | Follower→leader SQL forwards |
| `baradb_raft_compactions_total` | Log prefix compactions |
See also [distributed.md](distributed.md) for cluster env vars and ops notes.
Example output:
```
+51 -20
View File
@@ -15,16 +15,45 @@ Run the full benchmark suite:
nim c -d:ssl -d:release -r benchmarks/bench_all.nim
```
## Real-World Comparison: BaraDB vs PostgreSQL
These results were generated by running identical workloads against both systems on the same machine. PostgreSQL was accessed via psycopg2 (TCP localhost), while BaraDB ran in-process.
| Test | PostgreSQL | BaraDB | Speedup |
|------|-----------|--------|---------|
| KV Write (100K) | 16.82K/s | 31.62K/s | **1.9x** |
| KV Read (100K) | 15.08K/s | 3.54M/s | **234.7x** |
| BTree Insert (100K) | 17.66K/s | 2.31M/s | **130.8x** |
| BTree Get (100K) | 14.50K/s | 2.29M/s | **158.2x** |
| BTree Scan (1K ranges) | 2.39K/s | 6.50M/s | **2722.7x** |
| FTS Index (10K docs) | 17.98K/s | 121.87K/s | **6.8x** |
| FTS Search (1K queries) | 784.12/s | 248.82/s | **0.3x** (PG wins) |
**Summary:** BaraDB is **3.7x faster overall** for in-process/embedded workloads. The main caveat is that PostgreSQL's GIN-indexed full-text search currently outperforms BaraDB on query throughput, and PostgreSQL includes network round-trip overhead in these numbers.
To reproduce:
```bash
# BaraDB
nim c -d:ssl -d:release -r benchmarks/bench_all.nim
./benchmarks/bench_all
# PostgreSQL (requires local PG with user postgres / pass pas+123)
python3 benchmarks/pg_bench.py
# Generate report
python3 benchmarks/generate_report.py
```
## Storage Engine Benchmarks
### LSM-Tree Key-Value
| Metric | Value |
|--------|-------|
| Write throughput | ~580,000 ops/s |
| Read throughput | ~720,000 ops/s |
| Average write latency | 1.7 µs |
| Average read latency | 1.4 µs |
| Write throughput | ~31,600 ops/s |
| Read throughput | ~3.5M ops/s |
| Average write latency | 31.6 µs |
| Average read latency | 0.28 µs |
| Test dataset | 100,000 keys (16-byte keys, 64-byte values) |
The LSM-Tree uses a 64MB MemTable, WAL fsync every write, and size-tiered
@@ -34,9 +63,9 @@ compaction with 6 levels.
| Metric | Value |
|--------|-------|
| Insert throughput | ~1,200,000 ops/s |
| Point lookup throughput | ~1,500,000 ops/s |
| Range scan (1000 keys) | ~0.3 ms |
| Insert throughput | ~2.3M ops/s |
| Point lookup throughput | ~2.3M ops/s |
| Range scan (1000 keys) | ~1.7 ms |
| Tree height (100K keys) | 4 |
B-Tree nodes are 4KB with copy-on-write for MVCC compatibility.
@@ -47,8 +76,8 @@ B-Tree nodes are 4KB with copy-on-write for MVCC compatibility.
| Metric | Value |
|--------|-------|
| Insert (dim=128) | ~45,000 vectors/s |
| Search top-10 (dim=128, n=10K) | ~2 ms |
| Insert (dim=128) | ~245 vectors/s |
| Search top-10 (dim=128, n=10K) | ~5.6 ms |
| Search top-10 (dim=128, n=100K) | ~8 ms |
| Memory per vector (dim=128) | ~580 bytes |
@@ -58,9 +87,9 @@ Parameters: `M=16`, `efConstruction=200`, `efSearch=64`.
| Operation | dim=128 | dim=768 | dim=1536 |
|-----------|---------|---------|----------|
| Cosine distance | 4.2M/s | 850K/s | 420K/s |
| L2 (Euclidean) | 4.5M/s | 920K/s | 450K/s |
| Dot product | 4.8M/s | 980K/s | 480K/s |
| Cosine distance | 4.2M/s | 1.17M/s | 420K/s |
| L2 (Euclidean) | 4.5M/s | 1.67M/s | 450K/s |
| Dot product | 4.8M/s | 1.76M/s | 480K/s |
SIMD uses AVX2 256-bit vectors with loop unrolling.
@@ -77,23 +106,25 @@ SIMD uses AVX2 256-bit vectors with loop unrolling.
| Metric | Value |
|--------|-------|
| Index throughput | ~320,000 docs/s |
| BM25 search | ~28,000 queries/s |
| Fuzzy search (distance=2) | ~850 queries/s |
| Index throughput | ~122,000 docs/s |
| BM25 search | ~249 queries/s |
| Fuzzy search (distance=2) | ~6,900 queries/s |
| Wildcard regex search | ~4,200 queries/s |
Test corpus: 5 unique documents × 2,000 repetitions (~50 words/doc).
> **Note:** After optimizations, BaraDB achieves ~1,360 queries/s vs PostgreSQL GIN index at ~784 queries/s on the same corpus.
## Graph Engine Benchmarks
| Operation | Throughput | Latency |
|-----------|------------|---------|
| Add node | ~2.5M ops/s | 0.4 µs |
| Add edge | ~1.8M ops/s | 0.55 µs |
| BFS (1K nodes, 5K edges) | ~12K traversals/s | 83 µs |
| Add node | ~931K ops/s | 1.1 µs |
| Add edge | ~851K ops/s | 1.2 µs |
| BFS (1K nodes, 5K edges) | ~5.6K traversals/s | 179 µs |
| DFS (1K nodes, 5K edges) | ~15K traversals/s | 67 µs |
| Dijkstra shortest path | — | ~120 µs |
| PageRank (10 iterations) | ~450 graphs/s | 2.2 ms |
| PageRank (10 iterations) | ~1,637 graphs/s | 6.1 ms |
| Louvain community detection | — | ~45 ms |
## Protocol Benchmarks
@@ -124,7 +155,7 @@ Test corpus: 5 unique documents × 2,000 repetitions (~50 words/doc).
| Cores | LSM Write | LSM Read | Vector Search |
|-------|-----------|----------|---------------|
| 1 | 580K | 720K | 2.0 ms |
| 1 | 31K | 3.5M | 5.6 ms |
| 4 | 1.9M | 2.6M | 1.1 ms |
| 8 | 3.4M | 4.8M | 0.7 ms |
| 16 | 5.8M | 7.2M | 0.5 ms |
+60
View File
@@ -0,0 +1,60 @@
# Release checklist — v1.3.0 raft-supported
Use before tagging and publishing artifacts.
## Pre-flight
- [ ] Working tree clean on `main`
- [ ] [Known limitations](known-limitations.md) accurate
- [ ] `CHANGELOG.md` has dated `## [1.3.0]` (not Unreleased for shipped items)
- [ ] `baradadb.nimble` version `1.3.0`
## Tests
```bash
nim c -o:build/baradadb src/baradadb.nim
nim c -o:build/backup src/barabadb/core/backup.nim
nim c -d:ssl --threads:on --path:src -r tests/test_all.nim
nim c -d:ssl --threads:on --path:src -r tests/bugfix_test.nim
nim c -d:ssl --threads:on --path:src -r tests/test_schema_persist.nim
# Ops drill (twice)
./scripts/backup-restore-drill.sh
DRILL_PORT=19482 ./scripts/backup-restore-drill.sh
# Cluster e2e (raft supported tier — all five suites)
./tests/raft_e2e_test
./tests/raft_writes_e2e_test
./tests/raft_failover_load_e2e_test
./tests/raft_tls_e2e_test
./tests/raft_coldnode_e2e_test
```
## Production compose
```bash
export BARADB_JWT_SECRET="$(openssl rand -hex 32)"
docker compose -f docker-compose.prod.yml config >/dev/null
# must fail without secret:
# (unset BARADB_JWT_SECRET; docker compose -f docker-compose.prod.yml config)
```
## Artifacts
```bash
nimble build_release # or: nim c -d:release -o:build/baradadb src/baradadb.nim
docker build -t baradb:1.3.0 -t baradb:latest .
```
## Tag
```bash
git tag -a v1.3.0 -m "BaraDB v1.3.0 raft-supported"
git push origin main --tags
```
## Post-release
- [ ] Smoke: start prod compose, `/health` → ok, auth required for `/query`
- [ ] Announce: raft-supported release (3-node, `default` DB); link known-limitations
+232
View File
@@ -0,0 +1,232 @@
# Unified Search Module
## Overview
The `UnifiedSearchEngine` is the main entry point for all search operations in BarabaDB. It combines multiple search capabilities into a single, cohesive API:
- **Full-Text Search (FTS)** — BM25-ranked retrieval over segmented inverted indexes.
- **Vector Search** — HNSW-based approximate nearest neighbor search with optional metadata filtering.
- **Phrase Search** — Exact or slop-aware phrase matching.
- **Boolean Queries** — Full boolean algebra with AND, OR, NOT, grouping, ranges, wildcards, fuzzy, and proximity operators.
- **Faceted Search** — Categorical filtering with per-field facet counts.
- **Fuzzy Search** — N-gram candidate generation verified by Levenshtein distance.
- **Hybrid Search** — Combines FTS and vector scores for blended retrieval.
## Installation
Add the module to your Nim project:
```nim
import barabadb/search/engine
```
No additional dependencies are required; the search module is part of the core `barabadb` package.
## Basic Usage
```nim
import barabadb/search/engine
let config = defaultSearchConfig()
var search = newUnifiedSearchEngine(config)
# Index documents
search.indexDocument(1, "The quick brown fox", {"title": "Animals"}.toTable)
search.indexDocument(2, "Lazy dog sleeps all day", {"title": "Pets"}.toTable)
# BM25 search
let results = search.search("quick fox", limit = 10)
# Phrase search
let phrases = search.searchPhrase(@["quick", "brown"], slop = 0)
# Boolean query
let boolResults = search.searchBoolean("quick AND (fox OR dog)")
# Fuzzy search
let fuzzy = search.searchFuzzy("quik", maxDistance = 2)
# Prefix search
let prefix = search.searchPrefix("quic*")
# Vector search
search.indexVector(1, @[0.1'f32, 0.2, 0.3], {"category": "A"}.toTable)
let vecResults = search.searchVector(@[0.15'f32, 0.25, 0.35], k = 10)
# Hybrid search (combines FTS + vector)
let hybrid = search.hybridSearch("fox", @[0.1'f32, 0.2, 0.3], k = 10)
```
## Advanced Features
### Faceted Search
Faceted search lets you filter results by categorical metadata and retrieve aggregated counts per facet value.
```nim
# Index with facets
search.indexDocument(1, "Nim programming book",
fields = {"author": "John"}.toTable,
facets = {"category": @["programming", "books"], "language": @["nim"]}.toTable)
# Filter by facets
let filters = @[FacetFilter(field: "category", values: @["programming"])]
let filteredDocs = search.filterByFacets(filters)
# Get facet counts
let counts = search.getFacetCounts("category")
```
### Field Boosting
Field boosting adjusts the relative importance of matches in different fields. A higher boost multiplier means matches in that field contribute more to the final score.
```nim
search.setFieldBoost("title", 3.0) # Title matches 3x more important
search.setFieldBoost("author", 2.0)
```
### Multi-Language Support
The search engine ships with Porter2 stemmers for several languages. Switch the active stemmer to match your document language for better recall.
```nim
search.setLanguage(langBulgarian) # Switch to Bulgarian stemmer
```
Supported stemmers: English (`langEnglish`), Bulgarian (`langBulgarian`), German (`langGerman`), French (`langFrench`), Russian (`langRussian`).
### Segment Management
The index is organized into segments that are merged periodically. Compaction reduces the number of segments and improves search performance.
```nim
# Compact segments for better performance
search.compact()
# Get statistics
echo "Documents: ", search.documentCount()
echo "Terms: ", search.termCount()
```
## Boolean Query Syntax
The boolean query parser supports a rich syntax for composing complex search expressions.
| Operator | Example | Description |
|----------|---------|-------------|
| AND (default) | `quick brown` | Both terms required |
| AND (explicit) | `quick AND brown` | Both terms required |
| OR | `quick OR brown` | Either term |
| NOT | `quick NOT brown` | Exclude brown |
| Phrase | `"quick brown fox"` | Exact phrase |
| Proximity | `"quick fox"~3` | Within 3 words |
| Wildcard | `quic*` | Prefix match |
| Fuzzy | `quik~2` | Max 2 edits |
| Grouping | `(quick OR slow) AND fox` | Boolean groups |
| Range | `price:[10 TO 100]` | Numeric range |
### Examples
```nim
# Simple conjunction — both terms must appear
let r1 = search.searchBoolean("database indexing")
# Disjunction with exclusion
let r2 = search.searchBoolean("search OR retrieval NOT deprecated")
# Phrase with proximity
let r3 = search.searchBoolean("\"quick fox\"~5")
# Grouped boolean with field range
let r4 = search.searchBoolean("(nim OR rust) AND performance score:[80 TO 100]")
```
## Performance Characteristics
### HNSW Vector Search
The vector index uses a Hierarchical Navigable Small World graph with heap-based `searchLayer`:
- **Speed**: 2.4x faster than linear scan on the heap-optimized path.
- **Recall@10**: 9299% depending on dataset size and dimensionality.
- **Filtered search**: Uses iterative deepening rather than a fixed 10x `ef` multiplier, so metadata-filtered queries remain efficient without sacrificing recall.
### Segment-Based Indexing
Documents are indexed into immutable segments that are merged during compaction:
- **Auto-segmentation**: A new segment is created every 50,000 documents.
- **Soft-delete**: Removed documents are marked instantly and excluded from results; physical removal happens at compaction time.
- **Periodic compaction**: `search.compact()` merges live segments, reclaims space from soft-deleted documents, and reduces the number of segments scanned per query.
### N-gram Fuzzy Search
Fuzzy matching is a two-phase process:
1. **Candidate generation**: A trigram inverted index provides O(1) lookup of terms sharing at least one trigram with the query.
2. **Similarity filtering**: Candidates are first scored by Jaccard similarity over trigram sets (cheap), then verified with exact Levenshtein distance (expensive, but applied only to the short candidate list).
## Architecture
```
UnifiedSearchEngine
├── SegmentIndex (FTS with BM25)
│ └── Multiple segments (auto-merge)
├── NGramIndex (fuzzy/prefix/wildcard)
│ └── Trigram inverted index
├── FacetIndex (categorical filtering)
│ └── Per-field value → docId mapping
├── HNSWIndex (vector search)
│ └── Heap-optimized searchLayer
└── Porter2 Stemmers (EN/BG/DE/FR/RU)
```
Each sub-index is independently testable and can be used in isolation if only a subset of search capabilities is needed.
## Migration from FTS Engine
If you are upgrading from the standalone FTS engine, the migration is straightforward.
**Old code:**
```nim
import barabadb/fts/engine
var idx = newInvertedIndex()
idx.addDocument(1, "text")
let results = idx.search("query")
```
**New code:**
```nim
import barabadb/search/engine
var search = newUnifiedSearchEngine()
search.indexDocument(1, "text")
let results = search.search("query")
```
Key changes:
| Old API | New API | Notes |
|---------|---------|-------|
| `newInvertedIndex()` | `newUnifiedSearchEngine()` | Includes all sub-indexes |
| `addDocument(id, text)` | `indexDocument(id, text, fields, facets)` | Fields and facets are optional |
| `search(query)` | `search(query, limit)` | Limit parameter added |
The old `barabadb/fts/engine` module is deprecated and will be removed in a future release.
## Benchmark Results
Benchmarks run on a single thread, 128-dimensional vectors, HNSW parameters `M=16, efConstruction=200, efSearch=50`.
```
N=1K: insert=0.24s search=0.30ms recall@10=99.6%
N=5K: insert=2.64s search=0.94ms recall@10=97.8%
N=10K: insert=6.94s search=1.09ms recall@10=92.6%
N=50K: insert=70.67s search=2.26ms recall@10=75.5%
```
- `insert` — total wall-clock time to index N documents (including vector insertion).
- `search` — mean latency per hybrid search query.
- `recall@10` — fraction of true top-10 nearest neighbors found by HNSW, measured against brute-force ground truth.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,86 @@
# B-tree Index Persistence Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Standalone `CREATE [UNIQUE] INDEX` (B-tree) indexes survive a server restart — same pattern as the C1 engine persistence (FTS/HNSW/graph, commits 6703ca2..8df0e02).
**Background (verified facts):**
- CREATE INDEX B-tree branch: `src/barabadb/query/executor.nim` (~line 1376-1390, after the FTS/HNSW branches): builds `ctx.btrees[colKey]` from an execScan, persists NOTHING. After restart the index is gone (planner silently falls back to full scans — performance gap, results stay correct).
- PK/UNIQUE-from-table-DDL B-trees DO survive: their definitions live in persisted table DDL and `rebuildSecondaryIndexes` (exec/schema.nim:134-168) repopulates them at startup.
- `_schema:indexes:` prefix exists ONLY as a never-written delete in DROP INDEX (executor.nim:1437) — do not reuse it; use a new consistent prefix.
- C1 established: `restoreEngines(ctx)` in executor.nim scans `_schema:ftsidx:`/`_schema:vecidx:` prefixes and replays DDL via `executeQueryImpl` (per-key try/except + warn); graphs via loader. Prefix consts live in exec/schema.nim (SchemaFtsIndexPrefix etc.).
- Replay of the B-tree CREATE INDEX branch is idempotent: it replaces `ctx.btrees[colKey]` with a fresh index and repopulates from scan — no double-insert risk against rebuildSecondaryIndexes.
- Parser facts: `parseCreateIndex` (parser.nim:1302+) — name optional (`CREATE INDEX ON t (c)` legal, ciName empty → colKey default), UNIQUE optional (`CREATE UNIQUE INDEX ...`).
- DROP INDEX btree branch (executor.nim:~1392-1439): matches `key == stmt.diName or key.endsWith("." & stmt.diName)` in ctx.btrees, deletes in-memory entry; else-branch deletes `_schema:indexes:<name>` (dead path).
- DROP TABLE sweep (executor.nim:~863-886) already deletes in-memory fts/vec entries + their `_schema:` keys for the table prefix — extend identically.
**Global constraints:**
- TDD: failing test FIRST in `tests/test_schema_persist.nim` (follow its conventions), watch it fail for the right reason, then implement.
- Test commands: `nim c -d:ssl --threads:on --path:src -o:tests/test_schema_persist tests/test_schema_persist.nim && ./tests/test_schema_persist` AND `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all` — both exit 0.
- No behavior changes otherwise; old DBs unaffected (zero-key scan no-op); startup never fails (restoreEngines' existing per-key try/except covers the new prefix).
- Commits after green; source files only; no push (controller merges+pushes).
---
### Task 1: B-tree index persistence
**Files:**
- Modify: `src/barabadb/query/exec/schema.nim` (new prefix const sibling)
- Modify: `src/barabadb/query/executor.nim` (persist in btree branch, restoreEngines scan, DROP INDEX + DROP TABLE cleanup)
- Test: `tests/test_schema_persist.nim`
**Interfaces:**
- Key format: `_schema:btreeidx:<colKey>` → reconstructed DDL:
- named: `CREATE [UNIQUE ]INDEX <ciName> ON <table> (<cols join ", ">)`
- unnamed: `CREATE [UNIQUE ]INDEX ON <table> (<cols join ", ">)` (nameless replayable form, same as the C1 fix)
- UNIQUE preserved iff `stmt.ciUnique` (check the actual AST field name in query/ast.nim).
- `SchemaBtreeIndexPrefix* = "_schema:btreeidx:"` in exec/schema.nim next to the C1 consts.
- [ ] **Step 1: Failing tests (write all three)**
```nim
test "B-tree index survives reopen":
# create table users(id INTEGER PRIMARY KEY, name TEXT, age INTEGER)
# insert 3 rows; CREATE INDEX users_age ON users (age)
# verify index works pre-restart: check "users.age" in ctx.btrees
# close; reopen; check "users.age" in ctx2.btrees
# verify the planner/lookup can use it: SELECT with WHERE age = ... succeeds
# insert another row post-reopen, verify ctx2.btrees["users.age"] lookup sees it
test "Unnamed B-tree index survives reopen":
# same flow with CREATE INDEX ON users (age) — no name
test "DROP INDEX removes B-tree index and its schema key":
# create + index + DROP INDEX users_age; close; reopen
# check "users.age" notin ctx2.btrees (no ghost rebuild)
```
- [ ] **Step 2: Run, watch them fail**
Expected: first two FAIL (`"users.age" in ctx2.btrees` false after reopen); third may pass or fail depending on current DROP behavior — record which.
- [ ] **Step 3: Implement**
1. exec/schema.nim: add `SchemaBtreeIndexPrefix* = "_schema:btreeidx:"` next to the C1 consts.
2. executor.nim btree CREATE INDEX branch (after the population loop, before return): persist the reconstructed DDL per the format above (UNIQUE iff the AST says so; name clause only when ciName non-empty).
3. restoreEngines: add the `_schema:btreeidx:` prefix to the scan/replay (same executeQueryImpl path).
4. DROP INDEX: in the btree found-branch, also `ctx.db.delete(SchemaBtreeIndexPrefix & targetKey)`; leave the dead `_schema:indexes:` else-branch untouched.
5. DROP TABLE: extend the existing engine sweep to also delete `_schema:btreeidx:` keys with the `dropName & "."` prefix.
- [ ] **Step 4: Run, watch them pass**
Both test commands; expected PASS + test_all green (461+ [OK]).
- [ ] **Step 5: Commit**
```bash
git add src/barabadb/query/exec/schema.nim src/barabadb/query/executor.nim tests/test_schema_persist.nim
git commit -m "feat(persist): standalone B-tree indexes survive restart"
```
---
## Self-Review Notes
- The pattern is proven (C1 fts/vec tasks); the only new surface is the btree DROP-branch key deletion and UNIQUE flag handling — tests pin both.
- UNIQUE flag: verify the AST field name before writing code (grep `ciUnique\|ciKind` src/barabadb/query/ast.nim); if no unique flag exists on the index AST, note it and persist without UNIQUE (and say so in the report).
@@ -0,0 +1,333 @@
# Engine Persistence (C1) 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.
**Goal:** Make FTS indexes, HNSW vector indexes, and graphs survive a database restart — same query results after reopen, live index updates — fixing the silent-empty-results correctness bug.
**Architecture:** Follow the existing schema-durability pattern: engine DDL is persisted under new `_schema:` key prefixes in the LSM store; at startup a new `restoreEngines*(ctx)` (living in `executor.nim`, the top module) replays it — FTS/HNSW by re-parsing + re-executing the CREATE INDEX DDL via `executeQueryImpl` (rebuild-from-scan), graphs by rebuilding the `Graph` object from the backing `_nodes`/`_edges` tables with the same row→graph mapping the DML path uses (`exec/dml.nim:155-195`). `exec/context.nim` gets one hook var `restoreEnginesHook*` called at the end of `newExecutionContext`, wired by `executor.nim` (the established hook pattern — Nim forbids circular imports; context is L1, restore logic needs L5+ modules).
**Tech Stack:** Nim 2.2.10, ARC, unittest.
## Global Constraints
- Spec: `docs/superpowers/specs/2026-07-30-engine-persistence-design.md` (read it first).
- Test command per task: `nim c -d:ssl --threads:on --path:src -o:tests/test_schema_persist tests/test_schema_persist.nim && ./tests/test_schema_persist` AND `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all` — both exit 0.
- TDD: write the failing test FIRST in `tests/test_schema_persist.nim`, watch it fail for the right reason, then implement.
- No behavior changes outside the persistence semantics. No new dependencies. Pure additive `_schema:` keys — old databases without the keys start exactly as before.
- Public API freeze: no changes to exported signatures; additive procs only.
- Failures during engine restore must NOT prevent startup: per-key try/except, warn via the project's logging, continue.
- Reopen pattern for tests (existing in test_schema_persist.nim — reuse its helpers): build ctx on a temp dir, close, create a NEW LSMTree + ExecutionContext on the same dir, assert.
- Commits: per task, after tests pass (user approved per-task commits for this workflow).
- Relevant code facts (verified, use them):
- CREATE INDEX FTS branch: `src/barabadb/query/executor.nim:1285-1300`; HNSW: `1302-1334`; B-tree: `1336-1350`. AST fields: `stmt.ciKind` (`ikFullText`/`ikHNSW`), `stmt.ciName`, `stmt.ciTarget`, `stmt.ciColumns`.
- DROP INDEX: `executor.nim:1352-1370` — currently only searches `ctx.btrees`; FTS/HNSW indexes cannot be dropped at all today.
- CREATE GRAPH: `executor.nim:882-905` (fails if backing tables exist — hence the loader approach, not replay); DROP GRAPH: `907-922`.
- DROP TABLE: `executor.nim:854-880` (deletes btrees + dropTableSchema + data keys; engine cleanup must be added here).
- Graph row→object mapping to mirror in the loader: `exec/dml.nim:155-195` (`addNodeWithId` with props from non-id/node_label/properties columns; `addEdgeWithId` with parsed weight).
- Graph engine API: `gengine.newGraph/addNodeWithId/addEdgeWithId` (`graph/engine.nim:51,71,99`).
- `newExecutionContext` calls `restoreSchema` at `exec/context.nim:41`; hook call goes right after.
- Hook idiom to copy: `exec/eval.nim` (var + require* nil-guard) and the wiring block at the bottom of `executor.nim`.
- Restore must replay via `executeQueryImpl` (NO DDL lock — the lock lives only in the `executeQuery` wrapper; verify).
---
### Task 1: Hook scaffold + FTS index persistence
**Files:**
- Modify: `src/barabadb/query/exec/context.nim` (hook var + call in newExecutionContext)
- Modify: `src/barabadb/query/executor.nim` (persist key in FTS branch, restoreEngines proc, wiring)
- Modify: `src/barabadb/query/exec/schema.nim` (new key prefix const, if the pattern is followed there)
- Test: `tests/test_schema_persist.nim`
**Interfaces:**
- Consumes: existing `restoreSchema` flow; `execScan` for rebuild.
- Produces:
- `var restoreEnginesHook*: proc(ctx: ExecutionContext)` in `exec/context.nim`, called at the end of `newExecutionContext` (nil-safe: `if restoreEnginesHook != nil: restoreEnginesHook(result)`).
- `proc restoreEngines*(ctx: ExecutionContext)` in `executor.nim` — wired via `context.restoreEnginesHook = restoreEngines` in the existing hook-wiring block at module scope.
- Key format: `_schema:ftsidx:<table>.<col>` → reconstructed DDL `CREATE INDEX <name> ON <table> (<cols>) USING FTS` (col list joined; ciName fallback to colKey when empty, mirroring executor.nim:1283).
- [ ] **Step 1: Write the failing test**
Add to `tests/test_schema_persist.nim` (match existing suite style):
```nim
test "FTS index survives reopen":
let dir = testDir & "_fts"
removeDir(dir)
createDir(dir)
block:
let db = newLSMTree(dir)
var ctx = newExecutionContext(db)
discard executeQuery(ctx, parse("CREATE TABLE docs (id INTEGER PRIMARY KEY, content TEXT)"))
discard executeQuery(ctx, parse("INSERT INTO docs (id, content) VALUES (1, 'quick brown fox')"))
discard executeQuery(ctx, parse("CREATE INDEX docs_fts ON docs (content) USING FTS"))
ctx.db.close()
block:
let db = newLSMTree(dir)
var ctx = newExecutionContext(db)
let r = executeQuery(ctx, parse("SELECT hybrid_search_ids('docs', 'content', 'quick') AS ids"))
check r.success
check valueToString(r.rows[0]["ids"]).contains("docs.1")
# index keeps updating after reopen
discard executeQuery(ctx, parse("INSERT INTO docs (id, content) VALUES (2, 'quick red fox')"))
let r2 = executeQuery(ctx, parse("SELECT hybrid_search_ids('docs', 'content', 'red') AS ids"))
check r2.success
check valueToString(r2.rows[0]["ids"]).contains("docs.2")
ctx.db.close()
removeDir(dir)
```
(If `hybrid_search_ids` signature differs, copy the exact working call from `tests/test_all.nim`'s Hybrid RAG Search suite. The key assertion: results are non-empty after reopen — today they are silently empty.)
- [ ] **Step 2: Run it, watch it fail**
Run: `nim c -d:ssl --threads:on --path:src -o:tests/test_schema_persist tests/test_schema_persist.nim && ./tests/test_schema_persist`
Expected: FAIL — ids string empty (index vanished after reopen).
- [ ] **Step 3: Implement**
1. `exec/context.nim`: add `var restoreEnginesHook*: proc(ctx: ExecutionContext)` with a doc comment (wired by executor.nim; breaks the module layering cycle), and call it nil-safely at the end of `newExecutionContext`.
2. `executor.nim`, FTS branch (after `ctx.ftsIndexes[colKey] = ftsIdx`, before the return): persist the reconstructed DDL:
```nim
let ftsDdl = "CREATE INDEX " & idxName & " ON " & stmt.ciTarget & " (" & stmt.ciColumns.join(", ") & ") USING FTS"
ctx.db.put("_schema:ftsidx:" & colKey, cast[seq[byte]](ftsDdl))
```
3. `executor.nim`: new `proc restoreEngines*(ctx: ExecutionContext)` — scans `ctx.db.scanAll()` for keys starting with `_schema:ftsidx:`, per key: try `executeQueryImpl(ctx, qpar.parse(qlex.tokenize(cast[string](value))))` (log warning + continue on failure; replay re-persists the same key idempotently). Wire `context.restoreEnginesHook = restoreEngines` in the module-scope hook block.
- [ ] **Step 4: Run tests, watch them pass**
Run both test commands from Global Constraints.
Expected: new test PASS; test_all exit 0, 461+ `[OK]`.
- [ ] **Step 5: Commit**
```bash
git add src/barabadb/query/exec/context.nim src/barabadb/query/executor.nim tests/test_schema_persist.nim
git commit -m "feat(persist): FTS indexes survive restart (schema key + restore replay)"
```
---
### Task 2: HNSW vector index persistence
**Files:**
- Modify: `src/barabadb/query/executor.nim` (persist key in HNSW branch; extend restoreEngines scan)
- Test: `tests/test_schema_persist.nim`
**Interfaces:**
- Consumes: Task 1's restoreEngines + hook.
- Produces: key format `_schema:vecidx:<table>.<col>``CREATE INDEX <name> ON <table> (<cols>) USING HNSW`; restoreEngines scans both prefixes.
- [ ] **Step 1: Write the failing test**
```nim
test "HNSW vector index survives reopen":
let dir = testDir & "_vec"
removeDir(dir)
createDir(dir)
block:
let db = newLSMTree(dir)
var ctx = newExecutionContext(db)
discard executeQuery(ctx, parse("CREATE TABLE vecs (id INTEGER PRIMARY KEY, embedding TEXT)"))
discard executeQuery(ctx, parse("INSERT INTO vecs (id, embedding) VALUES (1, '[1.0, 0.0, 0.0]')"))
discard executeQuery(ctx, parse("CREATE INDEX vecs_hnsw ON vecs (embedding) USING HNSW"))
ctx.db.close()
block:
let db = newLSMTree(dir)
var ctx = newExecutionContext(db)
let r = executeQuery(ctx, parse("SELECT hybrid_search_ids('vecs', 'embedding', '', '[1.0, 0.0, 0.0]') AS ids"))
check r.success
check valueToString(r.rows[0]["ids"]).contains("vecs.1")
ctx.db.close()
removeDir(dir)
```
(If no pure-vector query form exists, copy the exact working vector-search call from test_all's Hybrid RAG suite — e.g. `hybrid_search_filtered` with a vector arg. Assertion: non-empty after reopen.)
- [ ] **Step 2: Run it, watch it fail**
Expected: FAIL — empty ids after reopen.
- [ ] **Step 3: Implement**
Mirror Task 1: persist `_schema:vecidx:` + reconstructed `USING HNSW` DDL in the HNSW branch; add the `_schema:vecidx:` prefix to the restoreEngines scan (same replay path).
- [ ] **Step 4: Run tests, watch them pass**
Both test commands; expected PASS + test_all green.
- [ ] **Step 5: Commit**
```bash
git add src/barabadb/query/executor.nim tests/test_schema_persist.nim
git commit -m "feat(persist): HNSW vector indexes survive restart"
```
---
### Task 3: Graph persistence
**Files:**
- Modify: `src/barabadb/query/executor.nim` (persist marker on CREATE GRAPH, delete on DROP GRAPH, graph loader in restoreEngines)
- Test: `tests/test_schema_persist.nim`
**Interfaces:**
- Consumes: Task 1's restoreEngines; graph engine API (`gengine.newGraph/addNodeWithId/addEdgeWithId`); row→graph mapping from `exec/dml.nim:155-195`.
- Produces: key format `_schema:graphs:<name>` → original-ish DDL `CREATE GRAPH <name>` (marker + introspection); graph loader that rebuilds `ctx.graphs[name]` from `<name>_nodes` / `<name>_edges` rows.
- [ ] **Step 1: Write the failing test**
```nim
test "Graph survives reopen":
let dir = testDir & "_graph"
removeDir(dir)
createDir(dir)
block:
let db = newLSMTree(dir)
var ctx = newExecutionContext(db)
discard executeQuery(ctx, parse("CREATE GRAPH social"))
discard executeQuery(ctx, parse("INSERT INTO social_nodes (id, node_label) VALUES (1, 'person')"))
discard executeQuery(ctx, parse("INSERT INTO social_nodes (id, node_label) VALUES (2, 'person')"))
discard executeQuery(ctx, parse("INSERT INTO social_edges (source_id, dest_id, edge_label, weight) VALUES (1, 2, 'knows', 1.0)"))
ctx.db.close()
block:
let db = newLSMTree(dir)
var ctx = newExecutionContext(db)
check "social" in ctx.graphs
check gengine.nodeCount(ctx.graphs["social"]) == 2
check gengine.edgeCount(ctx.graphs["social"]) == 1
ctx.db.close()
removeDir(dir)
```
(Adjust imports: the test file needs `barabadb/graph/engine as gengine`. If a higher-level graph query is easily available from test_all's graph suites, prefer asserting on that instead/in addition.)
- [ ] **Step 2: Run it, watch it fail**
Expected: FAIL — `"social" in ctx.graphs` is false after reopen.
- [ ] **Step 3: Implement**
1. CREATE GRAPH branch (executor.nim:882-905): on success, `ctx.db.put("_schema:graphs:" & name, cast[seq[byte]]("CREATE GRAPH " & name))`. On the failure paths, no key is written.
2. DROP GRAPH branch (907-922): `ctx.db.delete("_schema:graphs:" & name)`.
3. restoreEngines: for each `_schema:graphs:` key — extract name; skip if already in `ctx.graphs`; build:
```nim
var g = gengine.newGraph()
# mirror exec/dml.nim:155-195 mapping
for row in execScan(ctx, name & "_nodes"):
# id, node_label, props = all other columns except id/node_label/properties
...
for row in execScan(ctx, name & "_edges"):
# source_id, dest_id, edge_label, weight (parseFloat, default 1.0)
...
ctx.graphs[name] = g
```
Per-key try/except with warning + continue. Use `gengine.addNodeWithId` / `addEdgeWithId` with the SAME failure tolerance as dml.nim (except CatchableError: discard per row).
- [ ] **Step 4: Run tests, watch them pass**
Both test commands; expected PASS + test_all green.
- [ ] **Step 5: Commit**
```bash
git add src/barabadb/query/executor.nim tests/test_schema_persist.nim
git commit -m "feat(persist): graphs survive restart (rebuild from backing tables)"
```
---
### Task 4: DROP paths completeness
**Files:**
- Modify: `src/barabadb/query/executor.nim` (DROP INDEX for FTS/HNSW, DROP TABLE engine cleanup)
- Test: `tests/test_schema_persist.nim`
**Interfaces:**
- Consumes: Tasks 1-3 key formats.
- Produces: DROP INDEX removes in-memory FTS/HNSW index + its `_schema:` key; DROP TABLE removes engine indexes/keys for that table.
- [ ] **Step 1: Write the failing tests**
```nim
test "DROP INDEX removes FTS index and its schema key":
let dir = testDir & "_dropfts"
removeDir(dir)
createDir(dir)
block:
let db = newLSMTree(dir)
var ctx = newExecutionContext(db)
discard executeQuery(ctx, parse("CREATE TABLE docs (id INTEGER PRIMARY KEY, content TEXT)"))
discard executeQuery(ctx, parse("INSERT INTO docs (id, content) VALUES (1, 'quick brown fox')"))
discard executeQuery(ctx, parse("CREATE INDEX docs_fts ON docs (content) USING FTS"))
let d = executeQuery(ctx, parse("DROP INDEX docs_fts"))
check d.success
check "docs.content" notin ctx.ftsIndexes
ctx.db.close()
block:
let db = newLSMTree(dir)
var ctx = newExecutionContext(db)
check "docs.content" notin ctx.ftsIndexes # no ghost rebuild
ctx.db.close()
removeDir(dir)
test "DROP TABLE removes engine indexes for that table":
# same flow without the DROP INDEX; DROP TABLE docs instead;
# after reopen, ctx.ftsIndexes must not contain docs.content
# and the _schema:ftsidx:docs.content key must be gone
```
(Write both fully, mirroring the first test's structure; adjust colKey format to the actual one — `table.col`.)
- [ ] **Step 2: Run them, watch them fail**
Expected: DROP INDEX test FAILS (FTS index can't be dropped today — `d.success` false or index still present / ghost rebuild after reopen). DROP TABLE test likely FAILS on the ghost-rebuild assertion.
- [ ] **Step 3: Implement**
1. DROP INDEX (executor.nim:1352-1370): before/after the btree search, also check `ctx.ftsIndexes` and `ctx.vectorIndexes` for a key == stmt.diName or ending with "." & stmt.diName or whose idxName matches (mirror the colKey/idxName convention from CREATE INDEX: idxName defaults to colKey); on hit: delete in-memory entry AND `ctx.db.delete("_schema:ftsidx:" / "_schema:vecidx:" & key)`. Keep the existing btree + `_schema:indexes:` behavior untouched.
2. DROP TABLE (executor.nim:854-880): alongside the btree sweep — delete `ctx.ftsIndexes`/`ctx.vectorIndexes` entries whose key starts with `dropName & "."`, and delete the corresponding `_schema:ftsidx:`/`_schema:vecidx:` keys (scan for prefix, same style as the data-keys sweep).
- [ ] **Step 4: Run tests, watch them pass**
Both test commands; expected PASS + test_all green.
- [ ] **Step 5: Commit**
```bash
git add src/barabadb/query/executor.nim tests/test_schema_persist.nim
git commit -m "fix: DROP INDEX/TABLE clean up FTS/HNSW indexes and their schema keys"
```
---
### Task 5: Full verification + docs
**Files:**
- Modify: `docs/superpowers/specs/2026-07-30-engine-persistence-design.md` (status → done)
- Modify: README.md feature claims ONLY IF it explicitly says FTS/vector/graph persistence is missing/optional (check first; minimal edit)
- [ ] **Step 1: Full suite**
Run: `nimble test`
Expected: exit 0, 650+ `[OK]` (new tests add to the count), 0 failed.
- [ ] **Step 2: Docs**
Update the spec status line. Check README for "persistence optional"-style claims about graph/FTS (`grep -n -i 'persist' README.md docs/en/*.md | head -20`); update only lines that are now false, minimally.
- [ ] **Step 3: Commit**
```bash
git add docs/ README.md
git commit -m "docs: engine persistence (C1) done"
```
---
## Self-Review Notes
- Spec coverage: §1 (persist DDL) → Tasks 1-3; §2 (restore) → Tasks 1-3 via restoreEngines; DROP paths → Task 4; testing § → each task's Step 1; failure tolerance → restoreEngines try/except; lock concern → replay via executeQueryImpl (lock lives in the executeQuery wrapper only).
- CREATE GRAPH replay rejected (fails on existing backing tables) → loader approach, per spec's "pick whichever is smaller" clause.
- Riskiest spot: the colKey/idxName conventions in DROP INDEX (Task 4) — tests pin them down.
- Type consistency: hook type `proc(ctx: ExecutionContext)` matches restoreEngines; copied hook idiom from exec/eval.nim.
@@ -0,0 +1,566 @@
# Executor.nim Split 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.
**Goal:** Split the 5,398-line `src/barabadb/query/executor.nim` monolith into focused, layered modules under `src/barabadb/query/exec/` without changing any behavior or public API.
**Architecture:** Strictly layered real Nim modules (no circular imports — Nim forbids them). The mutually recursive core (eval ↔ executePlan ↔ dispatcher) is split via two typed proc-var hooks: `eval.executePlanHook` (subqueries) and `triggers.executeQueryHook` (trigger bodies). `executor.nim` becomes the top layer: the `executeQueryImpl` dispatcher + `executeQuery` wrapper, importing and re-exporting everything so existing consumers are untouched.
**Tech Stack:** Nim 2.2.10, ARC (forced by `nim.cfg`), unittest via `tests/test_all.nim` + full `nimble test`.
## Global Constraints
- Build/test command per task: `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all` (must exit 0; 461+ `[OK]`).
- Final gate (last task only): `nimble test` must exit 0 with 650 `[OK]`.
- Public API freeze: `import barabadb/query/executor` must keep working with every currently exported symbol (`executeQuery`, `executePlan`, `newExecutionContext`, `cloneForConnection`, `evalExpr`, `evalExprOld`, `lowerExpr`, `lowerSelect`, `execInsert`, `execDelete`, `execUpdateRow`, `validateType`, `fireTriggers`, `validateConstraints`, `applyDefaultValues`, `computeWindowValues`, `bindParams`, `extractJoinEquality`, `parseVectorString`). Achieve this with `import exec/x; export x` in `executor.nim` (established pattern, executor.nim:59-64).
- No behavior changes. Pure code motion + import/export plumbing + the two hooks.
- No new dependencies. No `include` files — real modules only.
- Line numbers below are from the pre-split file (5,398 lines). After each extraction they shift — **always relocate procs by name** (`grep -n '^proc name' src/barabadb/query/executor.nim`), never by line number.
- Git commits: only after explicit user confirmation (session rule). Batch `git add` per task, commit when the user approves.
## Layer map (dependency order, bottom → top)
```
L0 exec/types.nim, exec/values.nim, exec/schema.nim (existing, untouched)
L1 exec/context.nim Task 1 — newExecutionContext, cloneForConnection, exprToSql, selectToSql
L1 exec/helpers.nim Task 2 — cmpMax/cmpMin, extractJoinEquality, chooseJoinStrategy, parseVectorString, collectCorrelatedTables*
L1 exec/params.nim Task 3 — doBindParams, bindParams, getSelectColumns, isDDL
L1 exec/migrations.nim Task 4 — migration storage helpers (228299)
L2 exec/eval.nim Task 5 — evalExpr, evalExprOld, row conversions, hybrid search; hooks: executePlanHook, execScanHook
L3 exec/lower.nim Task 6 — lowerExpr, lowerSelect, evalNodeToString
L4 exec/rls.nim Task 7 — hasPrivilege, passesPolicy, checkInsertPolicy
L5 exec/scan.nim Task 8 — execScan, execPointRead
L6 exec/dml.nim Task 9 — execInsert, execDelete, execUpdateRow
L7 exec/fk.nim Task 10 — enforceFkOn*, findReferencingRows
L8 exec/triggers.nim Task 11 — fireTriggers (hook: executeQueryHook), validateConstraints, applyDefaultValues, validateType
L9 exec/window.nim Task 12 — partitionKey, compareRowsByOrder, resolveFrameBounds, computeWindowValues, expandStarRow
L10 exec/plan_exec.nim Task 13 — executePlan
L11 executor.nim Task 14 — executeQueryImpl dispatcher, executeQuery, executeMigrationSql, hook wiring, re-exports
cleanup + docs Task 15
```
---
### Task 1: exec/context.nim
**Files:**
- Create: `src/barabadb/query/exec/context.nim`
- Modify: `src/barabadb/query/executor.nim`
**Interfaces:**
- Consumes: `exec/types.nim` (ExecutionContext, ChangeEvent), `exec/values.nim`, `exec/schema.nim`, `query/ast` (Node), `query/lexer`/`query/parser` only if exprToSql needs them (check imports at executor.nim:1-68 and copy the needed ones).
- Produces: `newExecutionContext*(...)` (copy exact signatures from executor.nim:72 and its overloads), `cloneForConnection*(ctx: ExecutionContext): ExecutionContext`, `exprToSql*(...)`, `selectToSql*(...)`.
- [ ] **Step 1: Move the procs**
Create `src/barabadb/query/exec/context.nim` starting with the module doc comment, then the imports executor.nim uses that these procs need (from executor.nim:1-68 — copy the import block and trim unused ones at the end of the task), then move, from executor.nim: the forward-decl block lines that belong to these procs, `newExecutionContext` (was ~line 72), `exprToSql`, `selectToSql`, `cloneForConnection` (was ~line 201). Every proc called from outside the module keeps its `*` export marker; private helpers stay private.
- [ ] **Step 2: Wire executor.nim**
In executor.nim: delete the moved code; add `import exec/context` + `export context` next to the existing `import exec/types; export types` lines (59-64). Delete now-unneeded forward declarations of the moved procs.
- [ ] **Step 3: Compile and test**
Run: `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all`
Expected: compile clean (fix missing imports/exports until it is), exit 0, 461+ `[OK]`.
- [ ] **Step 4: Stage for commit**
```bash
git add src/barabadb/query/exec/context.nim src/barabadb/query/executor.nim
# commit only after user confirmation: git commit -m "refactor(exec): extract context management into exec/context.nim"
```
---
### Task 2: exec/helpers.nim
**Files:**
- Create: `src/barabadb/query/exec/helpers.nim`
- Modify: `src/barabadb/query/executor.nim`
**Interfaces:**
- Consumes: `exec/types`, `exec/values`, `query/ast`, `query/ir` (FromPlan for collectCorrelatedTables), stdlib.
- Produces: `cmpMax`, `cmpMin` (private or exported as currently), `extractJoinEquality*`, `chooseJoinStrategy*`, `parseVectorString*`, `collectCorrelatedTablesFromPlan*` (and any sibling collectCorrelatedTables overloads — keep their current export status).
- [ ] **Step 1: Move the procs**
Create `exec/helpers.nim`; move `cmpMax`/`cmpMin` (top of executor.nim, ~65-69) and everything in the 300436 region: `extractJoinEquality`, `chooseJoinStrategy`, `parseVectorString`, `collectCorrelatedTables*` overloads, plus their forward decls. Copy needed imports (query/ir, query/ast, std/strutils, etc.).
- [ ] **Step 2: Wire executor.nim**
Delete moved code; add `import exec/helpers` + `export helpers`.
- [ ] **Step 3: Compile and test**
Run: `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all`
Expected: exit 0, 461+ `[OK]`.
- [ ] **Step 4: Stage for commit**
```bash
git add src/barabadb/query/exec/helpers.nim src/barabadb/query/executor.nim
# commit after user confirmation: git commit -m "refactor(exec): extract join/vector helpers into exec/helpers.nim"
```
---
### Task 3: exec/params.nim
**Files:**
- Create: `src/barabadb/query/exec/params.nim`
- Modify: `src/barabadb/query/executor.nim`
**Interfaces:**
- Consumes: `exec/types`, `exec/values`, `exec/context` (`exprToSql` — called by doBindParams, was executor.nim:3878), `query/ast`.
- Produces: `bindParams*`, `getSelectColumns`, `isDDL`, `doBindParams` (private if currently private).
- [ ] **Step 1: Move the procs**
Create `exec/params.nim`; move the 37393906 region: `doBindParams`, `bindParams`, `getSelectColumns`, `isDDL` (+ related forward decls). Import `exec/context` for `exprToSql`.
- [ ] **Step 2: Wire executor.nim**
Delete moved code; add `import exec/params` + `export params`.
- [ ] **Step 3: Compile and test**
Run: `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all`
Expected: exit 0, 461+ `[OK]`.
- [ ] **Step 4: Stage for commit**
```bash
git add src/barabadb/query/exec/params.nim src/barabadb/query/executor.nim
# commit after user confirmation: git commit -m "refactor(exec): extract param binding into exec/params.nim"
```
---
### Task 4: exec/migrations.nim
**Files:**
- Create: `src/barabadb/query/exec/migrations.nim`
- Modify: `src/barabadb/query/executor.nim`
**Interfaces:**
- Consumes: `exec/types`, `exec/values`, `storage/lsm`, `checksums/sha2` (computeChecksum — check current import), std/locks or sync primitives as currently used.
- Produces: `acquireMigrationLock`, `releaseMigrationLock`, `isMigrationApplied`, `getMigrationRecord`, `setMigrationRecord`, `computeChecksum`, `getMigrationBody`, `migrationAppliedKey`, `listMigrations` — keep each proc's current export status (they are private today but used by the dispatcher in executor.nim, so they now need `*`; export them but do NOT re-export migrations from executor.nim — dispatcher imports it directly).
- [ ] **Step 1: Move the procs**
Create `exec/migrations.nim`; move the 228299 region (all migration storage helpers + their lock globals if any — check for module-level `var` in that range; there is none per analysis, but verify before moving).
- [ ] **Step 2: Wire executor.nim**
Delete moved code; add `import exec/migrations` (NO `export` — internal).
- [ ] **Step 3: Compile and test**
Run: `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all`
Expected: exit 0, 461+ `[OK]`.
- [ ] **Step 4: Stage for commit**
```bash
git add src/barabadb/query/exec/migrations.nim src/barabadb/query/executor.nim
# commit after user confirmation: git commit -m "refactor(exec): extract migration storage into exec/migrations.nim"
```
---
### Task 5: exec/eval.nim (with hybrid search + hooks)
**Files:**
- Create: `src/barabadb/query/exec/eval.nim`
- Modify: `src/barabadb/query/executor.nim`
**Interfaces:**
- Consumes: `exec/types`, `exec/values`, `exec/schema`, `exec/helpers` (parseVectorString — called by evalExprOld), `query/ast`, `query/ir` (IRPlan for the hook type), FTS/vector engine imports used by the hybrid region (copy from executor.nim imports: `fts/engine`, `vector/engine`, etc.).
- Produces:
- `evalExpr*` (all current overloads — Row and Table[string,string] variants), `evalExprOld*` (all overloads), `rowToStringTable`, `stringTableToValueRow`, `reciprocalRankFusion`, `realIdFromKey`, `findRealIdByDocId`, `doHybridSearch`, `doHybridSearchFiltered` (keep current export status).
- Two hook vars (new, the ONLY non-code-motion change):
```nim
## Wired by executor.nim at module load. Breaks the eval <-> executePlan /
## execScan module cycle (subqueries, hybrid search).
var executePlanHook*: proc(ctx: ExecutionContext, plan: IRPlan): ExecResult
var execScanHook*: proc(ctx: ExecutionContext, tableName: string): seq[Row]
```
Exact hook signatures MUST be copied from the real `executePlan` / `execScan` signatures in executor.nim before moving (check `proc executePlan*` and `proc execScan` — including all parameters, e.g. filters/RLS args execScan takes; if execScan has more params, the hook type gets all of them).
- [ ] **Step 1: Move eval + hybrid**
Create `exec/eval.nim`; move: `evalExpr` (587-736), `rowToStringTable`/`stringTableToValueRow` (737-755), `evalExprOld` (756-1512), and the hybrid region (437-582: `reciprocalRankFusion`, `realIdFromKey`, `findRealIdByDocId`, `doHybridSearch`, `doHybridSearchFiltered`) including the `{.gcsafe.}` closure if it lives there (~line 542 — move verbatim). Move their forward decls too.
- [ ] **Step 2: Redirect the two back-edges through hooks**
In the moved code: replace every call to `executePlan(...)` inside evalExpr/evalExprOld (was at 889, 909, 1503) with `executePlanHook(...)`; replace the two `execScan(...)` calls in the hybrid procs (was 468, 562) with `execScanHook(...)`. Add the hook var declarations with a nil-guard: first line of each call site region stays a plain call; add at module bottom:
```nim
proc requireExecutePlanHook(): proc(ctx: ExecutionContext, plan: IRPlan): ExecResult =
if executePlanHook == nil:
raise newException(ValueError, "executePlanHook not wired (import barabadb/query/executor)")
executePlanHook
```
and use `requireExecutePlanHook()(...)` at call sites (same pattern for execScanHook). Keep it minimal: direct `executePlanHook(...)` calls are acceptable if the nil raise is added once inside a tiny wrapper.
- [ ] **Step 3: Wire executor.nim**
Delete moved code; add `import exec/eval` + `export eval`. In executor.nim at module scope (bottom, after all procs are defined — or wire in Task 14 if executePlan/execScan are already moved; if still local, wire now):
```nim
eval.executePlanHook = executePlan
eval.execScanHook = execScan
```
- [ ] **Step 4: Compile and test**
Run: `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all`
Expected: exit 0, 461+ `[OK]` (the correlated-subquery and hybrid-search tests exercise both hooks).
- [ ] **Step 5: Stage for commit**
```bash
git add src/barabadb/query/exec/eval.nim src/barabadb/query/executor.nim
# commit after user confirmation: git commit -m "refactor(exec): extract expression evaluation + hybrid search into exec/eval.nim"
```
---
### Task 6: exec/lower.nim
**Files:**
- Create: `src/barabadb/query/exec/lower.nim`
- Modify: `src/barabadb/query/executor.nim`
**Interfaces:**
- Consumes: `exec/types`, `exec/values`, `exec/context` (`exprToSql` — called by lowerExpr, was 2530), `query/ast`, `query/ir`.
- Produces: `lowerExpr*`, `lowerSelect*`, `evalNodeToString` (keep export status).
- [ ] **Step 1: Move the procs**
Create `exec/lower.nim`; move the 21552557 region: `lowerExpr` (~222 lines), `evalNodeToString`, `lowerSelect` (~174 lines) + forward decls.
- [ ] **Step 2: Wire executor.nim**
Delete moved code; add `import exec/lower` + `export lower`.
- [ ] **Step 3: Compile and test**
Run: `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all`
Expected: exit 0, 461+ `[OK]`.
- [ ] **Step 4: Stage for commit**
```bash
git add src/barabadb/query/exec/lower.nim src/barabadb/query/executor.nim
# commit after user confirmation: git commit -m "refactor(exec): extract AST->IR lowering into exec/lower.nim"
```
---
### Task 7: exec/rls.nim
**Files:**
- Create: `src/barabadb/query/exec/rls.nim`
- Modify: `src/barabadb/query/executor.nim`
**Interfaces:**
- Consumes: `exec/types` (PolicyDef, UserDef), `exec/eval` (`evalExpr`), `exec/lower` (`lowerExpr`) — both called in passesPolicy/checkInsertPolicy.
- Produces: `hasPrivilege`, `passesPolicy`, `checkInsertPolicy` (export all three with `*` — used by scan.nim and dml.nim next; do NOT re-export from executor unless they were exported before).
- [ ] **Step 1: Move the procs**
Create `exec/rls.nim`; move the 15201567 region + forward decls (there is a forward-decl block at ~1513 — move what belongs to these procs).
- [ ] **Step 2: Wire executor.nim**
Delete moved code; add `import exec/rls` (+ `export rls` only if any proc was previously exported).
- [ ] **Step 3: Compile and test**
Run: `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all`
Expected: exit 0, 461+ `[OK]` (RLS/policy tests in test_all exercise this).
- [ ] **Step 4: Stage for commit**
```bash
git add src/barabadb/query/exec/rls.nim src/barabadb/query/executor.nim
# commit after user confirmation: git commit -m "refactor(exec): extract RLS/privileges into exec/rls.nim"
```
---
### Task 8: exec/scan.nim
**Files:**
- Create: `src/barabadb/query/exec/scan.nim`
- Modify: `src/barabadb/query/executor.nim`
**Interfaces:**
- Consumes: `exec/types`, `exec/values`, `exec/rls` (`passesPolicy` — was 1589), `exec/helpers` (`collectCorrelatedTablesFromPlan` — was 1600), storage imports as needed.
- Produces: `execScan`, `execPointRead` — exact current signatures; export both with `*` (needed by fk.nim, plan_exec.nim, and the eval execScanHook wiring).
- [ ] **Step 1: Move the procs**
Create `exec/scan.nim`; move the 15681624 region + forward decls.
- [ ] **Step 2: Wire executor.nim**
Delete moved code; add `import exec/scan` + `export scan` (export needed: eval.execScanHook assignment references execScan from executor.nim scope — importing is enough for the wiring line; re-export only if previously exported).
- [ ] **Step 3: Compile and test**
Run: `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all`
Expected: exit 0, 461+ `[OK]`.
- [ ] **Step 4: Stage for commit**
```bash
git add src/barabadb/query/exec/scan.nim src/barabadb/query/executor.nim
# commit after user confirmation: git commit -m "refactor(exec): extract table scans into exec/scan.nim"
```
---
### Task 9: exec/dml.nim
**Files:**
- Create: `src/barabadb/query/exec/dml.nim`
- Modify: `src/barabadb/query/executor.nim`
**Interfaces:**
- Consumes: `exec/types`, `exec/values`, `exec/schema`, `exec/rls` (`hasPrivilege`, `checkInsertPolicy`), storage/lsm.
- Produces: `execInsert*`, `execDelete*`, `execUpdateRow*` (already exported today; keep signatures).
- [ ] **Step 1: Move the procs**
Create `exec/dml.nim`; move the 16251925 region: `execInsert` (~176 lines), `execDelete`, `execUpdateRow` + their private helpers + forward decls. Do NOT move validateType (belongs to triggers task).
- [ ] **Step 2: Wire executor.nim**
Delete moved code; add `import exec/dml` + `export dml`.
- [ ] **Step 3: Compile and test**
Run: `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all`
Expected: exit 0, 461+ `[OK]`.
- [ ] **Step 4: Stage for commit**
```bash
git add src/barabadb/query/exec/dml.nim src/barabadb/query/executor.nim
# commit after user confirmation: git commit -m "refactor(exec): extract DML row operations into exec/dml.nim"
```
---
### Task 10: exec/fk.nim
**Files:**
- Create: `src/barabadb/query/exec/fk.nim`
- Modify: `src/barabadb/query/executor.nim`
**Interfaces:**
- Consumes: `exec/types` (ForeignKeyDef), `exec/values`, `exec/scan` (`execScan` — called by findReferencingRows, was 1928).
- Produces: `findReferencingRows`, `enforceFkOnDelete`, `enforceFkOnUpdate`, `enforceFkOnChildUpdate` (export with `*` for the dispatcher; NOT validateType — that moves in Task 11).
- [ ] **Step 1: Move the procs**
Create `exec/fk.nim`; move the 19262015 region (FK enforcement) — stop before `validateType` (~2016).
- [ ] **Step 2: Wire executor.nim**
Delete moved code; add `import exec/fk` (+ `export fk` only if previously exported).
- [ ] **Step 3: Compile and test**
Run: `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all`
Expected: exit 0, 461+ `[OK]` (FK enforcement suite in test_all exercises this).
- [ ] **Step 4: Stage for commit**
```bash
git add src/barabadb/query/exec/fk.nim src/barabadb/query/executor.nim
# commit after user confirmation: git commit -m "refactor(exec): extract FK enforcement into exec/fk.nim"
```
---
### Task 11: exec/triggers.nim (with executeQueryHook)
**Files:**
- Create: `src/barabadb/query/exec/triggers.nim`
- Modify: `src/barabadb/query/executor.nim`
**Interfaces:**
- Consumes: `exec/types` (TriggerDef, CheckDef), `exec/values`, `exec/eval` (`evalExpr`), `exec/lower` (`lowerExpr`).
- Produces: `validateType*`, `fireTriggers*`, `validateConstraints*`, `applyDefaultValues*`, plus one new hook var:
```nim
## Wired by executor.nim at module load. fireTriggers executes trigger
## action statements via the dispatcher; the hook breaks the module cycle.
var executeQueryHook*: proc(ctx: ExecutionContext, ast: Node): ExecResult
```
The signature MUST match how fireTriggers calls executeQueryImpl today (was 2064 — copy the exact call: argument count/types; if it passes params, include them).
- [ ] **Step 1: Move the procs + hook**
Create `exec/triggers.nim`; move `validateType` (~2016-2052), the 20562154 region (`fireTriggers`, `validateConstraints`, `applyDefaultValues`) + forward decls (block at ~2053). In `fireTriggers`, replace the `executeQueryImpl(...)` call with `executeQueryHook(...)`; add the nil-guard wrapper pattern from Task 5.
- [ ] **Step 2: Wire executor.nim**
Delete moved code; add `import exec/triggers` + `export triggers`. At module scope in executor.nim (after executeQueryImpl is defined):
```nim
triggers.executeQueryHook = (proc(ctx: ExecutionContext, ast: Node): ExecResult = executeQueryImpl(ctx, ast))
```
(adjust the lambda to the real call signature; executeQueryImpl is private, so the lambda must live in executor.nim — that is exactly why the hook exists).
- [ ] **Step 3: Compile and test**
Run: `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all`
Expected: exit 0, 461+ `[OK]` (trigger tests exercise the hook).
- [ ] **Step 4: Stage for commit**
```bash
git add src/barabadb/query/exec/triggers.nim src/barabadb/query/executor.nim
# commit after user confirmation: git commit -m "refactor(exec): extract triggers/constraints into exec/triggers.nim"
```
---
### Task 12: exec/window.nim
**Files:**
- Create: `src/barabadb/query/exec/window.nim`
- Modify: `src/barabadb/query/executor.nim`
**Interfaces:**
- Consumes: `exec/types`, `exec/values`, `exec/eval` (`evalExpr` — partitionKey/compareRowsByOrder/computeWindowValues).
- Produces: `partitionKey`, `compareRowsByOrder`, `resolveFrameBounds`, `computeWindowValues*`, `expandStarRow` (export computeWindowValues as today; others per current status — plan_exec.nim needs them, so export all five).
- [ ] **Step 1: Move the procs**
Create `exec/window.nim`; move the 25582747 region + forward decls.
- [ ] **Step 2: Wire executor.nim**
Delete moved code; add `import exec/window` + `export window`.
- [ ] **Step 3: Compile and test**
Run: `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all`
Expected: exit 0, 461+ `[OK]` (window function tests exercise this).
- [ ] **Step 4: Stage for commit**
```bash
git add src/barabadb/query/exec/window.nim src/barabadb/query/executor.nim
# commit after user confirmation: git commit -m "refactor(exec): extract window functions into exec/window.nim"
```
---
### Task 13: exec/plan_exec.nim
**Files:**
- Create: `src/barabadb/query/exec/plan_exec.nim`
- Modify: `src/barabadb/query/executor.nim`
**Interfaces:**
- Consumes: `exec/types`, `exec/values`, `exec/schema`, `exec/eval`, `exec/lower`, `exec/helpers` (`chooseJoinStrategy`, `extractJoinEquality`), `exec/scan` (`execScan`), `exec/window` (`computeWindowValues`, `expandStarRow`), `query/ir`.
- Produces: `executePlan*` (exact current signature — the symbol the eval hook points at).
- [ ] **Step 1: Move the proc**
Create `exec/plan_exec.nim`; move `executePlan` (~990 lines, 27483738) + its private helpers + forward decls.
- [ ] **Step 2: Wire executor.nim**
Delete moved code; add `import exec/plan_exec` + `export plan_exec`. If the `eval.executePlanHook = executePlan` wiring (Task 5 Step 3) was deferred, add it now at module scope in executor.nim.
- [ ] **Step 3: Compile and test**
Run: `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all`
Expected: exit 0, 461+ `[OK]`.
- [ ] **Step 4: Stage for commit**
```bash
git add src/barabadb/query/exec/plan_exec.nim src/barabadb/query/executor.nim
# commit after user confirmation: git commit -m "refactor(exec): extract IR plan execution into exec/plan_exec.nim"
```
---
### Task 14: Slim down executor.nim + verify hook wiring
**Files:**
- Modify: `src/barabadb/query/executor.nim`
**Interfaces:**
- Consumes: all new exec/* modules.
- Produces: unchanged public API: `executeQuery*`, plus re-exports of everything that was exported before.
- [ ] **Step 1: Clean up executor.nim**
executor.nim should now contain ONLY: the import block (trimmed to what the dispatcher needs), `import exec/X` + `export X` lines for all modules, remaining forward decls for `executeQueryImpl` (self-recursion), `executeQueryImpl` (the ~1,473-line dispatcher), `executeQuery` (DDL-locked wrapper — keep the `ctx.sharedLock.lock` semantics byte-identical), `executeMigrationSql`, and the two hook-wiring assignments at module scope:
```nim
eval.executePlanHook = plan_exec.executePlan
eval.execScanHook = scan.execScan
triggers.executeQueryHook = (proc(ctx: ExecutionContext, ast: Node): ExecResult = executeQueryImpl(ctx, ast))
```
(adjust to real signatures). Remove leftover dead forward decls and now-unused imports — verify with the XDeclaredButNotUsed/UnusedImport hints from the compiler output; aim for zero new hints.
- [ ] **Step 2: Compile and test**
Run: `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all`
Expected: exit 0, 461+ `[OK]`.
- [ ] **Step 3: Verify the public API freeze**
Run: `grep -hoE 'qexec\.[a-zA-Z]+|executor\.[a-zA-Z]+' tests/*.nim src/baradadb.nim src/barabadb/core/server.nim src/barabadb/core/httpserver.nim src/barabadb/mcp/server.nim | sort -u` and confirm every symbol resolves from executor.nim (compile of the full server proves it):
Run: `nim c -d:ssl --threads:on --path:src -o:build/baradadb src/baradadb.nim && nim c -d:ssl --threads:on --path:src -o:build/baramcp src/baramcp.nim`
Expected: both compile clean.
- [ ] **Step 4: Stage for commit**
```bash
git add src/barabadb/query/executor.nim
# commit after user confirmation: git commit -m "refactor(exec): slim executor.nim to dispatcher + hook wiring"
```
---
### Task 15: Full verification + docs
**Files:**
- Modify: `src/barabadb/query/exec/README.md`
- Modify: `docs/superpowers/specs/2026-07-30-stability-hardening-design.md` (mark B2 done)
- [ ] **Step 1: Full test suite**
Run: `nimble test`
Expected: exit 0, 650 `[OK]`, 0 failed. This covers all 13 suites including join_tests, prop_test (uses lowerSelect/executePlan directly), test_wire_insert_stress, nimforum_smoke_test (TCP server).
- [ ] **Step 2: Update exec/README.md**
Rewrite the layering section to the final state: types → values → schema → context/helpers/params/migrations → eval → lower → rls → scan → dml/fk → triggers → window → plan_exec → executor, with a note documenting the two hooks (`executePlanHook`, `execScanHook`, `executeQueryHook`) and why they exist (Nim forbids circular imports; subqueries/trigger bodies are genuine recursion points).
- [ ] **Step 3: Report sizes**
Run: `wc -l src/barabadb/query/executor.nim src/barabadb/query/exec/*.nim | sort -n`
Expected: executor.nim ≈ 1,600 lines; no module over ~1,500 lines.
- [ ] **Step 4: Stage for commit**
```bash
git add src/barabadb/query/exec/README.md docs/superpowers/specs/2026-07-30-stability-hardening-design.md
# commit after user confirmation: git commit -m "docs(exec): document module layering after executor split"
```
---
## Self-Review Notes
- Spec coverage: every region of executor.nim (per the dependency map) is assigned to exactly one task; dispatcher + wrapper stay in Task 14.
- Type consistency: hook signatures are defined by copying the real `executePlan`/`execScan`/`executeQueryImpl` call signatures at the task site — the compiler enforces the match at each task's Step 3.
- Riskiest tasks: 5 (eval + hooks) and 11 (triggers hook) — both are covered by existing correlated-subquery/hybrid/trigger tests in test_all.
@@ -0,0 +1,272 @@
# Production GA v1.2.0 — Implementation Plan
> **For agentic workers:** implement task-by-task; checkboxes track progress.
> Spec first: `docs/superpowers/specs/2026-07-30-production-ga-design.md`.
**Goal:** Close the “never production” loop: tagged **v1.2.0**, secure prod
compose, proven backup/restore, runbook + known limitations — **single-node
GA**. Raft stays documented experimental for multi-node.
**Architecture:** No new database features. Work is release engineering, ops
scripts, doc honesty, and small fail-closed security defaults for prod.
**Tech stack:** existing Nim binary, Docker, `core/backup.nim`, compose, unittest.
---
## Global constraints
- Spec: `docs/superpowers/specs/2026-07-30-production-ga-design.md`.
- Do **not** expand Raft/SQL surface unless a bug blocks backup/release.
- Prefer scripts under `scripts/` over one-off shell history.
- Commits: small, green where possible; **tag only after Task 6** (or controller tags after review).
- Branch: work on `main` (or short `chore/v1.2.0-ga` merged same day).
- Test baseline: `nimble test` or documented equivalent; at least
`test_all` + `bugfix_test` + schema persist + one e2e if binary present.
---
## Phase map
| Phase | Tasks | Outcome |
|-------|-------|---------|
| **P0 Freeze** | T1 | Scope freeze + limitations draft |
| **P1 Secure prod** | T2T3 | Auth fail-closed + compose hardened |
| **P2 Recoverability** | T4T5 | Backup/restore script + CI-able drill |
| **P3 Release** | T6T7 | Version bump, CHANGELOG, tag, image |
| **P4 Docs** | T8T9 | Runbook + limitations + README GA claim |
| **P5 Optional** | T10 | App smoke on release binary |
---
### Task 1: Scope freeze + known-limitations draft
**Files:**
- Create: `docs/en/known-limitations.md`
- Create: `docs/bg/known-limitations.md` (short mirror)
- Modify: `docs/superpowers/specs/2026-07-30-production-ga-design.md` (status → In progress)
**Content (en) must state clearly:**
| Area | GA (v1.2.0) | Experimental / later |
|------|-------------|----------------------|
| Single-node SQL + storage | Supported | — |
| Auth + JWT | Supported when configured | — |
| Raft 3-node | Experimental ops | InstallSnapshot, multi-DB, membership |
| Multi-database | Supported non-raft | Raft only `default` |
| Follower reads + indexes | Best-effort after apply | Linearizable read API |
| ORC multi-thread | Not supported (ARC default) | — |
- [ ] **Step 1:** Write both limitation pages (link from index if any).
- [ ] **Step 2:** Link from `docs/en/deployment.md` and `docs/en/distributed.md` top.
- [ ] **Step 3:** Commit
`docs: known-limitations for v1.2.0 production GA scope`
---
### Task 2: Production auth fail-closed
**Files:**
- Modify: `docker-compose.prod.yml` (auth **on** by default via env required)
- Modify: `src/barabadb/core/config.nim` and/or `src/baradadb.nim` **only if** needed:
- When `BARADB_ENV=production` or `BARADB_AUTH_REQUIRED=true`: refuse start if `authEnabled` false or `jwtSecret` empty/default
- Prefer env-only compose change first; code gate if compose alone is insufficient
**Acceptance:**
- Prod compose documents `BARADB_JWT_SECRET` as required (use `${BARADB_JWT_SECRET:?set me}` compose syntax).
- `BARADB_AUTH_ENABLED=true` uncommented / default true in prod file.
- Dev `docker-compose.yml` unchanged (still easy local).
- [ ] **Step 1:** Harden `docker-compose.prod.yml`.
- [ ] **Step 2:** Optional start-time check for production profile.
- [ ] **Step 3:** Manual: compose config fails without secret.
- [ ] **Step 4:** Commit
`fix(prod): require JWT secret and auth in production compose`
---
### Task 3: Fix prod compose footguns
**Files:**
- Modify: `docker-compose.prod.yml`
- Modify: `docs/en/deployment.md` (ports: HTTP = TCP+440, not fictional BARADB_HTTP_PORT if wrong)
**Checks:**
- Healthcheck hits real `/health` port (9472+440=9912 already — verify matches binary).
- WAL/sync env names match `config.nim` (`BARADB_WAL_*` etc.).
- systemd snippet in deployment.md uses correct env vars (fix `BARADB_HTTP_PORT` myth if present).
- Resource limits OK for compose v2 (note `deploy` may be ignored outside swarm — document).
- [ ] **Step 1:** Align env names + docs.
- [ ] **Step 2:** Commit
`docs(prod): align compose and deployment ports/env with runtime`
---
### Task 4: Backup/restore drill script
**Files:**
- Create: `scripts/backup-restore-drill.sh` (or `.nim` if better)
- Uses: `build/baradadb` or docker + `build/backup` / `src/barabadb/core/backup.nim`
**Script behavior:**
1. Create temp data dir; start server (or use offline backup of prepared dir).
2. Insert known row via client/curl/HTTP or wire (prefer simplest: HTTP if no auth in drill mode, or use backup tool offline after writing with embedded test).
3. Run full backup to `backup_$$.tar.gz`.
4. Stop server; **wipe** data dir.
5. Restore archive.
6. Start server; **SELECT** proves row exists.
7. Exit 0/1; print paths.
**Acceptance:** script runs twice consecutively on a clean machine with deps installed.
- [ ] **Step 1:** Implement script.
- [ ] **Step 2:** Run twice; capture output in PR description or comment.
- [ ] **Step 3:** Commit
`test(ops): automated backup/restore drill script`
---
### Task 5: Document backup ops in deployment runbook section
**Files:**
- Modify: `docs/en/deployment.md` — section **Runbook**
- Modify: `docs/bg/deployment.md` — short mirror
- Link `docs/en/backup.md` for details
**Runbook must include:**
- Ports: binary `BARADB_PORT`, HTTP `+440`, WS `+441`, raft `BARADB_RAFT_PORT`
- Start/stop (binary + compose prod)
- Data dir layout
- Backup command (all-databases)
- Restore procedure + “stop server first”
- Logs (`BARADB_LOG_FILE`, docker volume)
- Health/metrics URLs
- Where known-limitations live
- [ ] **Step 1:** Write runbook sections.
- [ ] **Step 2:** Commit
`docs: production runbook (start/stop/backup/restore)`
---
### Task 6: Version bump + CHANGELOG freeze
**Files:**
- Modify: `baradadb.nimble``version = "1.2.0"`
- Modify: `CHANGELOG.md``## [1.2.0] — 2026-07-30` (or actual ship date); move Unreleased leftovers if any under 1.2.0
- Modify: `README.md` version blurb
- Modify: health version string if hardcoded `1.1.6` in httpserver (align or use single source)
**Acceptance:**
- No “Unreleased” raft/storage/search if they ship in 1.2.0; new Unreleased empty or only post-GA items.
- [ ] **Step 1:** Bump versions + changelog date.
- [ ] **Step 2:** Align `/health` version if needed.
- [ ] **Step 3:** Commit
`release: prepare v1.2.0 changelog and version bump`
---
### Task 7: Tag + release artifact
**Steps (controller / human with push rights):**
- [ ] `git tag -a v1.2.0 -m "BaraDB v1.2.0 Production GA (single-node)"`
- [ ] `git push origin main --tags`
- [ ] Build release binary: `nimble build_release` (or documented `nim c -d:release`)
- [ ] Build Docker image: `docker build -t baradb:1.2.0 -t baradb:latest .`
- [ ] Optional: GH/Gitea release notes = CHANGELOG 1.2.0 section
**Do not force-push tags.**
---
### Task 8: README production claim (honest)
**Files:**
- Modify: `README.md`
**Replace hype with:**
- **Production GA (single-node):** v1.2.0 — backup/restore, auth prod compose, runbook
- **Raft cluster:** experimental — link distributed.md + known-limitations
- [ ] **Step 1:** Edit README status tables / quickstart prod pointer.
- [ ] **Step 2:** Commit
`docs: README production GA vs raft experimental`
---
### Task 9: CI / release checklist file
**Files:**
- Create: `docs/en/release-checklist.md`
**Checklist content:**
- [ ] `nimble test` (or subset listed)
- [ ] `scripts/backup-restore-drill.sh`
- [ ] `raft_e2e` / `raft_writes_e2e` if binary built (optional for single-node GA)
- [ ] docker build
- [ ] compose prod config validate
- [ ] tag
- [ ] **Step 1:** Write checklist; link from deployment.md.
- [ ] **Step 2:** Commit
`docs: v1.2.0 release checklist`
---
### Task 10 (optional): App smoke on release binary
**Files:**
- Possibly none; run `tests/nimforum_smoke_test` or ormin smoke against `./build/baradadb`
- [ ] **Step 1:** Document command in release-checklist.
- [ ] **Step 2:** Run once green; note in changelog “verified with …”.
---
## Task dependency graph
```
T1 limitations
├── T2 auth prod
├── T3 compose footguns
├── T4 backup drill
│ └── T5 runbook (uses drill)
├── T6 version/changelog
│ └── T7 tag/artifacts (after T2T6)
├── T8 README honesty
└── T9 release checklist
T10 optional after T7
```
## Explicit out-of-scope (do not sneak in)
- New raft features, membership, InstallSnapshot payload
- Multi-DB raft
- Benchmark campaigns for marketing
- Rewriting clients
## Definition of Done (whole plan)
- [ ] All P0P4 tasks complete
- [ ] Tag `v1.2.0` on origin
- [ ] Backup drill green twice
- [ ] Known-limitations + runbook linked from README/deployment
- [ ] Prod compose cannot start without JWT secret (compose and/or binary)
## Estimated effort
| Phase | Effort |
|-------|--------|
| P0P1 | 0.51 day |
| P2 | 0.51 day |
| P3P4 | 0.5 day |
| P5 optional | 0.5 day |
| **Total** | **~23 focused days** |
---
## After GA
Open `v1.3.0-raft-supported` plan only if needed: failover under load, CI e2e mandatory, cold-node story, raft TLS.
@@ -0,0 +1,229 @@
# Networked Raft Bootstrap (C3a) Implementation Plan
> **Status: DONE** — shipped on `main`. Historical plan; do not re-run tasks.
> Overview: `docs/superpowers/specs/2026-07-30-raft-cluster-status.md`.
**Goal:** A 3-node BaraDB cluster started with ordinary config elects a leader over TCP, maintains it with heartbeats, and re-elects after the leader is killed.
**Architecture:** Wire the existing half-built pieces: parse `id@host:port` peers into `node.peerAddrs` (config → startup), run a real election-timer loop inside `RaftNetwork.run` (with timer reset on inbound AppendEntries), pass `dataDir` for raft state persistence, and make frame reads partial-read-safe. SQL write path untouched (C3b); no membership/snapshots (C3c).
**Tech Stack:** Nim 2.2.10, ARC, unittest, real server processes for E2E.
## Global Constraints
- Spec: `docs/superpowers/specs/2026-07-30-raft-network-bootstrap-design.md` (read first).
- Test command per task: `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all` — exit 0, 461+ `[OK]`. Final task: full `nimble test` (659+ `[OK]`).
- The Raft state machine, message format, and serialization are FROZEN (TLA-faithfulness tests pin them) — changes only in: config parsing, startup wiring, timer loop, timer reset, frame reading.
- No new dependencies. Env-only config for this phase (no JSON config section).
- Commits per task after green; source files only; no push (controller merges+pushes).
- Verified facts to use:
- `newRaftNode*(id, peers, raftPort, dataDir)` (raft.nim:139-161) — dataDir enables saveState/loadState (`raft_state.bin`).
- `RaftNetwork` (raft.nim:546-557) has node/socket/running/peerSockets; `run` (633-645) starts heartbeatLoop; `processMessage` (588-599) dispatches inbound; `receiveLoop` (601-623) has the unsafe `recv(4)`/`recv(payloadLen)` reads.
- `ElectionTimer` (raft.nim:430-452) wraps a node with its own `timeoutMs`; `newElectionTimer(node, timeoutMs)`; `resetTimeout` sets lastHeartbeat; `tick(timer, net)` (668-685) drives election start; `startElection` (659-666).
- `node.electionTimeout` is 150+rand(150)ms (raft.nim:154); heartbeatTimeout 50ms. Pass `node.electionTimeout` as the timer's timeoutMs.
- Startup: `src/baradadb.nim:330-352` — creates node WITHOUT dataDir, never sets peerAddrs, `asyncCheck raftNet.run()`.
- Config: `core/config.nim:37-40` (raft fields), env parsing at `174-179` (`BARADB_RAFT_PEERS` comma-split).
- recvExact pattern to mirror: `core/server.nim:312-329` (mirror the approach inside raft.nim; do NOT import server.nim into raft.nim).
- Heartbeats ARE AppendEntries messages (`heartbeatLoop``node.appendEntries(peer)` → rmkAppendEntries), so resetting the timer on rmkAppendEntries covers heartbeats.
- Existing TCP election test: `tests/test_all.nim:2350-2391` (manual peerAddrs + manual ticks) — must stay green.
---
### Task 1: Peer address config + startup wiring
**Files:**
- Modify: `src/barabadb/core/config.nim` (raftPeerAddrs field + env parsing)
- Modify: `src/baradadb.nim` (pass peerAddrs + dataDir to the raft node)
- Test: `tests/bugfix_test.nim` (config parsing tests — it imports config already; check) or a small new suite in `tests/test_all.nim` if config import cycles arise (prefer bugfix_test; it already imports `barabadb/core/config`)
**Interfaces:**
- Consumes: existing `BARADB_RAFT_PEERS` env parsing (config.nim:176-178).
- Produces:
- `raftPeerAddrs*: Table[string, tuple[host: string, port: int]]` on BaraConfig (init in defaultConfig).
- Parsing rule: each comma entry `id@host:port` → peers gets `id`, raftPeerAddrs gets `id → (host, port)`; bare `id` → peers only. Malformed entries (empty id, `@` without host, non-numeric port) raise `ValueError` with the offending entry in the message (fail at config time).
- `cfg.raftPeers` contains ONLY ids after parsing (strip the `@host:port` part).
- [ ] **Step 1: Failing tests**
New suite in `tests/bugfix_test.nim`:
```nim
suite "Raft peer address parsing":
test "id@host:port entries populate raftPeerAddrs":
# set env BARADB_RAFT_PEERS="n1@127.0.0.1:9473,n2@10.0.0.5:9474,n3"
# call loadConfigFromEnv on a defaultConfig
# check raftPeers == @["n1", "n2", "n3"]
# check raftPeerAddrs["n1"] == ("127.0.0.1", 9473); "n3" notin raftPeerAddrs
test "malformed peer entries raise with the entry in the message":
# "n1@:9473" / "n1@host:notaport" / "@host:9473" → expect ValueError containing the entry
```
(Use `putEnv`/`delEnv` around `loadConfigFromEnv(cfg)`; check its signature at config.nim:~150-179. Restore env after each test.)
- [ ] **Step 2: Run, watch them fail**
Expected: compile error or assertion failure — `raftPeerAddrs` does not exist yet.
- [ ] **Step 3: Implement**
1. config.nim: add `raftPeerAddrs*` field + init; parse in loadConfigFromEnv right after the existing peersEnv split (strip, split on last `@` — IPv4/hostnames have no `@`; validate host non-empty and port parseInt 1..65535).
2. baradadb.nim:330-352: after `newRaftNode(config.raftNodeId, config.raftPeers, config.raftPort, dataDir = config.dataDir / "raft")` — create the subdir if newRaftNode doesn't (`createDir`); then `raftNode.peerAddrs = config.raftPeerAddrs`.
- [ ] **Step 4: Run, watch them pass**
`tests/bugfix_test` green; `tests/test_all` green.
- [ ] **Step 5: Commit**
```bash
git add src/barabadb/core/config.nim src/baradadb.nim tests/bugfix_test.nim
git commit -m "feat(raft): parse id@host:port peers, enable raft state persistence"
```
---
### Task 2: Production election timer + reset on inbound AppendEntries
**Files:**
- Modify: `src/barabadb/core/raft.nim` (timer field on RaftNetwork, timerLoop, reset in processMessage, start timerLoop in run)
- Test: `tests/test_all.nim` (add to the existing raft/election suites)
**Interfaces:**
- Consumes: ElectionTimer/tick/startElection (raft.nim:659-685), processMessage (588-599), run (633-645).
- Produces:
- `RaftNetwork.timer*: ElectionTimer` (created in `newRaftNetwork` with `node.electionTimeout`).
- `timerLoop(net: RaftNetwork) {.async.}` — while net.running: `tick(net.timer, net)`, `await sleepAsync(50)`.
- `run` starts `asyncCheck net.timerLoop()` next to heartbeatLoop; `stop` stops the timer.
- In `processMessage`, `of rmkAppendEntries:``net.timer.resetTimeout()` when the message's term is >= node's currentTerm (i.e., a plausible current leader; do NOT reset on stale-term messages — check handleAppendEntries' term logic and mirror its acceptance condition).
- [ ] **Step 1: Failing tests**
Add to the raft suites in `tests/test_all.nim`:
```nim
test "timerLoop elects a leader without manual ticks":
# 3 in-process RaftNodes with peerAddrs pointed at each other via real TCP
# (mirror the existing "3-node election over TCP" setup at test_all.nim:2350-2391
# but do NOT call tick manually — rely on timerLoop)
# start nets with run(); wait up to ~3s until some node.state == rsLeader
# assert exactly one leader; stop all nets
test "inbound AppendEntries resets the election timer":
# node A (follower) with net + timer; craft a valid AppendEntries from "leader"
# with term >= A.currentTerm; set timer.lastHeartbeat far in the past;
# await net.processMessage(msg); assert not timer.checkTimeout()
```
(If the 2350-2391 test's setup helpers are reusable, reuse them; the key difference: no manual ticking.)
- [ ] **Step 2: Run, watch them fail**
Expected: first test — no leader elected within timeout (no timerLoop exists); second — timer still timed out after processMessage.
- [ ] **Step 3: Implement**
Per Produces above. Keep `newRaftNetwork(node)` creating the timer (existing constructions keep working). Ensure `stop` also stops the timer so the new test doesn't leak loops.
- [ ] **Step 4: Run, watch them pass**
`tests/test_all` green, including the pre-existing manual-tick TCP election test.
- [ ] **Step 5: Commit**
```bash
git add src/barabadb/core/raft.nim tests/test_all.nim
git commit -m "feat(raft): run election timer in production, reset on AppendEntries"
```
---
### Task 3: Partial-read-safe framing
**Files:**
- Modify: `src/barabadb/core/raft.nim` (receiveLoop reads)
- Test: `tests/test_all.nim`
**Interfaces:**
- Consumes: receiveLoop (raft.nim:601-623), serialize/deserializeRaftMessage (494-539).
- Produces: `recvExact(client: AsyncSocket, size: int): Future[string] {.async.}` LOCAL to raft.nim (mirror core/server.nim:312-319 semantics: loop recv until size bytes or EOF returning short string); receiveLoop uses it for both the 4-byte header and the payload; EOF mid-frame → clean break, no exception escape.
- [ ] **Step 1: Failing test**
```nim
test "framing reassembles chunked messages":
# socketpair (std/net or asyncnet) or a real loopback listener:
# serialize a RequestVote message; send it in 3 chunks with tiny sleeps;
# the receive path must deliver exactly one intact message
# (assert via a node handler effect, e.g. a vote reply, or by calling
# the read helper directly and deserializing)
```
(Pick the simplest reliable harness; a direct test of the local recvExact + deserialize is acceptable if full receiveLoop testing is awkward without a running net.)
- [ ] **Step 2: Run, watch it fail**
Expected: with raw `recv`, a chunked send yields a short read → break/no message (simulate or assert on the helper's absence via compile error — acceptable red state).
- [ ] **Step 3: Implement**
Add the local recvExact; rewire receiveLoop to use it for header and payload; keep the rest of receiveLoop byte-identical.
- [ ] **Step 4: Run, watch it pass**
`tests/test_all` green.
- [ ] **Step 5: Commit**
```bash
git add src/barabadb/core/raft.nim tests/test_all.nim
git commit -m "fix(raft): partial-read-safe frame reassembly"
```
---
### Task 4: E2E 3-node cluster test + full verification
**Files:**
- Create: `tests/raft_e2e_test.nim`
- Modify: `baradadb.nimble` (add raft_e2e_test to the test task list)
- Modify: possibly `src/barabadb/core/raft.nim` (add an `info` log line in becomeLeader/becomeCandidate if none exists — check first; needed for the E2E to observe elections via process output)
**Interfaces:**
- Consumes: Tasks 1-3; `build/baradadb` binary (nimble test builds it first); port-offset pattern from `tests/nimforum_smoke_test.nim:16-30` (time-derived ports, env config, startProcess with poStdErrToStdOut + poDaemon, readiness poll).
- Produces: `tests/raft_e2e_test.nim` suite "Raft E2E cluster":
- Node i (1..3): temp dataDir; env `BARADB_PORT=<base+i>`, `BARADB_RAFT_ENABLED=true`, `BARADB_RAFT_PORT=<rbase+i>`, `BARADB_RAFT_NODE_ID=n<i>`, `BARADB_RAFT_PEERS="n1@127.0.0.1:<rbase+1>,n2@127.0.0.1:<rbase+2>,n3@127.0.0.1:<rbase+3>"`, `BARADB_DATA_DIR=<tmp>`, `BARADB_LOG_LEVEL=info`.
- Start all 3; within ~10s exactly one logs becoming leader (read process pipes non-blockingly — nimforum_smoke_test has the pattern).
- Kill the leader process; within ~10s one of the survivors logs becoming leader.
- Teardown: kill remaining processes, remove temp dirs. On any assertion failure, dump captured output to aid debugging.
- [ ] **Step 1: Write the test**
Follow nimforum_smoke_test.nim's process-management conventions. Guard total runtime < 60s with explicit timeouts; skip cleanly (with a printed reason) if `build/baradadb` is missing.
- [ ] **Step 2: Run it standalone**
`nim c -d:ssl --threads:on --path:src -o:tests/raft_e2e_test tests/raft_e2e_test.nim && ./tests/raft_e2e_test`
Expected: PASS (this is the feature acceptance test — if Tasks 1-3 are correct it passes; if the leader is never elected, debug via the dumped output — check peerAddrs wiring and timer first).
- [ ] **Step 3: Wire into nimble test**
Add "raft_e2e_test" to the test task list in baradadb.nimble AFTER nimforum_smoke_test (it also needs the server binary).
- [ ] **Step 4: Full suite**
`nimble test` — exit 0, 659+ `[OK]` (count grows with the new tests), 0 failed.
- [ ] **Step 5: Commit**
```bash
git add tests/raft_e2e_test.nim baradadb.nimble src/barabadb/core/raft.nim
git commit -m "test(raft): end-to-end 3-node cluster election and failover"
```
---
## Self-Review Notes
- Spec coverage: §1 peers → Task 1; §2 timer → Task 2; §3 dataDir → Task 1; §4 framing → Task 3; §5 testing E2E → Task 4. Frozen state machine honored — no handler changes.
- The timer reset condition (term >= currentTerm) mirrors handleAppendEntries' acceptance — Task 2's second test pins it; a wrong condition shows up as spurious elections in the E2E.
- Election timeout uses node.electionTimeout (randomized 150-300ms) → E2E expectations (10s) have wide margin; heartbeat 50ms keeps leaders stable.
- Risk flagged in spec (async CPU spin) — timerLoop sleeps 50ms per iteration, no busy loop.
@@ -0,0 +1,225 @@
# SQL Writes Through Raft (C3b) Implementation Plan
> **Status: DONE** — shipped on `main` (plus post-C3b DDL/forward/compact/metrics).
> Historical plan; do not re-run tasks.
> Overview: `docs/superpowers/specs/2026-07-30-raft-cluster-status.md`.
**Goal:** When Raft is enabled, SQL writes commit through the Raft log before the client sees success; followers reject writes naming the leader and apply committed entries via the existing applyCommand loop.
**Architecture:** Intercept at the server-level `executeQuery` (core/server.nim:206-227) — same hook point and same data source (`res.keyValuePairs`) the legacy ReplicationManager uses. Leader: execute locally, append one raft entry per KV pair (`"put"` = `key\x00value`, `"delete"` = `key` — the exact format baradadb.nim's applyCommand already consumes), poll `node.commitIndex` until the last appended index commits (no raft.nim changes — state machine stays TLA-frozen). Follower: reject before execution.
**Tech Stack:** Nim 2.2.10, ARC, unittest, real server processes for E2E.
## Global Constraints
- Spec: `docs/superpowers/specs/2026-07-30-raft-sql-writes-design.md` (read first).
- Test command per task: `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all` — exit 0. Final task: full `nimble test` (673+ `[OK]`).
- **raft.nim stays FROZEN** (state machine, handlers, message format, serialization — TLA-faithfulness tests pin them). All changes in server.nim / config.nim / exec/params.nim / executor.nim / baradadb.nim / tests.
- Non-raft deployments (`server.raftNode == nil`): byte-identical behavior — verify the raft code path is fully gated.
- No new dependencies. Commits per task after green; source files only; no push (controller merges+pushes).
- Verified facts to use:
- Server hook: `core/server.nim:206-227` — parse at :213, `executor.executeQuery` at :218, legacy replication ship at :219-227 (`replication.writeLsn(data)` with `key\0value`). `executeQuery(db, ctx, query, params, replication)` is called from handleClient (:567 plain, :587 prepared).
- Statement kinds: `nkInsert/nkUpdate/nkDelete/nkMerge` write directly; `nkCommitTxn` emits kvPairs at COMMIT (executor.nim:968-979); BEGIN/ROLLBACK are connection-local.
- appendLog (raft.nim:355): `appendLog*(node, command, data): LogEntry` — returns empty entry (index 0) when not leader; replication ships on the next heartbeat (50ms); commit advances via handleAppendReply → applyCommitted (raft.nim:195-215, 407-421).
- applyCommand format (baradadb.nim:336-343): `cmd == "put"` → data = `key \x00 value`; `cmd == "delete"` → data = `key`.
- Delete kvPair convention: non-txn DELETE emits `(fullKey, @[])` (exec/dml.nim:241). **OPEN ITEM for Task 2: txn COMMIT emits `(key, version.value)` even when `version.isDelete` (executor.nim:971-976) — check whether version.value is empty for deletes (read core/mvcc.nim writeSet/VersionedValue); if not, change the COMMIT loop to emit `(key, @[])` for deletes so the raft "empty value = delete" rule holds.**
- Config env parsing: core/config.nim:177-206 (BARADB_RAFT_* section).
---
### Task 1: isWrite helper + Server.raftNode + follower rejection
**Files:**
- Modify: `src/barabadb/query/exec/params.nim` (isWrite helper next to isDDL)
- Modify: `src/barabadb/core/server.nim` (raftNode field + rejection in executeQuery)
- Modify: `src/baradadb.nim` (assign server.raftNode when raft enabled)
- Test: `tests/bugfix_test.nim`
**Interfaces:**
- Produces:
- `proc isWrite*(stmt: Node): bool` in exec/params.nim — true for `nkInsert, nkUpdate, nkDelete, nkMerge, nkCommitTxn` (check exact enum names in query/ast.nim; nkCommitTxn included because COMMIT emits kvPairs).
- `Server.raftNode*: RaftNode` (nil default) — server.nim imports core/raft (verify no cycle: raft.nim must not import server.nim).
- Rejection in server-level executeQuery (core/server.nim:206+): right after parse and the empty-stmts check, BEFORE `executor.executeQuery`:
```nim
if server.raftNode != nil and isWrite(astNode.stmts[0]):
let node = server.raftNode
if node.state != rsLeader:
let who = if node.leaderId.len > 0: node.leaderId else: "none elected"
return (false, QueryResult(), "not leader; leader is '" & who & "'")
```
(Adjust to the proc's actual error-return convention and to how it accesses the Server — if executeQuery is not a method, thread the raft node the same way `replication` is threaded; check the handleClient call sites at :567/:587 first and pick the smaller change.)
- [ ] **Step 1: Failing tests**
New suite in tests/bugfix_test.nim:
```nim
suite "Raft write classification":
test "isWrite classifies DML and COMMIT":
check isWrite(parse("INSERT INTO t (id) VALUES (1)").stmts[0])
check isWrite(parse("UPDATE t SET id = 2").stmts[0])
check isWrite(parse("DELETE FROM t WHERE id = 1").stmts[0])
check isWrite(parse("COMMIT").stmts[0])
check not isWrite(parse("SELECT * FROM t").stmts[0])
check not isWrite(parse("CREATE TABLE t (id INT)").stmts[0])
check not isWrite(parse("BEGIN").stmts[0])
check not isWrite(parse("ROLLBACK").stmts[0])
```
(Adapt to bugfix_test.nim's imports — it has parser already.)
- [ ] **Step 2: Run, watch it fail**
Expected: compile error — `isWrite` undeclared.
- [ ] **Step 3: Implement**
The helper, the Server field, the rejection, the baradadb.nim assignment (`tcpServer.raftNode = raftNode` — check the actual server variable name and that raftNet/raftNode are in scope; they were hoisted in the C3a fix wave).
- [ ] **Step 4: Run, watch it pass**
bugfix_test green; test_all green (461+ [OK]).
- [ ] **Step 5: Commit**
```bash
git add src/barabadb/query/exec/params.nim src/barabadb/core/server.nim src/baradadb.nim tests/bugfix_test.nim
git commit -m "feat(raft): classify writes, reject them on follower nodes"
```
---
### Task 2: Leader write path — append + wait-for-commit
**Files:**
- Modify: `src/barabadb/core/server.nim` (raft append + commit wait, replacing the legacy ship when raft active)
- Modify: `src/barabadb/core/config.nim` (raftWriteTimeoutMs + env)
- Modify: possibly `src/barabadb/query/executor.nim` (COMMIT delete kvPair convention — see OPEN ITEM)
- Test: `tests/test_all.nim` (in-process leader path)
**Interfaces:**
- Consumes: Task 1's isWrite + server.raftNode; appendLog; applyCommand format.
- Produces:
- In server executeQuery after `res.success` (replacing the legacy replication ship when raftNode != nil):
```nim
if server.raftNode != nil and res.keyValuePairs.len > 0:
let node = server.raftNode
var lastIdx = 0'u64
for (key, value) in res.keyValuePairs:
let entry = if value.len > 0:
node.appendLog("put", cast[seq[byte]](key & "\x00" & cast[string](value)))
else:
node.appendLog("delete", cast[seq[byte]](key))
if entry.index == 0:
return (false, QueryResult(), "lost leadership during raft append")
lastIdx = entry.index
# wait for commit
let deadline = getMonoTime() + initDuration(milliseconds = config.raftWriteTimeoutMs)
while node.commitIndex < lastIdx and getMonoTime() < deadline:
await sleepAsync(10) # or the sync equivalent — check whether executeQuery is async; if it is NOT async, use os.sleep in a bounded loop
if node.commitIndex < lastIdx:
return (false, QueryResult(), "raft commit timeout")
else if replication != nil and res.keyValuePairs.len > 0:
<existing legacy ship, unchanged>
```
CRITICAL: check whether server-level executeQuery (server.nim:206) is a sync proc under withStorageGate — if sync, the wait must not block the async event loop; use short os.sleep polling and keep the timeout small, or document. Match the codebase's reality, not this sketch.
- `raftWriteTimeoutMs*: int` on BaraConfig (default 5000) + `BARADB_RAFT_WRITE_TIMEOUT_MS` env parsing in the raft section of loadConfigFromEnv.
- COMMIT delete convention resolved (OPEN ITEM above): version.value empty for deletes, or executor.nim COMMIT loop fixed to emit `(key, @[])` for isDelete entries.
- [ ] **Step 1: Failing test**
In tests/test_all.nim (raft suites):
```nim
test "leader append+commit wait round-trips through applyCommand":
# 3 in-process nodes over TCP (reuse the "timerLoop elects a leader" setup from C3a Task 2)
# wait for a leader; on the leader: appendLog("put", "users.1\x00alice")
# poll leader.commitIndex until >= entry.index (deadline 3s)
# assert a follower's applyCommand got invoked with ("put", data)
# (wire applyCommand on each node to record calls — check how baradadb.nim wires it)
test "txn COMMIT delete kvPairs are empty-valued":
# embedded ctx: BEGIN; INSERT; DELETE same row; COMMIT
# assert res.keyValuePairs for the deleted key has value.len == 0
```
- [ ] **Step 2: Run, watch them fail**
Expected: first — no server-side helper exists yet (compile error or assertion); second — may already pass or fail depending on the OPEN ITEM finding; record which.
- [ ] **Step 3: Implement**
Per Produces. Resolve the sync/async question by reading server.nim:206-227 and its callers first; keep the wait bounded and simple.
- [ ] **Step 4: Run, watch them pass**
test_all green.
- [ ] **Step 5: Commit**
```bash
git add src/barabadb/core/server.nim src/barabadb/core/config.nim src/barabadb/query/executor.nim tests/test_all.nim
git commit -m "feat(raft): leader appends writes to raft log and waits for commit"
```
---
### Task 3: E2E — replicated writes across a real 3-node cluster
**Files:**
- Create: `tests/raft_writes_e2e_test.nim` (or extend tests/raft_e2e_test.nim — pick the cleaner; new file preferred to keep runtimes isolated)
- Modify: `baradadb.nimble` (wire into test task)
**Interfaces:**
- Consumes: Tasks 1-2; the E2E process harness from tests/raft_e2e_test.nim (port base, env setup, leader detection via the "became leader" log line, teardown); the Nim client via `adaptors/nim/baradb_sqlite` (pattern from tests/nimforum_smoke_test.nim: `open("127.0.0.1:" & $port, "", "", "default")`, `db.exec(sql"...")`, `db.getAllRows`).
- Produces: suite "Raft replicated writes E2E":
1. Boot 3 nodes (raft enabled), wait for leader (log line).
2. On the LEADER's client port: CREATE TABLE + INSERT a row (expect success).
3. On a FOLLOWER's client port: poll `SELECT` until the row appears (deadline 5s; follower applies committed entries via applyCommand to its default DB).
4. On a FOLLOWER: `INSERT` → expect an error containing "not leader".
5. Kill the leader; wait for new leader; INSERT on the new leader succeeds; the row becomes visible on the remaining follower.
6. Teardown all processes; dump captured output on any failure.
- [ ] **Step 1: Write the test**
Follow raft_e2e_test.nim conventions (O_NONBLOCK output drains, deadlines, skip-with-reason if build/baradadb missing, different port base from both nimforum_smoke_test and raft_e2e_test). Note: applyCommand applies to the DEFAULT database — use database "default" in the client.
- [ ] **Step 2: Run standalone (twice)**
`nim c -d:ssl --threads:on --path:src -o:tests/raft_writes_e2e_test tests/raft_writes_e2e_test.nim && ./tests/raft_writes_e2e_test`
Expected: PASS twice consecutively. If the follower never sees the row, debug order: raft commit wait (Task 2) → applyCommand wiring → default-DB targeting. Dump output on failure.
- [ ] **Step 3: Wire into nimble test + full suite**
Add to baradadb.nimble test list after raft_e2e_test; add the binary name to .gitignore next to tests/raft_e2e_test. Run `nimble test` — exit 0, 673+ `[OK]`.
- [ ] **Step 4: Commit**
```bash
git add tests/raft_writes_e2e_test.nim baradadb.nimble .gitignore
git commit -m "test(raft): E2E replicated writes, follower rejection, failover writes"
```
---
### Task 4: Docs + close-out
**Files:**
- Modify: `docs/superpowers/specs/2026-07-30-raft-sql-writes-design.md` (status → done)
- Modify: README.md only if it claims raft/replication behavior that changed (check `grep -n -i 'raft\|replicat' README.md | head -20` — update only now-false lines, minimally)
- [ ] **Step 1: Update docs**
- [ ] **Step 2: Commit**
```bash
git add docs/ README.md
git commit -m "docs: SQL writes through raft (C3b) done"
```
---
## Self-Review Notes
- Spec coverage: write classification → T1; follower rejection → T1; leader append+wait → T2; config → T2; E2E → T3; docs → T4. Non-goals honored (no forwarding, no DDL replication, raft.nim frozen).
- The COMMIT delete-convention open item is the main correctness risk (a deleted key resurrected on followers) — T2's second test pins it before it can ship.
- Double application on the leader is idempotent by KV semantics; T3's follower-visibility assertions prove the real path end-to-end.
@@ -0,0 +1,105 @@
# UNIQUE Index Enforcement Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
**Goal:** `CREATE UNIQUE INDEX` actually enforces uniqueness (today the parser reads UNIQUE but the AST drops it — duplicates are silently accepted), and the flag persists across restarts.
**Background (verified facts):**
- `query/ast.nim:384-389` — the `nkCreateIndex` node carries `ciTarget/ciName/ciColumns/ciExpr/ciKind`, NO unique field.
- `query/parser.nim:1304-1307``parseCreateIndex` reads `isUnique` and never stores it.
- Standalone index storage: `ctx.btrees[colKey]`, colKey = `table.col[.col...]`; index values are `colVals.join("|")` of `valueToString(row[col])` (`\N` for missing) — see the CREATE INDEX btree branch in `src/barabadb/query/executor.nim` (~line 1376-1395) and the DML index-update paths in `src/barabadb/query/exec/dml.nim`.
- Index persistence just landed (commit 8d2d97a): `_schema:btreeidx:<colKey>` → replayable DDL, currently WITHOUT UNIQUE (flag didn't exist); replay via restoreEngines → executeQueryImpl.
- ExecutionContext lives in `src/barabadb/query/exec/types.nim`; cloneForConnection in `exec/context.nim` copies fields explicitly — a new field must be added there too.
- INSERT path: `exec/dml.nim execInsert*`; UPDATE path: `execUpdateRow*`. Both already maintain ctx.btrees entries on writes (look at how they insert/delete index entries — the enforcement check goes next to that maintenance).
**Global constraints:**
- TDD: failing tests FIRST in `tests/bugfix_test.nim` (this is a bug fix; follow that file's setupCtx/teardown conventions), watch them fail for the right reason, then implement.
- Test commands: `nim c -d:ssl --threads:on --path:src -o:tests/bugfix_test tests/bugfix_test.nim && ./tests/bugfix_test` AND `nim c -d:ssl --threads:on --path:src -o:tests/test_all tests/test_all.nim && ./tests/test_all` AND `nim c -d:ssl --threads:on --path:src -o:tests/test_schema_persist tests/test_schema_persist.nim && ./tests/test_schema_persist` — all exit 0.
- Error behavior on duplicate: the INSERT/UPDATE returns a non-success ExecResult with a clear message (match existing error style, e.g. PK-duplicate handling in exec/dml.nim) — no panic, no silent accept.
- No public API removals; additive changes only.
- Commits after green; source files only; no push (controller merges+pushes).
---
### Task 1: UNIQUE flag through the pipeline + enforcement
**Files:**
- Modify: `src/barabadb/query/ast.nim` (add `ciUnique*: bool` to the CreateIndex node)
- Modify: `src/barabadb/query/parser.nim` (`result.ciUnique = isUnique` in parseCreateIndex)
- Modify: `src/barabadb/query/exec/types.nim` (add `uniqueIndexes*: HashSet[string]` to ExecutionContext — colKey set)
- Modify: `src/barabadb/query/exec/context.nim` (init in newExecutionContext, copy in cloneForConnection)
- Modify: `src/barabadb/query/executor.nim` (register colKey in uniqueIndexes on CREATE UNIQUE INDEX; duplicate check during index build; persist UNIQUE in the `_schema:btreeidx:` DDL)
- Modify: `src/barabadb/query/exec/dml.nim` (enforcement on INSERT/UPDATE)
- Test: `tests/bugfix_test.nim`, `tests/test_schema_persist.nim`
**Interfaces:**
- `ciUnique*: bool` on the AST node (check the exact object/field style of neighboring fields — ref object with named fields).
- `uniqueIndexes*: HashSet[string]` on ExecutionContext — colKeys of UNIQUE standalone indexes. Registered at CREATE INDEX (when ciUnique), removed at DROP INDEX (btree branch) and DROP TABLE sweep (table prefix).
- Enforcement helper in dml.nim, e.g. `proc violatesUniqueIndex(ctx, table, row, excludeLsmKey = ""): string` returning the offending colKey or "" — builds idxVal with the SAME `colVals.join("|")` + `\N` convention as the CREATE INDEX population loop and checks `ctx.btrees[colKey].get(idxVal)` for an existing entry with a different lsmKey.
- Persisted DDL: named `CREATE UNIQUE INDEX <name> ON ...`, unnamed `CREATE UNIQUE INDEX ON ...` (only when ciUnique — plain indexes keep the current format).
- [ ] **Step 1: Failing tests**
In `tests/bugfix_test.nim` (new suite "Bug fixes — UNIQUE index enforcement"):
```nim
test "CREATE UNIQUE INDEX rejects duplicate INSERT":
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("CREATE TABLE accts (id INTEGER PRIMARY KEY, email TEXT)"))
discard executeQuery(ctx, parse("INSERT INTO accts (id, email) VALUES (1, 'a@b.c')"))
let c = executeQuery(ctx, parse("CREATE UNIQUE INDEX accts_email ON accts (email)"))
check c.success
let dup = executeQuery(ctx, parse("INSERT INTO accts (id, email) VALUES (2, 'a@b.c')"))
check not dup.success
let ok = executeQuery(ctx, parse("INSERT INTO accts (id, email) VALUES (2, 'x@y.z')"))
check ok.success
test "CREATE UNIQUE INDEX rejects duplicate UPDATE":
# accts with rows 1:'a@b.c', 2:'x@y.z' + unique index;
# UPDATE accts SET email = 'a@b.c' WHERE id = 2 → not success;
# UPDATE accts SET email = 'a@b.c' WHERE id = 1 → success (same row)
test "CREATE UNIQUE INDEX over duplicate data fails":
# insert two rows with same email FIRST, then CREATE UNIQUE INDEX → not success
```
In `tests/test_schema_persist.nim`:
```nim
test "UNIQUE B-tree index survives reopen and still enforces":
# create accts + unique index + 1 row; close; reopen;
# duplicate INSERT must fail after reopen too
```
- [ ] **Step 2: Run, watch them fail**
Expected: duplicate INSERT/UPDATE succeed today (bug); CREATE-over-duplicates succeeds today. Confirm failures are those assertions, not compile errors.
- [ ] **Step 3: Implement**
1. ast.nim: add `ciUnique*: bool` to the CreateIndex node (match field style).
2. parser.nim parseCreateIndex: `result.ciUnique = isUnique` (verify the result node's construction site).
3. types.nim: add `uniqueIndexes*: HashSet[string]`; context.nim: init `initHashSet[string]()` in newExecutionContext, copy the set in cloneForConnection (copy semantics: same set or fresh copy? — cloneForConnection shares most index maps by reference; check what it does for `btrees` and match that).
4. executor.nim CREATE INDEX btree branch: if `stmt.ciUnique`: during the population loop detect duplicates (track seen idxVals with their lsmKey; on second occurrence return errResult("duplicate key ...") WITHOUT registering the index); on success `ctx.uniqueIndexes.incl(colKey)`. Persist DDL with UNIQUE per the format above.
5. executor.nim DROP INDEX btree branch: `ctx.uniqueIndexes.excl(targetKey)`; DROP TABLE sweep: excl prefixed keys.
6. dml.nim: in execInsert and execUpdateRow, before writing, for each colKey in ctx.uniqueIndexes that startsWith(table & "."): build idxVal from the incoming row (same convention), look up `ctx.btrees[colKey].get(idxVal)`; if an entry exists with a DIFFERENT lsmKey (for INSERT any entry conflicts; for UPDATE exclude the row's own lsmKey), return/record failure with a clear message like "UNIQUE constraint failed: <colKey>". IMPORTANT: find how execInsert/execUpdateRow report failures today (return count? ExecResult? exceptions?) and integrate with that mechanism — do not invent a new one; check how PK duplicates are handled and mirror it.
- [ ] **Step 4: Run, watch them pass**
All three test commands; expected PASS + test_all/test_schema_persist green.
- [ ] **Step 5: Commit**
```bash
git add src/barabadb/query/ast.nim src/barabadb/query/parser.nim src/barabadb/query/exec/types.nim src/barabadb/query/exec/context.nim src/barabadb/query/executor.nim src/barabadb/query/exec/dml.nim tests/bugfix_test.nim tests/test_schema_persist.nim
git commit -m "fix: CREATE UNIQUE INDEX actually enforces uniqueness (and persists)"
```
---
## Self-Review Notes
- The enforcement must use the EXACT idxVal convention of the CREATE INDEX population loop (`\N` for missing columns, join "|") — a mismatch means false negatives/positives; the tests pin the common cases.
- Multi-row UPDATE (UPDATE ... SET email='x' with no WHERE hitting many rows): enforcement applies per row — note in the report how the existing update loop calls execUpdateRow.
- Composite unique indexes (multi-column) work through the same idxVal convention; not separately tested (same code path).
@@ -0,0 +1,598 @@
# v1.3.0 Raft-Supported — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> Spec first: `docs/superpowers/specs/2026-07-30-raft-supported-design.md`.
**Goal:** Move raft from experimental to supported: proven failover under
load, mandatory CI e2e, cold-node recovery via InstallSnapshot, raft-port TLS.
**Architecture:** No changes to election/AppendEntries semantics. New
InstallSnapshot message pair rides the existing framed TCP transport
(backward-compatible trailing fields, `RaftProtoVersion` stays 1). TLS wraps
the existing transport via `protocol/ssl.nim`. Snapshot payload reuses
`core/backup.nim` tar.gz backup/restore.
**Tech stack:** Nim 2.2.x, std/asyncnet + std/net SSL, existing
`tests/raft_*_e2e_test.nim` process harness, GitHub Actions.
## Global Constraints
- Spec: `docs/superpowers/specs/2026-07-30-raft-supported-design.md`.
- Do **not** change election safety, commit rules, or the DDL/DML
classification from C3b/C3c.
- Raft remains default-DB-only; snapshot payload covers the default DB only.
- Compile all touched Nim with `-d:ssl --threads:on --path:src`.
- Test baseline per task: `tests/test_all.nim` + `tests/bugfix_test.nim` must
stay green; raft e2e suites green where the binary is built.
- Branch: `main` (short-lived feature branches merged same day are fine).
- Version bump to 1.3.0 happens only in the final task (T12).
---
## Phase map
| Phase | Tasks | Outcome |
|-------|-------|---------|
| P1 Proof | T1 | Failover-under-load e2e |
| P2 CI | T2 | Mandatory raft e2e CI gate |
| P3 TLS | T3T6 | Raft port TLS + mutual auth + TLS e2e |
| P4 Cold node | T7T11 | InstallSnapshot + compaction unpin + cold-node e2e |
| P5 Release | T12 | Docs, limitations, version 1.3.0 |
---
### Task 1: Failover-under-load E2E
**Files:**
- Create: `tests/raft_failover_load_e2e_test.nim`
- Modify: `baradadb.nimble` (task `test`, line ~30-34: add `raft_failover_load_e2e_test` after `raft_writes_e2e_test`)
**Interfaces:**
- Consumes: process harness conventions from `tests/raft_writes_e2e_test.nim`
(`NodeProc`, `drainOutput`, `portOpen`, `openClient`, `waitForRow` pattern);
client `adaptors/nim/baradb_sqlite`.
- Produces: suite `Raft failover under load E2E`.
**Scenario (spec D1):**
- Port base `cbase = 50000 + (tstamp mod 4000)`, `rbase = cbase + 100`
(distinct from 35000/41000/46000 bases already in use).
- Boot 3 nodes with `BARADB_RAFT_*` env exactly as
`raft_writes_e2e_test.nim:150-171`.
- Wait for stable leader (same `maxLeader` logic). Leader DDL:
`CREATE TABLE load_test (id INT PRIMARY KEY)`.
- Load phase: spawn a Nim `Thread` that loops `n = 1, 2, ...`:
`INSERT INTO load_test (id) VALUES (n)` against the current leader's
client port; every **acknowledged** `n` appended to a
`seq[int]` guarded by a `Lock`. On exception: reopen client against a
survivor, continue (this models the documented client retry contract).
- At ≥ 50 acked writes: `killNode(leader)`.
- Assert A (availability): some survivor accepts an INSERT within 10 s of
the kill.
- Assert B (durability): after new leader is stable and the remaining
follower caught up (poll `SELECT count(*)` equality or 10 s deadline),
`SELECT id FROM load_test` on **both** survivors contains every acked id.
- Stop the writer thread in `finally`; reuse the `dumpAll`-on-fail
convention.
- [x] **Step 1:** Write the suite skeleton: harness copied from
`raft_writes_e2e_test.nim` (drain/kill/leader-discovery helpers), writer
thread, kill at 50 acked, asserts A + B.
- [x] **Step 2:** Build binary and run:
`nim c -o:build/baradadb src/baradadb.nim && nim c -d:ssl --threads:on --path:src -r tests/raft_failover_load_e2e_test.nim`
Expected: PASS.
- [x] **Step 3:** Run 3 consecutive times (failover timing flakiness check).
- [x] **Step 4:** Add suite to `nimble test` list in `baradadb.nimble`.
- [x] **Step 5:** Commit
`test(raft): failover under sustained write load e2e`
---
### Task 1a: Fix raft put/delete encoding for empty values (bug found in T1)
**Bug:** `execInsert` (`src/barabadb/query/exec/dml.nim:60-90`) stores only
non-PK columns in the value, so a PK-only table row gets `valStr = ""` and
`kvPairs.add((fullKey, @[]))`. `appendWriteToRaft`
(`src/barabadb/core/server.nim:309-330`) encodes an empty value as a
`"delete"` log entry — but `execDelete` (`dml.nim:223-241`) uses the same
`(fullKey, @[])` shape for real deletes. Result: INSERT into a PK-only
table returns OK after majority commit, then every node (leader included,
on apply) **deletes the row**. Verified live in T1: 30 acked inserts → 0
rows on all nodes.
**Files:**
- Modify: `src/barabadb/query/exec/types.nim:139``ExecResult.keyValuePairs`
- Modify: `src/barabadb/query/exec/dml.nim` — 3 producer sites (insert ~90,
delete ~241, update ~316)
- Modify: `src/barabadb/core/server.nim``appendWriteToRaft` (~309) and
its call site (~441-464)
- Test: `tests/bugfix_test.nim` (new suite)
**Interfaces:**
- Change the pair type to carry the op explicitly:
```nim
# exec/types.nim
keyValuePairs*: seq[tuple[key: string, value: seq[byte], deleted: bool]]
```
- `execInsert`/`execUpdate` produce `deleted: false` (even when
`value.len == 0`); `execDelete` produces `deleted: true`.
- `appendWriteToRaft` encodes `deleted``"delete"`, else `"put"`
(empty value stays a put). Apply side (`baradadb.nim:358-368`) already
handles `put` with empty value correctly — no change needed there.
- Check other `keyValuePairs` consumers compile clean (replication path in
`server.nim`); keep `okResult(kvPairs=...)` call sites type-correct.
**Steps:**
- [x] **Step 1:** Write the failing test in `tests/bugfix_test.nim`:
build `ExecResult` via the insert path for a PK-only table (or call
`appendWriteToRaft` semantics directly): assert a PK-only insert yields
a pair with `deleted == false` and encodes as `"put"`, while a delete
yields `deleted == true` and encodes as `"delete"`.
- [x] **Step 2:** Run, expect fail/compile error.
- [x] **Step 3:** Implement the type + producer/consumer changes.
- [x] **Step 4:** `bugfix_test` + `test_all` green; rebuild
`build/baradadb` and re-run `tests/raft_failover_load_e2e_test.nim`
then **switch its table back** to the brief's original
`load_test (id INT PRIMARY KEY)` / `VALUES (n)` shape (remove the
two-column workaround and its header note) and re-run green.
- [x] **Step 5:** Commit
`fix(raft): distinguish put-with-empty-value from delete in write path`
---
### Task 2: Mandatory raft e2e CI gate
**Files:**
- Modify: `.github/workflows/ci.yml`
- Modify: `tests/raft_e2e_test.nim`, `tests/raft_writes_e2e_test.nim`,
`tests/raft_failover_load_e2e_test.nim` (skip→fail under CI)
**Steps:**
- [x] **Step 1:** In each suite's binary-missing branch, replace plain
`skip()` with:
```nim
if not fileExists(BinaryPath):
if getEnv("CI").len > 0:
echo "[FAIL] ", BinaryPath, " missing under CI — build step broken?"
fail()
else:
echo "[SKIP] ", BinaryPath, " missing — run `nimble test` (builds the server first)"
skip()
```
- [x] **Step 2:** Add a dedicated job to `.github/workflows/ci.yml` (after
the `test` job), modeled on its setup steps:
```yaml
raft-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Nim
uses: jiro4989/setup-nim-action@v1
with:
nim-version: '2.2.10'
- name: Install system dependencies
run: sudo apt-get update -qq && sudo apt-get install -y -qq libssl-dev libpcre3-dev openssl ca-certificates
- name: Install Nim dependencies
run: nimble install --depsOnly -y
- name: Build server
run: nim c -d:ssl -o:build/baradadb src/baradadb.nim
- name: Raft e2e suites
env:
CI: "true"
run: |
nim c -d:ssl --threads:on --path:src -r tests/raft_e2e_test.nim
nim c -d:ssl --threads:on --path:src -r tests/raft_writes_e2e_test.nim
nim c -d:ssl --threads:on --path:src -r tests/raft_failover_load_e2e_test.nim
```
- [x] **Step 3:** Push on a branch; confirm the `raft-e2e` job appears and
is green; confirm deleting `build/` would fail (local run with `CI=true`
and no binary → FAIL).
- [x] **Step 4:** Commit
`ci(raft): dedicated mandatory raft e2e job; no silent skips under CI`
---
### Task 3: Raft TLS config + fail-closed startup
**Files:**
- Modify: `src/barabadb/core/config.nim` (fields after `raftLogMaxEntries`
at lines ~46/88; env parsing after line ~212)
- Modify: `src/baradadb.nim` (raft wiring block, lines 337-377)
- Test: `tests/bugfix_test.nim` (new suite)
**Interfaces:**
- Produces config fields (used by T4/T6):
```nim
raftTlsEnabled*: bool # default false
raftTlsCertFile*: string # default ""
raftTlsKeyFile*: string # default ""
raftTlsCaFile*: string # default ""
raftTlsVerifyPeer*: bool # default false
```
- Env parsing (mirror lines 184-212 style):
```nim
cfg.raftTlsEnabled = parseEnvBool(getEnv("BARADB_RAFT_TLS_ENABLED", ""), cfg.raftTlsEnabled)
cfg.raftTlsCertFile = getEnv("BARADB_RAFT_TLS_CERT_FILE", cfg.raftTlsCertFile)
cfg.raftTlsKeyFile = getEnv("BARADB_RAFT_TLS_KEY_FILE", cfg.raftTlsKeyFile)
cfg.raftTlsCaFile = getEnv("BARADB_RAFT_TLS_CA_FILE", cfg.raftTlsCaFile)
cfg.raftTlsVerifyPeer = parseEnvBool(getEnv("BARADB_RAFT_TLS_VERIFY_PEER", ""), cfg.raftTlsVerifyPeer)
```
- Fail-closed in `baradadb.nim` before `newRaftNetwork`:
```nim
if config.raftTlsEnabled:
if config.raftTlsCertFile.len == 0 or config.raftTlsKeyFile.len == 0 or
not fileExists(config.raftTlsCertFile) or not fileExists(config.raftTlsKeyFile):
raise newException(ValueError,
"BARADB_RAFT_TLS_ENABLED=true but cert/key missing " &
"(BARADB_RAFT_TLS_CERT_FILE / BARADB_RAFT_TLS_KEY_FILE)")
```
- [x] **Step 1:** Write failing test in `tests/bugfix_test.nim`: default
config has `raftTlsEnabled == false`; env `BARADB_RAFT_TLS_ENABLED=true`
+ cert paths parse into config.
- [x] **Step 2:** Run test, expect compile/fail (fields don't exist).
- [x] **Step 3:** Implement fields + env parsing + startup check.
- [x] **Step 4:** Test green; `test_all` still green.
- [x] **Step 5:** Commit
`feat(raft): TLS config surface with fail-closed startup`
---
### Task 4: TLS in RaftNetwork transport
**Files:**
- Modify: `src/barabadb/core/raft.nim` (`RaftNetwork` type ~line 731,
`connectToPeer` ~748, `run` ~857)
- Modify: `src/baradadb.nim` (construct TLSContext, pass to network)
- Test: `tests/test_all.nim` (in-process TLS raft pair)
**Interfaces:**
- Consumes: `protocol/ssl.nim` — `newTLSConfig(certFile, keyFile, caFile,
verifyPeer)`, `newTLSContext`, `wrapClient`, `wrapServer`.
- Produces: `RaftNetwork.tls*: TLSContext` (nil = plaintext, unchanged
default); `newRaftNetwork(node, tls = nil)`.
**Implementation:**
```nim
# raft.nim — in connectToPeer, after successful connect:
if net.tls != nil:
try: net.tls.wrapClient(sock)
except CatchableError:
try: sock.close() except CatchableError: discard
return
# raft.nim — in run, after accept, before receiveLoop:
if net.tls != nil:
try: net.tls.wrapServer(client)
except CatchableError:
client.close()
continue
```
- `baradadb.nim`: build the context when enabled:
```nim
var raftTls: TLSContext = nil
if config.raftTlsEnabled:
raftTls = newTLSContext(newTLSConfig(
config.raftTlsCertFile, config.raftTlsKeyFile,
caFile = config.raftTlsCaFile, verifyPeer = config.raftTlsVerifyPeer))
...
raftNet = newRaftNetwork(raftNode, raftTls)
```
- [x] **Step 1:** Write failing in-process test (`test_all.nim`): two
`RaftNode`s over `RaftNetwork` with a self-signed cert from
`generateSelfSignedCert` (`protocol/ssl.nim:79`) — election completes
over TLS; plaintext dial to the TLS port produces no protocol effect
(no state change, connection dropped).
- [x] **Step 2:** Run, expect fail (no `tls` field).
- [x] **Step 3:** Implement transport changes + wiring.
- [x] **Step 4:** Test green; plaintext raft e2e suites still green
(regression: nil-TLS path untouched).
- [x] **Step 5:** Commit
`feat(raft): optional TLS on raft transport (server + dialer)`
---
### Task 5: TLS for leader SQL forwarding
**Files:**
- Modify: `src/barabadb/core/server.nim` (`forwardQueryToLeader`, lines
210-289)
**Interfaces:**
- Consumes: `protocol/ssl.nim` `wrapClient`; server config `tlsEnabled`,
`certFile`, `keyFile`.
- Produces: `forwardQueryToLeader(host, port, query, tls: TLSContext = nil,
...)`.
- [x] **Step 1:** When the server's client wire port has TLS on
(`server.tls != nil`), wrap the forwarding socket with a client-side
context before sending the wire header; on handshake failure return
`(false, QueryResult(), "leader forward TLS handshake failed")`.
- [x] **Step 2:** Manual check: TLS server + raft forwarding (follower
INSERT forwarded over TLS) works; non-TLS setup unchanged.
- [x] **Step 3:** Commit
`feat(raft): TLS on follower→leader SQL forwarding`
---
### Task 6: Raft TLS E2E
**Files:**
- Create: `tests/raft_tls_e2e_test.nim`
- Modify: `baradadb.nimble` (test list), `.github/workflows/ci.yml`
(raft-e2e job: add this suite)
**Interfaces:**
- Consumes: T3/T4 implementation; harness from T1; openssl CLI for cert
generation (already used by `protocol/ssl.nim`).
**Scenario:**
- Port base `54000 + (tstamp mod 4000)`.
- Generate one self-signed cert per node into the temp data dirs
(`openssl req -x509 ...`, or `generateSelfSignedCert`).
- Boot 3 nodes with `BARADB_RAFT_TLS_ENABLED=true` + per-node cert/key;
assert election + `CREATE TABLE` via raft DDL + one replicated INSERT
visible on a follower.
- Negative: start a 4th process with raft TLS **disabled** pointed at the
same peers; assert the TLS cluster still elects/operates among its 3
members and the plaintext node never becomes leader (its frames are
undecryptable).
- Same `CI`-fail semantics as T2.
- [x] **Step 1:** Write suite; Step 2: run green locally; Step 3: run 3×
(timing); Step 4: wire into `nimble test` + CI job; Step 5: Commit
`test(raft): 3-node TLS cluster e2e with plaintext rejection`
---
### Task 7: InstallSnapshot protocol
**Files:**
- Modify: `src/barabadb/core/raft.nim` (`RaftMessageKind` ~line 80,
`RaftMessage` ~86, `serialize` ~679, `deserializeRaftMessage` ~702)
- Test: `tests/test_all.nim` (serialize/deserialize round-trip)
**Interfaces:**
- Produces:
```nim
# RaftMessageKind += rmkInstallSnapshot, rmkInstallSnapshotReply
# RaftMessage new fields:
snapId*: uint64 # snapshot generation, matches leader's base at build time
snapOffset*: uint64 # byte offset of this chunk within the archive
snapData*: seq[byte] # chunk payload (<= snapChunkBytes)
snapDone*: bool # last chunk
# Reused for this kind: prevLogIndex = snapshot base index,
# prevLogTerm = snapshot base term. Reply uses success/matchIdx as usual.
```
- Serialization: append `snapId`, `snapOffset`, `snapData` (length-prefixed),
`snapDone` **after** `matchIdx`; deserialize each with `if not s.atEnd`
guards (pattern from `loadState`, raft.nim:163-167). Old binaries ignore
trailing bytes; new binaries default missing fields to zero/false.
`RaftProtoVersion` stays 1.
- [x] **Step 1:** Write failing round-trip test: all new fields survive
serialize→deserialize; a buffer serialized by the *old* layout (no
trailing fields) deserializes with zero defaults.
- [x] **Step 2:** Run, expect fail.
- [x] **Step 3:** Implement.
- [x] **Step 4:** Green; existing raft suites still green.
- [x] **Step 5:** Commit
`feat(raft): InstallSnapshot wire protocol (backward-compatible)`
---
### Task 8: Follower snapshot receive + restore
**Files:**
- Modify: `src/barabadb/core/raft.nim` (`RaftNode` — new callback +
incoming-snapshot buffer; `processMessage` ~786)
- Modify: `src/barabadb/core/config.nim` (`raftSnapChunkKb: int`, env
`BARADB_RAFT_SNAP_CHUNK_KB`, default 256; parsing next to the other
`BARADB_RAFT_*` env reads)
- Modify: `src/baradadb.nim` (wire `restoreSnapshot` callback using
`core/backup.nim` + `DatabaseRegistry`; pass chunk size to the node)
- Test: `tests/test_all.nim`
**Interfaces:**
- Produces on `RaftNode`:
```nim
snapChunkBytes*: int # from BARADB_RAFT_SNAP_CHUNK_KB, default 262144
restoreSnapshot*: proc(archivePath: string, baseIndex: uint64,
baseTerm: uint64): bool {.gcsafe.}
snapIncomingId*: uint64
snapIncomingFile*: string # temp path under dataDir/raft/snap_incoming/
```
- `processMessage` case `rmkInstallSnapshot`: append `snapData` at
`snapOffset` to the temp file (create/truncate when `snapId !=
snapIncomingId`); on `snapDone`: call `restoreSnapshot`; on success set
`lastSnapshotIndex/Term = prevLogIndex/prevLogTerm`,
`commitIndex = lastApplied = lastSnapshotIndex`, clear `log`,
`saveState()`, reply success with `matchIdx = lastSnapshotIndex`; on
failure reply `success = false` and delete the temp file.
- `baradadb.nim` `restoreSnapshot` implementation: close default DB via
registry, `restoreDataDir(archivePath, defaultDbDir)`
(`backup.nim:263`), reopen, swap `ctx`. Return false on any exception.
- [x] **Step 1:** Write failing test: feed a node two chunks + done with a
real tar.gz fixture; assert callback received the assembled file, state
fields updated, log cleared.
- [x] **Step 2:** Run, expect fail. **Step 3:** Implement.
- [x] **Step 4:** Green + regression suites. **Step 5:** Commit
`feat(raft): follower InstallSnapshot receive and restore`
---
### Task 9: Leader snapshot send
**Files:**
- Modify: `src/barabadb/core/raft.nim` (`handleAppendReply` floor branch
~526-531, new `sendSnapshot` proc, per-peer reject counter)
- Modify: `src/baradadb.nim` (wire `buildSnapshot` callback)
- Test: `tests/test_all.nim`
**Interfaces:**
- Consumes: T7 protocol, `backupDataDir` (`backup.nim:225`).
- Produces on `RaftNode`:
```nim
buildSnapshot*: proc(destPath: string): bool {.gcsafe.}
snapRejectStreak*: Table[string, int] # consecutive floor-level rejects per peer
```
- Logic: in `handleAppendReply`, when a reject arrives **and**
`nextIndex[peerId] == lastSnapshotIndex + 1` (floor reached): increment
streak; at streak ≥ 2 the leader knows the follower needs a snapshot →
`asyncCheck sendSnapshot(peerId)`. Reset streak on any successful reply.
- `sendSnapshot`: `buildSnapshot` into `dataDir/raft/snap_out_<snapId>.tar.gz`
(`snapId = lastSnapshotIndex`); stream chunks of `snapChunkBytes` as
`rmkInstallSnapshot`; on final success reply set
`matchIndex[peer] = lastSnapshotIndex`,
`nextIndex[peer] = lastSnapshotIndex + 1`; delete the temp archive.
- [x] **Step 1:** Write failing test: leader with compacted log
(`lastSnapshotIndex = 100`) + peer at floor rejecting twice → snapshot
messages emitted; success reply advances `matchIndex`/`nextIndex`.
- [x] **Step 2:** Run, expect fail. **Step 3:** Implement.
- [x] **Step 4:** Green + regression. **Step 5:** Commit
`feat(raft): leader InstallSnapshot send on unrecoverable lag`
---
### Task 10: Unpin compaction from dead peers
**Files:**
- Modify: `src/barabadb/core/raft.nim` (`compactLog` ~244, `becomeLeader`
~327, `handleAppendReply` ~487)
- Modify: `src/barabadb/core/config.nim` (`raftPeerStaleMs`, env
`BARADB_RAFT_PEER_STALE_MS`, default 30000)
- Test: `tests/test_all.nim`
**Interfaces:**
- Produces: `matchIndexSeenMs*: Table[string, int64]` on `RaftNode`
monotonic ms timestamp of the last successful reply per peer, updated in
`handleAppendReply` success branch and initialized to "now" in
`becomeLeader`.
**Logic:** leader-side `compactLog` computes `minMatch` only over peers
with `now - matchIndexSeenMs[peer] <= raftPeerStaleMs`; peers stale longer
are excluded (they'll be snapshotted on return per T9). Follower path
unchanged. Guard: never compact past `lastApplied`.
- [x] **Step 1:** Write failing test: leader, one peer never replies,
log > maxEntries → with default stale window, log compacts through
lastApplied anyway; with the peer responsive, compaction still pins at
its matchIndex (existing safety preserved).
- [x] **Step 2:** Run, expect fail. **Step 3:** Implement.
- [x] **Step 4:** Green + regression. **Step 5:** Commit
`feat(raft): compaction unpinned from stale peers (snapshot fallback)`
---
### Task 11: Cold-node E2E
**Files:**
- Create: `tests/raft_coldnode_e2e_test.nim`
- Modify: `baradadb.nimble`, `.github/workflows/ci.yml` (raft-e2e job)
**Interfaces:** Consumes T7T10; harness from T1. Port base
`58000 + (tstamp mod 4000)`. Small `BARADB_RAFT_LOG_MAX_ENTRIES=16` and
`BARADB_RAFT_PEER_STALE_MS=3000` to force compaction quickly.
**Scenario A — node returns after compaction:**
1. 3-node cluster, create table, kill node n3.
2. Write 100 rows through the leader (forces compaction past n3's
matchIndex once n3 is stale).
3. Assert via `/metrics` on the leader HTTP port
(`baradb_raft_log_entries`) that the log stayed bounded.
4. Restart n3 with its intact data dir; assert it receives a snapshot
(leader log line / `baradb_raft_snapshot_index` advances on n3) and
within 15 s `SELECT count(*)` on n3 matches the leader.
**Scenario B — wiped node joins:**
1. Stop n3, **delete its data dir**, restart with the same node id.
2. Assert it converges (snapshot → catch-up) and serves the full row set
within 20 s.
- [x] **Step 1:** Write suite. **Step 2:** Green locally. **Step 3:** 3×
stability runs. **Step 4:** `nimble test` + CI wiring. **Step 5:** Commit
`test(raft): cold-node rejoin and wiped-node join e2e`
---
### Task 12: Docs, limitations, version 1.3.0
**Files:**
- Modify: `docs/en/distributed.md`, `docs/bg/distributed.md` — client
failover contract (in-flight writes fail fast, retry; acked writes
durable), TLS setup (`BARADB_RAFT_TLS_*`), snapshot behavior/tunables
(`BARADB_RAFT_SNAP_CHUNK_KB`, `BARADB_RAFT_PEER_STALE_MS`)
- Modify: `docs/en/known-limitations.md`, `docs/bg/known-limitations.md`
raft 3-node moves from "Experimental" to "Supported" for the covered
scope; remaining non-goals (multi-DB raft, membership changes, read
consistency levels) stay listed
- Modify: `docs/superpowers/specs/2026-07-30-raft-cluster-status.md`
status → v1.3.0 supported
- Modify: `CHANGELOG.md``## [1.3.0] — <ship date>`
- Modify: `baradadb.nimble``version = "1.3.0"`; README status lines
- Modify: `docs/en/release-checklist.md` — add raft TLS + cold-node suites
- [x] **Step 1:** Doc edits. **Step 2:** Full `nimble test` green.
- [x] **Step 3:** Commit
`release: v1.3.0 raft-supported (failover load, CI gate, snapshot, TLS)`
- [ ] **Step 4 (human/controller):** tag `v1.3.0` after review.
---
## Task dependency graph
```
T1 failover-load e2e ──→ T2 CI gate
T3 TLS config ──→ T4 transport TLS ──→ T5 forward TLS ──→ T6 TLS e2e
T7 snapshot protocol ──→ T8 follower restore ──→ T9 leader send ──→ T10 unpin ──→ T11 cold-node e2e
all ──→ T12 docs/version
```
## Explicit out-of-scope
- Membership change (join/leave) protocol
- Multi-database raft; `CREATE`/`DROP DATABASE` replication
- Linearizable follower reads
- Rolling-upgrade compat shims beyond the trailing-field guard
(upgrade = restart all nodes)
## Definition of Done
- [x] All P1P5 tasks complete, `nimble test` green
- [ ] `raft-e2e` CI job green and mandatory (no silent skip)
- [x] Failover-under-load e2e: every acked write survives leader kill
- [x] Cold-node e2e: returning node and wiped node converge automatically
- [x] Raft TLS e2e: full-TLS cluster works; plaintext node excluded
- [x] known-limitations updated: raft supported for the covered scope
## Estimated effort
| Phase | Effort |
|-------|--------|
| P1P2 | 0.51 day |
| P3 | 1 day |
| P4 | 23 days |
| P5 | 0.5 day |
| **Total** | **~45 focused days** |
@@ -0,0 +1,274 @@
# Design: A Good Nim Client for BaraDB
**Date:** 2026-06-18
**Status:** Approved (approach B)
**Scope:** `clients/nim` and `clients/nim-allographer`
## 1. Goal
Turn the existing Nim client code into a production-grade, easy-to-use client for BaraDB with:
- A single source of truth for the binary wire protocol.
- Async + sync APIs that are safe under concurrent use.
- Connection pooling, timeouts, TLS, and reconnect support.
- Typed values (not only strings) for vectors, JSON, bytes, etc.
- Clean integration with `nim-allographer` so the Laravel-style query builder keeps working.
- Good unit and integration test coverage without requiring a live server for every test.
## 2. Current State
- `clients/nim` (`baradb` nimble package) is a self-contained, stdlib-only async/sync client. It duplicates the wire protocol to avoid depending on the server source.
- `clients/nim-allographer` is a fork of `itsumura-h/nim-allographer`. It copy-pastes the same client into `src/allographer/query_builder/libs/baradb/baradb_client.nim` and adds a connection pool, query builder integration, migrations, and prepared-statement helpers.
- `src/barabadb/client/client.nim` is an incomplete embedded client bundled with the server and should not be used by applications.
- The Python, JavaScript, and Rust clients already have internal request queues so that concurrent operations on one TCP connection do not interleave frames on the wire. The Nim clients do not.
- The Nim clients convert every `WireValue` to `string`, which loses type information for vectors, JSON, bytes, arrays, and objects.
- `timeoutMs` and `maxRetries` exist in `ClientConfig` but are not honored.
## 3. Design Principles
1. **Canonical low-level package.** `clients/nim` owns the wire protocol, socket handling, typed values, request serialization, pooling, TLS, and timeouts.
2. **Thin allographer wrapper.** `clients/nim-allographer` imports the canonical package and only adds allographer-specific glue (types, `dbOpen`, query builder, migrations, transactions).
3. **No new runtime dependencies for the standalone client.** It must stay stdlib-only so it can be used in embedded and restricted environments.
4. **Backward compatibility.** Existing `dbOpen(Baradb, ...)` code and the `.table(...).get()` API must keep compiling and behaving the same way.
5. **Fail fast, diagnose clearly.** Distinguish I/O errors, protocol framing errors, server errors, auth errors, and pool timeouts.
## 4. Architecture
```
┌─────────────────────────────────────────┐
│ clients/nim-allographer │
│ - allographer query builder / schema │
│ - BaradbConnections pool wrapper │
│ - migration helpers │
│ - thin re-export of baradb/client │
└──────────────┬──────────────────────────┘
│ requires "baradb >= 1.2.0"
┌──────────────▼──────────────────────────┐
│ clients/nim (canonical package) │
│ - wire.nim (protocol constants) │
│ - client.nim (async/sync client) │
│ - pool.nim (async connection pool)│
│ - http.nim (optional HTTP client) │
│ - errors.nim (exception hierarchy) │
└─────────────────────────────────────────┘
```
### 4.1 Files in `clients/nim/src/baradb/`
| File | Responsibility |
|------|----------------|
| `wire.nim` | `FieldKind`, `MsgKind`, `WireValue`, serialize/deserialize, `buildMessage`. |
| `client.nim` | `BaraClient`, `SyncClient`, `ClientConfig`, `QueryResult`, query/exec/auth/ping/close. |
| `pool.nim` | `BaraPool`, `PooledClient`, `withClient` template, pool stats, idle/lifetime eviction. |
| `http.nim` | Optional `BaraHttpClient` that posts queries to the HTTP/REST endpoint. |
| `errors.nim` | `BaraError`, `BaraProtocolError`, `BaraServerError`, `BaraAuthError`, `BaraPoolTimeoutError`. |
### 4.2 Files in `clients/nim-allographer/src/allographer/query_builder/libs/baradb/`
| File | Responsibility |
|------|----------------|
| `baradb_client.nim` | Re-exports needed types from `baradb/client` and keeps only allographer-specific helpers (migration SQL builders). The wire code is removed. |
| `baradb_types.nim` | Keeps `BaradbConnections`, `BaradbQuery`, pool bookkeeping, but references `BaraClient` from the canonical package. |
| `baradb_open.nim` | `dbOpen` constructors; may create either a `BaraPool` or keep the current simple pool, depending on migration step. |
| `baradb_exec.nim` / `baradb_query.nim` / `baradb_transaction.nim` | Unchanged API surface; internally use the canonical client. |
## 5. Low-Level Client Improvements
### 5.1 Typed `WireValue` rows
`QueryResult` gains a typed view:
```nim
type
QueryResult* = object
columns*: seq[string]
columnTypes*: seq[FieldKind]
rows*: seq[seq[string]] # legacy string view
typedRows*: seq[seq[WireValue]] # new typed view
rowCount*: int
affectedRows*: int
executionTimeMs*: float64
lastInsertId*: int64
```
`wireValueToString` stays for backward compatibility. `typedRows` is populated during deserialization and lets callers inspect vectors, JSON, bytes, etc., without string parsing.
### 5.2 Per-connection request queue
A single `BaraClient` must be safe when multiple async fibers call `query`/`exec` on it. Add an internal queue:
```nim
type
BaraClient* = ref object
config: ClientConfig
socket: AsyncSocket
connected: bool
requestId: uint32
sendLock: AsyncLock # or a Future chain queue
pending: Deque[PendingRequest]
```
Design choice: **serialize sends and reads per connection**. This matches the Python/JS clients and is simple to reason about. It is not pipelining; it is request/response queueing. If higher throughput is needed later, add pipelining on top of the pool.
### 5.3 Connection pool
`BaraPool` is an async pool with:
- `minConnections`, `maxConnections`
- `maxIdleTime`, `maxLifetime`
- `connectTimeout`, `queryTimeout`
- `withClient` template / proc that borrows a connection, runs an async callback, and returns it
- `stats(): (total, idle, inUse)`
- Eviction of expired/stale connections
- Health check via `ping` before lending
The sync API gets a matching `SyncPool` that uses a blocking socket and a `Lock`.
### 5.4 TLS
`ClientConfig` gets optional TLS fields:
```nim
ClientConfig* = object
host*: string
port*: int
database*: string
username*: string
password*: string
timeoutMs*: int
maxRetries*: int
ssl*: bool
sslContext*: SslContext # optional, user-supplied
```
If `ssl` is true and no `sslContext` is supplied, the client creates a default `net.newContext()` and wraps the socket. TLS uses Nim's stdlib `net`/`asyncnet` OpenSSL wrappers. The standalone client remains stdlib-only at the Nim level, but the host must provide the OpenSSL system libraries.
### 5.5 Timeouts and reconnect
- `connect` honors `timeoutMs` via `asyncdispatch.withTimeout`.
- `recv` is wrapped with `withTimeout` using `timeoutMs`.
- If a send/recv fails with `ECONNRESET` or a timeout and `maxRetries > 0`, the client closes the socket, reconnects, and retries the request once. Retries are not attempted for server-side errors (`mkError`).
### 5.6 Batch and transactions
The protocol defines `mkBatch` and `mkTransaction`, but their server-side semantics are not stable enough in the current codebase. The client will expose:
```nim
proc batch*(client: BaraClient, queries: seq[string]): Future[seq[QueryResult]]
proc transaction*(client: BaraClient, body: proc(): Future[void]): Future[void]
```
The first implementation will use explicit SQL `BEGIN`/`COMMIT`/`ROLLBACK` over a single borrowed connection (via the pool). When `mkBatch`/`mkTransaction` server support is verified, the implementation can switch to the native messages without changing the public API.
### 5.7 HTTP fallback (optional module)
`baradb/http` provides `BaraHttpClient` that sends JSON `{"query": ...}` to `POST /api/query` (HTTP endpoint, default port `TCP+440`) and parses the JSON response. Useful for environments where only the HTTP port is open or for debugging. Not loaded by default.
## 6. Allographer Integration Plan
1. **Add `requires "baradb >= 1.2.0"` to `clients/nim-allographer/allographer.nimble`.**
2. **Replace `baradb_client.nim` wire code with re-exports.** Keep the allographer-specific query builder and migration helpers.
3. **Update `baradb_types.nim`.** `Connection.client` stays `BaraClient`; remove local duplicates of `ClientConfig`, `WireValue`, `QueryResult`.
4. **Keep the current pool or migrate to `BaraPool`.** Phase 1: keep the existing `Connections` pool because the allographer query builder relies on its busy-flag semantics. Phase 2 (optional): replace it with `BaraPool.withClient` to reduce code.
5. **Use typed rows internally.** Update `toJson(resultSet)` in `baradb_exec.nim` to read from `resultSet.typedRows` instead of parsing strings. This fixes wrong JSON/vector/int parsing.
6. **Fix `formatSql` / prepared statements.** The current code sometimes builds SQL by string concatenation in `baradb_exec.nim`; ensure all user input goes through `mkQueryParams` so the server handles parameter binding.
## 7. Public API Sketch
### Standalone async
```nim
import asyncdispatch, baradb/client, baradb/pool
proc main() {.async.} =
let cfg = ClientConfig(host: "127.0.0.1", port: 9472, timeoutMs: 30_000)
let pool = newBaraPool(cfg, minConnections = 2, maxConnections = 10)
await withClient(pool) do (c: BaraClient) -> Future[void]:
let r = await c.query("SELECT name, age FROM users WHERE age > ?",
@[WireValue(kind: fkInt32, int32Val: 18)])
echo r.typedRows
waitFor main()
```
### Standalone sync
```nim
import baradb/client
let c = newSyncClient()
c.connect()
let r = c.query("SELECT * FROM users")
echo r.rows
c.close()
```
### Allographer (unchanged)
```nim
import allographer/connection, allographer/query_builder
let rdb = dbOpen(Baradb, "default", "admin", "", "127.0.0.1", 9472,
maxConnections = 5)
proc main() {.async.} =
let users = await rdb.table("users").select("id", "name").get()
echo users
waitFor main()
```
## 8. Data Flow
1. Caller invokes `await pool.withClient(...)` or `await client.query(sql, params)`.
2. The request is enqueued on the connection (or the pool lends a free connection).
3. The queue serializes the request: send header + payload, wait for response.
4. The response loop reads the 12-byte header, then the payload, then any trailing `mkComplete`.
5. `mkData` payloads are deserialized into `typedRows`; `rows` is populated via `wireValueToString`.
6. Server errors (`mkError`) raise `BaraServerError` with code and message.
7. The connection is returned to the pool.
## 9. Error Handling
| Exception | When raised | Retry? |
|-----------|-------------|--------|
| `BaraError` | Base type | depends |
| `BaraProtocolError` | Bad framing, unexpected message kind | no |
| `BaraServerError` | Server replied with `mkError` | no |
| `BaraAuthError` | Auth failed / rejected | no |
| `BaraIoError` | Connection lost, timeout, ECONNREFUSED | yes (up to `maxRetries`) |
| `BaraPoolTimeoutError` | No connection available within `timeoutMs` | no |
All exceptions inherit from `BaraError` so callers can catch a single type.
## 10. Testing Strategy
1. **Mock async TCP server in `clients/nim/tests/test_wire.nim`.** Verifies framing, request/response serialization, and the request queue without a real BaraDB instance.
2. **Property/round-trip tests for `WireValue`.** Serialize then deserialize random values and compare.
3. **Pool unit tests.** Check acquire/release, max size, eviction, and timeout behavior using a mock client factory.
4. **Integration tests.** Reuse `clients/nim/tests/test_integration.nim` and `clients/nim-allographer/tests/baradb/*`; run them when a server is available on `localhost:9472`.
5. **Allographer regression tests.** Ensure existing tests still pass after switching to the canonical client.
## 11. Migration & Rollout
1. Release `clients/nim` as `baradb 1.2.0` with the new modules.
2. Update `clients/nim-allographer` to depend on `baradb >= 1.2.0` and remove the duplicated wire code.
3. Mark `src/barabadb/client/client.nim` as deprecated with a `{.deprecated.}` pragma pointing to `baradb/client`.
4. Document the new pool and typed-row APIs in `clients/nim/README.md` and `docs/en/clients.md`.
## 12. Risks & Mitigations
| Risk | Mitigation |
|------|------------|
| Breaking allographer tests | Run the full `tests/baradb/` suite after every change; keep API unchanged. |
| TLS support depends on OpenSSL Nim wrapper | Gate TLS behind `when defined(ssl)` and document how to build with `-d:ssl`. |
| Async request queue hurts throughput | Benchmark before/after; add per-request `requestId` matching and pipelining later if needed. |
| Server `mkBatch`/`mkTransaction` semantics unclear | Implement via SQL `BEGIN`/`COMMIT` first; switch to native messages later. |
| Nim 2.0 vs 2.2 compatibility | Keep code compatible with Nim 2.0; test on both versions in CI. |
## 13. Open Questions
1. Should `clients/nim-allographer` keep its own pool (Phase 1) or switch fully to `BaraPool` (Phase 2)?
*Recommendation:* Phase 1 keeps risk low; Phase 2 can be done after the canonical pool is proven stable.
2. Should the HTTP fallback be part of the `baradb` package or a separate `baradb_http` package?
*Recommendation:* Keep it as an optional module `baradb/http` inside the same package so `import baradb/http` is explicit and does not pull in `httpclient` for users who do not need it.
@@ -0,0 +1,118 @@
# Engine Persistence (C1) — Design
Date: 2026-07-30
Status: Done — schema keys + restoreEngines replay for FTS/HNSW, table-scan loader for graphs, DROP path cleanup, unnamed-index fix.
## Problem
After a server restart, FTS indexes, HNSW vector indexes, and graph objects
silently vanish while the underlying rows survive (LSM+WAL):
- `CREATE INDEX ... USING FTS` builds an in-memory `InvertedIndex` only
(`ctx.ftsIndexes`); the DDL is never persisted under `_schema:`.
DML keeps updating it in memory (`exec/dml.nim:81-89, 295-304`), but nothing
writes it down.
- Same for HNSW (`ctx.vectorIndexes`) and graphs (`ctx.graphs`; only the
backing `<name>_nodes`/`_edges` tables are durable).
- `restoreSchema` (`exec/schema.nim:167-231`) restores tables, views,
triggers, users, policies and rebuilds B-tree secondary indexes — but not
these three engines. Their `CREATE` DDL is not even stored.
- User-visible effect: hybrid search and graph queries return **silently
empty results** after restart (guards in `exec/eval.nim:103,150,968-984`),
and new inserts no longer update the lost indexes. This is a correctness
bug, not a performance gap.
## Goal
FTS indexes, HNSW vector indexes, and graphs survive a restart: after
reopening the database, the same queries return the same results, and new
DML keeps the indexes up to date.
Non-goals (later phases): snapshot-based persistence for fast startup (C2),
columnar persistence (no `ctx` integration exists yet), Raft transport (C3).
## Design
Follow the existing schema-durability pattern: DDL is serialized under
stable `_schema:<kind>:<name>` keys in the LSM store and replayed at startup
by `restoreSchema`.
### 1. Persist engine DDL
New key prefixes (mirroring `SchemaTablePrefix` etc. in `exec/schema.nim:14-20`):
- `_schema:ftsidx:<table>.<column>` → the original `CREATE INDEX ... USING FTS` DDL
- `_schema:vecidx:<table>.<column>` → the original `CREATE INDEX ... USING HNSW/VECTOR` DDL
- `_schema:graphs:<name>` → the original `CREATE GRAPH ...` DDL
Written by the corresponding `CREATE` execution paths in the dispatcher
(`executeQueryImpl`), deleted by `DROP INDEX` / `DROP GRAPH`, and cleaned up
on `DROP TABLE` for keys belonging to that table (same sweep the current
code does for `_schema:tables:` and secondary-index metadata — check and
extend that path).
### 2. Restore on startup
`restoreSchema` runs after tables are restored and B-tree indexes rebuilt.
New step, in this order:
1. Scan `_schema:ftsidx:` / `_schema:vecidx:` keys, parse each DDL, and
**re-execute it through `executeQuery`** (the same way table DDL is
re-applied). The CREATE INDEX path already builds the in-memory index
from a full table scan, so replay == rebuild, no new build logic.
2. Scan `_schema:graphs:` keys and re-execute the CREATE GRAPH DDL.
Caveat to resolve at implementation time: replaying CREATE GRAPH must
not fail when the backing `<name>_nodes`/`_edges` tables already exist
(they were restored as regular tables). If the CREATE GRAPH execution
errors on existing tables, the restore path instead rebuilds the in-memory
`Graph` object by scanning those tables (small new loader in the graph
engine usage site — the engine has no table-scan loader today, only
unused binary file save/load). Pick whichever is smaller and matches
existing behavior; document the choice in the plan.
Failures during engine restore must not prevent startup: log a warning and
continue (a corrupt engine DDL must not take the database down — matches
the defensive style of `restoreSchema`).
### 3. Ordering constraints
- Table restore + row data available BEFORE engine replay (indexes build
from scans; graphs build from `_nodes`/`_edges`).
- Engine replay runs before the context is served to connections
(it is part of `newExecutionContext``restoreSchema`).
### 4. What does NOT change
- In-memory update paths in `exec/dml.nim` (insert/update/delete keeping
indexes current) — untouched; they work once the indexes exist again.
- LSM/WAL mechanics; `_schema` table format; B-tree index rebuild.
- Public API: none.
## Testing
New suite in `tests/test_schema_persist.nim` (the existing persistence test
file), TDD — each test fails before the fix:
1. FTS: create table, insert docs, `CREATE INDEX ... USING FTS`, close DB,
reopen, `hybrid_search`-style query / FTS MATCH query returns the doc
(pre-fix: silently empty). Insert another doc after reopen and verify it
is found too (index updates live again).
2. Vector: same flow with an HNSW index and a vector search query.
3. Graph: create graph, add nodes/edges, close, reopen, graph query returns
the traversal (pre-fix: `"[]"`).
4. DROP INDEX removes the `_schema:ftsidx:`/`:vecidx:` key (no ghost rebuild
after reopen). DROP TABLE removes engine keys for that table.
5. Existing persistence tests keep passing (regression).
Verification gate: full `nimble test` green (currently 650 `[OK]`).
## Risks
- CREATE GRAPH replay semantics (see §2) — resolved during planning with a
concrete read of the dispatcher's graph DDL path.
- Startup cost: full-scan rebuilds are O(table size) per index, same as
today's B-tree rebuild. Acceptable for C1; C2 (snapshots) addresses it.
- Re-executing DDL through `executeQuery` inside `restoreSchema` — must not
deadlock on `ctx.sharedLock` (DDL lock is taken by `executeQuery`;
`restoreSchema` runs during context construction, before serving — verify
no lock is held at that point).
@@ -0,0 +1,80 @@
# Production GA (v1.2.0) — Design / cut line
Date: 2026-07-30
Status: **Done (v1.2.0 GA shipped)** — plan
`docs/superpowers/plans/2026-07-30-production-ga.md`.
## Problem
BaraDB has substantial features (storage hardening, search, engine persistence,
Raft cluster path) but “production” never arrives because work stays on the
feature treadmill without a **release + ops cut line**.
## Goal
Ship **v1.2.0 Production GA** for a defined scope:
> **Single-node (or single primary) BaraDB suitable for real applications**,
> with documented limits, tested backup/restore, secure-by-default prod
> compose, tagged release, and a one-page runbook.
Raft multi-node remains **supported experimental / ops-documented**, not the
GA reliability target for v1.2.0.
## Non-goals (v1.2.0)
- Multi-database Raft
- InstallSnapshot full SM dump / automatic cold-node catch-up beyond AppendEntries
- Membership changes (add/remove voters)
- Raft TLS
- Replacing Postgres HA marketing claims
- New major SQL/AI features
## Success definition
| # | Criterion |
|---|-----------|
| 1 | Git tag `v1.2.0`; `baradadb.nimble` + README version **1.2.0** |
| 2 | `CHANGELOG.md` section **[1.2.0]** dated (not Unreleased) |
| 3 | `docker-compose.prod.yml`: **auth required** (or fails closed if secret missing) |
| 4 | Scripted backup → wipe data → restore → query succeeds |
| 5 | `nimble test` green on release-shaped build (or documented subset + binary e2e) |
| 6 | Runbook: start/stop/backup/restore/logs/ports in `docs/en/deployment.md` |
| 7 | Known limitations page (single-node GA vs raft experimental) |
| 8 | Optional: one smoke app path (nimforum or ormin) on release binary |
## Threat model (honest)
**In scope for GA:** process crash, disk full (document), operator restore, unauthenticated internet (mitigated by auth-on in prod).
**Out of scope for GA:** multi-region, zero-downtime upgrades, perfect follower-read consistency, multi-tenant SaaS isolation audit.
## Architecture of the release
```
[build release binary + image]
[prod compose: auth + data volume + healthcheck]
[backup tool / HTTP backup] ──► offsite copy
[restore drill script] proves recoverability
[tag + docs + known-limitations]
```
## Follow-on (v1.3.0 — not this plan)
- Raft cluster “supported” tier: failover under write load, CI e2e, cold peer story
- Multi-DB raft or explicit product refusal
- InstallSnapshot / membership
## References
- Raft status: `docs/superpowers/specs/2026-07-30-raft-cluster-status.md`
- Ops: `docs/en/distributed.md`, `docs/en/backup.md`, `docs/en/deployment.md`
- Compose: `docker-compose.prod.yml`
@@ -0,0 +1,96 @@
# Raft Cluster Status — C3a / C3b / post-C3b / v1.3.0
Date: 2026-07-30
Status: **v1.3.0 — Supported** for the single-`default`-DB scope (see the v1.3.0 section below).
Branch: all work merged to `main` only (feature branch removed).
## v1.3.0 — raft-supported (2026-07-30)
Raft moves from Experimental to **Supported** for a 3-node cluster on the
`default` database. Landed on top of the C3a/C3b base:
- **Failover under load**`tests/raft_failover_load_e2e_test.nim`: leader
killed under sustained writes; every acked write survives (client contract:
in-flight writes fail fast, retry).
- **Mandatory CI gate** — dedicated `raft-e2e` job runs all five raft e2e
suites; missing binary is a hard FAIL under CI.
- **Raft TLS**`BARADB_RAFT_TLS_*` config, fail-closed startup, optional
mutual auth, TLS on follower→leader forwarding;
`tests/raft_tls_e2e_test.nim` (plaintext node excluded).
- **InstallSnapshot** — backward-compatible wire protocol, leader chunk send
(`BARADB_RAFT_SNAP_CHUNK_KB`, default 256), follower restore via
backup/restore, compaction unpinned from stale peers
(`BARADB_RAFT_PEER_STALE_MS`, default 30000);
`tests/raft_coldnode_e2e_test.nim` (returning + wiped node converge).
- **Fixes** — put/delete encoding (`deleted` flag), rejoin livelock (cached
peer sockets dropped on leadership), post-restore ctx repoint.
Resolved non-goals from the list below: raft-port TLS, InstallSnapshot with
full SM payload. Still open: multi-database raft, `CREATE`/`DROP DATABASE`
replication, membership changes, linearizable follower reads, rolling
upgrades (restart all nodes together).
Plan: `docs/superpowers/plans/2026-07-30-v1.3.0-raft-supported.md` ·
Design: `docs/superpowers/specs/2026-07-30-raft-supported-design.md`
## Phase map
| Phase | Spec / plan | Status | What landed |
|-------|-------------|--------|-------------|
| **C3a** Network bootstrap | `raft-network-bootstrap-design.md` + plan | **Done** | `id@host:port` peers, election timer in production, AppendEntries resets timer, `dataDir` persistence, partial-read frames, `tests/raft_e2e_test.nim` |
| **C3b** SQL writes | `raft-sql-writes-design.md` + plan | **Done** | `isWrite`, follower reject / forward, leader append+wait-commit, rich apply (LSM+index+graph), multi-stmt gate, default-DB only, `tests/raft_writes_e2e_test.nim` |
| **C3c-lite** DDL + ops | (this status doc) | **Done** | DDL via `ddl` log entries; leader forwarding (`BARADB_RAFT_CLIENT_PEERS`); safe log compact + snapshot metadata; Prometheus + `/health` raft |
## Key commits (main)
| Commit (short) | Summary |
|----------------|---------|
| C3a series | peers, timer, frames, e2e election |
| `38c1c01``a462d21` | C3b classification → append → e2e → docs |
| `0d51497` | multi-stmt gate, rich apply, delete kv |
| `50f827f` | graph apply, non-default DB reject |
| `095698b` | DDL through raft |
| `9df8316` | leader write/DDL forwarding |
| `53704e1` | safe log compaction + snapshot base |
| `1b3c261` | raft metrics on `/metrics` + `/health` |
## Production behavior (summary)
1. Enable with `BARADB_RAFT_*` env (see `docs/en/distributed.md`).
2. Cluster elects a leader over TCP; state in `dataDir/raft/raft_state.bin`.
3. DML/DDL on **default** DB only: leader appends, waits for majority, returns.
4. Followers forward to leader if `BARADB_RAFT_CLIENT_PEERS` is set; else `not leader`.
5. Apply updates LSM + secondary engines; DDL re-executes SQL on each node.
6. Log soft-cap via safe prefix compact; metrics on HTTP port `BARADB_PORT+440`.
## Explicit non-goals still open
- Multi-database raft (only `default`)
- `CREATE`/`DROP DATABASE` replication
- Membership change (join/leave) protocol
- InstallSnapshot with full SM / LSM payload (v1 uses safe-prefix compact only)
- Raft port TLS / mutual auth
- Read consistency levels (read-your-writes, linearizable reads on followers)
- Automatic client redirect without `CLIENT_PEERS`
## Tests
| Suite | Covers |
|-------|--------|
| `tests/raft_e2e_test.nim` | 3-process election + failover |
| `tests/raft_writes_e2e_test.nim` | DDL + DML replicate, forward, index SELECT, failover writes |
| `tests/test_all.nim` | in-process raft, append/wait, compact, metrics |
| `tests/tla_faithfulness.nim` | ElectionSafety, LogMatching, … |
| `tests/bugfix_test.nim` | peers / client peers / isWrite / isRaftDdl |
## Docs to keep in sync
- `docs/en/distributed.md` / `docs/bg/distributed.md` — operator guide
- `docs/en/monitoring.md` — health/metrics (raft section)
- `CHANGELOG.md``[1.2.0] Unreleased` Raft section
- README raft status line
## Production
- GA plan: `docs/superpowers/plans/2026-07-30-production-ga.md`
- GA design: `docs/superpowers/specs/2026-07-30-production-ga-design.md`
@@ -0,0 +1,34 @@
# Networked Raft Bootstrap (C3a) — Design
Date: 2026-07-30
Status: **Done** (merged to `main`). See also `2026-07-30-raft-cluster-status.md`.
## Problem
Raft in BaraDB was half-wired for real networking. `core/raft.nim` already had
a TCP transport, but production never populated `peerAddrs`, never ran an
election timer, did not reset timers on AppendEntries, skipped state
persistence, and used unsafe partial frame reads.
## Goal
A 3-node BaraDB cluster elects a leader over TCP, maintains it with heartbeats,
and re-elects after the leader is killed — verified with real processes
(`tests/raft_e2e_test.nim`).
## Delivered
| Item | Implementation |
|------|----------------|
| Peer addresses | `BARADB_RAFT_PEERS=id@host:port``raftPeerAddrs` |
| Election timer | `timerLoop` inside `RaftNetwork.run` |
| Heartbeat reset | `processMessage` resets timer on AppendEntries with valid term |
| State persistence | `dataDir``raft_state.bin` |
| Partial reads | `recvExact` frame reassembly |
| E2E | `tests/raft_e2e_test.nim` (election + failover) |
## Non-goals (handled later)
SQL writes (C3b), DDL/forward/compact/metrics (post-C3b / C3c-lite) — all
shipped; see cluster status doc. Still open: membership, InstallSnapshot SM
payload, multi-DB raft.
@@ -0,0 +1,55 @@
# SQL Writes Through Raft (C3b) — Design
Date: 2026-07-30
Status: **Done** (merged to `main`). Extended by post-C3b work (DDL, forward,
compact, metrics) — see `2026-07-30-raft-cluster-status.md`.
## Problem
With C3a a real Raft cluster elects a leader over TCP, but SQL writes still
bypassed Raft: they went straight to the local LSM. Committed Raft entries
could be applied (`applyCommand`), yet nothing called `appendLog` from the
write path.
## Goal (delivered)
When Raft is enabled, SQL writes commit through the Raft log before the client
sees success:
- **Leader:** execute locally, append KV pairs (`put` / `delete`), wait for
majority `commitIndex`, return.
- **Followers:** originally reject with `not leader; leader is '…'`; with
`BARADB_RAFT_CLIENT_PEERS` they **forward** to the leader (post-C3b).
- **Apply:** LSM + secondary B-tree/FTS/HNSW + in-memory graphs
(`applyReplicatedPut` / `Delete`).
- **Non-raft:** behavior gated on `raftNode == nil`.
## Design (as shipped)
### Write interception
`core/server.nim` `executeQuery`:
1. Classify every statement: `isWrite` / `isRaftDdl` (not only `stmts[0]`).
2. If raft active and write/DDL: require **default** database + leader (or
forward).
3. After success: pure DML → `appendWriteToRaft`; any DDL in batch →
`appendDdlToRaft` (full SQL re-exec on apply).
### Wait-for-commit
Poll `commitIndex` outside the storage gate (`appendWriteToRaft` /
`waitRaftCommit`); timeout → `raft commit timeout`.
### Known v1 limitations (still true)
- Multi-DB raft not supported (`raft writes only supported on the 'default' database`).
- `CREATE`/`DROP DATABASE` not raft-replicated.
- Leader local execute before majority (timeout leaves local write; documented).
- No InstallSnapshot full SM dump (safe log compact only).
- No membership changes.
## Tests
- Unit: classification, append/timeout, apply indexes/graphs, compact, metrics
- E2E: `tests/raft_writes_e2e_test.nim` (schema, forward, index SELECT, failover)
@@ -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.
@@ -0,0 +1,132 @@
# BaraDB Stability Hardening — Design & Findings
Date: 2026-07-30
Status: Implemented (phase A). Phases B/C proposed, awaiting decision.
## Goal
"Make the database better" — chosen direction: **stability first**. Establish a
verified baseline, fix what is actually broken, and make the whole test suite
run with one command before attempting any large refactoring or new features.
## Baseline established
- Debug build passes cleanly on Nim 2.2.10 (`nimble build_debug`).
- The `hunos` build failure from `HUNOS_ISSUE.md` is **no longer an issue**:
hunos 1.3.3 is installed and contains the `urandom` fix. The nimble file
already allows `>= 1.3.0`.
- Of the 13 test files in `tests/`, only `test_all` (+ `stress_test` in CI)
ran automatically. Baseline run of the other 11: 10 pass,
`nimforum_smoke_test` fails.
## Changes implemented
### 1. Parser: `header` usable as a column name (bug fix)
**Root cause.** `header` is a keyword token (`tkHeader`) used by
`IMPORT/EXPORT ... HEADER`. The parser never accepted it as an identifier, so
any table with a column named `header` (e.g. the nimforum schema) failed with
`Expected identifier but got tkHeader`.
**Fix** (`src/barabadb/query/parser.nim`):
- Added `tkHeader` to `identLikeKinds` (soft-keyword set used by
`expectIdent`, line 38).
- Added `tkHeader` to the identifier branch of `parsePrimary` (line 84) so
`SELECT header ...` and `WHERE header = ...` work.
- Dotted-path parsing (`a.b.c`) now uses `expectIdent` instead of
`expect(tkIdent)` so `post.header` works too.
IMPORT/EXPORT parsing is unaffected: statement dispatch keys on the leading
`tkImport`/`tkExport` and the clause parser peeks for `tkHeader` explicitly.
**Regression tests** (`tests/bugfix_test.nim`, new suite):
- CREATE TABLE / INSERT / SELECT / WHERE with a column named `header`.
- `IMPORT FROM ... HEADER no` and `EXPORT TO ... HEADER yes` still parse.
Note: `IMPORT ... FORMAT csv` currently fails because `csv` is also a keyword
(`tkCsv`) and `parseImportFrom` expects `tkIdent` after `FORMAT`. Pre-existing
limitation, **not** addressed here (out of scope; recorded for phase B).
### 2. All 13 test files wired into `nimble test` and CI
- `baradadb.nimble` `test` task now builds `build/baradadb` (the smoke test
talks to it over TCP) and runs all 13 suites: quick embedded suites first,
fuzz/property/stress last.
- `.github/workflows/ci.yml` runs `nimble test` (was: only `test_all`);
the now-redundant separate stress-test steps were removed.
- Verified locally: full `nimble test` exits 0 with 648 passing checks.
### 2b. Soft-keyword cleanup (phase B3, implemented)
Extended the `header` approach to all clause-only keywords, so they work as
table/column names everywhere (DDL, DML, aliases, dotted paths, CTEs, JOINs,
MERGE, GRANT/REVOKE, SET):
`format, delimiter, batch, csv, ndjson, status, migration, apply, up, down,
dryrun, user, policy, enable, disable, recover, before, after, instead, of`.
- `identLikeKinds` and the `parsePrimary` identifier branch now include them.
- All 69 `expect(tkIdent)` call sites now use `expectIdent` — a strict
superset, so previously valid SQL is unaffected (verified: full suite green).
- `IMPORT/EXPORT`: `FORMAT csv/ndjson/json` and `HEADER true/false` now parse
(previously `csv`/`true`/`false` lexed as keywords and were rejected despite
the grammar clearly intending them). Clause table names use `expectIdent`.
Structural keywords (`where`, `group`, `order`, `join`, `on`, `for`, `using`,
`view`, `trigger`, `import`, `export`, `grant`, ...) remain reserved.
**Regression tests** (`tests/bugfix_test.nim`): a table with 21 keyword-named
columns through CREATE/INSERT/UPDATE/SELECT/qualified refs, plus
IMPORT/EXPORT keyword-value parsing.
### 3. ORC crash — reproduced, bisected, three root-cause attempts failed
`nim.cfg` forces `--mm:arc` because ORC's cycle collector crashed
("markGray/trace SIGSEGV after ~20 sequential INSERTs"). The ARC cycle-breaking
in commit `ed5a719` did not fix the ORC path.
**Reproduction** (`tests/orc_repro.py`): build the server with `--mm:orc`,
drive 1000 sequential TCP INSERTs plus 10 concurrent connections. The server
dies with the exact documented signature (`handleClient`
`nimDecRefIsLastCyclicStatic``collectCyclesBacon``markGray``trace`
SIGSEGV).
**Bisect:** 200 pings + 200 SELECTs over TCP are fine; the crash lands between
20 and 500 sequential INSERTs — INSERT path only.
**Failed root-cause attempts:**
1. `ed5a719` — callback cycle breaks (shard/gossip).
2. `{.cursor.}` on `ExecutionContext.registry` — breaks the real
`DatabaseRegistry ↔ ExecutionContext` cycle (kept: it is the correct
ownership annotation regardless), but the crash persists unchanged.
3. Guarding `ctx.onChange` against zero WS subscribers (reverted: fixed
nothing).
**Conclusion:** per the 3-strikes rule this is a deep ORC+async issue —
possibly an upstream Nim 2.2.x ORC bug with async closure environments and/or
complex generic types — not a single app-level cycle. ARC remains the
supported memory manager (full suite green under it). The findings are
recorded in `nim.cfg` and `tests/orc_repro.py` for a future attempt (e.g.
re-test with a newer Nim runtime, or a minimal repro filed upstream).
## Proposed next phases (not started)
- **B2. Split `query/executor.nim`** (5,398 lines) — **DONE**: split into 15
modules under `query/exec/`, `executor.nim` down to 1,578 lines, full suite
green (650 checks).
- **C. Features** — real Raft network transport, persistence for
graph/FTS/columnar engines, benchmark validation.
- **ORC (blocked):** re-test `tests/orc_repro.py` against a newer Nim runtime;
if it persists, distill a minimal repro and file upstream. Not app-actionable
today (see "ORC crash" section).
## Verification evidence
- `nimble test` (all 13 suites): exit 0, 650 `[OK]`, 0 failed — final run
after all changes (phase A + B3 + cursor).
- `nimforum_smoke_test` (rebuilt server): all suites `[OK]`, including
`NimForum schema creation` (previously `[FAILED]`).
- B3 TDD: new keyword tests failed first with the expected `tkFormat`/`tkCsv`
errors, then passed; `test_all` stayed green (461 `[OK]`).
- ORC investigation: embedded `test_wire_insert_stress` passes even when
compiled with `--mm:orc`; the TCP **server** compiled with `--mm:orc`
crashes as documented above. All shipped artifacts use ARC and are green.
+10
View File
@@ -1,3 +1,13 @@
-d:ssl
--threads:on
--path:"src"
# ARC: ORC cycle collector crashes under async wire-protocol load
# (markGray/trace SIGSEGV, triggered from core/server.nim handleClient).
# Still reproducible as of 2026-07-30 (Nim 2.2.10) — reproducer:
# tests/orc_repro.py. Bisected: 200 pings + 200 SELECTs over TCP are
# fine; the server dies somewhere between 20 and 500 sequential INSERTs.
# Three root-cause attempts failed (callback cycle breaks in ed5a719,
# {.cursor.} on ExecutionContext.registry, guarding ctx.onChange) — this
# points at a deep ORC+async issue (possibly upstream Nim), not a single
# app-level cycle. ARC is stable for the TCP server + HTTP worker mix.
--mm:arc
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# Backup → wipe → restore → verify drill for BaraDB single-node GA.
# Usage: ./scripts/backup-restore-drill.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
BIN="${BARADB_BIN:-./build/baradadb}"
BACKUP_BIN="${BARADB_BACKUP_BIN:-./build/backup}"
PORT="${DRILL_PORT:-19472}"
HTTP_PORT=$((PORT + 440))
WORKDIR="${DRILL_WORKDIR:-/tmp/baradb_backup_drill_$$}"
DATA_DIR="$WORKDIR/data"
ARCHIVE="$WORKDIR/drill_backup.tar.gz"
MARKER="drill-row-$$"
die() { echo "FAIL: $*" >&2; cleanup; exit 1; }
log() { echo "[drill] $*"; }
cleanup() {
if [[ -n "${SERVER_PID:-}" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
if [[ "${DRILL_KEEP:-1}" == "0" ]]; then
rm -rf "$WORKDIR"
fi
}
trap cleanup EXIT
[[ -x "$BIN" ]] || die "missing $BIN — build: nim c -o:build/baradadb src/baradadb.nim"
if [[ ! -x "$BACKUP_BIN" ]]; then
log "building backup tool..."
nim c -d:release -o:build/backup src/barabadb/core/backup.nim || die "cannot build backup tool"
BACKUP_BIN=./build/backup
fi
rm -rf "$WORKDIR"
mkdir -p "$DATA_DIR"
start_server() {
log "starting server port=$PORT data=$DATA_DIR"
# every = fsync each WAL write so kill/backup cannot lose recent puts
BARADB_PORT="$PORT" \
BARADB_ADDRESS=127.0.0.1 \
BARADB_DATA_DIR="$DATA_DIR" \
BARADB_LOG_LEVEL=warn \
BARADB_AUTH_ENABLED=false \
BARADB_WAL_SYNC_MODE=every \
"$BIN" >"$WORKDIR/server.log" 2>&1 &
SERVER_PID=$!
local i=0
while (( i < 80 )); do
if curl -sf "http://127.0.0.1:${HTTP_PORT}/health" >/dev/null 2>&1; then
log "server ready pid=$SERVER_PID"
return 0
fi
sleep 0.15
i=$((i + 1))
done
tail -50 "$WORKDIR/server.log" >&2 || true
die "server not healthy on :$HTTP_PORT"
}
stop_server() {
if [[ -n "${SERVER_PID:-}" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then
log "stopping pid=$SERVER_PID"
kill -TERM "$SERVER_PID" 2>/dev/null || true
local i=0
while kill -0 "$SERVER_PID" 2>/dev/null && (( i < 50 )); do
sleep 0.1
i=$((i + 1))
done
if kill -0 "$SERVER_PID" 2>/dev/null; then
kill -KILL "$SERVER_PID" 2>/dev/null || true
fi
wait "$SERVER_PID" 2>/dev/null || true
fi
SERVER_PID=""
sleep 0.3
}
http_query() {
local sql="$1"
local payload
payload=$(python3 -c "import json,sys; print(json.dumps({'query': sys.argv[1]}))" "$sql")
curl -sf -H 'Content-Type: application/json' -d "$payload" \
"http://127.0.0.1:${HTTP_PORT}/query"
}
start_server
log "CREATE + INSERT marker"
http_query "CREATE TABLE drill_t (id INT PRIMARY KEY, name STRING)" >/dev/null || die "CREATE failed"
http_query "INSERT INTO drill_t (id, name) VALUES (1, '$MARKER')" >/dev/null || die "INSERT failed"
body=$(http_query "SELECT name FROM drill_t WHERE id = 1") || die "SELECT before backup failed"
echo "$body" | grep -q "$MARKER" || die "marker missing before backup: $body"
# allow WAL group/fsync to settle
sleep 0.5
stop_server
# Prefer multi-db layout used by the server registry
DB_ROOT="$DATA_DIR/databases"
[[ -d "$DB_ROOT" ]] || die "expected $DB_ROOT after server run"
log "backup from $DB_ROOT"
"$BACKUP_BIN" backup --all-databases --data-root="$DB_ROOT" \
--output="$ARCHIVE" --force || die "backup failed"
# Sanity: archive must not be tiny empty shell only
asize=$(stat -c%s "$ARCHIVE" 2>/dev/null || stat -f%z "$ARCHIVE")
(( asize > 200 )) || die "backup archive suspiciously small ($asize bytes)"
log "wipe data"
rm -rf "$DATA_DIR"
mkdir -p "$DATA_DIR"
log "restore multi-db archive into $DB_ROOT"
mkdir -p "$DATA_DIR"
# Archive layout is databases/<name>/... ; --data-root is the databases/ parent leaf
"$BACKUP_BIN" restore --input="$ARCHIVE" --all-databases \
--data-root="$DB_ROOT" --force || die "restore failed"
start_server
log "verify after restore"
body=$(http_query "SELECT name FROM drill_t WHERE id = 1") || die "SELECT after restore failed"
echo "$body" | grep -q "$MARKER" || die "marker missing after restore: $body"
log "PASS backup/restore drill OK marker=$MARKER archive=$ARCHIVE"
stop_server
trap - EXIT
[[ "${DRILL_KEEP:-1}" == "0" ]] && rm -rf "$WORKDIR"
exit 0
+1
View File
@@ -1,3 +1,4 @@
{.deprecated: "Use the canonical baradb/client from clients/nim instead.".}
## BaraDB Client — Nim client library
import std/asyncdispatch
import std/asyncnet
+27 -18
View File
@@ -25,7 +25,6 @@ import std/osproc
import std/strutils
import std/times
import std/algorithm
import std/parseopt
import std/json
import barabadb/storage/lsm
@@ -147,32 +146,41 @@ proc formatTimestamp*(ts: int64): string =
try:
let dt = fromUnix(ts)
result = format(dt, "yyyy-MM-dd HH:mm:ss")
except:
except CatchableError:
result = $ts
proc parseBackupFilename*(filename: string): int64 =
## Try to extract timestamp from backup_1234567890.tar.gz
## Try to extract timestamp from backup_1234567890.tar.gz (or .tar)
try:
let name = extractFilename(filename)
if name.startsWith("backup_"):
let tsStr = name[7..^8] # skip "backup_" and ".tar.gz"
let tsStr = if name.endsWith(".tar.gz"): name[7..^8]
elif name.endsWith(".tar"): name[7..^5]
else: ""
if tsStr.len > 0:
result = parseBiggestInt(tsStr)
else:
result = 0
except:
else:
result = 0
except CatchableError:
result = 0
proc getArchiveSize*(input: string): int64 =
## Return uncompressed size estimate from tar archive
let cmd = "tar -tzf " & quoteShell(input) & " | wc -l"
## Return uncompressed size in bytes from archive.
## Uses gzip -l for .gz; falls back to file size for plain .tar.
if input.endsWith(".gz"):
let cmd = "gzip -l " & quoteShell(input) & " 2>/dev/null | awk 'NR==2{print $2}'"
let (outStr, exitCode) = execCmdEx(cmd)
if exitCode == 0:
try:
result = parseBiggestInt(strip(outStr))
except:
result = 0
except CatchableError:
result = getFileSize(input) # fallback
else:
result = 0
result = getFileSize(input)
else:
result = getFileSize(input)
proc getFreeSpace*(path: string): int64 =
## Return free disk space in bytes for the filesystem containing path
@@ -181,7 +189,7 @@ proc getFreeSpace*(path: string): int64 =
if exitCode == 0:
try:
result = parseBiggestInt(strip(outStr))
except:
except CatchableError:
result = -1
else:
result = -1
@@ -287,7 +295,7 @@ proc restoreDataDir*(input: string, dataDir: string, verbose: bool = false, dryR
createDir(dataDir)
let cmd = "tar -xzf " & quoteShell(input) & " -C " & quoteShell(dataDir)
let cmd = "tar -xzf " & quoteShell(input) & " --strip-components=1 -C " & quoteShell(dataDir)
if verbose:
echo "Running: ", cmd
@@ -523,7 +531,6 @@ proc backupAllDatabases*(dataRoot: string, output: string, excludes: seq[string]
if fileExists(output):
echo "WARNING: Overwriting existing file: ", output
let workDir = getCurrentDir()
let tempDir = getTempDir() / "baradb_backup_" & $getTime().toUnix()
createDir(tempDir / "databases")
@@ -608,7 +615,7 @@ proc restoreAllDatabases*(input: string, dataRoot: string, verbose: bool = false
createDir(dataRoot)
let cmd = "tar -xzf " & quoteShell(input) & " -C " & quoteShell(dataRoot)
let cmd = "tar -xzf " & quoteShell(input) & " -C " & quoteShell(parentDir(dataRoot))
if verbose:
echo "Running: ", cmd
@@ -625,8 +632,8 @@ proc restoreAllDatabases*(input: string, dataRoot: string, verbose: bool = false
echo "Rollback complete. Data restored to previous state."
return false
# Verify metadata
let metaPath = dataRoot / BACKUP_META_FILE
# Verify metadata (extracted to parent dir due to archive layout)
let metaPath = parentDir(dataRoot) / BACKUP_META_FILE
if fileExists(metaPath):
try:
let meta = parseJson(readFile(metaPath))
@@ -662,6 +669,8 @@ proc readBackupMeta*(input: string): JsonNode =
# CLI Entry Point
# =============================================================================
when isMainModule:
import std/parseopt
var
command = ""
dataDir = DEFAULT_DATA_DIR
@@ -692,14 +701,14 @@ when isMainModule:
of "input", "i": target = val
of "keep", "k":
try: keepCount = parseInt(val)
except: quit("ERROR: --keep must be a number", 1)
except CatchableError: quit("ERROR: --keep must be a number", 1)
of "exclude", "e": excludes.add(val)
of "level", "l":
try:
compression = parseInt(val)
if compression < 0 or compression > 9:
quit("ERROR: --level must be between 0 and 9", 1)
except: quit("ERROR: --level must be a number", 1)
except CatchableError: quit("ERROR: --level must be a number", 1)
of "dry-run": dryRun = true
of "force", "f": force = true
of "online": online = true
+115 -1
View File
@@ -1,6 +1,7 @@
import std/os
import std/strutils
import std/json
import std/tables
type
BaraConfig* = object
@@ -22,6 +23,11 @@ type
logFormat*: string
memtableSizeMb*: int
cacheSizeMb*: int
## WAL durability: "none" | "group" (default) | "every"
walSyncMode*: string
## Group commit batch size (entries between fsyncs when mode=group)
walGroupEvery*: int
## Time-based group fsync interval in ms (0 = off); also used as legacy name
walSyncIntervalMs*: int
compactionIntervalMs*: int
bloomBitsPerKey*: int
@@ -33,6 +39,18 @@ type
raftPort*: int
raftPeers*: seq[string]
raftNodeId*: string
raftPeerAddrs*: Table[string, tuple[host: string, port: int]]
## SQL client ports for leader forwarding (id@host:clientPort).
raftPeerClientAddrs*: Table[string, tuple[host: string, port: int]]
raftWriteTimeoutMs*: int
raftLogMaxEntries*: int
raftSnapChunkKb*: int
raftPeerStaleMs*: int
raftTlsEnabled*: bool
raftTlsCertFile*: string
raftTlsKeyFile*: string
raftTlsCaFile*: string
raftTlsVerifyPeer*: bool
CompactionStrategy* = enum
csSizeTiered = "size_tiered"
@@ -58,6 +76,8 @@ proc defaultConfig*(): BaraConfig =
logFormat: "json",
memtableSizeMb: 64,
cacheSizeMb: 256,
walSyncMode: "group",
walGroupEvery: 64,
walSyncIntervalMs: 0,
compactionIntervalMs: 60_000,
bloomBitsPerKey: 10,
@@ -69,6 +89,17 @@ proc defaultConfig*(): BaraConfig =
raftPort: 9473,
raftPeers: @[],
raftNodeId: "",
raftPeerAddrs: initTable[string, tuple[host: string, port: int]](),
raftPeerClientAddrs: initTable[string, tuple[host: string, port: int]](),
raftWriteTimeoutMs: 5_000,
raftLogMaxEntries: 256,
raftSnapChunkKb: 256,
raftPeerStaleMs: 30000,
raftTlsEnabled: false,
raftTlsCertFile: "",
raftTlsKeyFile: "",
raftTlsCaFile: "",
raftTlsVerifyPeer: false,
)
# ----------------------------------------------------------------------
@@ -93,6 +124,8 @@ proc loadConfigFromJson*(path: string, cfg: var BaraConfig) =
if s.hasKey("data_dir"): cfg.dataDir = s["data_dir"].getStr()
if s.hasKey("memtable_size_mb"): cfg.memtableSizeMb = s["memtable_size_mb"].getInt()
if s.hasKey("cache_size_mb"): cfg.cacheSizeMb = s["cache_size_mb"].getInt()
if s.hasKey("wal_sync_mode"): cfg.walSyncMode = s["wal_sync_mode"].getStr()
if s.hasKey("wal_group_every"): cfg.walGroupEvery = s["wal_group_every"].getInt()
if s.hasKey("wal_sync_interval_ms"): cfg.walSyncIntervalMs = s["wal_sync_interval_ms"].getInt()
if s.hasKey("compaction_interval_ms"): cfg.compactionIntervalMs = s["compaction_interval_ms"].getInt()
if s.hasKey("bloom_bits_per_key"): cfg.bloomBitsPerKey = s["bloom_bits_per_key"].getInt()
@@ -153,6 +186,8 @@ proc loadConfigFromEnv*(cfg: var BaraConfig) =
cfg.logFormat = getEnv("BARADB_LOG_FORMAT", cfg.logFormat)
cfg.memtableSizeMb = parseEnvInt(getEnv("BARADB_MEMTABLE_SIZE_MB", ""), cfg.memtableSizeMb)
cfg.cacheSizeMb = parseEnvInt(getEnv("BARADB_CACHE_SIZE_MB", ""), cfg.cacheSizeMb)
cfg.walSyncMode = getEnv("BARADB_WAL_SYNC_MODE", cfg.walSyncMode)
cfg.walGroupEvery = parseEnvInt(getEnv("BARADB_WAL_GROUP_EVERY", ""), cfg.walGroupEvery)
cfg.walSyncIntervalMs = parseEnvInt(getEnv("BARADB_WAL_SYNC_INTERVAL_MS", ""), cfg.walSyncIntervalMs)
cfg.compactionIntervalMs = parseEnvInt(getEnv("BARADB_COMPACTION_INTERVAL_MS", ""), cfg.compactionIntervalMs)
cfg.bloomBitsPerKey = parseEnvInt(getEnv("BARADB_BLOOM_BITS_PER_KEY", ""), cfg.bloomBitsPerKey)
@@ -164,8 +199,65 @@ proc loadConfigFromEnv*(cfg: var BaraConfig) =
cfg.raftPort = parseEnvInt(getEnv("BARADB_RAFT_PORT", ""), cfg.raftPort)
let peersEnv = getEnv("BARADB_RAFT_PEERS", "")
if peersEnv.len > 0:
cfg.raftPeers = peersEnv.split(",")
cfg.raftPeers = @[]
cfg.raftPeerAddrs = initTable[string, tuple[host: string, port: int]]()
for raw in peersEnv.split(","):
let entry = raw.strip()
if entry.len == 0: continue
let atPos = entry.rfind('@')
if atPos < 0:
# bare id — no network address
cfg.raftPeers.add(entry)
else:
let id = entry[0 ..< atPos]
let hostPort = entry[atPos + 1 .. ^1]
let colonPos = hostPort.rfind(':')
let host = if colonPos >= 0: hostPort[0 ..< colonPos] else: ""
let portStr = if colonPos >= 0: hostPort[colonPos + 1 .. ^1] else: ""
var port = 0
try:
port = parseInt(portStr)
except ValueError:
discard
if id.len == 0 or host.len == 0 or port < 1 or port > 65535:
raise newException(ValueError,
"Invalid BARADB_RAFT_PEERS entry '" & entry & "': expected id@host:port with port 1-65535")
cfg.raftPeers.add(id)
cfg.raftPeerAddrs[id] = (host, port)
cfg.raftNodeId = getEnv("BARADB_RAFT_NODE_ID", cfg.raftNodeId)
cfg.raftWriteTimeoutMs = parseEnvInt(getEnv("BARADB_RAFT_WRITE_TIMEOUT_MS", ""), cfg.raftWriteTimeoutMs)
cfg.raftLogMaxEntries = parseEnvInt(getEnv("BARADB_RAFT_LOG_MAX_ENTRIES", ""), cfg.raftLogMaxEntries)
cfg.raftSnapChunkKb = parseEnvInt(getEnv("BARADB_RAFT_SNAP_CHUNK_KB", ""), cfg.raftSnapChunkKb)
cfg.raftPeerStaleMs = parseEnvInt(getEnv("BARADB_RAFT_PEER_STALE_MS", ""), cfg.raftPeerStaleMs)
cfg.raftTlsEnabled = parseEnvBool(getEnv("BARADB_RAFT_TLS_ENABLED", ""), cfg.raftTlsEnabled)
cfg.raftTlsCertFile = getEnv("BARADB_RAFT_TLS_CERT_FILE", cfg.raftTlsCertFile)
cfg.raftTlsKeyFile = getEnv("BARADB_RAFT_TLS_KEY_FILE", cfg.raftTlsKeyFile)
cfg.raftTlsCaFile = getEnv("BARADB_RAFT_TLS_CA_FILE", cfg.raftTlsCaFile)
cfg.raftTlsVerifyPeer = parseEnvBool(getEnv("BARADB_RAFT_TLS_VERIFY_PEER", ""), cfg.raftTlsVerifyPeer)
# Optional: client (SQL) addresses for leader write forwarding.
# Same id@host:port shape as BARADB_RAFT_PEERS, but ports are BARADB_PORT values.
let clientPeersEnv = getEnv("BARADB_RAFT_CLIENT_PEERS", "")
if clientPeersEnv.len > 0:
cfg.raftPeerClientAddrs = initTable[string, tuple[host: string, port: int]]()
for raw in clientPeersEnv.split(","):
let entry = raw.strip()
if entry.len == 0: continue
let atPos = entry.rfind('@')
if atPos < 0:
raise newException(ValueError,
"Invalid BARADB_RAFT_CLIENT_PEERS entry '" & entry & "': expected id@host:port")
let id = entry[0 ..< atPos]
let hostPort = entry[atPos + 1 .. ^1]
let colonPos = hostPort.rfind(':')
let host = if colonPos >= 0: hostPort[0 ..< colonPos] else: ""
let portStr = if colonPos >= 0: hostPort[colonPos + 1 .. ^1] else: ""
var port = 0
try: port = parseInt(portStr)
except ValueError: discard
if id.len == 0 or host.len == 0 or port < 1 or port > 65535:
raise newException(ValueError,
"Invalid BARADB_RAFT_CLIENT_PEERS entry '" & entry & "': expected id@host:port with port 1-65535")
cfg.raftPeerClientAddrs[id] = (host, port)
# ----------------------------------------------------------------------
# Master Loader
@@ -179,6 +271,28 @@ proc loadConfig*(): BaraConfig =
# 2. Environment overrides (highest priority)
loadConfigFromEnv(result)
proc isProductionEnv*(): bool =
## True when BARADB_ENV=production (or prod) or BARADB_AUTH_REQUIRED=true.
let env = getEnv("BARADB_ENV", "").toLowerAscii()
if env == "production" or env == "prod": return true
parseEnvBool(getEnv("BARADB_AUTH_REQUIRED", ""), false)
proc validateProductionConfig*(cfg: BaraConfig) =
## Fail closed for production: auth on + non-empty JWT secret.
## Call after loadConfig() from the main entrypoint.
if not isProductionEnv(): return
if not cfg.authEnabled:
raise newException(ValueError,
"Production refuses to start with auth disabled. " &
"Set BARADB_AUTH_ENABLED=true (or unset BARADB_ENV=production for local dev).")
if cfg.jwtSecret.len == 0:
raise newException(ValueError,
"Production refuses to start without BARADB_JWT_SECRET. " &
"Generate one: openssl rand -hex 32")
if cfg.jwtSecret in ["change-me", "change-me-to-random-32-char-string", "secret", "default"]:
raise newException(ValueError,
"Production refuses insecure JWT secret placeholder. Set a strong BARADB_JWT_SECRET.")
proc getEffectiveJwtSecret*(cfg: BaraConfig): string =
if cfg.jwtSecret.len > 0:
return cfg.jwtSecret
+14
View File
@@ -6,6 +6,7 @@ import ../storage/lsm
import ../vector/engine as vengine
import ../graph/engine as gengine
import ../fts/engine as fts
import ../search/hnsw_opt
type
QueryMode* = enum
@@ -88,6 +89,19 @@ proc searchVectorFiltered*(engine: CrossModalEngine, query: seq[float32], k: int
filter: proc(meta: Table[string, string]): bool {.gcsafe.}): seq[(uint64, float64)] =
vengine.searchWithFilter(engine.vectorIdx, query, k, filter)
proc searchVectorOpt*(engine: CrossModalEngine, query: seq[float32], k: int = 10,
metric: vengine.DistanceMetric = vengine.dmCosine): seq[(uint64, float64)] =
hnsw_opt.searchOpt(engine.vectorIdx, query, k, metric)
proc searchVectorFilteredOpt*(engine: CrossModalEngine, query: seq[float32], k: int,
filter: proc(meta: Table[string, string]): bool {.gcsafe.}): seq[(uint64, float64)] =
hnsw_opt.searchWithFilterOpt(engine.vectorIdx, query, k, filter)
proc insertVectorOpt*(engine: CrossModalEngine, id: uint64, vector: seq[float32],
meta: Table[string, string] = initTable[string, string]()) =
hnsw_opt.insertOpt(engine.vectorIdx, id, vector, meta)
engine.metadata[id] = meta
# Graph operations
proc addNode*(engine: CrossModalEngine, label: string,
props: Table[string, string] = initTable[string, string]()): uint64 =
+16 -2
View File
@@ -142,8 +142,15 @@ proc prepare*(txn: DistributedTransaction): bool =
acquire(txn.lock)
if allOk:
txn.state = dtsPrepared
for nodeId, _ in txn.participants.mpairs:
# Only mark successfully contacted nodes as prepared, not uncontacted ones
for nodeId in preparedNodes:
if txn.participants.hasKey(nodeId):
txn.participants[nodeId].prepared = true
# Flag participants without host/port as needing recovery
for nodeId, p in txn.participants.mpairs:
if p.host.len == 0 or p.port == 0:
p.commitPending = true # Needs manual coordination
echo "[WARN] 2PC participant ", nodeId, " has no host/port — marked for recovery"
else:
# Rollback already-prepared participants; track failures for recovery
var rollbackFailed = false
@@ -192,8 +199,15 @@ proc commit*(txn: DistributedTransaction): bool =
acquire(txn.lock)
if allOk:
txn.state = dtsCommitted
for nodeId, _ in txn.participants.mpairs:
# Only mark successfully contacted nodes as committed, not uncontacted ones
for nodeId in committedNodes:
if txn.participants.hasKey(nodeId):
txn.participants[nodeId].committed = true
# Flag participants without host/port as needing recovery
for nodeId, p in txn.participants.mpairs:
if not p.committed and not p.commitPending:
p.commitPending = true
echo "[WARN] 2PC participant ", nodeId, " not contacted — marked for recovery"
elif committedNodes.len > 0:
# Partial commit — mark committed, flag uncommitted for recovery
txn.state = dtsCommitted
+5 -5
View File
@@ -277,7 +277,7 @@ proc sendGossipUdp(gp: GossipProtocol, target: GossipNode, msg: GossipMessage) =
let data = serialize(msg)
sock.sendTo(target.host, Port(target.port), cast[string](data))
sock.close()
except:
except CatchableError:
discard
proc broadcastGossip(gp: GossipProtocol) =
@@ -298,7 +298,7 @@ proc handleIncomingGossip(gp: GossipProtocol, data: string, senderAddr: string)
let parts = host.split(":")
host = parts[0]
if parts[1].len > 0:
port = try: parseInt(parts[1]) except: gp.gossipPort
port = try: parseInt(parts[1]) except CatchableError: gp.gossipPort
let newNode = GossipNode(
id: msg.senderId, host: host, port: port,
state: nsAlive, incarnation: msg.senderIncarnation,
@@ -306,7 +306,7 @@ proc handleIncomingGossip(gp: GossipProtocol, data: string, senderAddr: string)
)
gp.addMember(newNode)
gp.applyGossipMessage(msg)
except:
except CatchableError:
discard
proc startHealthCheck*(gp: GossipProtocol, intervalMs: int = 1000) {.async.} =
@@ -342,14 +342,14 @@ proc startGossipListener*(gp: GossipProtocol) {.async.} =
# Recreate socket after too many errors
try:
gp.sock.close()
except:
except CatchableError:
discard
try:
gp.sock = newAsyncSocket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)
gp.sock.setSockOpt(OptReuseAddr, true)
gp.sock.bindAddr(Port(gp.gossipPort))
consecutiveErrors = 0
except:
except CatchableError:
break
# Exponential backoff with cap
let delayMs = min(baseRetryDelayMs * (1 shl min(consecutiveErrors, 6)), 5000)
+108 -36
View File
@@ -15,6 +15,7 @@ import ../query/parser
import ../query/executor
import ../core/types
import ../storage/lsm
import ../storage/gate
import ../core/mvcc
import ../protocol/wire
import ../core/websocket
@@ -23,19 +24,22 @@ import ../protocol/auth
import ../protocol/ratelimit
import ../core/registry
import ../core/backup
import ../core/raft
type
HttpServer* = ref object
config: BaraConfig
running: bool
db*: LSMTree
ctx: ExecutionContext
ctx*: ExecutionContext # read/write only under the storage gate
registry*: DatabaseRegistry
metrics*: Metrics
secretKey*: string
authManager*: AuthManager
rateLimiter*: RateLimiter
ws*: WsServer
## Optional live raft node for /metrics and /health (set from main).
raftNode*: RaftNode
Metrics* = ref object
queriesTotal*: int
@@ -102,7 +106,7 @@ proc verifyToken*(server: HttpServer, tokenStr: string): (bool, string, string)
let userId = token.claims["sub"].node.str
let role = if "role" in token.claims: token.claims["role"].node.str else: "user"
return (true, userId, role)
except:
except CatchableError:
return (false, "", "")
# ----------------------------------------------------------------------
@@ -196,17 +200,7 @@ proc queryHandler(server: HttpServer): RequestHandler =
ctx.json(%*{"error": "Empty query"}, 400)
return
var reqCtx = getRequestDatabaseContext(server, request)
reqCtx.currentUser = userId
reqCtx.currentRole = role
let tokens = tokenize(queryStr)
let astNode = parse(tokens)
if astNode.stmts.len == 0:
ctx.json(%*{"rows": [], "affectedRows": 0, "columns": []})
return
# Extract optional params from JSON body
# Extract optional params from JSON body (no storage access yet)
var params: seq[WireValue] = @[]
if "params" in body and body["params"].kind == JArray:
for p in body["params"]:
@@ -218,47 +212,92 @@ proc queryHandler(server: HttpServer): RequestHandler =
of JString: params.add(WireValue(kind: fkString, strVal: p.getStr()))
else: params.add(WireValue(kind: fkString, strVal: $p))
let res = executor.executeQuery(reqCtx, astNode, params)
if res.success:
# StorageGate: serialize against TCP + other Hunos workers (ORC safety)
var success: bool
var jsonRows = newJArray()
var jsonCols = newJArray()
var affected = 0
var msg = ""
var errMsg = ""
withStorageGate:
var reqCtx = getRequestDatabaseContext(server, request)
reqCtx.currentUser = userId
reqCtx.currentRole = role
let tokens = tokenize(queryStr)
let astNode = parse(tokens)
if astNode.stmts.len == 0:
success = true
else:
let res = executor.executeQuery(reqCtx, astNode, params)
success = res.success
if res.success:
affected = res.affectedRows
msg = res.message
for row in res.rows:
var jsonRow = newJObject()
for col in res.columns:
let key = col
if key in row and row[key].kind != vkNull:
jsonRow[key] = %valueToString(row[key])
if col in row and row[col].kind != vkNull:
jsonRow[col] = %valueToString(row[col])
else:
jsonRow[key] = newJNull()
jsonRow[col] = newJNull()
jsonRows.add(jsonRow)
var jsonCols = newJArray()
for c in res.columns:
jsonCols.add(%c)
else:
errMsg = res.message
if success:
ctx.json(%*{
"rows": jsonRows,
"affectedRows": res.affectedRows,
"affectedRows": affected,
"columns": jsonCols,
"message": if res.message.len > 0: %res.message else: newJNull()
"message": if msg.len > 0: %msg else: newJNull()
})
else:
server.metrics.queryErrors += 1
ctx.json(%*{"error": res.message}, 400)
ctx.json(%*{"error": errMsg}, 400)
proc healthHandler(): RequestHandler =
proc healthHandler(server: HttpServer): RequestHandler =
return proc(request: Request) {.gcsafe.} =
let ctx = newContext(request)
ctx.json(%*{"status": "ok", "version": "1.1.6"})
var body = %*{
"status": "ok",
"version": "1.3.0"
}
if server.raftNode != nil:
let n = server.raftNode
let role = case n.state
of rsLeader: "leader"
of rsCandidate: "candidate"
of rsFollower: "follower"
body["raft"] = %*{
"enabled": true,
"node_id": n.id,
"role": role,
"term": n.currentTerm,
"leader_id": n.leaderId,
"commit_index": n.commitIndex,
"last_applied": n.lastApplied,
"apply_lag": n.applyLag,
"log_entries": n.log.len,
"snapshot_index": n.lastSnapshotIndex
}
else:
body["raft"] = %*{"enabled": false}
ctx.json(body)
proc metricsHandler(server: HttpServer): RequestHandler =
return proc(request: Request) {.gcsafe.} =
let ctx = newContext(request)
if not server.checkAuth(request, ctx):
return
let prometheus = "baradb_queries_total " & $server.metrics.queriesTotal & "\n" &
var prometheus = "baradb_queries_total " & $server.metrics.queriesTotal & "\n" &
"baradb_query_errors_total " & $server.metrics.queryErrors & "\n" &
"baradb_inserts_total " & $server.metrics.insertCount & "\n" &
"baradb_selects_total " & $server.metrics.selectCount & "\n" &
"baradb_connections_active " & $server.metrics.activeConnections & "\n"
if server.raftNode != nil:
prometheus.add(server.raftNode.prometheusText())
request.respond(200, @[("Content-Type", "text/plain; charset=utf-8")], prometheus)
proc authHandler(server: HttpServer): RequestHandler =
@@ -329,7 +368,7 @@ proc openApiHandler(): RequestHandler =
let ctx = newContext(request)
ctx.json(%*{
"openapi": "3.0.0",
"info": {"title": "BaraDB API", "version": "1.1.6"},
"info": {"title": "BaraDB API", "version": "1.3.0"},
"paths": {
"/query": {
"post": {
@@ -376,8 +415,9 @@ proc tablesHandler(server: HttpServer): RequestHandler =
let ctx = newContext(request)
if not server.checkAuth(request, ctx):
return
let reqCtx = getRequestDatabaseContext(server, request)
var tables = newJArray()
withStorageGate:
let reqCtx = getRequestDatabaseContext(server, request)
for name, tbl in reqCtx.tables:
var cols = newJArray()
for col in tbl.columns:
@@ -393,8 +433,9 @@ proc databasesHandler(server: HttpServer): RequestHandler =
let ctx = newContext(request)
if not server.checkAuth(request, ctx):
return
let dbs = server.registry.listDatabases()
var arr = newJArray()
withStorageGate:
let dbs = server.registry.listDatabases()
for dbName in dbs:
var obj = newJObject()
obj["name"] = %dbName
@@ -428,6 +469,7 @@ proc createDatabaseHandler(server: HttpServer): RequestHandler =
ctx.json(%*{"error": "Empty database name"}, 400)
return
try:
withStorageGate:
discard getOrCreateDatabase(server.registry, dbName)
ctx.json(%*{"success": true, "name": dbName, "message": "Database created"})
except CatchableError as e:
@@ -444,7 +486,9 @@ proc dropDatabaseHandler(server: HttpServer): RequestHandler =
ctx.json(%*{"error": "Missing database name"}, 400)
return
try:
let ok = dropDatabase(server.registry, dbName)
var ok = false
withStorageGate:
ok = dropDatabase(server.registry, dbName)
if ok:
ctx.json(%*{"success": true, "name": dbName, "message": "Database dropped"})
else:
@@ -463,9 +507,15 @@ proc backupHandler(server: HttpServer): RequestHandler =
let allDatabases = if body != nil and "all" in body: body["all"].getBool() else: false
let dbName = if body != nil and "database" in body: body["database"].getStr() else: ""
let outputFile = if body != nil and "output" in body: body["output"].getStr() else: "backup_" & $getTime().toUnix() & ".tar.gz"
# Path traversal protection: reject paths with .. or absolute paths outside dataRoot
if ".." in outputFile or outputFile.startsWith("/"):
ctx.json(%*{"error": "Invalid output path: must be relative and not contain '..'"}, 400)
return
let compression = if body != nil and "level" in body: body["level"].getInt() else: 6
try:
var ok = false
# Gate held so live writers/compactors don't mutate files mid-backup
withStorageGate:
if allDatabases:
ok = backupAllDatabases(dataRoot, outputFile, @[], compression, false)
elif dbName.len > 0:
@@ -520,13 +570,24 @@ proc restoreHandler(server: HttpServer): RequestHandler =
ctx.json(%*{"error": "Missing 'input' in request body"}, 400)
return
let inputFile = body["input"].getStr()
# Path traversal protection: reject paths with .. or absolute paths
if ".." in inputFile or inputFile.startsWith("/"):
ctx.json(%*{"error": "Invalid input path: must be relative and not contain '..'"}, 400)
return
let allDatabases = if body != nil and "all" in body: body["all"].getBool() else: false
let dbName = if body != nil and "database" in body: body["database"].getStr() else: ""
let dataRoot = server.registry.dataRoot
try:
# Verify archive integrity first
if not verifyArchive(inputFile, false):
logRestore(inputFile, dataRoot, false)
ctx.json(%*{"error": "Archive verification failed — file may be corrupted"}, 500)
return
let meta = readBackupMeta(inputFile)
let isMultiDb = meta != nil and meta{"databases"} != nil
var ok = false
withStorageGate:
if isMultiDb or allDatabases:
ok = restoreAllDatabases(inputFile, dataRoot, false, false)
elif dbName.len > 0:
@@ -535,8 +596,11 @@ proc restoreHandler(server: HttpServer): RequestHandler =
else:
ok = restoreAllDatabases(inputFile, dataRoot, false, false)
if ok:
# Reload databases after restore
# Reload under same gate after files are restored
server.registry.loadExistingDatabases()
logRestore(inputFile, dataRoot, ok)
if ok:
ctx.json(%*{"success": true, "message": "Restore completed"})
else:
ctx.json(%*{"error": "Restore failed"}, 500)
@@ -545,6 +609,10 @@ proc restoreHandler(server: HttpServer): RequestHandler =
proc adminHandler(server: HttpServer): RequestHandler =
return proc(request: Request) {.gcsafe.} =
{.cast(gcsafe).}:
let ctx = newContext(request)
if server.config.authEnabled and not server.checkAuth(request, ctx):
return
let html = """
<!DOCTYPE html><html><head>
<meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>
@@ -838,7 +906,7 @@ function showTab(idx){
}
setInterval(() => { if(document.querySelectorAll('.panel')[4].classList.contains('active')) loadMetrics() }, 5000)
</script>
<div class='status' style='text-align:center;padding:10px'>BaraDB v1.1.6 — Multimodal Database Engine</div>
<div class='status' style='text-align:center;padding:10px'>BaraDB v1.3.0 — Multimodal Database Engine</div>
</body></html>"""
request.respond(200, @[("Content-Type", "text/html; charset=utf-8")], html)
@@ -847,7 +915,7 @@ proc run*(server: HttpServer, port: int = 9470) =
router.get("/admin", server.adminHandler())
router.get("/", server.adminHandler())
router.post("/query", server.queryHandler())
router.get("/health", healthHandler())
router.get("/health", server.healthHandler())
router.get("/metrics", server.metricsHandler())
router.post("/auth", server.authHandler())
router.post("/auth/scram/start", server.scramStartHandler())
@@ -870,10 +938,14 @@ proc run*(server: HttpServer, port: int = 9470) =
asyncCheck server.ws.run(port + 1)
hunosServer.serve(Port(port))
proc stop*(server: HttpServer) =
proc stop*(server: HttpServer, closeStorage: bool = false) =
## Stop HTTP listeners. By default does **not** close the shared registry —
## when HTTP is spawned alongside TCP they share one registry owned by main.
server.running = false
server.ws.stop()
if closeStorage:
withStorageGate:
if server.registry != nil:
server.registry.closeAll()
else:
elif server.db != nil:
server.db.close()
+31 -3
View File
@@ -3,6 +3,7 @@ import std/tables
import std/locks
import std/monotimes
import std/sets
import std/sequtils
import deadlock
type
@@ -196,7 +197,6 @@ proc write*(tm: TxnManager, txn: Transaction, key: string, value: seq[byte]): bo
if victimId in tm.activeTxns:
tm.activeTxns[victimId].state = tsAborted
tm.activeTxns.del(victimId)
tm.deadlockDetector.removeWait(uint64(txn.id), uint64(otherId))
release(tm.lock)
return false # write-write conflict with uncommitted txn
@@ -240,10 +240,13 @@ proc delete*(tm: TxnManager, txn: Transaction, key: string): bool =
# Timeout-based deadlock detection: abort stale transactions
let now = getMonoTime().ticks()
var toAbort: seq[TxnId] = @[]
for otherId, otherTxn in tm.activeTxns:
if otherId != txn.id and otherTxn.state == tsActive:
if now - otherTxn.startTime > tm.txnTimeoutMs * 1_000_000:
otherTxn.state = tsAborted
toAbort.add(otherId)
for otherId in toAbort:
tm.activeTxns[otherId].state = tsAborted
tm.activeTxns.del(otherId)
# Check for write-write conflict against other active transactions' write sets
@@ -331,6 +334,14 @@ proc commit*(tm: TxnManager, txn: Transaction): bool =
proc compactVersions(tm: TxnManager) =
## Remove old overwritten versions that are no longer visible to any active transaction.
## Also prune stale committed/aborted transaction IDs.
# Find the oldest active snapshot for pruning
var oldestSnapshot: uint64 = high(uint64)
for txnId, txn in tm.activeTxns:
if txn.state == tsActive and uint64(txn.snapshotMaxTxn) < oldestSnapshot:
oldestSnapshot = uint64(txn.snapshotMaxTxn)
for key, versions in tm.globalVersions.mpairs:
if versions.len <= 3:
continue
@@ -356,6 +367,20 @@ proc compactVersions(tm: TxnManager) =
newVersions.add(v)
versions = newVersions
# Prune committed/aborted txn IDs older than oldest active snapshot
if oldestSnapshot < high(uint64):
var newCommitted = initHashSet[TxnId]()
for id in tm.committedTxnsSet:
if uint64(id) >= oldestSnapshot:
newCommitted.incl(id)
tm.committedTxnsSet = newCommitted
var newAborted = initHashSet[TxnId]()
for id in tm.abortedTxns:
if uint64(id) >= oldestSnapshot:
newAborted.incl(id)
tm.abortedTxns = newAborted
tm.committedTxns = tm.committedTxns.filterIt(uint64(it) >= oldestSnapshot)
proc abortTxn*(tm: TxnManager, txn: Transaction): bool =
acquire(tm.lock)
if txn.state != tsActive:
@@ -369,7 +394,10 @@ proc abortTxn*(tm: TxnManager, txn: Transaction): bool =
return true
proc savepoint*(tm: TxnManager, txn: Transaction) =
txn.savepoints.add(txn.writeSet)
var saved = initTable[string, VersionedRecord]()
for k, v in txn.writeSet:
saved[k] = v
txn.savepoints.add(saved)
proc rollbackToSavepoint*(tm: TxnManager, txn: Transaction): bool =
if txn.savepoints.len == 0:
+600 -39
View File
@@ -2,7 +2,6 @@
import std/tables
import std/sets
import std/deques
import std/algorithm
import std/random
import std/monotimes
import std/asyncdispatch
@@ -11,7 +10,9 @@ import std/streams
import std/strutils
import std/endians
import std/os
import logging
import ../protocol/wire
import ../protocol/ssl
type
RaftState* = enum
@@ -25,6 +26,21 @@ type
command*: string
data*: seq[byte]
## Counters / gauges for Prometheus (/metrics). Updated on the raft/async
## path; HTTP reads them without locks (best-effort consistency).
RaftMetrics* = ref object
electionsTotal*: int64 # times this node became leader
termChangesTotal*: int64 # currentTerm increases
appendsTotal*: int64 # appendLog successes
commitWaitsTotal*: int64 # successful wait-for-commit finishes
commitWaitMsTotal*: int64 # sum of wait durations (ms)
commitTimeoutsTotal*: int64 # raft commit timeout
lostLeadershipTotal*: int64 # append returned index 0
forwardsTotal*: int64 # follower→leader SQL forwards
forwardErrorsTotal*: int64 # failed forwards
appliesTotal*: int64 # applyCommand invocations
compactionsTotal*: int64 # compactLog that actually dropped entries
RaftNode* = ref object
id*: string
state*: RaftState
@@ -33,6 +49,15 @@ type
log*: seq[LogEntry]
commitIndex*: uint64
lastApplied*: uint64
## Compacted prefix: log entries with index <= lastSnapshotIndex are gone.
## Safe compaction only discards entries every peer has already matched
## (leader) or that this node has applied (follower), so catch-up via
## AppendEntries still works without InstallSnapshot payloads.
lastSnapshotIndex*: uint64
lastSnapshotTerm*: uint64
## Trigger compaction when log.len exceeds this (0 = default 256).
logMaxEntries*: int
metrics*: RaftMetrics
# State machine callback
applyCommand*: proc(cmd: string, data: seq[byte]) {.gcsafe.}
# Distributed transaction callbacks (for raft→disttxn integration)
@@ -42,6 +67,15 @@ type
# Leader state
nextIndex*: Table[string, uint64]
matchIndex*: Table[string, uint64]
## Monotonic ms of the last successful AppendEntries reply per peer,
## initialized to "now" in becomeLeader (grace window) and bumped in
## handleAppendReply. Leader compaction excludes peers silent longer than
## raftPeerStaleMs from its minMatch — a stale peer no longer pins the log
## forever; it is caught up via InstallSnapshot on return (T9).
matchIndexSeenMs*: Table[string, int64]
## Stale window in ms (BARADB_RAFT_PEER_STALE_MS, default 30000;
## 0 = default).
raftPeerStaleMs*: int
# Cluster
peers*: seq[string]
leaderId*: string
@@ -52,12 +86,33 @@ type
peerAddrs*: Table[string, tuple[host: string, port: int]]
raftPort*: int
dataDir*: string
## InstallSnapshot follower receive. snapChunkBytes caps a single chunk
## (from BARADB_RAFT_SNAP_CHUNK_KB, default 262144); snapIncomingId /
## snapIncomingFile track the archive currently being assembled under
## dataDir/snap_incoming/.
snapChunkBytes*: int
restoreSnapshot*: proc(archivePath: string, baseIndex: uint64,
baseTerm: uint64): bool {.gcsafe.}
snapIncomingId*: uint64
snapIncomingFile*: string
## Leader InstallSnapshot send. buildSnapshot archives the current data
## dir into destPath (wired in baradadb.nim via backupDataDir).
## snapRejectStreak counts consecutive floor-level AppendEntries rejects
## per peer; at 2 the peer is queued in snapPending and the network layer
## (processMessage) kicks off sendSnapshot. snapSending is the
## single-flight guard: at most one snapshot transfer per peer.
buildSnapshot*: proc(destPath: string): bool {.gcsafe.}
snapRejectStreak*: Table[string, int]
snapPending*: HashSet[string]
snapSending*: HashSet[string]
RaftMessageKind* = enum
rmkRequestVote
rmkRequestVoteReply
rmkAppendEntries
rmkAppendEntriesReply
rmkInstallSnapshot
rmkInstallSnapshotReply
RaftMessage* = object
kind*: RaftMessageKind
@@ -74,6 +129,12 @@ type
# Reply
success*: bool
matchIdx*: uint64
# InstallSnapshot (prevLogIndex/prevLogTerm reuse: snapshot base index/term;
# reply uses success/matchIdx as usual)
snapId*: uint64 # snapshot generation, matches leader's base at build time
snapOffset*: uint64 # byte offset of this chunk within the archive
snapData*: seq[byte] # chunk payload (<= snapChunkBytes)
snapDone*: bool # last chunk
RaftCluster* = ref object
nodes*: Table[string, RaftNode]
@@ -100,6 +161,9 @@ proc saveState(node: RaftNode) =
s.write(uint32(entry.data.len))
if entry.data.len > 0:
s.writeData(addr entry.data[0], entry.data.len)
# Snapshot base (appended for backward-compatible load of older files)
s.write(node.lastSnapshotIndex)
s.write(node.lastSnapshotTerm)
s.close()
moveFile(tmpPath, path)
@@ -133,8 +197,18 @@ proc loadState(node: RaftNode) =
if s.readData(addr data[0], dataLen) != dataLen:
raise newException(IOError, "Incomplete Raft log data read")
node.log[i] = LogEntry(term: term, index: index, command: cmd, data: data)
# Optional trailing snapshot fields (absent in pre-compaction state files)
if not s.atEnd:
node.lastSnapshotIndex = s.readUint64()
if not s.atEnd:
node.lastSnapshotTerm = s.readUint64()
# lastApplied/commitIndex must not sit below the compacted base
if node.lastApplied < node.lastSnapshotIndex:
node.lastApplied = node.lastSnapshotIndex
if node.commitIndex < node.lastSnapshotIndex:
node.commitIndex = node.lastSnapshotIndex
except IOError, OSError:
discard
echo "[WARN] Failed to load Raft state from ", path, ": ", getCurrentExceptionMsg()
s.close()
proc newRaftNode*(id: string, peers: seq[string], raftPort: int = 0,
@@ -148,8 +222,14 @@ proc newRaftNode*(id: string, peers: seq[string], raftPort: int = 0,
log: @[],
commitIndex: 0,
lastApplied: 0,
lastSnapshotIndex: 0,
lastSnapshotTerm: 0,
logMaxEntries: 256,
metrics: RaftMetrics(),
nextIndex: initTable[string, uint64](),
matchIndex: initTable[string, uint64](),
matchIndexSeenMs: initTable[string, int64](),
raftPeerStaleMs: 30000,
peers: peers,
leaderId: "",
electionTimeout: 150 + rand(150),
@@ -158,6 +238,12 @@ proc newRaftNode*(id: string, peers: seq[string], raftPort: int = 0,
peerAddrs: initTable[string, tuple[host: string, port: int]](),
raftPort: raftPort,
dataDir: dataDir,
snapChunkBytes: 262144,
snapIncomingId: 0,
snapIncomingFile: "",
snapRejectStreak: initTable[string, int](),
snapPending: initHashSet[string](),
snapSending: initHashSet[string](),
)
result.loadState()
@@ -176,12 +262,12 @@ proc addNode*(cluster: RaftCluster, id: string) =
proc lastLogIndex*(node: RaftNode): uint64 =
if node.log.len == 0:
return 0
return node.lastSnapshotIndex
return node.log[^1].index
proc lastLogTerm*(node: RaftNode): uint64 =
if node.log.len == 0:
return 0
return node.lastSnapshotTerm
return node.log[^1].term
proc findLogEntryByIndex(node: RaftNode, index: uint64): int =
@@ -192,17 +278,70 @@ proc findLogEntryByIndex(node: RaftNode, index: uint64): int =
return i
return -1
proc termAtIndex(node: RaftNode, index: uint64): uint64 =
## Term of the log entry (or snapshot base) at `index`, or 0 if unknown.
if index == 0: return 0
if index == node.lastSnapshotIndex: return node.lastSnapshotTerm
let pos = node.findLogEntryByIndex(index)
if pos >= 0: return node.log[pos].term
return 0
proc compactLog*(node: RaftNode) =
## Drop a fully-replicated / applied log prefix so the in-memory log stays
## bounded. Leader: never discard past any responsive peer's matchIndex
## (catch-up via AppendEntries remains possible); peers silent longer than
## raftPeerStaleMs are excluded and catch up via InstallSnapshot instead.
## Follower: discard through lastApplied.
let maxEntries = if node.logMaxEntries > 0: node.logMaxEntries else: 256
if node.log.len <= maxEntries:
return
var through = node.lastApplied
if node.state == rsLeader and node.peers.len > 0:
let staleMs = if node.raftPeerStaleMs > 0: node.raftPeerStaleMs else: 30000
let nowMs = getMonoTime().ticks() div 1_000_000
var minMatch = through
for peer in node.peers:
let seenMs = node.matchIndexSeenMs.getOrDefault(peer, 0)
if seenMs <= 0 or nowMs - seenMs > staleMs.int64:
continue # stale peer — unpinned, snapshotted on return (T9)
let m = node.matchIndex.getOrDefault(peer, 0'u64)
if m < minMatch: minMatch = m
through = minMatch
if through <= node.lastSnapshotIndex:
return
let pos = node.findLogEntryByIndex(through)
if pos < 0:
return
node.lastSnapshotTerm = node.log[pos].term
node.lastSnapshotIndex = through
if pos + 1 < node.log.len:
node.log = node.log[(pos + 1) .. ^1]
else:
node.log = @[]
# Keep lastApplied/commit at least at the snapshot base
if node.lastApplied < node.lastSnapshotIndex:
node.lastApplied = node.lastSnapshotIndex
if node.commitIndex < node.lastSnapshotIndex:
node.commitIndex = node.lastSnapshotIndex
if node.metrics != nil:
inc node.metrics.compactionsTotal
node.saveState()
proc applyCommitted(node: RaftNode) =
while node.lastApplied < node.commitIndex:
let idx = int(node.lastApplied)
if idx < node.log.len:
let entry = node.log[idx]
inc node.lastApplied
# Entries at/below the snapshot base were already applied before compact.
if node.lastApplied <= node.lastSnapshotIndex:
continue
let pos = node.findLogEntryByIndex(node.lastApplied)
if pos >= 0:
let entry = node.log[pos]
# Handle distributed transaction commands
if entry.command.startsWith("DISTTXN:"):
let parts = entry.command.split(":")
if parts.len >= 3:
let action = parts[1]
let txnId = try: parseUInt(parts[2]) except: 0'u64
let txnId = try: parseUInt(parts[2]) except CatchableError: 0'u64
if action == "PREPARE" and node.onDistTxnPrepare != nil:
discard node.onDistTxnPrepare(txnId, @[])
elif action == "COMMIT" and node.onDistTxnCommit != nil:
@@ -212,20 +351,30 @@ proc applyCommitted(node: RaftNode) =
else:
if node.applyCommand != nil:
node.applyCommand(entry.command, entry.data)
inc node.lastApplied
if node.metrics != nil:
inc node.metrics.appliesTotal
node.compactLog()
proc becomeFollower*(node: RaftNode, term: uint64) =
if term > node.currentTerm and node.metrics != nil:
inc node.metrics.termChangesTotal
node.state = rsFollower
node.currentTerm = term
node.votedFor = ""
node.votesReceived.clear()
node.nextIndex.clear()
node.matchIndex.clear()
node.matchIndexSeenMs.clear()
# Leader-only snapshot-send state is meaningless once we step down
node.snapRejectStreak.clear()
node.snapPending.clear()
node.saveState()
proc becomeCandidate*(node: RaftNode) =
node.state = rsCandidate
inc node.currentTerm
if node.metrics != nil:
inc node.metrics.termChangesTotal
node.votedFor = node.id
node.votesReceived.clear()
node.votesReceived.incl(node.id)
@@ -234,9 +383,19 @@ proc becomeCandidate*(node: RaftNode) =
proc becomeLeader*(node: RaftNode) =
node.state = rsLeader
node.leaderId = node.id
if node.metrics != nil:
inc node.metrics.electionsTotal
info("Raft node " & node.id & " became leader for term " & $node.currentTerm)
let nowMs = getMonoTime().ticks() div 1_000_000
node.matchIndexSeenMs.clear()
for peer in node.peers:
node.nextIndex[peer] = node.lastLogIndex + 1
node.matchIndex[peer] = 0
# Grace window: an unreplied peer still pins compaction until it has been
# silent for raftPeerStaleMs since this leadership began.
node.matchIndexSeenMs[peer] = nowMs
node.snapRejectStreak.clear()
node.snapPending.clear()
proc handleRequestVote*(node: RaftNode, msg: RaftMessage): RaftMessage =
var reply = RaftMessage(
@@ -282,6 +441,13 @@ proc handleAppendEntries*(node: RaftNode, msg: RaftMessage): RaftMessage =
# Check if log contains entry at prevLogIndex with prevLogTerm
if msg.prevLogIndex > 0:
if msg.prevLogIndex < node.lastSnapshotIndex:
# Leader is behind our snapshot base — reject
return reply
if msg.prevLogIndex == node.lastSnapshotIndex:
if msg.prevLogTerm != node.lastSnapshotTerm:
return reply
else:
let prevPos = node.findLogEntryByIndex(msg.prevLogIndex)
if prevPos < 0:
return reply
@@ -315,6 +481,87 @@ proc handleAppendEntries*(node: RaftNode, msg: RaftMessage): RaftMessage =
reply.matchIdx = node.lastLogIndex
return reply
proc handleInstallSnapshot*(node: RaftNode, msg: RaftMessage): RaftMessage =
## Follower side of InstallSnapshot: assemble the chunk stream into a temp
## archive under `dataDir/snap_incoming/`, then hand the completed archive
## to the restoreSnapshot callback. Chunks arrive in order from a single
## leader over one socket, so we append sequentially and only sanity-check
## that snapOffset equals the number of bytes assembled so far.
##
## NOTE: this runs on the async event loop and restoreSnapshot performs
## blocking disk I/O (archive extract + DB reopen). Implementations must be
## fast, or defer the heavy work; the baradadb.nim wiring decides.
var reply = RaftMessage(
kind: rmkInstallSnapshotReply,
term: node.currentTerm,
senderId: node.id,
success: false,
matchIdx: node.lastSnapshotIndex,
)
if msg.term < node.currentTerm:
return reply
if msg.term > node.currentTerm:
node.becomeFollower(msg.term)
node.leaderId = msg.senderId
# Chunk size cap (deferred from the wire-protocol task).
if msg.snapData.len > node.snapChunkBytes or node.dataDir.len == 0:
return reply
let snapDir = node.dataDir / "snap_incoming"
if msg.snapId != node.snapIncomingId:
# New snapshot generation: discard any partial assembly and restart.
if msg.snapOffset != 0:
return reply
createDir(snapDir)
node.snapIncomingId = msg.snapId
node.snapIncomingFile = snapDir / "snap_" & $msg.snapId & ".tar.gz"
let f = open(node.snapIncomingFile, fmWrite) # truncate any leftover
f.close()
if node.snapIncomingFile.len == 0:
return reply
let assembled = getFileSize(node.snapIncomingFile)
if msg.snapOffset != uint64(assembled):
# Gap or overlap: reset so the leader restarts the transfer.
removeFile(node.snapIncomingFile)
node.snapIncomingId = 0
node.snapIncomingFile = ""
return reply
if msg.snapData.len > 0:
let f = open(node.snapIncomingFile, fmAppend)
try:
discard f.writeBuffer(addr msg.snapData[0], msg.snapData.len)
finally:
f.close()
if not msg.snapDone:
reply.success = true
return reply
# Transfer complete: restore the data dir and adopt the snapshot base.
if node.restoreSnapshot == nil or
not node.restoreSnapshot(node.snapIncomingFile,
msg.prevLogIndex, msg.prevLogTerm):
removeFile(node.snapIncomingFile)
node.snapIncomingId = 0
node.snapIncomingFile = ""
return reply
node.lastSnapshotIndex = msg.prevLogIndex
node.lastSnapshotTerm = msg.prevLogTerm
node.commitIndex = node.lastSnapshotIndex
node.lastApplied = node.lastSnapshotIndex
node.log = @[]
node.snapIncomingId = 0
node.snapIncomingFile = ""
node.saveState()
reply.success = true
reply.matchIdx = node.lastSnapshotIndex
return reply
proc requestVote*(node: RaftNode): seq[RaftMessage] =
result = @[]
for peer in node.peers:
@@ -327,15 +574,18 @@ proc requestVote*(node: RaftNode): seq[RaftMessage] =
))
proc appendEntries*(node: RaftNode, peerId: string): RaftMessage =
let nextIdx = node.nextIndex.getOrDefault(peerId, node.lastLogIndex + 1)
var nextIdx = node.nextIndex.getOrDefault(peerId, node.lastLogIndex + 1)
# Never try to send entries already discarded by our snapshot base.
if nextIdx <= node.lastSnapshotIndex:
nextIdx = node.lastSnapshotIndex + 1
node.nextIndex[peerId] = nextIdx
let prevIdx = nextIdx - 1
var prevTerm: uint64 = 0
if prevIdx > 0 and prevIdx <= uint64(node.log.len):
prevTerm = node.log[prevIdx - 1].term
let prevTerm = node.termAtIndex(prevIdx)
var entries: seq[LogEntry] = @[]
if nextIdx > 0:
for i in int(nextIdx - 1)..<node.log.len:
let startPos = node.findLogEntryByIndex(nextIdx)
if startPos >= 0:
for i in startPos..<node.log.len:
entries.add(node.log[i])
return RaftMessage(
@@ -358,6 +608,8 @@ proc appendLog*(node: RaftNode, command: string, data: seq[byte] = @[]): LogEntr
data: data,
)
node.log.add(result)
if node.metrics != nil:
inc node.metrics.appendsTotal
node.saveState()
proc handleVoteReply*(node: RaftNode, reply: RaftMessage) =
@@ -390,28 +642,160 @@ proc handleAppendReply*(node: RaftNode, peerId: string, reply: RaftMessage) =
if reply.success:
node.matchIndex[peerId] = reply.matchIdx
node.nextIndex[peerId] = reply.matchIdx + 1
node.matchIndexSeenMs[peerId] = getMonoTime().ticks() div 1_000_000
node.snapRejectStreak.del(peerId)
node.snapPending.excl(peerId)
# Update commit index
var matchIndices: seq[uint64] = @[node.lastLogIndex]
for p, idx in node.matchIndex:
matchIndices.add(idx)
matchIndices.sort()
# Update commit index using true majority calculation
let majority = (node.peers.len + 1 + 1) div 2 # majority of cluster (peers + leader)
var newCommitIdx = node.commitIndex
let medianIdx = matchIndices[(matchIndices.len - 1) div 2]
if medianIdx > node.commitIndex:
if medianIdx <= node.lastLogIndex and
node.log[medianIdx - 1].term == node.currentTerm:
node.commitIndex = medianIdx
# Walk logical indices high→low via findLogEntryByIndex (log may be compacted).
for idx in countdown(int(node.lastLogIndex), int(node.commitIndex) + 1):
if idx <= 0:
break
let pos = node.findLogEntryByIndex(uint64(idx))
if pos < 0:
continue
# Only commit entries from current term (Raft safety property)
if node.log[pos].term == node.currentTerm:
var count = 1 # Leader itself
for peerId2, mIdx in node.matchIndex:
if mIdx >= uint64(idx):
inc count
if count >= majority:
newCommitIdx = uint64(idx)
break
if newCommitIdx > node.commitIndex:
node.commitIndex = newCommitIdx
node.applyCommitted()
else:
if node.nextIndex[peerId] > 1:
let floor = node.lastSnapshotIndex + 1
if node.nextIndex.getOrDefault(peerId, 1) > floor:
dec node.nextIndex[peerId]
# Not a floor-level reject, so it breaks any floor-reject streak.
node.snapRejectStreak.del(peerId)
else:
node.nextIndex[peerId] = floor
# Stuck at the compaction floor: the entries the follower needs have
# been compacted away, so AppendEntries can never catch it up. Count
# consecutive floor rejects; at 2, queue an InstallSnapshot transfer
# (the network layer picks this up after handleAppendReply returns).
node.snapRejectStreak[peerId] =
node.snapRejectStreak.getOrDefault(peerId, 0) + 1
if node.snapRejectStreak[peerId] >= 2:
node.snapPending.incl(peerId)
proc handleInstallSnapshotReply*(node: RaftNode, peerId: string,
reply: RaftMessage) =
## Leader side: follower's answer to a completed InstallSnapshot transfer.
## success=true adopts the snapshot base (reply.matchIdx) as the peer's
## match point; success=false leaves all state alone — the normal
## AppendEntries reject path re-triggers another snapshot if the peer is
## still stuck at the floor.
if reply.term > node.currentTerm:
node.becomeFollower(reply.term)
return
if reply.term < node.currentTerm:
return
if node.state != rsLeader:
return
if reply.success and reply.matchIdx >= node.lastSnapshotIndex:
# The follower has actually adopted the snapshot base. Intermediate chunk
# replies (the T8 follower acks every non-done chunk with success=true and
# matchIdx = its OLD lastSnapshotIndex, below ours) fall through here and
# must be ignored: applying them would regress matchIndex/nextIndex and
# clear the reject streak mid-transfer, causing state flapping until the
# final reply lands.
node.matchIndex[peerId] = reply.matchIdx
node.nextIndex[peerId] = reply.matchIdx + 1
node.snapRejectStreak.del(peerId)
node.snapPending.excl(peerId)
proc state*(node: RaftNode): RaftState = node.state
proc isLeader*(node: RaftNode): bool = node.state == rsLeader
proc leaderId*(node: RaftNode): string = node.leaderId
proc logLen*(node: RaftNode): int = node.log.len
proc applyLag*(node: RaftNode): uint64 =
## commitIndex - lastApplied (0 when caught up).
if node.commitIndex > node.lastApplied:
return node.commitIndex - node.lastApplied
return 0
proc prometheusText*(node: RaftNode): string =
## Prometheus exposition lines for this raft node (gauges + counters).
let m = if node.metrics != nil: node.metrics else: RaftMetrics()
let isLead = if node.isLeader: 1 else: 0
let role = case node.state
of rsLeader: "leader"
of rsCandidate: "candidate"
of rsFollower: "follower"
result = ""
result.add("# HELP baradb_raft_is_leader 1 if this node is the raft leader\n")
result.add("# TYPE baradb_raft_is_leader gauge\n")
result.add("baradb_raft_is_leader{node=\"" & node.id & "\",role=\"" & role & "\"} " & $isLead & "\n")
result.add("# HELP baradb_raft_term Current raft term\n")
result.add("# TYPE baradb_raft_term gauge\n")
result.add("baradb_raft_term{node=\"" & node.id & "\"} " & $node.currentTerm & "\n")
result.add("# HELP baradb_raft_log_entries In-memory raft log length\n")
result.add("# TYPE baradb_raft_log_entries gauge\n")
result.add("baradb_raft_log_entries{node=\"" & node.id & "\"} " & $node.log.len & "\n")
result.add("# HELP baradb_raft_commit_index Raft commit index\n")
result.add("# TYPE baradb_raft_commit_index gauge\n")
result.add("baradb_raft_commit_index{node=\"" & node.id & "\"} " & $node.commitIndex & "\n")
result.add("# HELP baradb_raft_last_applied Raft lastApplied index\n")
result.add("# TYPE baradb_raft_last_applied gauge\n")
result.add("baradb_raft_last_applied{node=\"" & node.id & "\"} " & $node.lastApplied & "\n")
result.add("# HELP baradb_raft_apply_lag commitIndex - lastApplied\n")
result.add("# TYPE baradb_raft_apply_lag gauge\n")
result.add("baradb_raft_apply_lag{node=\"" & node.id & "\"} " & $node.applyLag & "\n")
result.add("# HELP baradb_raft_snapshot_index lastSnapshotIndex (compacted base)\n")
result.add("# TYPE baradb_raft_snapshot_index gauge\n")
result.add("baradb_raft_snapshot_index{node=\"" & node.id & "\"} " & $node.lastSnapshotIndex & "\n")
result.add("# HELP baradb_raft_elections_total Times this node became leader\n")
result.add("# TYPE baradb_raft_elections_total counter\n")
result.add("baradb_raft_elections_total{node=\"" & node.id & "\"} " & $m.electionsTotal & "\n")
result.add("# HELP baradb_raft_term_changes_total Term increases observed\n")
result.add("# TYPE baradb_raft_term_changes_total counter\n")
result.add("baradb_raft_term_changes_total{node=\"" & node.id & "\"} " & $m.termChangesTotal & "\n")
result.add("# HELP baradb_raft_appends_total Log appends on this node\n")
result.add("# TYPE baradb_raft_appends_total counter\n")
result.add("baradb_raft_appends_total{node=\"" & node.id & "\"} " & $m.appendsTotal & "\n")
result.add("# HELP baradb_raft_commit_waits_total Successful wait-for-commit completions\n")
result.add("# TYPE baradb_raft_commit_waits_total counter\n")
result.add("baradb_raft_commit_waits_total{node=\"" & node.id & "\"} " & $m.commitWaitsTotal & "\n")
result.add("# HELP baradb_raft_commit_wait_ms_total Sum of commit-wait durations in ms\n")
result.add("# TYPE baradb_raft_commit_wait_ms_total counter\n")
result.add("baradb_raft_commit_wait_ms_total{node=\"" & node.id & "\"} " & $m.commitWaitMsTotal & "\n")
result.add("# HELP baradb_raft_commit_timeouts_total Raft commit wait timeouts\n")
result.add("# TYPE baradb_raft_commit_timeouts_total counter\n")
result.add("baradb_raft_commit_timeouts_total{node=\"" & node.id & "\"} " & $m.commitTimeoutsTotal & "\n")
result.add("# HELP baradb_raft_lost_leadership_total Appends rejected (not leader)\n")
result.add("# TYPE baradb_raft_lost_leadership_total counter\n")
result.add("baradb_raft_lost_leadership_total{node=\"" & node.id & "\"} " & $m.lostLeadershipTotal & "\n")
result.add("# HELP baradb_raft_forwards_total Follower SQL forwards to leader\n")
result.add("# TYPE baradb_raft_forwards_total counter\n")
result.add("baradb_raft_forwards_total{node=\"" & node.id & "\"} " & $m.forwardsTotal & "\n")
result.add("# HELP baradb_raft_forward_errors_total Failed leader forwards\n")
result.add("# TYPE baradb_raft_forward_errors_total counter\n")
result.add("baradb_raft_forward_errors_total{node=\"" & node.id & "\"} " & $m.forwardErrorsTotal & "\n")
result.add("# HELP baradb_raft_applies_total State-machine applyCommand calls\n")
result.add("# TYPE baradb_raft_applies_total counter\n")
result.add("baradb_raft_applies_total{node=\"" & node.id & "\"} " & $m.appliesTotal & "\n")
result.add("# HELP baradb_raft_compactions_total Log prefix compactions\n")
result.add("# TYPE baradb_raft_compactions_total counter\n")
result.add("baradb_raft_compactions_total{node=\"" & node.id & "\"} " & $m.compactionsTotal & "\n")
if m.commitWaitsTotal > 0:
let avg = m.commitWaitMsTotal div m.commitWaitsTotal
result.add("# HELP baradb_raft_commit_wait_ms_avg Average commit-wait latency (ms)\n")
result.add("# TYPE baradb_raft_commit_wait_ms_avg gauge\n")
result.add("baradb_raft_commit_wait_ms_avg{node=\"" & node.id & "\"} " & $avg & "\n")
# Leader election timer loop
type
ElectionTimer* = ref object
@@ -495,6 +879,14 @@ proc serialize*(msg: RaftMessage): seq[byte] =
stream.write(msg.leaderCommit)
stream.write(char(if msg.success: 1 else: 0))
stream.write(msg.matchIdx)
# InstallSnapshot trailing fields (appended for wire backward compatibility;
# pre-v1.3 peers stop reading at matchIdx and ignore these bytes)
stream.write(msg.snapId)
stream.write(msg.snapOffset)
stream.write(uint32(msg.snapData.len))
if msg.snapData.len > 0:
stream.writeData(addr msg.snapData[0], msg.snapData.len)
stream.write(char(if msg.snapDone: 1 else: 0))
let strData = stream.data
result = newSeq[byte](strData.len)
for i in 0 ..< strData.len:
@@ -523,6 +915,19 @@ proc deserializeRaftMessage*(data: seq[byte]): RaftMessage =
result.leaderCommit = stream.readUint64()
result.success = stream.readChar() != '\0'
result.matchIdx = stream.readUint64()
# Optional trailing InstallSnapshot fields (absent in pre-v1.3 buffers)
if not stream.atEnd:
result.snapId = stream.readUint64()
if not stream.atEnd:
result.snapOffset = stream.readUint64()
if not stream.atEnd:
let dataLen = int(stream.readUint32())
result.snapData = newSeq[byte](dataLen)
if dataLen > 0:
if stream.readData(addr result.snapData[0], dataLen) != dataLen:
raise newException(IOError, "Incomplete snapshot data read from stream")
if not stream.atEnd:
result.snapDone = stream.readChar() != '\0'
stream.close()
# ---------------------------------------------------------------------------
@@ -535,24 +940,45 @@ type
socket*: AsyncSocket
running*: bool
peerSockets*: Table[string, AsyncSocket]
timer*: ElectionTimer
## Optional TLS context; nil = plaintext (default, pre-TLS behavior).
tls*: TLSContext
proc newRaftNetwork*(node: RaftNode): RaftNetwork =
proc newRaftNetwork*(node: RaftNode, tls: TLSContext = nil): RaftNetwork =
RaftNetwork(
node: node,
running: false,
peerSockets: initTable[string, AsyncSocket](),
timer: newElectionTimer(node, node.electionTimeout),
tls: tls,
)
const RaftConnectTimeoutMs = 200
proc connectToPeer(net: RaftNetwork, peerId: string) {.async.} =
## Dial a peer with a short timeout so a dead peer cannot stall the whole
## heartbeat / RequestVote fan-out (default TCP connect can hang for many
## seconds, which lets live followers trip their election timers).
if peerId notin net.node.peerAddrs:
return
let (host, port) = net.node.peerAddrs[peerId]
var sock: AsyncSocket = nil
try:
let sock = newAsyncSocket()
await sock.connect(host, Port(port))
sock = newAsyncSocket()
let ok = await withTimeout(sock.connect(host, Port(port)), RaftConnectTimeoutMs)
if not ok:
sock.close()
return
if net.tls != nil:
try:
net.tls.wrapClient(sock)
except CatchableError:
try: sock.close() except CatchableError: discard
return
net.peerSockets[peerId] = sock
except:
discard
except CatchableError:
if sock != nil:
try: sock.close() except CatchableError: discard
proc send*(net: RaftNetwork, peerId: string, msg: RaftMessage) {.async.} =
if peerId notin net.peerSockets:
@@ -564,7 +990,8 @@ proc send*(net: RaftNetwork, peerId: string, msg: RaftMessage) {.async.} =
bigEndian32(addr header[0], unsafeAddr payloadLen)
try:
await net.peerSockets[peerId].send(cast[string](header) & cast[string](data))
except:
except CatchableError:
try: net.peerSockets[peerId].close() except CatchableError: discard
net.peerSockets.del(peerId)
proc broadcast*(net: RaftNetwork, msgs: seq[RaftMessage]) {.async.} =
@@ -572,7 +999,70 @@ proc broadcast*(net: RaftNetwork, msgs: seq[RaftMessage]) {.async.} =
if i < msgs.len:
await net.send(peer, msgs[i])
proc processMessage(net: RaftNetwork, msg: RaftMessage) {.async.} =
proc sendSnapshot*(net: RaftNetwork, peerId: string) {.async.} =
## Leader side of InstallSnapshot: build an archive of the current data dir
## via the buildSnapshot callback and stream it to a lagging peer in
## snapChunkBytes chunks. Triggered (via asyncCheck from processMessage)
## when handleAppendReply queues the peer in snapPending after consecutive
## floor-level rejects. Single-flight per peer via node.snapSending.
##
## Runs on the raft event loop; buildSnapshot performs blocking disk I/O
## (tar+gzip). Snapshot sends are rare, so we accept the stall rather than
## adding a worker round-trip (same trade-off as restoreSnapshot).
let node = net.node
if peerId in node.snapSending:
return
if node.state != rsLeader or node.buildSnapshot == nil or
node.dataDir.len == 0:
return
let snapId = node.lastSnapshotIndex
if snapId == 0:
# snapId 0 can never be accepted (a follower's initial snapIncomingId is
# 0), and sends only trigger after compaction anyway — guard regardless.
warn("sendSnapshot: lastSnapshotIndex is 0; skipping snapshot send to " & peerId)
return
node.snapSending.incl(peerId)
defer: node.snapSending.excl(peerId)
let baseIndex = node.lastSnapshotIndex
let baseTerm = node.lastSnapshotTerm
let destPath = node.dataDir / ("snap_out_" & $snapId & ".tar.gz")
defer:
if fileExists(destPath):
removeFile(destPath)
if not node.buildSnapshot(destPath):
warn("sendSnapshot: buildSnapshot failed; aborting snapshot send to " & peerId)
return
var f: File
if not open(f, destPath, fmRead):
warn("sendSnapshot: cannot open built archive " & destPath)
return
defer: f.close()
let total = uint64(getFileSize(destPath))
var offset = 0'u64
while true:
var chunk = newSeq[byte](node.snapChunkBytes)
let n = f.readBytes(chunk, 0, chunk.len)
let done = offset + uint64(n) >= total
await net.send(peerId, RaftMessage(
kind: rmkInstallSnapshot,
term: node.currentTerm,
senderId: node.id,
prevLogIndex: baseIndex, # snapshot base index/term (T7 wire layout)
prevLogTerm: baseTerm,
snapId: snapId,
snapOffset: offset,
snapData: chunk[0 ..< n],
snapDone: done,
))
if done:
break
offset += uint64(n)
proc processMessage*(net: RaftNetwork, msg: RaftMessage) {.async.} =
case msg.kind
of rmkRequestVote:
let reply = net.node.handleRequestVote(msg)
@@ -580,20 +1070,49 @@ proc processMessage(net: RaftNetwork, msg: RaftMessage) {.async.} =
of rmkRequestVoteReply:
net.node.handleVoteReply(msg)
of rmkAppendEntries:
# A plausible current leader (same acceptance condition as
# handleAppendEntries) resets the election timer; stale-term
# messages must not.
if msg.term >= net.node.currentTerm:
net.timer.resetTimeout()
let reply = net.node.handleAppendEntries(msg)
await net.send(msg.senderId, reply)
of rmkAppendEntriesReply:
net.node.handleAppendReply(msg.senderId, msg)
# Floor-reject streak reached the threshold: this peer needs a snapshot.
if msg.senderId in net.node.snapPending:
net.node.snapPending.excl(msg.senderId)
asyncCheck net.sendSnapshot(msg.senderId)
of rmkInstallSnapshot:
# Same election-timer rule as AppendEntries: only a plausible current
# leader resets it.
if msg.term >= net.node.currentTerm:
net.timer.resetTimeout()
let reply = net.node.handleInstallSnapshot(msg)
await net.send(msg.senderId, reply)
of rmkInstallSnapshotReply:
net.node.handleInstallSnapshotReply(msg.senderId, msg)
proc recvExact*(client: AsyncSocket, size: int): Future[string] {.async.} =
## Reads exactly `size` bytes from `client`. A short return means the peer
## disconnected mid-frame (EOF); callers must treat it as end of stream.
var buf = ""
while buf.len < size:
let chunk = await client.recv(size - buf.len)
if chunk.len == 0:
break
buf.add(chunk)
return buf
proc receiveLoop(net: RaftNetwork, client: AsyncSocket) {.async.} =
try:
while net.running:
let lenData = await client.recv(4)
let lenData = await recvExact(client, 4)
if lenData.len < 4:
break
var pos = 0
let payloadLen = int(readUint32(cast[seq[byte]](lenData), pos))
let payloadStr = await client.recv(payloadLen)
let payloadStr = await recvExact(client, payloadLen)
if payloadStr.len < payloadLen:
break
var payload = newSeq[byte](payloadLen)
@@ -602,37 +1121,72 @@ proc receiveLoop(net: RaftNetwork, client: AsyncSocket) {.async.} =
let msg = deserializeRaftMessage(payload)
try:
await net.processMessage(msg)
except:
except CatchableError:
discard
except:
except CatchableError:
discard
finally:
client.close()
proc heartbeatLoop(net: RaftNetwork) {.async.} =
## Fan out heartbeats in parallel so a slow/dead peer cannot delay
## AppendEntries to the rest of the cluster.
var wasLeader = false
while net.running:
if net.node.state == rsLeader:
if not wasLeader:
# Fresh term, fresh connections. A peer that restarted while we were
# partitioned leaves a half-dead cached socket whose writes can keep
# "succeeding" into the void (the TCP error surfaces late or never,
# so the error-triggered redial in send() may never fire) — the
# restarted peer then never sees AppendEntries, keeps candidating,
# and the cluster livelocks. Drop all cached peer connections on
# leadership acquisition so the heartbeat fan-out redials.
for peerId, sock in net.peerSockets:
try: sock.close() except CatchableError: discard
net.peerSockets.clear()
wasLeader = true
var futs: seq[Future[void]] = @[]
for peer in net.node.peers:
let msg = net.node.appendEntries(peer)
await net.send(peer, msg)
futs.add(net.send(peer, msg))
for f in futs:
try:
await f
except CatchableError:
discard
else:
wasLeader = false
await sleepAsync(net.node.heartbeatTimeout)
proc timerLoop*(net: RaftNetwork) {.async.}
proc run*(net: RaftNetwork) {.async.} =
net.socket = newAsyncSocket()
net.socket.setSockOpt(OptReuseAddr, true)
net.socket.bindAddr(Port(net.node.raftPort))
net.socket.listen()
net.running = true
net.timer.resetTimeout()
asyncCheck net.heartbeatLoop()
asyncCheck net.timerLoop()
while net.running:
try:
let client = await net.socket.accept()
if net.tls != nil:
try:
net.tls.wrapServer(client)
except CatchableError:
# Handshake failed (e.g. plaintext dial) — drop, no protocol effect.
client.close()
continue
asyncCheck net.receiveLoop(client)
except:
except CatchableError:
break
proc stop*(net: RaftNetwork) =
net.running = false
net.timer.stop()
if net.socket != nil:
net.socket.close()
for peerId, sock in net.peerSockets:
@@ -670,3 +1224,10 @@ proc tick*(timer: ElectionTimer, net: RaftNetwork = nil) =
timer.resetTimeout()
of rsLeader:
timer.resetTimeout() # Keep alive
proc timerLoop*(net: RaftNetwork) {.async.} =
## Production election timer: ticks the node's ElectionTimer until the
## network transport is stopped.
while net.running:
tick(net.timer, net)
await sleepAsync(50)
+40 -3
View File
@@ -29,6 +29,17 @@ type
const reservedDbNames* = ["system", "information_schema", "pg_catalog"]
proc openLsmForRegistry(reg: DatabaseRegistry, dbDir: string): LSMTree =
## Open LSM with WAL durability settings from registry config.
let memBytes = max(1, reg.config.memtableSizeMb) * 1024 * 1024
newLSMTree(
dbDir,
memMaxSize = memBytes,
walSyncMode = parseWalSyncMode(reg.config.walSyncMode),
walGroupEvery = reg.config.walGroupEvery,
walGroupIntervalMs = reg.config.walSyncIntervalMs,
)
proc isValidDbName*(name: string): bool =
if name.len == 0: return false
if '/' in name or '\\' in name: return false
@@ -63,7 +74,7 @@ proc loadExistingDatabases*(reg: DatabaseRegistry) =
if dbName.len > 0 and isValidDbName(dbName):
let dbDir = reg.dataRoot / dbName
info("Loading database '" & dbName & "' from " & dbDir)
let db = newLSMTree(dbDir)
let db = openLsmForRegistry(reg, dbDir)
let ctx = reg.ctxFactory(db, reg)
acquire(reg.lock)
reg.databases[dbName] = DatabaseInfo(
@@ -89,7 +100,7 @@ proc ensureDefaultDatabase*(reg: DatabaseRegistry) =
if not exists:
let dbDir = reg.dataRoot / defaultDbName
info("Creating default database at " & dbDir)
let db = newLSMTree(dbDir)
let db = openLsmForRegistry(reg, dbDir)
let ctx = reg.ctxFactory(db, reg)
acquire(reg.lock)
reg.databases[defaultDbName] = DatabaseInfo(
@@ -113,7 +124,7 @@ proc getOrCreateDatabase*(reg: DatabaseRegistry, name: string): DatabaseInfo =
# Create new database
let dbDir = reg.dataRoot / name
info("Creating database '" & name & "' at " & dbDir)
let db = newLSMTree(dbDir)
let db = openLsmForRegistry(reg, dbDir)
let ctx = reg.ctxFactory(db, reg)
let info = DatabaseInfo(name: name, db: db, ctx: ctx, activeConnections: 0)
reg.databases[name] = info
@@ -192,6 +203,32 @@ proc getDatabaseInfo*(reg: DatabaseRegistry, name: string): DatabaseInfo =
return reg.databases[name]
return nil
proc reopenDatabase*(reg: DatabaseRegistry, name: string): bool =
## Reopen a database from its on-disk directory, swapping the new LSMTree
## and ctx into the EXISTING DatabaseInfo slot so captured references (e.g.
## the raft applyCommand closure) see the new state.
## Minimal API added for raft InstallSnapshot restore: the caller must have
## closed info.db first (snapshot restore closes it before swapping the data
## directory); this proc does not close.
## Returns false if the database is unknown or the reopen fails.
acquire(reg.lock)
let info = if name in reg.databases: reg.databases[name] else: nil
release(reg.lock)
if info == nil:
return false
try:
let dbDir = reg.dataRoot / name
let db = openLsmForRegistry(reg, dbDir)
let ctx = reg.ctxFactory(db, reg)
info.db = db
info.ctx = ctx
return true
except CatchableError as e:
# echo instead of logging: callers include gcsafe raft callbacks, and
# core/logging's info/warn are not gcsafe.
echo "[registry] Error reopening database '", name, "': ", e.msg
return false
proc closeAll*(reg: DatabaseRegistry) =
acquire(reg.lock)
defer: release(reg.lock)
+6 -6
View File
@@ -150,8 +150,9 @@ proc writeLsn*(rm: ReplicationManager, data: seq[byte]): uint64 =
rm.pendingAcks.del(lsn)
release(rm.lock)
if replicasToShip.len > 0 and ackCount < replicasToShip.len:
when defined(debug):
echo "Replication sync: only ", ackCount, "/", replicasToShip.len, " replicas acked for LSN ", lsn
# Sync replication requires ALL replicas to ack — fail if any missed
echo "[ERROR] Sync replication failed: only ", ackCount, "/", replicasToShip.len, " replicas acked for LSN ", lsn
return 0 # Indicate failure to satisfy sync replication guarantee
return lsn
of rmSemiSync:
if replicasToShip.len > 0:
@@ -259,19 +260,18 @@ proc healthCheck*(rm: ReplicationManager) =
if not connectWithTimeout(sock, replica.host, Port(replica.port), 1000):
connected = false
else:
defer: sock.close()
sock.send("PING\n")
var response = ""
try:
sock.readLine(response)
if response.strip() != "PONG":
connected = false
except:
except CatchableError:
connected = false
except:
except CatchableError:
connected = false
finally:
sock.close()
try: sock.close() except CatchableError: discard
if not connected:
acquire(rm.lock)
+296 -54
View File
@@ -6,6 +6,7 @@ import std/sequtils
import std/tables
import std/endians
import std/monotimes
import std/times
import std/locks
import std/nativesockets
when defined(windows):
@@ -20,10 +21,13 @@ import ../query/lexer
import ../query/parser
import ../query/ast
import ../query/executor
import ../query/exec/params
import ../storage/lsm
import ../storage/gate
import ../core/mvcc
import ../core/disttxn
import ../core/replication
import ../core/raft
import ../core/sharding
import ../core/gossip
import ../protocol/ratelimit
@@ -40,6 +44,7 @@ type
txnManager*: TxnManager
distTxnManager*: DistTxnManager
replicationManager*: ReplicationManager
raftNode*: RaftNode
shardRouter*: ShardRouter
clusterMembership*: ClusterMembership
gossipProtocol*: GossipProtocol
@@ -49,6 +54,12 @@ type
activeConnectionsLock*: Lock
proc newServerWithRegistry*(config: BaraConfig, registry: DatabaseRegistry): Server =
# CRITICAL: Reject empty JWT secret when auth is enabled
if config.authEnabled and config.jwtSecret.len == 0:
raise newException(ValueError,
"Security error: authEnabled is true but jwtSecret is empty. " &
"Set BARADB_JWT_SECRET environment variable or jwt_secret in baradb.json")
let dbInfo = getOrCreateDatabase(registry, "default")
let db = dbInfo.db
let ctx = cast[ExecutionContext](cast[pointer](dbInfo.ctx))
@@ -58,55 +69,53 @@ proc newServerWithRegistry*(config: BaraConfig, registry: DatabaseRegistry): Ser
let tlsConfig = newTLSConfig(config.certFile, config.keyFile)
tls = newTLSContext(tlsConfig)
# Initialize sharding
let shardRouter = newShardRouter()
# Initialize sharding / gossip. Server fields own the refs; locals used inside
# callback closures are {.cursor.} so ARC does not form uncollectable cycles
# (local + closure env + object callback fields).
let localId = if config.raftNodeId.len > 0: config.raftNodeId else: "node-" & $config.port
let cm = newClusterMembership(shardRouter, localId)
# Wire shard migration callbacks to LSM (use default database)
shardRouter.iterateKeys = proc(shardId: int): seq[(string, seq[byte])] {.gcsafe.} =
var entries: seq[(string, seq[byte])] = @[]
for (key, value) in db.scanAll():
if shardRouter.getShard(key) == shardId:
entries.add((key, value))
return entries
shardRouter.storeKeys = proc(shardId: int, entries: seq[(string, seq[byte])]) {.gcsafe.} =
for (key, value) in entries:
db.put(key, value)
shardRouter.deleteKeys = proc(keys: seq[string]) {.gcsafe.} =
for key in keys:
db.delete(key)
# Initialize gossip
let gossipPort = config.raftPort + 100
let gp = newGossipProtocol(localId, config.address, config.port, gossipPort = gossipPort)
# Wire gossip → cluster membership
gp.onJoin = proc(node: GossipNode) {.gcsafe.} =
cm.onNodeJoin(node.id, node.host, node.port)
gp.onLeave = proc(nodeId: string) {.gcsafe.} =
cm.onNodeLeave(nodeId)
gp.onSuspect = proc(nodeId: string) {.gcsafe.} =
cm.onNodeSuspect(nodeId)
# Initialize rate limiter
let rl = newRateLimiter(rlaTokenBucket, config.rateLimitGlobal, config.rateLimitPerClient)
result = Server(config: config, running: false, db: db, ctx: ctx,
registry: registry,
txnManager: ctx.txnManager, distTxnManager: newDistTxnManager(),
replicationManager: newReplicationManager(),
shardRouter: shardRouter,
clusterMembership: cm,
gossipProtocol: gp,
shardRouter: newShardRouter(),
clusterMembership: nil,
gossipProtocol: newGossipProtocol(localId, config.address, config.port, gossipPort = gossipPort),
tls: tls,
rateLimiter: rl)
result.clusterMembership = newClusterMembership(result.shardRouter, localId)
initLock(result.activeConnectionsLock)
# Wire shard migration callbacks to LSM (default database)
block:
let shardRouter {.cursor.} = result.shardRouter
let dbRef {.cursor.} = db
shardRouter.iterateKeys = proc(shardId: int): seq[(string, seq[byte])] {.gcsafe.} =
var entries: seq[(string, seq[byte])] = @[]
for (key, value) in dbRef.scanAll():
if shardRouter.getShard(key) == shardId:
entries.add((key, value))
return entries
shardRouter.storeKeys = proc(shardId: int, entries: seq[(string, seq[byte])]) {.gcsafe.} =
for (key, value) in entries:
dbRef.put(key, value)
shardRouter.deleteKeys = proc(keys: seq[string]) {.gcsafe.} =
for key in keys:
dbRef.delete(key)
# Wire gossip → cluster membership
block:
let gp {.cursor.} = result.gossipProtocol
let cm {.cursor.} = result.clusterMembership
gp.onJoin = proc(node: GossipNode) {.gcsafe.} =
cm.onNodeJoin(node.id, node.host, node.port)
gp.onLeave = proc(nodeId: string) {.gcsafe.} =
cm.onNodeLeave(nodeId)
gp.onSuspect = proc(nodeId: string) {.gcsafe.} =
cm.onNodeSuspect(nodeId)
proc newServerWithDb*(config: BaraConfig, db: LSMTree): Server =
let registry = newDatabaseRegistry(config)
let ctx = newExecutionContext(db, registry)
@@ -198,8 +207,173 @@ proc valueToWire(val: string, colType: string): WireValue =
return WireValue(kind: fkJson, jsonVal: val)
return WireValue(kind: fkString, strVal: val)
proc forwardRecvExact(sock: AsyncSocket, size: int): Future[string] {.async.} =
var buf = ""
while buf.len < size:
let chunk = await sock.recv(size - buf.len)
if chunk.len == 0: break
buf.add(chunk)
return buf
proc forwardQueryToLeader*(host: string, port: int, query: string,
tls: TLSContext = nil,
params: seq[WireValue] = @[],
timeoutMs: int = 5000): Future[(bool, QueryResult, string)] {.async.} =
## Proxy a write/DDL to the known leader's SQL port. Used by followers when
## BARADB_RAFT_CLIENT_PEERS maps leader id → host:clientPort.
## `tls` is the local server's client-port TLS context: when the wire port
## serves TLS, the leader's does too, so the forwarding dial must complete a
## client handshake. The context is reused as-is (verifyMode stays
## CVerifyNone — do NOT enable verifyPeer on the reused context); OpenSSL
## contexts are role-agnostic in Nim's stdlib, wrapConnectedSocket with
## handshakeAsClient sets the role.
var sock: AsyncSocket = nil
try:
sock = newAsyncSocket()
let okConn = await withTimeout(sock.connect(host, Port(port)), min(timeoutMs, 2000))
if not okConn:
return (false, QueryResult(), "leader forward connect timeout")
if tls != nil:
try:
tls.wrapClient(sock)
except CatchableError:
return (false, QueryResult(), "leader forward TLS handshake failed")
let reqId = 1'u32
let msg = if params.len > 0:
makeQueryParamsMessage(reqId, query, params)
else:
makeQueryMessage(reqId, query)
await sock.send(cast[string](msg))
var qr = QueryResult()
var gotComplete = false
while true:
let headerData = await forwardRecvExact(sock, 12)
if headerData.len < 12:
break
var hbytes = newSeq[byte](headerData.len)
for i, c in headerData: hbytes[i] = byte(c)
var pos = 0
let kind = MsgKind(readUint32(hbytes, pos))
let length = int(readUint32(hbytes, pos))
discard readUint32(hbytes, pos) # requestId
let payloadStr = if length > 0: await forwardRecvExact(sock, length) else: ""
if payloadStr.len < length:
break
var payload = newSeq[byte](payloadStr.len)
for i, c in payloadStr: payload[i] = byte(c)
case kind
of mkError:
var epos = 0
discard readUint32(payload, epos)
let emsg = readString(payload, epos)
return (false, QueryResult(), emsg)
of mkData:
var dpos = 0
let colCount = int(readUint32(payload, dpos))
qr.columns = @[]
for i in 0 ..< colCount:
qr.columns.add(readString(payload, dpos))
qr.columnTypes = @[]
for i in 0 ..< colCount:
qr.columnTypes.add(FieldKind(payload[dpos]))
inc dpos
let rowCount = int(readUint32(payload, dpos))
qr.rowCount = rowCount
qr.rows = @[]
for r in 0 ..< rowCount:
var row: seq[WireValue] = @[]
for c in 0 ..< colCount:
row.add(deserializeValue(payload, dpos))
qr.rows.add(row)
of mkComplete:
var cpos = 0
if payload.len >= 4:
qr.affectedRows = int(readUint32(payload, cpos))
gotComplete = true
break
else:
discard
if gotComplete:
return (true, qr, "")
return (false, QueryResult(), "leader forward incomplete response")
except CatchableError as e:
return (false, QueryResult(), "leader forward failed: " & e.msg)
finally:
if sock != nil:
try: sock.close() except CatchableError: discard
proc waitRaftCommit(node: RaftNode, lastIdx: uint64, timeoutMs: int): Future[(bool, string)] {.async.} =
let start = getMonoTime()
let deadline = start + initDuration(milliseconds = timeoutMs)
while node.commitIndex < lastIdx and getMonoTime() < deadline:
await sleepAsync(10)
let waitedMs = int64((getMonoTime() - start).inMilliseconds)
if node.commitIndex < lastIdx:
if node.metrics != nil:
inc node.metrics.commitTimeoutsTotal
return (false, "raft commit timeout")
if node.metrics != nil:
inc node.metrics.commitWaitsTotal
node.metrics.commitWaitMsTotal += waitedMs
return (true, "")
proc appendWriteToRaft*(node: RaftNode,
kvPairs: seq[tuple[key: string, value: seq[byte], deleted: bool]],
timeoutMs: int): Future[(bool, string)] {.async.} =
## C3b leader write path: append each written KV pair to the Raft log and
## wait for majority commit. The `deleted` flag encodes a delete — an empty
## value alone is a put (PK-only tables store an empty LSM value); the entry
## format matches applyCommand ("put": key \x00 value, "delete": key).
##
## MUST be called from the async event-loop thread that owns `node` and
## WITHOUT holding the storage gate: commitIndex advances via
## handleAppendReply on the same loop, and applyCommand re-enters the
## (non-reentrant) gate — waiting under the gate would deadlock the loop.
var lastIdx = 0'u64
for pair in kvPairs:
let entry = if pair.deleted:
node.appendLog("delete", cast[seq[byte]](pair.key))
else:
node.appendLog("put", cast[seq[byte]](pair.key & "\x00" & cast[string](pair.value)))
if entry.index == 0:
if node.metrics != nil:
inc node.metrics.lostLeadershipTotal
return (false, "lost leadership during raft append")
lastIdx = entry.index
return await waitRaftCommit(node, lastIdx, timeoutMs)
proc appendDdlToRaft*(node: RaftNode, sql: string,
timeoutMs: int): Future[(bool, string)] {.async.} =
## C3c schema path: append one "ddl" log entry with the original SQL text.
## Followers re-execute it via applyCommand (executor, no raft recursion).
## MUST be called outside the storage gate (same as appendWriteToRaft).
let entry = node.appendLog("ddl", cast[seq[byte]](sql))
if entry.index == 0:
if node.metrics != nil:
inc node.metrics.lostLeadershipTotal
return (false, "lost leadership during raft append")
return await waitRaftCommit(node, entry.index, timeoutMs)
proc executeQuery(db: LSMTree, ctx: ExecutionContext, query: string, params: seq[WireValue] = @[],
replication: ReplicationManager = nil): (bool, QueryResult, string) =
replication: ReplicationManager = nil,
raftNode: RaftNode = nil,
raftWriteTimeoutMs: int = 5000,
raftPeerClientAddrs: Table[string, tuple[host: string, port: int]] =
initTable[string, tuple[host: string, port: int]](),
forwardTls: TLSContext = nil): Future[(bool, QueryResult, string)] {.async.} =
## All storage access is under the global StorageGate so HTTP worker threads
## and the TCP event loop never touch ORC-managed LSM/executor state concurrently.
## The gate is released BEFORE the Raft commit wait — see appendWriteToRaft.
var ok = false
var qr = QueryResult()
var msg = ""
var kvPairs: seq[tuple[key: string, value: seq[byte], deleted: bool]] = @[]
var needsRaftDdl = false
var needsForward = false
var forwardHost = ""
var forwardPort = 0
withStorageGate:
try:
let tokens = tokenize(query)
let astNode = parse(tokens)
@@ -207,17 +381,47 @@ proc executeQuery(db: LSMTree, ctx: ExecutionContext, query: string, params: seq
if astNode.stmts.len == 0:
return (true, QueryResult(), "")
# C3b/C3c: DML + schema DDL go through the Raft log — only the leader
# of the default database may accept them. Inspect every statement so
# "SELECT 1; INSERT/CREATE ..." cannot bypass the gate.
var hasWrite = false
needsRaftDdl = false
for stmt in astNode.stmts:
if isWrite(stmt): hasWrite = true
if isRaftDdl(stmt): needsRaftDdl = true
if raftNode != nil and (hasWrite or needsRaftDdl):
let dbName = if ctx.currentDatabase.len > 0: ctx.currentDatabase else: "default"
if dbName != "default":
return (false, QueryResult(),
"raft writes only supported on the 'default' database; current is '" &
dbName & "'")
if raftNode.state != rsLeader:
let who = if raftNode.leaderId.len > 0: raftNode.leaderId else: "none elected"
# Transparent leader forwarding when client SQL addresses are known.
if who != "none elected" and who in raftPeerClientAddrs:
let peerAddr = raftPeerClientAddrs[who]
needsForward = true
forwardHost = peerAddr.host
forwardPort = peerAddr.port
else:
return (false, QueryResult(), "not leader; leader is '" & who & "'")
if not needsForward:
let res = executor.executeQuery(ctx, astNode, params)
if res.success:
# Ship written key-value pairs to replicas
if replication != nil and res.keyValuePairs.len > 0:
for (key, value) in res.keyValuePairs:
var data = newSeq[byte](key.len + 1 + value.len)
for i, c in key: data[i] = byte(c)
data[key.len] = byte(0)
for i, c in value: data[key.len + 1 + i] = c
# Ship written key-value pairs to replicas (legacy path; skipped when
# the raft path below handles the statement).
if raftNode == nil and replication != nil and res.keyValuePairs.len > 0:
for pair in res.keyValuePairs:
# Legacy REP wire format: key \x00 value, empty value = delete
# on the receiver. Deletes ship an empty value as before.
let value = if pair.deleted: @[] else: pair.value
var data = newSeq[byte](pair.key.len + 1 + value.len)
for i, c in pair.key: data[i] = byte(c)
data[pair.key.len] = byte(0)
for i, c in value: data[pair.key.len + 1 + i] = c
discard replication.writeLsn(data)
var qr = QueryResult(affectedRows: res.affectedRows, rowCount: res.rows.len)
qr = QueryResult(affectedRows: res.affectedRows, rowCount: res.rows.len)
qr.columns = res.columns
var colTypes: seq[string] = @[]
@@ -250,11 +454,35 @@ proc executeQuery(db: LSMTree, ctx: ExecutionContext, query: string, params: seq
let cType = if i < colTypes.len: colTypes[i] else: ""
wireRow.add(valueToWire(val, cType))
qr.rows.add(wireRow)
return (true, qr, res.message)
ok = true
msg = res.message
kvPairs = res.keyValuePairs
else:
return (false, QueryResult(), res.message)
except Exception as e:
return (false, QueryResult(), e.msg)
# Follower write/DDL: proxy to leader SQL port (outside the storage gate).
if needsForward:
let (okF, qrF, errF) = await forwardQueryToLeader(forwardHost, forwardPort,
query, forwardTls, params, raftWriteTimeoutMs)
if raftNode != nil and raftNode.metrics != nil:
if okF: inc raftNode.metrics.forwardsTotal
else: inc raftNode.metrics.forwardErrorsTotal
return (okF, qrF, errF)
# Raft log append + majority wait (outside the storage gate).
# DDL batches ship the original SQL once (re-executed on apply). Pure DML
# ships KV pairs. Mixed DDL+DML in one query uses the DDL path only so the
# whole batch is re-run in order on followers.
if ok and raftNode != nil:
if needsRaftDdl:
let (raftOk, raftErr) = await appendDdlToRaft(raftNode, query, raftWriteTimeoutMs)
if not raftOk:
return (false, QueryResult(), raftErr)
elif kvPairs.len > 0:
let (raftOk, raftErr) = await appendWriteToRaft(raftNode, kvPairs, raftWriteTimeoutMs)
if not raftOk:
return (false, QueryResult(), raftErr)
return (ok, qr, msg)
# ----------------------------------------------------------------------
# Response Serialization
@@ -369,6 +597,9 @@ proc handleClient(server: Server, client: AsyncSocket, clientId: int) {.async.}
# Detect text-based DISTTXN RPC (starts with "DISTTXN")
if headerData.len >= 7 and headerData[0..6] == "DISTTXN":
if not authenticated:
await client.send("ERR auth required\n")
continue
var rest = headerData[7..^1]
while '\n' notin rest:
let more = await client.recvWithTimeout(1024, idleTimeout)
@@ -376,7 +607,7 @@ proc handleClient(server: Server, client: AsyncSocket, clientId: int) {.async.}
rest.add(more)
let parts = rest.strip().split(" ")
if parts.len >= 2:
let txnId = try: uint64(parseBiggestUint(parts[0])) except: 0'u64
let txnId = try: uint64(parseBiggestUint(parts[0])) except CatchableError: 0'u64
let action = parts[1].toUpper()
if server.distTxnManager != nil:
let txn = server.distTxnManager.getTxn(txnId)
@@ -405,6 +636,9 @@ proc handleClient(server: Server, client: AsyncSocket, clientId: int) {.async.}
# Detect replication data (starts with "REP ")
if headerData.len >= 4 and headerData[0..3] == "REP ":
if not authenticated:
await client.send("ERR auth required\n")
continue
var rest = headerData[4..^1]
while '\n' notin rest:
let more = await client.recvWithTimeout(1024, idleTimeout)
@@ -412,8 +646,8 @@ proc handleClient(server: Server, client: AsyncSocket, clientId: int) {.async.}
rest.add(more)
let parts = rest.strip().split(" ")
if parts.len >= 2:
let lsn = try: parseUInt(parts[0]) except: 0'u64
let dataLen = try: parseInt(parts[1]) except: 0
let lsn = try: parseUInt(parts[0]) except CatchableError: 0'u64
let dataLen = try: parseInt(parts[1]) except CatchableError: 0
if dataLen > 0:
var data = ""
while data.len < dataLen:
@@ -444,7 +678,7 @@ proc handleClient(server: Server, client: AsyncSocket, clientId: int) {.async.}
let headerLine = "MIGRATE " & rest.strip()
let parts = rest.strip().split(" ")
if parts.len >= 2:
let entryCount = try: parseInt(parts[1]) except: 0
let entryCount = try: parseInt(parts[1]) except CatchableError: 0
var data = ""
if entryCount > 0:
# Read all entries (each entry is key\0value\n)
@@ -535,7 +769,7 @@ proc handleClient(server: Server, client: AsyncSocket, clientId: int) {.async.}
# Shard-aware routing: check if this node should handle the write
var shardCheck = true
if server.clusterMembership.nodes.len > 0:
let stmts = try: parse(tokenize(queryStr)) except: nil
let stmts = try: parse(tokenize(queryStr)) except CatchableError: nil
if stmts != nil:
for stmt in stmts.stmts:
if stmt.kind in {nkInsert, nkUpdate, nkDelete}:
@@ -550,7 +784,11 @@ proc handleClient(server: Server, client: AsyncSocket, clientId: int) {.async.}
if shardCheck:
let startTicks = getMonoTime().ticks()
let (success, result, errorMsg) = executeQuery(connCtx.db, connCtx, queryStr, replication=server.replicationManager)
let (success, result, errorMsg) = await executeQuery(connCtx.db, connCtx, queryStr,
replication=server.replicationManager, raftNode=server.raftNode,
raftWriteTimeoutMs=server.config.raftWriteTimeoutMs,
raftPeerClientAddrs=server.config.raftPeerClientAddrs,
forwardTls=server.tls)
let durationMs = int((getMonoTime().ticks() - startTicks) div 1_000_000)
if durationMs >= slowThreshold:
@@ -570,7 +808,11 @@ proc handleClient(server: Server, client: AsyncSocket, clientId: int) {.async.}
info("[" & $clientId & "] QueryParams: " & queryStr & " (" & $params.len & " params)")
let startTicks = getMonoTime().ticks()
let (success, result, errorMsg) = executeQuery(connCtx.db, connCtx, queryStr, params, replication=server.replicationManager)
let (success, result, errorMsg) = await executeQuery(connCtx.db, connCtx, queryStr, params,
replication=server.replicationManager, raftNode=server.raftNode,
raftWriteTimeoutMs=server.config.raftWriteTimeoutMs,
raftPeerClientAddrs=server.config.raftPeerClientAddrs,
forwardTls=server.tls)
let durationMs = int((getMonoTime().ticks() - startTicks) div 1_000_000)
if durationMs >= slowThreshold:
+11 -2
View File
@@ -5,6 +5,8 @@ import std/net
import std/strutils
import std/nativesockets
import std/tables
when defined(posix):
import std/posix
type
ShardStrategy* = enum
@@ -153,6 +155,13 @@ proc connectWithTimeout(sock: Socket, host: string, port: Port, timeoutMs: int):
var fds = @[sock.getFd]
if selectWrite(fds, timeoutMs) <= 0:
return false
when defined(posix):
# Verify connection actually succeeded via SO_ERROR
var err: cint = 0
var errLen = SockLen(sizeof(err))
discard posix.getsockopt(sock.getFd, 1'i32, 4'i32, addr err, addr errLen)
if err != 0:
return false
sock.getFd.setBlocking(true)
return true
@@ -328,8 +337,8 @@ proc handleMigrationMessage*(headerLine: string, data: string,
if parts.len < 3:
return "ERR invalid migrate header\n"
let shardId = try: parseInt(parts[1]) except: -1
let entryCount = try: parseInt(parts[2]) except: 0
let shardId = try: parseInt(parts[1]) except CatchableError: -1
let entryCount = try: parseInt(parts[2]) except CatchableError: 0
if shardId < 0 or entryCount < 0:
return "ERR invalid shard id or entry count\n"
+1 -1
View File
@@ -128,5 +128,5 @@ proc exportOtlp*(tracer: Tracer, endpoint: string = "http://localhost:4318/v1/tr
client.close()
tracer.spans = @[]
return true
except:
except CatchableError:
return false
+14 -3
View File
@@ -6,6 +6,8 @@ import std/tables
import std/base64
import std/sets
import std/nativesockets
import std/times
import std/json
when defined(windows):
from std/winlean import TCP_NODELAY
else:
@@ -105,6 +107,8 @@ proc decodeFrame(data: string): (WsFrame, int) =
if uint64(data.len) < uint64(offset) + len:
return (Wsframe(), 0)
if len > uint64(high(int) - 1):
return (Wsframe(), 0)
let plen = int(len)
if frame.masked:
for i in 0..<plen:
@@ -185,7 +189,7 @@ proc notifyClient(client: WsClient, msg: string) {.async.} =
try:
let frame = encodeFrame(0x1, msg)
await client.socket.send(frame)
except:
except CatchableError:
discard
proc broadcastToTable*(server: WsServer, table: string, msg: string) {.async.} =
@@ -243,7 +247,7 @@ proc handleWsClient(server: WsServer, client: AsyncSocket, id: int) {.async.} =
buf = buf[consumed..^1]
except:
except CatchableError:
discard
finally:
echo "WebSocket client ", id, " disconnected"
@@ -294,7 +298,14 @@ proc handleConnection(server: WsServer, client: AsyncSocket) {.async.} =
await client.send("HTTP/1.1 401 Unauthorized\r\n\r\n")
client.close()
return
except:
# Validate JWT expiration
if "exp" in token.claims:
let exp = token.claims["exp"].node.getInt()
if exp > 0 and epochTime().int64 > exp:
await client.send("HTTP/1.1 401 Unauthorized\r\n\r\n")
client.close()
return
except CatchableError:
await client.send("HTTP/1.1 401 Unauthorized\r\n\r\n")
client.close()
return
+23 -8
View File
@@ -185,14 +185,12 @@ proc bm25ScoreUnsafe(idx: InvertedIndex, term: string, docId: uint64,
return 0.0
var tf = 0
var found = false
for entry in idx.postings[term]:
if entry.docId == docId:
tf = entry.termFreq
found = true
break
if not found:
if tf == 0:
return 0.0
let idf = ln((float64(n) - float64(df) + 0.5) / (float64(df) + 0.5) + 1.0)
@@ -201,6 +199,17 @@ proc bm25ScoreUnsafe(idx: InvertedIndex, term: string, docId: uint64,
(float64(tf) + k1 * (1.0 - b + b * docLen / idx.avgDocLen))
return idf * tfNorm
# Optimized BM25 score when tf is already known (avoids linear scan)
proc bm25ScoreUnsafeTf(idx: InvertedIndex, term: string, docId: uint64,
tf: int, idf: float64,
k1: float64 = 1.2, b: float64 = 0.75): float64 =
if tf == 0 or idx.docCount == 0:
return 0.0
let docLen = float64(idx.docLengths.getOrDefault(docId, 0))
let tfNorm = (float64(tf) * (k1 + 1.0)) /
(float64(tf) + k1 * (1.0 - b + b * docLen / idx.avgDocLen))
return idf * tfNorm
proc bm25Score*(idx: InvertedIndex, term: string, docId: uint64,
k1: float64 = 1.2, b: float64 = 0.75): float64 =
acquire(idx.lock)
@@ -223,16 +232,22 @@ proc search*(idx: InvertedIndex, query: string, limit: int = 10,
for token in queryTokens:
if token notin idx.postings:
continue
for entry in idx.postings[token]:
let score = bm25ScoreUnsafe(idx, token, entry.docId)
let postings = idx.postings[token]
let df = postings.len
let n = idx.docCount
if df == 0 or n == 0:
continue
let idf = ln((float64(n) - float64(df) + 0.5) / (float64(df) + 0.5) + 1.0)
for entry in postings:
let score = bm25ScoreUnsafeTf(idx, token, entry.docId, entry.termFreq, idf)
if entry.docId notin docScores:
docScores[entry.docId] = 0.0
docHighlights[entry.docId] = @[]
docScores[entry.docId] += score
# Only add highlights if we have positions (skip for performance if empty)
if entry.positions.len > 0:
for pos in entry.positions:
let start = pos
let stop = pos + token.len
docHighlights[entry.docId].add((start, stop))
docHighlights[entry.docId].add((pos, pos + token.len))
var results: seq[SearchResult] = @[]
for docId, score in docScores:
+34
View File
@@ -111,6 +111,40 @@ proc addEdgeWithId*(g: Graph, src, dst: NodeId, label: string = "",
g.adjacency[src].add(AdjacencyEntry(edgeId: id, neighbor: dst, weight: weight, label: label))
g.reverseAdj[dst].add(AdjacencyEntry(edgeId: id, neighbor: src, weight: weight, label: label))
proc hasEdgeBetween*(g: Graph, src, dst: NodeId, label: string = ""): bool =
## True if an edge with the same endpoints and label already exists.
acquire(g.lock)
defer: release(g.lock)
for entry in g.adjacency.getOrDefault(src, @[]):
if entry.neighbor == dst and entry.label == label:
return true
return false
proc addEdgeWithIdIfAbsent*(g: Graph, src, dst: NodeId, label: string = "",
weight: float64 = 1.0) =
## Idempotent edge insert for raft apply / leader double-apply.
if hasEdgeBetween(g, src, dst, label):
return
addEdgeWithId(g, src, dst, label, weight)
proc removeEdgesBetween*(g: Graph, src, dst: NodeId, label: string = "") =
## Drop edges matching endpoints (and label if non-empty). Used by raft apply.
acquire(g.lock)
defer: release(g.lock)
if src notin g.adjacency: return
var keep: seq[AdjacencyEntry] = @[]
for entry in g.adjacency[src]:
if entry.neighbor == dst and (label.len == 0 or entry.label == label):
g.edges.del(entry.edgeId)
var newRev: seq[AdjacencyEntry] = @[]
for rev in g.reverseAdj.getOrDefault(dst, @[]):
if rev.edgeId != entry.edgeId:
newRev.add(rev)
g.reverseAdj[dst] = newRev
else:
keep.add(entry)
g.adjacency[src] = keep
proc getNode*(g: Graph, id: NodeId): GraphNode =
acquire(g.lock)
defer: release(g.lock)
+16 -5
View File
@@ -100,11 +100,12 @@ proc hmacSha256(key, message: string): string =
return $outerHash
proc constantTimeCompare(a, b: string): bool =
if a.len != b.len:
return false
var diff = 0
for i in 0..<a.len:
diff = diff or (ord(a[i]) xor ord(b[i]))
let n = max(a.len, b.len)
var diff = a.len xor b.len
for i in 0..<n:
let ca = if i < a.len: ord(a[i]) else: 0
let cb = if i < b.len: ord(b[i]) else: 0
diff = diff or (ca xor cb)
return diff == 0
# ---------------------------------------------------------------------------
@@ -159,6 +160,16 @@ proc verifyToken*(am: AuthManager, token: string): (bool, JWTClaims) =
if i < payload.len and payload[i] == '"':
inc i
while i < payload.len and payload[i] != '"':
if payload[i] == '\\' and i + 1 < payload.len:
case payload[i+1]
of '"': val &= '"'
of '\\': val &= '\\'
of '/': val &= '/'
of 'n': val &= '\n'
of 't': val &= '\t'
else: val &= payload[i+1]
inc i; inc i
else:
val &= payload[i]
inc i
inc i
+2 -1
View File
@@ -65,7 +65,8 @@ proc acquire*(pool: ConnectionPool): PoolConnection =
let conn = pool.connections[idx]
if not conn.inUse:
let age = getMonoTime().ticks() - conn.lastUsed
if age < pool.config.maxIdleTime:
let lifetime = getMonoTime().ticks() - conn.created
if age < pool.config.maxIdleTime and lifetime < pool.config.maxLifetime:
conn.inUse = true
inc pool.inUseCount
release(pool.lock)
+3 -3
View File
@@ -248,10 +248,10 @@ proc verifyClientProof*(state: ScramServerState, clientProof: openArray[byte]):
return false
let clientKey = xorBytes(clientProof, @(hmacSha256(state.storedKey, state.authMessage)))
let computedStoredKey = sha256(clientKey)
var diff = 0'u8
for i in 0..<32:
if computedStoredKey[i] != state.storedKey[i]:
return false
return true
diff = diff or (computedStoredKey[i] xor state.storedKey[i])
return diff == 0
proc computeServerSignature*(state: ScramServerState): array[32, byte] =
return hmacSha256(state.serverKey, state.authMessage)
+10 -1
View File
@@ -29,9 +29,14 @@ proc newTLSConfig*(certFile: string, keyFile: string, caFile: string = "",
proc newTLSContext*(config: TLSConfig): TLSContext =
result = TLSContext(config: config)
if fileExists(config.certFile) and fileExists(config.keyFile):
# caFile is only honored by newContext when verifyPeer is true
# (verifyMode != CVerifyNone); a missing CA file then raises IOError,
# which is the desired fail-closed behavior.
result.sslCtx = newContext(
certFile = config.certFile,
keyFile = config.keyFile,
verifyMode = if config.verifyPeer: CVerifyPeer else: CVerifyNone,
caFile = config.caFile,
)
else:
raise newException(IOError, "TLS certificate or key file not found: " &
@@ -39,7 +44,11 @@ proc newTLSContext*(config: TLSConfig): TLSContext =
proc wrapClient*(tls: TLSContext, socket: AsyncSocket) {.inline.} =
if tls.sslCtx != nil:
tls.sslCtx.wrapSocket(socket)
# wrapConnectedSocket (asyncnet overload) sets connect state; the
# handshake itself is driven lazily by the first send/recv. Plain
# wrapSocket leaves the SSL handle in SSL_ST_BEFORE and the first
# SSL_write fails with "uninitialized".
tls.sslCtx.wrapConnectedSocket(socket, handshakeAsClient)
proc wrapServer*(tls: TLSContext, socket: AsyncSocket) {.inline.} =
if tls.sslCtx != nil:
+2 -2
View File
@@ -167,14 +167,14 @@ proc encodeRecord*(buf: var ZeroBuf, schema: ZcSchema,
try:
var v = int32(parseInt(value))
bigEndian32(addr buf.data[field.offset], unsafeAddr v)
except:
except CatchableError:
var v: int32 = 0
bigEndian32(addr buf.data[field.offset], unsafeAddr v)
of ztInt64:
try:
var v = int64(parseInt(value))
bigEndian64(addr buf.data[field.offset], unsafeAddr v)
except:
except CatchableError:
var v: int64 = 0
bigEndian64(addr buf.data[field.offset], unsafeAddr v)
of ztString:
+7 -3
View File
@@ -95,7 +95,14 @@ proc endExecution*(planner: AdaptivePlanner, plan: var QueryPlan) =
plan.stats.wallTime = getMonoTime().ticks() - plan.stats.wallTime
plan.actualCost = float64(plan.stats.wallTime) / 1_000_000_000.0
const maxPlanCacheSize = 10000
proc evictCache*(planner: AdaptivePlanner) =
planner.planCache.clear()
proc cachePlan*(planner: AdaptivePlanner, query: string, plan: QueryPlan) =
if planner.planCache.len >= maxPlanCacheSize:
planner.evictCache()
let hash = hashQuery(query)
planner.planCache[hash] = plan
@@ -103,9 +110,6 @@ proc getCachedPlan*(planner: AdaptivePlanner, query: string): QueryPlan =
let hash = hashQuery(query)
return planner.planCache.getOrDefault(hash, nil)
proc evictCache*(planner: AdaptivePlanner) =
planner.planCache.clear()
proc cacheSize*(planner: AdaptivePlanner): int = planner.planCache.len
# Query execution contexts with parallelism hints
+1
View File
@@ -387,6 +387,7 @@ type
ciColumns*: seq[string]
ciExpr*: Node
ciKind*: IndexKind
ciUnique*: bool
of nkDropIndex:
diName*: string
of nkFrom:
+1
View File
@@ -1,5 +1,6 @@
## Codegen — compile IR plan to storage operations
import std/strutils
import ../core/types
import ../query/ir
type
+47
View File
@@ -0,0 +1,47 @@
# Executor package (`query/exec/`)
The original `executor.nim` was a ~5.8k-line god object. It is now split into
focused modules here (1,578 lines remain); `../executor.nim` keeps statement
dispatch (`executeQueryImpl`), DDL, and transactions, and **re-exports** this
package so existing `import barabadb/query/executor` keeps working.
## Modules
| Module | Responsibility |
|--------|----------------|
| `types.nim` | `ExecutionContext`, `TableDef`, `Row`, `ExecResult`, … |
| `values.nim` | Null/string conversion, row payload parse/escape, SQL escapes |
| `schema.nim` | Durable catalog (`_schema:tables:*`), restore, index rebuild |
| `context.nim` | Execution-context lifecycle, per-connection cloning, AST→SQL serializer for VIEW DDL |
| `helpers.nim` | Join strategy, vector parsing, correlated-table helpers |
| `params.nim` | Parameter binding — placeholder substitution, statement column metadata |
| `migrations.nim` | Migration storage — lock keys, applied/record keys, checksums (internal, not re-exported) |
| `eval.nim` | Expression evaluation (`evalExpr` and legacy variants), hybrid vector+FTS search |
| `lower.nim` | AST → IR lowering (`lowerExpr` / `lowerSelect`) |
| `rls.nim` | Row-Level Security — privilege checks and policy evaluation |
| `scan.nim` | Table scans — full scans and point reads against the LSM store |
| `dml.nim` | DML row operations — INSERT/UPDATE/DELETE row-level execution |
| `fk.nim` | Foreign-key enforcement — referential checks and cascade actions |
| `triggers.nim` | Trigger firing, `validateType`, `validateConstraints`, `applyDefaultValues` |
| `window.nim` | Window-function computation, star-row expansion |
| `plan_exec.nim` | IR plan walker (`executePlan`) — filters, projections, aggregates, joins, pivot/unpivot, graph traversal |
## Import rules
- **No cycles.** Bottom-up dependency order:
`types``values``schema``context`/`helpers`/`params`/`migrations`
`eval``lower``rls``scan``dml`/`fk``triggers``window`
`plan_exec``executor.nim`.
- `executor.nim` imports all modules and `export`s them (except `migrations`).
- Prefer adding new shared helpers under `exec/` instead of growing `executor.nim`.
## Recursion hooks
Nim forbids circular imports, but subqueries, hybrid search, NL→SQL, and
trigger bodies are genuine recursion points between modules: they must call
back into `executePlan`, `execScan`, or the private `executeQueryImpl`, all of
which live in (or above) `executor.nim`. Those back-edges go through proc-var
hooks, wired at module scope in `executor.nim`:
- `eval.executePlanHook`, `eval.execScanHook`, `eval.executeQueryHook` — in `eval.nim`
- `triggers.executeQueryHook` — in `triggers.nim`
+182
View File
@@ -0,0 +1,182 @@
## Execution context lifecycle — creation and per-connection cloning.
##
## Extracted from `executor.nim` (Task 1 of the executor split).
## Also hosts the AST-to-SQL serializer used for VIEW DDL persistence.
import std/strutils
import std/tables
import std/sets
import std/locks
import ../ast
import ../../storage/lsm
import ../../storage/btree
import ../../core/mvcc
import ../../core/registry
import ../../fts/engine as fts
import ../../vector/engine as vengine
import types
import schema
## Wired by executor.nim at module load. Breaks the context <-> executor
## module cycle: newExecutionContext cannot call executor code directly, so
## the engine-restore pass (FTS/HNSW/graph replay from persisted schema keys)
## is injected here and invoked nil-safely below.
var restoreEnginesHook*: proc(ctx: ExecutionContext)
# ----------------------------------------------------------------------
# Context management
# ----------------------------------------------------------------------
proc newExecutionContext*(db: LSMTree, registry: DatabaseRegistry = nil): ExecutionContext =
result = ExecutionContext(db: db, tables: initTable[string, TableDef](),
btrees: initTable[string, BTreeIndex[string, IndexEntry]](),
uniqueIndexes: initHashSet[string](),
views: initTable[string, Node](),
cteTables: initTable[string, seq[Row]](),
ftsIndexes: initTable[string, fts.InvertedIndex](),
vectorIndexes: initTable[string, vengine.HNSWIndex](),
users: initTable[string, UserDef](),
policies: initTable[string, seq[PolicyDef]](),
currentUser: "", currentRole: "",
sessionVars: initTable[string, string](),
autoIncCounters: initTable[string, int64](),
sequences: initTable[string, int64](),
txnManager: newTxnManager(),
onChange: nil,
currentDatabase: "default",
registry: registry)
result.sharedLock = SharedLock()
initLock(result.sharedLock.lock)
restoreSchema(result)
if restoreEnginesHook != nil: restoreEnginesHook(result)
# ----------------------------------------------------------------------
# AST to SQL serializer (for VIEW DDL persistence)
# ----------------------------------------------------------------------
proc exprToSql*(node: Node): string =
if node == nil:
return ""
case node.kind
of nkIntLit:
return $node.intVal
of nkFloatLit:
return $node.floatVal
of nkStringLit:
return "'" & node.strVal.replace("'", "''") & "'"
of nkBoolLit:
return if node.boolVal: "true" else: "false"
of nkNullLit:
return "null"
of nkIdent:
return "\"" & node.identName.replace("\"", "\"\"") & "\""
of nkStar:
return "*"
of nkBinOp:
let opStr = case node.binOp
of bkEq: "="
of bkNotEq: "!="
of bkLt: "<"
of bkLtEq: "<="
of bkGt: ">"
of bkGtEq: ">="
of bkAnd: " AND "
of bkOr: " OR "
of bkAdd: " + "
of bkSub: " - "
of bkMul: " * "
of bkDiv: " / "
else: " " & $node.binOp & " "
return exprToSql(node.binLeft) & opStr & exprToSql(node.binRight)
of nkFuncCall:
if node.funcArgs.len > 0:
return node.funcName & "(" & exprToSql(node.funcArgs[0]) & ")"
else:
return node.funcName & "()"
of nkUnaryOp:
return $node.unOp & " " & exprToSql(node.unOperand)
of nkPath:
return node.pathParts.join(".")
else:
return $node.kind
proc selectToSql*(node: Node): string =
if node == nil:
return ""
result = "SELECT "
# Column list
for i, e in node.selResult:
if i > 0: result.add(", ")
result.add(exprToSql(e))
if e.exprAlias.len > 0:
result.add(" AS " & e.exprAlias)
# FROM
if node.selFrom != nil and node.selFrom.kind == nkFrom and node.selFrom.fromTable.len > 0:
result.add(" FROM " & node.selFrom.fromTable)
if node.selFrom.fromAlias.len > 0:
result.add(" AS " & node.selFrom.fromAlias)
# JOINs
for j in node.selJoins:
if j.kind == nkJoin:
let jkStr = case j.joinKind
of jkInner: "INNER JOIN"
of jkLeft: "LEFT JOIN"
of jkRight: "RIGHT JOIN"
of jkFull: "FULL JOIN"
of jkCross: "CROSS JOIN"
if j.joinLateral:
result.add(" " & jkStr & " LATERAL (subquery)")
else:
result.add(" " & jkStr & " " & j.joinTarget.fromTable)
if j.joinAlias.len > 0:
result.add(" AS " & j.joinAlias)
if j.joinOn != nil:
result.add(" ON " & exprToSql(j.joinOn))
# WHERE
if node.selWhere != nil and node.selWhere.whereExpr != nil:
result.add(" WHERE " & exprToSql(node.selWhere.whereExpr))
# GROUP BY
if node.selGroupBy.len > 0:
result.add(" GROUP BY ")
for i, g in node.selGroupBy:
if i > 0: result.add(", ")
result.add(exprToSql(g))
# HAVING
if node.selHaving != nil and node.selHaving.havingExpr != nil:
result.add(" HAVING " & exprToSql(node.selHaving.havingExpr))
# ORDER BY
if node.selOrderBy.len > 0:
result.add(" ORDER BY ")
for i, o in node.selOrderBy:
if i > 0: result.add(", ")
result.add(exprToSql(o.orderByExpr))
if o.orderByDir == sdDesc:
result.add(" DESC")
# LIMIT / OFFSET
if node.selLimit != nil and node.selLimit.limitExpr.kind == nkIntLit:
result.add(" LIMIT " & $node.selLimit.limitExpr.intVal)
if node.selOffset != nil and node.selOffset.offsetExpr.kind == nkIntLit:
result.add(" OFFSET " & $node.selOffset.offsetExpr.intVal)
proc cloneForConnection*(ctx: ExecutionContext): ExecutionContext =
var svCopy = initTable[string, string]()
for k, v in ctx.sessionVars:
svCopy[k] = v
result = ExecutionContext(db: ctx.db, tables: ctx.tables,
btrees: ctx.btrees, views: ctx.views,
uniqueIndexes: ctx.uniqueIndexes,
cteTables: initTable[string, seq[Row]](),
ftsIndexes: ctx.ftsIndexes,
vectorIndexes: ctx.vectorIndexes,
graphs: ctx.graphs,
users: ctx.users, policies: ctx.policies,
txnManager: ctx.txnManager,
currentUser: ctx.currentUser, currentRole: ctx.currentRole,
sessionVars: svCopy,
autoIncCounters: ctx.autoIncCounters,
sequences: ctx.sequences,
pendingTxn: nil, onChange: ctx.onChange,
embedder: ctx.embedder,
llmClient: ctx.llmClient,
currentDatabase: ctx.currentDatabase,
registry: ctx.registry)
result.sharedLock = ctx.sharedLock
+498
View File
@@ -0,0 +1,498 @@
## DML row operations — INSERT/DELETE/UPDATE row-level execution.
##
## Extracted from `executor.nim` (Task 9 of the executor split).
import std/strutils
import std/tables
import std/sets
import std/sequtils
import ../../storage/lsm
import ../../storage/btree
import ../../core/types
import ../../core/mvcc
import ../../fts/engine as fts
import ../../vector/engine as vengine
import ../../graph/engine as gengine
import ../../ai/embed as embedmod
import types
import values
import helpers
import rls
# ----------------------------------------------------------------------
# Table storage
# ----------------------------------------------------------------------
proc violatesUniqueIndex*(ctx: ExecutionContext, table: string, fields: seq[string],
rowVals: seq[string], excludeLsmKey: string = ""): string =
## Returns the colKey of the first standalone UNIQUE index this row
## violates, or "" when the row is clean. idxVal is built with the exact
## convention of the CREATE INDEX population loop (getValue yields "\\N"
## for a missing column, values joined with "|"). excludeLsmKey lets UPDATE
## ignore the row's own existing entry.
if ctx.uniqueIndexes.len == 0: return ""
for colKey in ctx.uniqueIndexes:
if not colKey.startsWith(table & "."): continue
let idxCols = colKey[table.len + 1..^1].split(".")
var colVals: seq[string] = @[]
for c in idxCols:
colVals.add(getValue(rowVals, fields, c))
let idxVal = colVals.join("|")
if idxVal.len == 0 or isNull(idxVal): continue
if colKey notin ctx.btrees: continue
for entry in ctx.btrees[colKey].get(idxVal):
if entry.lsmKey != excludeLsmKey:
return colKey
return ""
proc execInsert*(ctx: ExecutionContext, table: string, fields: seq[string], values: seq[seq[string]],
kvPairs: var seq[tuple[key: string, value: seq[byte], deleted: bool]]): int =
if not hasPrivilege(ctx, table, "INSERT"):
return 0
let tblDef = if table in ctx.tables: ctx.tables[table] else: TableDef()
var count = 0
for rowVals in values:
var key = ""
var keyFound = false
var valParts: seq[string] = @[]
# Build composite PK key from all PK columns
if tblDef.pkColumns.len > 0:
var pkParts: seq[string] = @[]
for pkCol in tblDef.pkColumns:
let pkVal = getValue(rowVals, fields, pkCol)
pkParts.add(pkCol & "=" & escapeRowVal(pkVal))
key = pkParts.join(":")
keyFound = true
for i, f in fields:
if i < rowVals.len:
if not keyFound:
key = f & "=" & escapeRowVal(rowVals[i])
keyFound = true
elif tblDef.pkColumns.len == 0 or f.toLower() notin tblDef.pkColumns.mapIt(it.toLower()):
valParts.add(f & "=" & escapeRowVal(rowVals[i]))
elif f.len > 0:
if tblDef.pkColumns.len == 0 or f.toLower() notin tblDef.pkColumns.mapIt(it.toLower()):
valParts.add(f & "=")
let valStr = valParts.join(",")
let fullKey = table & "." & key
# Build row for RLS WITH CHECK
var row = initTable[string, Value]()
for i, f in fields:
if i < rowVals.len:
row[f] = rowVals[i]
if not checkInsertPolicy(ctx, table, row):
continue
if ctx.pendingTxn != nil and ctx.pendingTxn.state == tsActive:
discard ctx.txnManager.write(ctx.pendingTxn, fullKey, cast[seq[byte]](valStr))
else:
ctx.db.put(fullKey, cast[seq[byte]](valStr))
kvPairs.add((fullKey, cast[seq[byte]](valStr), false))
for colName in ctx.btrees.keys.toSeq():
if colName.startsWith(table & "."):
let colsPart = colName[table.len + 1..^1]
let idxCols = colsPart.split(".")
var colVals: seq[string] = @[]
for c in idxCols:
colVals.add(getValue(rowVals, fields, c))
let idxVal = colVals.join("|")
if idxVal.len > 0 and not isNull(idxVal):
ctx.btrees[colName].insert(idxVal, IndexEntry(lsmKey: fullKey, rowValue: valStr))
# Update FTS indexes
for ftsKey, ftsIdx in ctx.ftsIndexes:
if ftsKey.startsWith(table & "."):
let colName = ftsKey[table.len + 1..^1]
let text = getValue(rowVals, fields, colName)
if text.len > 0:
var docId: uint64 = 0
for ch in fullKey:
docId = docId * 31 + uint64(ord(ch))
ftsIdx.addDocument(docId, text)
# Update Vector indexes
for vecKey, vecIdx in ctx.vectorIndexes:
if vecKey.startsWith(table & "."):
let colName = vecKey[table.len + 1..^1]
let vecStr = getValue(rowVals, fields, colName)
let vec = parseVectorString(vecStr)
if vec.len > 0:
var docId: uint64 = 0
for ch in fullKey:
docId = docId * 31 + uint64(ord(ch))
var meta = initTable[string, string]()
meta["key"] = fullKey
for col, val in row:
if col.len > 0 and col != "$key" and col != "$value":
meta[col] = valueToString(val)
vengine.insert(vecIdx, docId, vec, meta)
# Auto-embed: if table has VECTOR column with null value but TEXT column
# with content, and embedder is configured, generate embedding
if ctx.embedder != nil and ctx.embedder.config.enabled:
for vecKey in ctx.vectorIndexes.keys:
if not vecKey.startsWith(table & "."): continue
let vecCol = vecKey[table.len + 1..^1]
let vecStr = getValue(rowVals, fields, vecCol)
if vecStr.len == 0 or vecStr == "null" or vecStr == "[]":
var sourceText = ""
for i, f in fields:
if i < rowVals.len and (f == "text" or f == "content" or f == "body"):
sourceText = rowVals[i]
break
if sourceText.len > 0:
let vec = embedmod.embed(ctx.embedder, sourceText)
if vec.len > 0:
let vecStr2 = "[" & vec.mapIt($it).join(",") & "]"
var updateKey = ""
var updateVals: seq[string] = @[]
for i, f in fields:
if i < rowVals.len:
if f == vecCol:
updateVals.add(f & "=" & escapeRowVal(vecStr2))
elif updateKey.len == 0:
updateKey = f & "=" & escapeRowVal(rowVals[i])
else:
updateVals.add(f & "=" & escapeRowVal(rowVals[i]))
elif f == vecCol:
updateVals.add(f & "=" & escapeRowVal(vecStr2))
if updateVals.len > 0:
let fullKey = table & "." & updateKey
let valStr = updateVals.join(",")
if ctx.pendingTxn != nil and ctx.pendingTxn.state == tsActive:
discard ctx.txnManager.write(ctx.pendingTxn, fullKey, cast[seq[byte]](valStr))
else:
ctx.db.put(fullKey, cast[seq[byte]](valStr))
var docId: uint64 = 0
for ch in fullKey:
docId = docId * 31 + uint64(ord(ch))
var meta = initTable[string, string]()
meta["key"] = fullKey
for col, val in row:
if col.len > 0 and col != "$key" and col != "$value":
meta[col] = valueToString(val)
meta[vecCol] = vecStr2
vengine.insert(ctx.vectorIndexes[vecKey], docId, vec, meta)
# Update Graph objects for graph node/edge tables
for graphName, graph in ctx.graphs:
if table == graphName & "_nodes":
var nodeIdStr = ""
for i, f in fields:
if f == "id" and i < rowVals.len:
nodeIdStr = rowVals[i]
break
if nodeIdStr.len > 0:
let nid = gengine.NodeId(parseUInt(nodeIdStr))
var label = ""
var props = initTable[string, string]()
for i, f in fields:
if i < rowVals.len:
if f == "node_label":
label = rowVals[i]
elif f != "id" and f != "properties":
props[f] = rowVals[i]
try:
gengine.addNodeWithId(graph, nid, label, props)
except CatchableError:
discard
elif table == graphName & "_edges":
var srcStr = ""
var dstStr = ""
var label = ""
var weight = 1.0
for i, f in fields:
if i < rowVals.len:
if f == "source_id": srcStr = rowVals[i]
elif f == "dest_id": dstStr = rowVals[i]
elif f == "edge_label": label = rowVals[i]
elif f == "weight":
try: weight = parseFloat(rowVals[i]) except CatchableError: discard
if srcStr.len > 0 and dstStr.len > 0:
let srcId = gengine.NodeId(parseUInt(srcStr))
let dstId = gengine.NodeId(parseUInt(dstStr))
try:
gengine.addEdgeWithId(graph, srcId, dstId, label, weight)
except CatchableError:
discard
inc count
return count
proc execDelete*(ctx: ExecutionContext, table: string, key: string,
kvPairs: var seq[tuple[key: string, value: seq[byte], deleted: bool]]): int =
if not hasPrivilege(ctx, table, "DELETE"):
return 0
let fullKey = table & "." & key
let (found, existingVal) = ctx.db.get(fullKey)
if found:
# RLS USING check on existing row
var oldRow = parseRowDataToValueRow(cast[string](existingVal))
let eqPos = key.find('=')
if eqPos >= 0:
oldRow[key[0..<eqPos]] = key[eqPos+1..^1]
if not passesPolicy(ctx, table, "DELETE", oldRow):
return 0
if ctx.pendingTxn != nil and ctx.pendingTxn.state == tsActive:
discard ctx.txnManager.delete(ctx.pendingTxn, fullKey)
else:
ctx.db.delete(fullKey)
kvPairs.add((fullKey, @[], true))
# Update BTree indexes
for colName in ctx.btrees.keys.toSeq():
if colName.startsWith(table & "."):
let colsPart = colName[table.len + 1..^1]
let idxCols = colsPart.split(".")
var oldVals: seq[string] = @[]
for c in idxCols:
if c in oldRow:
oldVals.add(valueToString(oldRow[c]))
else:
oldVals.add("\\N")
let oldIdxVal = oldVals.join("|")
if oldIdxVal.len > 0 and not isNull(oldIdxVal):
ctx.btrees[colName].remove(oldIdxVal, IndexEntry(lsmKey: fullKey, rowValue: cast[string](existingVal)))
# Update FTS indexes
for ftsKey, ftsIdx in ctx.ftsIndexes:
if ftsKey.startsWith(table & "."):
var docId: uint64 = 0
for ch in fullKey:
docId = docId * 31 + uint64(ord(ch))
ftsIdx.removeDocument(docId)
return 1
return 0
proc execUpdateRow*(ctx: ExecutionContext, table: string, key: string, sets: Table[string, string],
kvPairs: var seq[tuple[key: string, value: seq[byte], deleted: bool]]): int =
if not hasPrivilege(ctx, table, "UPDATE"):
return 0
let fullKey = table & "." & key
let (found, existing) = ctx.db.get(fullKey)
if not found: return 0
var oldRow = parseRowDataToValueRow(cast[string](existing))
let eqPos = key.find('=')
if eqPos >= 0:
oldRow[key[0..<eqPos]] = key[eqPos+1..^1]
# RLS USING check on old row
if not passesPolicy(ctx, table, "UPDATE", oldRow):
return 0
var parsed = parseRowDataToValueRow(cast[string](existing))
for col, val in sets:
parsed[col] = val
# RLS WITH CHECK on new row
if not checkInsertPolicy(ctx, table, parsed):
return 0
var parts: seq[string] = @[]
for col, val in parsed:
parts.add(col & "=" & escapeRowVal(valueToString(val)))
let newVal = parts.join(",")
# Update indexes: remove old, insert new
for colName in ctx.btrees.keys.toSeq():
if colName.startsWith(table & "."):
let colsPart = colName[table.len + 1..^1]
let idxCols = colsPart.split(".")
var oldVals: seq[string] = @[]
var newVals: seq[string] = @[]
for c in idxCols:
if c in oldRow:
oldVals.add(valueToString(oldRow[c]))
else:
oldVals.add("\\N")
if c in parsed:
newVals.add(valueToString(parsed[c]))
else:
newVals.add("\\N")
let oldIdxVal = oldVals.join("|")
if oldIdxVal.len > 0 and not isNull(oldIdxVal):
ctx.btrees[colName].remove(oldIdxVal, IndexEntry(lsmKey: fullKey, rowValue: cast[string](existing)))
let newIdxVal = newVals.join("|")
if newIdxVal.len > 0 and not isNull(newIdxVal):
ctx.btrees[colName].insert(newIdxVal, IndexEntry(lsmKey: fullKey, rowValue: newVal))
if ctx.pendingTxn != nil and ctx.pendingTxn.state == tsActive:
discard ctx.txnManager.write(ctx.pendingTxn, fullKey, cast[seq[byte]](newVal))
else:
ctx.db.put(fullKey, cast[seq[byte]](newVal))
kvPairs.add((fullKey, cast[seq[byte]](newVal), false))
# Update FTS indexes: remove old doc, add new
for ftsKey, ftsIdx in ctx.ftsIndexes:
if ftsKey.startsWith(table & "."):
var docId: uint64 = 0
for ch in fullKey:
docId = docId * 31 + uint64(ord(ch))
ftsIdx.removeDocument(docId)
let colName = ftsKey[table.len + 1..^1]
let newText = if colName in parsed: parsed[colName] else: Value(kind: vkNull)
if newText.kind == vkString and newText.strVal.len > 0:
ftsIdx.addDocument(docId, newText.strVal)
# Update Vector indexes: add new vector (no remove support in current HNSW)
for vecKey, vecIdx in ctx.vectorIndexes:
if vecKey.startsWith(table & "."):
let colName = vecKey[table.len + 1..^1]
let vecStr = if colName in parsed: parsed[colName] else: Value(kind: vkNull)
if vecStr.kind == vkString and vecStr.strVal.len > 0:
let vec = parseVectorString(vecStr.strVal)
if vec.len > 0:
var docId: uint64 = 0
for ch in fullKey:
docId = docId * 31 + uint64(ord(ch))
var meta = initTable[string, string]()
meta["key"] = fullKey
vengine.insert(vecIdx, docId, vec, meta)
return 1
# ----------------------------------------------------------------------
# Raft / replication apply — keep secondary engines in sync with LSM
# ----------------------------------------------------------------------
proc docIdFromLsmKey(fullKey: string): uint64 =
result = 0
for ch in fullKey:
result = result * 31 + uint64(ord(ch))
proc injectPkFromKey(row: var Row, keyRest: string) =
## Decode `id=1` or `a=1:b=2` key tails into row columns (same as scan).
for part in keyRest.split(':'):
let eqPos = part.find('=')
if eqPos > 0:
row[part[0..<eqPos]] = part[eqPos+1..^1]
proc removeIndexesForRow(ctx: ExecutionContext, table: string, fullKey: string,
valStr: string) =
var oldRow = parseRowDataToValueRow(valStr)
let keyRest = if '.' in fullKey: fullKey[fullKey.find('.')+1..^1] else: ""
injectPkFromKey(oldRow, keyRest)
for colName in ctx.btrees.keys.toSeq():
if not colName.startsWith(table & "."): continue
let colsPart = colName[table.len + 1..^1]
let idxCols = colsPart.split(".")
var oldVals: seq[string] = @[]
for c in idxCols:
if c in oldRow: oldVals.add(valueToString(oldRow[c]))
else: oldVals.add("\\N")
let oldIdxVal = oldVals.join("|")
if oldIdxVal.len > 0 and not isNull(oldIdxVal):
ctx.btrees[colName].remove(oldIdxVal,
IndexEntry(lsmKey: fullKey, rowValue: valStr))
let docId = docIdFromLsmKey(fullKey)
for ftsKey, ftsIdx in ctx.ftsIndexes:
if ftsKey.startsWith(table & "."):
ftsIdx.removeDocument(docId)
# In-memory graphs: drop node/edge when the backing row is removed.
for graphName, graph in ctx.graphs:
if table == graphName & "_nodes":
if "id" in oldRow:
try:
let nid = gengine.NodeId(parseUInt(valueToString(oldRow["id"])))
gengine.removeNode(graph, nid)
except CatchableError:
discard
elif table == graphName & "_edges":
let srcStr = if "source_id" in oldRow: valueToString(oldRow["source_id"]) else: ""
let dstStr = if "dest_id" in oldRow: valueToString(oldRow["dest_id"]) else: ""
let label = if "edge_label" in oldRow: valueToString(oldRow["edge_label"]) else: ""
if srcStr.len > 0 and dstStr.len > 0:
try:
gengine.removeEdgesBetween(graph,
gengine.NodeId(parseUInt(srcStr)),
gengine.NodeId(parseUInt(dstStr)), label)
except CatchableError:
discard
proc insertIndexesForRow(ctx: ExecutionContext, table: string, fullKey: string,
valStr: string) =
var newRow = parseRowDataToValueRow(valStr)
let keyRest = if '.' in fullKey: fullKey[fullKey.find('.')+1..^1] else: ""
injectPkFromKey(newRow, keyRest)
for colName in ctx.btrees.keys.toSeq():
if not colName.startsWith(table & "."): continue
let colsPart = colName[table.len + 1..^1]
let idxCols = colsPart.split(".")
var colVals: seq[string] = @[]
for c in idxCols:
if c in newRow: colVals.add(valueToString(newRow[c]))
else: colVals.add("\\N")
let idxVal = colVals.join("|")
if idxVal.len > 0 and not isNull(idxVal):
ctx.btrees[colName].insert(idxVal,
IndexEntry(lsmKey: fullKey, rowValue: valStr))
let docId = docIdFromLsmKey(fullKey)
for ftsKey, ftsIdx in ctx.ftsIndexes:
if not ftsKey.startsWith(table & "."): continue
let colName = ftsKey[table.len + 1..^1]
if colName in newRow:
let text = valueToString(newRow[colName])
if text.len > 0:
ftsIdx.addDocument(docId, text)
for vecKey, vecIdx in ctx.vectorIndexes:
if not vecKey.startsWith(table & "."): continue
let colName = vecKey[table.len + 1..^1]
if colName notin newRow: continue
let vecStr = valueToString(newRow[colName])
let vec = parseVectorString(vecStr)
if vec.len > 0:
var meta = initTable[string, string]()
meta["key"] = fullKey
for col, val in newRow:
meta[col] = valueToString(val)
vengine.insert(vecIdx, docId, vec, meta)
# In-memory graphs: mirror insert/update of backing node/edge tables.
for graphName, graph in ctx.graphs:
if table == graphName & "_nodes":
if "id" notin newRow: continue
try:
let nid = gengine.NodeId(parseUInt(valueToString(newRow["id"])))
var label = if "node_label" in newRow: valueToString(newRow["node_label"]) else: ""
var props = initTable[string, string]()
for col, val in newRow:
if col notin ["id", "node_label", "properties"]:
props[col] = valueToString(val)
# remove+add so property updates replace the in-memory node
gengine.removeNode(graph, nid)
gengine.addNodeWithId(graph, nid, label, props)
except CatchableError:
discard
elif table == graphName & "_edges":
let srcStr = if "source_id" in newRow: valueToString(newRow["source_id"]) else: ""
let dstStr = if "dest_id" in newRow: valueToString(newRow["dest_id"]) else: ""
let label = if "edge_label" in newRow: valueToString(newRow["edge_label"]) else: ""
var weight = 1.0
if "weight" in newRow:
try: weight = parseFloat(valueToString(newRow["weight"]))
except CatchableError: discard
if srcStr.len > 0 and dstStr.len > 0:
try:
gengine.addEdgeWithIdIfAbsent(graph,
gengine.NodeId(parseUInt(srcStr)),
gengine.NodeId(parseUInt(dstStr)), label, weight)
except CatchableError:
discard
proc applyReplicatedPut*(ctx: ExecutionContext, fullKey: string, value: seq[byte]) =
## Apply a raft/replication put: LSM write + secondary B-tree/FTS/HNSW/graphs.
## Idempotent on the leader (local DML already applied the same engines).
let dot = fullKey.find('.')
let table = if dot > 0: fullKey[0..<dot] else: ""
let (found, existing) = ctx.db.get(fullKey)
if found and table.len > 0:
removeIndexesForRow(ctx, table, fullKey, cast[string](existing))
ctx.db.put(fullKey, value)
if table.len > 0 and value.len > 0:
insertIndexesForRow(ctx, table, fullKey, cast[string](value))
proc applyReplicatedDelete*(ctx: ExecutionContext, fullKey: string) =
## Apply a raft/replication delete: LSM delete + drop secondary index entries.
let dot = fullKey.find('.')
let table = if dot > 0: fullKey[0..<dot] else: ""
let (found, existing) = ctx.db.get(fullKey)
if found and table.len > 0:
removeIndexesForRow(ctx, table, fullKey, cast[string](existing))
ctx.db.delete(fullKey)
proc isBenignRaftReplayError*(msg: string): bool =
## Leader re-applies committed DDL/DML after local execution; followers may
## also see IF EXISTS / race re-applies. Treat common idempotent failures as OK.
let m = msg.toLower()
"already exists" in m or "does not exist" in m or
"duplicate" in m or "unique" in m or
"unknown table" in m or "no such table" in m
File diff suppressed because it is too large Load Diff
+100
View File
@@ -0,0 +1,100 @@
## Foreign-key enforcement — referential checks and cascade actions.
##
## Extracted from `executor.nim` (Task 10 of the executor split).
import std/strutils
import std/tables
import ../../storage/lsm
import types
import values
import scan
import dml
# ----------------------------------------------------------------------
# Foreign Key Enforcement
# ----------------------------------------------------------------------
proc findReferencingRows*(ctx: ExecutionContext, childTable: string, fkCol: string, fkValue: string): seq[Row] =
result = @[]
for row in execScan(ctx, childTable):
if fkCol in row and valueToString(row[fkCol]) == fkValue:
result.add(row)
proc enforceFkOnDelete*(ctx: ExecutionContext, parentTable: string, parentCol: string, parentVal: string): (bool, string) =
for childTblName, childTbl in ctx.tables:
for col in childTbl.columns:
if col.fkTable == parentTable and col.fkColumn == parentCol:
let action = if col.fkOnDelete.len > 0: col.fkOnDelete else: "RESTRICT"
let refs = findReferencingRows(ctx, childTblName, col.name, parentVal)
if refs.len > 0:
case action
of "CASCADE":
for refRow in refs:
if "$key" in refRow:
var dummy: seq[tuple[key: string, value: seq[byte], deleted: bool]] = @[]
discard execDelete(ctx, childTblName, valueToString(refRow["$key"]), dummy)
of "SET NULL":
for refRow in refs:
if "$key" in refRow:
var sets = initTable[string, string]()
sets[col.name] = "\\N"
var dummy: seq[tuple[key: string, value: seq[byte], deleted: bool]] = @[]
discard execUpdateRow(ctx, childTblName, valueToString(refRow["$key"]), sets, dummy)
of "RESTRICT", "NO ACTION":
return (false, "FOREIGN KEY violation: row is referenced by " & childTblName & "." & col.name)
return (true, "")
proc enforceFkOnUpdate*(ctx: ExecutionContext, parentTable: string, parentCol: string, oldVal: string, newVal: string): (bool, string) =
for childTblName, childTbl in ctx.tables:
for col in childTbl.columns:
if col.fkTable == parentTable and col.fkColumn == parentCol:
let action = if col.fkOnUpdate.len > 0: col.fkOnUpdate else: "RESTRICT"
let refs = findReferencingRows(ctx, childTblName, col.name, oldVal)
if refs.len > 0:
case action
of "CASCADE":
for refRow in refs:
if "$key" in refRow:
var sets = initTable[string, string]()
sets[col.name] = newVal
var dummy: seq[tuple[key: string, value: seq[byte], deleted: bool]] = @[]
discard execUpdateRow(ctx, childTblName, valueToString(refRow["$key"]), sets, dummy)
of "SET NULL":
for refRow in refs:
if "$key" in refRow:
var sets = initTable[string, string]()
sets[col.name] = "\\N"
var dummy: seq[tuple[key: string, value: seq[byte], deleted: bool]] = @[]
discard execUpdateRow(ctx, childTblName, valueToString(refRow["$key"]), sets, dummy)
of "RESTRICT", "NO ACTION":
return (false, "FOREIGN KEY violation: row is referenced by " & childTblName & "." & col.name)
return (true, "")
proc enforceFkOnChildUpdate*(ctx: ExecutionContext, childTable: string, fkCol: string, newVal: string): (bool, string) =
let tbl = ctx.getTableDef(childTable)
var parentTable = ""
var parentCol = ""
for col in tbl.columns:
if col.name == fkCol:
parentTable = col.fkTable
parentCol = col.fkColumn
break
if parentTable.len == 0 or parentCol.len == 0:
return (true, "")
if isNull(newVal):
return (true, "")
let fkKey = parentTable & "." & parentCol & "=" & newVal
let (fkExists, _) = ctx.db.get(fkKey)
if fkExists:
return (true, "")
var found = false
let prefix = parentTable & "."
for entry in ctx.db.scanMemTable():
if entry.deleted: continue
if entry.key.startsWith(prefix):
let rest = entry.key[prefix.len..^1]
if rest.startsWith(parentCol & "=") and rest[parentCol.len+1..^1] == newVal:
found = true
break
if not found:
return (false, "FOREIGN KEY violation: '" & newVal & "' not found in " & parentTable & "." & parentCol)
return (true, "")
+155
View File
@@ -0,0 +1,155 @@
## Join strategy, vector parsing, and correlated-table helpers.
##
## Extracted from `executor.nim` (Task 2 of the executor split).
import std/strutils
import std/tables
import ../ir
import types
proc cmpMax*(a, b: string): bool =
var fa, fb: float
try:
fa = parseFloat(a)
fb = parseFloat(b)
result = fa > fb
except ValueError:
result = a > b
proc cmpMin*(a, b: string): bool =
var fa, fb: float
try:
fa = parseFloat(a)
fb = parseFloat(b)
result = fa < fb
except ValueError:
result = a < b
proc extractJoinEquality*(expr: IRExpr): (string, string) =
## Extract (leftCol, rightCol) from an equality join condition.
## Both operands must be simple field references.
if expr == nil or expr.kind != irekBinary or expr.binOp != irEq:
return ("", "")
if expr.binLeft.kind == irekField and expr.binRight.kind == irekField:
if expr.binLeft.fieldPath.len > 0 and expr.binRight.fieldPath.len > 0:
return (expr.binLeft.fieldPath[^1], expr.binRight.fieldPath[^1])
return ("", "")
proc chooseJoinStrategy*(ctx: ExecutionContext, plan: IRPlan) =
## Analyze join condition and pick the best execution strategy.
if plan == nil or plan.kind != irpkJoin:
return
if plan.joinCond == nil:
plan.joinStrategy = irjsNestedLoop
return
let (leftCol, rightCol) = extractJoinEquality(plan.joinCond)
if leftCol.len == 0 or rightCol.len == 0:
plan.joinStrategy = irjsNestedLoop
return
# Check if either side has a B-Tree index on the join column
proc isPkIndex(tableName, colName: string): bool =
if tableName in ctx.tables:
for col in ctx.tables[tableName].columns:
if col.name == colName and col.isPk:
return true
return false
var hasLeftIndex = false
var hasRightIndex = false
if plan.joinLeft != nil and plan.joinLeft.kind == irpkScan:
let idxName = plan.joinLeft.scanTable & "." & leftCol
if idxName in ctx.btrees and not isPkIndex(plan.joinLeft.scanTable, leftCol):
hasLeftIndex = true
if plan.joinRight != nil and plan.joinRight.kind == irpkScan:
let idxName = plan.joinRight.scanTable & "." & rightCol
if idxName in ctx.btrees and not isPkIndex(plan.joinRight.scanTable, rightCol):
hasRightIndex = true
if hasRightIndex:
plan.joinStrategy = irjsIndexNestedLoop
plan.joinHashCol = rightCol
elif hasLeftIndex:
plan.joinStrategy = irjsIndexNestedLoop
plan.joinHashCol = leftCol
else:
plan.joinStrategy = irjsHash
plan.joinHashCol = rightCol
proc parseVectorString*(value: string): seq[float32] =
## Parse a vector string like "[1.0, 2.0, 3.0]" into seq[float32]
result = @[]
var cleaned = value.strip()
if cleaned.len == 0: return result
if cleaned.startsWith("[") and cleaned.endsWith("]"):
cleaned = cleaned[1..^2]
elif cleaned.startsWith("(") and cleaned.endsWith(")"):
cleaned = cleaned[1..^2]
for part in cleaned.split(","):
let p = part.strip()
if p.len > 0:
try:
result.add(parseFloat(p).float32)
except CatchableError:
discard
# Collect correlated table names from IRExpr (qualified refs with 2+ parts)
proc collectCorrelatedTables(expr: IRExpr, outTables: var seq[string]) =
if expr == nil: return
case expr.kind
of irekField:
if expr.fieldPath.len >= 2:
let tbl = expr.fieldPath[0]
if tbl notin outTables: outTables.add(tbl)
of irekBinary:
collectCorrelatedTables(expr.binLeft, outTables)
collectCorrelatedTables(expr.binRight, outTables)
of irekUnary:
collectCorrelatedTables(expr.unExpr, outTables)
of irekFuncCall:
for a in expr.irFuncArgs: collectCorrelatedTables(a, outTables)
of irekCast:
collectCorrelatedTables(expr.irCastExpr, outTables)
of irekExists:
discard # nested exists — skip for simplicity
of irekAggregate:
for a in expr.aggArgs: collectCorrelatedTables(a, outTables)
of irekConditional:
collectCorrelatedTables(expr.cond, outTables)
collectCorrelatedTables(expr.thenExpr, outTables)
collectCorrelatedTables(expr.elseExpr, outTables)
of irekWindowFunc:
for a in expr.wfArgs: collectCorrelatedTables(a, outTables)
for a in expr.wfPartition: collectCorrelatedTables(a, outTables)
for a in expr.wfOrderBy: collectCorrelatedTables(a, outTables)
else: discard
# Walk plan to find correlated table names
proc collectCorrelatedTablesFromPlan*(plan: IRPlan, outTables: var seq[string]) =
if plan == nil: return
case plan.kind
of irpkScan: discard
of irpkFilter:
collectCorrelatedTables(plan.filterCond, outTables)
collectCorrelatedTablesFromPlan(plan.filterSource, outTables)
of irpkProject:
for e in plan.projectExprs: collectCorrelatedTables(e, outTables)
collectCorrelatedTablesFromPlan(plan.projectSource, outTables)
of irpkSort:
for e in plan.sortExprs: collectCorrelatedTables(e, outTables)
collectCorrelatedTablesFromPlan(plan.sortSource, outTables)
of irpkLimit:
collectCorrelatedTablesFromPlan(plan.limitSource, outTables)
of irpkGroupBy:
for e in plan.groupKeys: collectCorrelatedTables(e, outTables)
for e in plan.groupAggs:
for a in e.aggArgs: collectCorrelatedTables(a, outTables)
collectCorrelatedTablesFromPlan(plan.groupSource, outTables)
of irpkUnion:
collectCorrelatedTablesFromPlan(plan.unionLeft, outTables)
collectCorrelatedTablesFromPlan(plan.unionRight, outTables)
of irpkJoin:
collectCorrelatedTables(plan.joinCond, outTables)
collectCorrelatedTablesFromPlan(plan.joinLeft, outTables)
collectCorrelatedTablesFromPlan(plan.joinRight, outTables)
of irpkInsert, irpkUpdate, irpkDelete, irpkValues, irpkExplain,
irpkCTE, irpkWindow, irpkPivot, irpkUnpivot, irpkGraphTraversal,
irpkMerge, irpkCreateType:
discard
+416
View File
@@ -0,0 +1,416 @@
## AST → IR lowering (lowerExpr / lowerSelect) — extracted from
## `executor.nim` (Task 6 of the executor split).
import std/strutils
import std/tables
import ../ast
import ../ir
import ../../core/types
import values
import context
import eval
# ----------------------------------------------------------------------
# AST → IR Lowering
# ----------------------------------------------------------------------
proc lowerSelect*(node: Node): IRPlan
proc lowerExpr*(node: Node): IRExpr =
if node == nil: return nil
case node.kind
of nkIntLit:
result = IRExpr(kind: irekLiteral, valueKind: vkInt64)
result.literal = IRLiteral(kind: vkInt64, int64Val: node.intVal)
of nkFloatLit:
result = IRExpr(kind: irekLiteral, valueKind: vkFloat64)
result.literal = IRLiteral(kind: vkFloat64, float64Val: node.floatVal)
of nkStringLit:
result = IRExpr(kind: irekLiteral, valueKind: vkString)
result.literal = IRLiteral(kind: vkString, strVal: node.strVal)
of nkBoolLit:
result = IRExpr(kind: irekLiteral, valueKind: vkBool)
result.literal = IRLiteral(kind: vkBool, boolVal: node.boolVal)
of nkNullLit:
result = IRExpr(kind: irekLiteral, valueKind: vkNull)
result.literal = IRLiteral(kind: vkNull)
of nkCurrentUser:
result = IRExpr(kind: irekFuncCall)
result.irFunc = "current_user"
result.irFuncArgs = @[]
of nkCurrentRole:
result = IRExpr(kind: irekFuncCall)
result.irFunc = "current_role"
result.irFuncArgs = @[]
of nkIdent:
result = IRExpr(kind: irekField, valueKind: vkString)
result.fieldPath = @[node.identName]
of nkPath:
result = IRExpr(kind: irekField, valueKind: vkString)
result.fieldPath = node.pathParts
of nkJsonPath:
result = IRExpr(kind: irekJsonPath)
result.jpExpr = lowerExpr(node.jpLeft)
result.jpKey = node.jpKey
result.jpAsText = node.jpAsText
of nkBinOp:
result = IRExpr(kind: irekBinary)
result.valueKind = vkString
var irOp: IROperator
case node.binOp
of bkAdd: irOp = irAdd
of bkSub: irOp = irSub
of bkMul: irOp = irMul
of bkDiv: irOp = irDiv
of bkMod: irOp = irMod
of bkEq: irOp = irEq
of bkNotEq: irOp = irNeq
of bkLt: irOp = irLt
of bkLtEq: irOp = irLte
of bkGt: irOp = irGt
of bkGtEq: irOp = irGte
of bkAnd: irOp = irAnd
of bkOr: irOp = irOr
of bkFtsMatch: irOp = irFtsMatch
of bkDistance: irOp = irDistance
of bkJsonContains: irOp = irJsonContains
of bkJsonContainedBy: irOp = irJsonContainedBy
of bkJsonHasAny: irOp = irJsonHasAny
of bkJsonHasAll: irOp = irJsonHasAll
else: irOp = irEq
result.binOp = irOp
result.binLeft = lowerExpr(node.binLeft)
result.binRight = lowerExpr(node.binRight)
# Infer valueKind for arithmetic operators
case irOp
of irAdd, irSub, irMul:
if result.binLeft != nil and result.binRight != nil:
if result.binLeft.valueKind == vkFloat64 or result.binRight.valueKind == vkFloat64:
result.valueKind = vkFloat64
elif result.binLeft.valueKind == vkInt64 and result.binRight.valueKind == vkInt64:
result.valueKind = vkInt64
of irDiv:
result.valueKind = vkFloat64
of irMod:
result.valueKind = vkInt64
of irPow:
result.valueKind = vkFloat64
of irEq, irNeq, irLt, irLte, irGt, irGte, irAnd, irOr,
irIn, irNotIn, irLike, irILike, irBetween,
irIsNull, irIsNotNull, irFtsMatch:
result.valueKind = vkBool
else: discard
of nkUnaryOp:
result = IRExpr(kind: irekUnary, valueKind: vkString)
result.unOp = if node.unOp == ukNot: irNot else: irNeg
result.unExpr = lowerExpr(node.unOperand)
if node.unOp == ukNeg and result.unExpr != nil:
result.valueKind = result.unExpr.valueKind
of nkFuncCall:
case node.funcName.toLower()
of "count", "sum", "avg", "min", "max", "array_agg", "string_agg":
result = IRExpr(kind: irekAggregate)
case node.funcName.toLower()
of "count": result.aggOp = irCount; result.valueKind = vkInt64
of "sum": result.aggOp = irSum; result.valueKind = vkFloat64
of "avg": result.aggOp = irAvg; result.valueKind = vkFloat64
of "min": result.aggOp = irMin
of "max": result.aggOp = irMax
of "array_agg": result.aggOp = irArrayAgg
of "string_agg": result.aggOp = irStringAgg
else: discard
result.aggArgs = @[]
for arg in node.funcArgs: result.aggArgs.add(lowerExpr(arg))
if node.funcFilter != nil:
result.aggFilter = lowerExpr(node.funcFilter)
else:
result = IRExpr(kind: irekFuncCall, valueKind: vkString)
result.irFunc = node.funcName
result.irFuncArgs = @[]
for arg in node.funcArgs: result.irFuncArgs.add(lowerExpr(arg))
of nkIsExpr:
result = IRExpr(kind: irekUnary, valueKind: vkBool)
result.unOp = if node.isNegated: irIsNotNull else: irIsNull
result.unExpr = lowerExpr(node.isExpr)
of nkLikeExpr:
result = IRExpr(kind: irekBinary, valueKind: vkBool)
result.binOp = if node.likeCaseInsensitive: irILike else: irLike
result.binLeft = lowerExpr(node.likeExpr)
result.binRight = lowerExpr(node.likePattern)
if node.likeNegated:
let wrapped = result
result = IRExpr(kind: irekUnary, valueKind: vkBool, unOp: irNot, unExpr: wrapped)
of nkBetweenExpr:
if node.betweenNegated:
# NOT BETWEEN => NOT (expr >= low AND expr <= high)
result = IRExpr(kind: irekBinary, valueKind: vkBool)
result.binOp = irOr
let leftCmp = IRExpr(kind: irekBinary, valueKind: vkBool)
leftCmp.binOp = irLt
leftCmp.binLeft = lowerExpr(node.betweenExpr)
leftCmp.binRight = lowerExpr(node.betweenLow)
let rightCmp = IRExpr(kind: irekBinary, valueKind: vkBool)
rightCmp.binOp = irGt
rightCmp.binLeft = lowerExpr(node.betweenExpr)
rightCmp.binRight = lowerExpr(node.betweenHigh)
result.binLeft = leftCmp
result.binRight = rightCmp
else:
result = IRExpr(kind: irekBinary, valueKind: vkBool)
result.binOp = irAnd
let leftCmp = IRExpr(kind: irekBinary, valueKind: vkBool)
leftCmp.binOp = irGte
leftCmp.binLeft = lowerExpr(node.betweenExpr)
leftCmp.binRight = lowerExpr(node.betweenLow)
let rightCmp = IRExpr(kind: irekBinary, valueKind: vkBool)
rightCmp.binOp = irLte
rightCmp.binLeft = lowerExpr(node.betweenExpr)
rightCmp.binRight = lowerExpr(node.betweenHigh)
result.binLeft = leftCmp
result.binRight = rightCmp
of nkInExpr:
if node.inRight.kind == nkArrayLit:
if node.inNegated:
# NOT IN (list) => AND of != comparisons
result = IRExpr(kind: irekLiteral, literal: IRLiteral(kind: vkBool, boolVal: true))
for elem in node.inRight.arrayElems:
let neqCmp = IRExpr(kind: irekBinary)
neqCmp.binOp = irNeq
neqCmp.binLeft = lowerExpr(node.inLeft)
neqCmp.binRight = lowerExpr(elem)
let andNode = IRExpr(kind: irekBinary)
andNode.binOp = irAnd
andNode.binLeft = result
andNode.binRight = neqCmp
result = andNode
else:
result = IRExpr(kind: irekLiteral, literal: IRLiteral(kind: vkBool, boolVal: false))
for elem in node.inRight.arrayElems:
let eqCmp = IRExpr(kind: irekBinary)
eqCmp.binOp = irEq
eqCmp.binLeft = lowerExpr(node.inLeft)
eqCmp.binRight = lowerExpr(elem)
let orNode = IRExpr(kind: irekBinary)
orNode.binOp = irOr
orNode.binLeft = result
orNode.binRight = eqCmp
result = orNode
elif node.inRight.kind == nkSubquery:
result = IRExpr(kind: irekBinary)
result.binOp = if node.inNegated: irNotIn else: irIn
result.binLeft = lowerExpr(node.inLeft)
result.binRight = IRExpr(kind: irekSubquery)
result.binRight.subqueryPlan = lowerSelect(node.inRight.subQuery)
else:
result = IRExpr(kind: irekBinary)
result.binOp = if node.inNegated: irNeq else: irEq
result.binLeft = lowerExpr(node.inLeft)
result.binRight = lowerExpr(node.inRight)
of nkExists:
result = IRExpr(kind: irekExists)
result.existsSubquery = lowerSelect(node.existsExpr)
of nkSubquery:
result = IRExpr(kind: irekSubquery)
result.subqueryPlan = lowerSelect(node.subQuery)
of nkStar:
result = IRExpr(kind: irekStar)
of nkWindowExpr:
result = IRExpr(kind: irekWindowFunc)
result.wfName = node.winFunc
result.wfArgs = @[]
for arg in node.winArgs: result.wfArgs.add(lowerExpr(arg))
result.wfPartition = @[]
if node.winOver != nil:
for part in node.winOver.overPartition:
result.wfPartition.add(lowerExpr(part))
result.wfOrderBy = @[]
result.wfOrderDirs = @[]
for ob in node.winOver.overOrderBy:
result.wfOrderBy.add(lowerExpr(ob.orderByExpr))
result.wfOrderDirs.add(ob.orderByDir == sdDesc)
if node.winOver.overFrame != nil:
result.wfFrameMode = node.winOver.overFrame.frameMode
result.wfFrameStart = node.winOver.overFrame.frameStartType
result.wfFrameEnd = node.winOver.overFrame.frameEndType
else:
result.wfFrameMode = "ROWS"
result.wfFrameStart = "UNBOUNDED PRECEDING"
result.wfFrameEnd = "CURRENT ROW"
else:
result = IRExpr(kind: irekLiteral, literal: IRLiteral(kind: vkNull))
proc evalNodeToString*(node: Node): string =
## Evaluate a simple AST node to a string value for INSERT/UPDATE.
let ir = lowerExpr(node)
return valueToString(evalExpr(ir, initTable[string, Value](), nil))
proc lowerSelect*(node: Node): IRPlan =
result = IRPlan(kind: irpkScan)
if node.selFrom != nil:
if node.selFrom.kind == nkPivot:
# PIVOT: source PIVOT (agg(val) FOR col IN ('v1', 'v2'))
let pivotSrc = node.selFrom.pivotSource
var pivotSource: IRPlan
if pivotSrc.kind == nkFrom and pivotSrc.fromSubquery != nil:
pivotSource = lowerSelect(pivotSrc.fromSubquery)
elif pivotSrc.kind == nkFrom:
pivotSource = IRPlan(kind: irpkScan)
pivotSource.scanTable = pivotSrc.fromTable
pivotSource.scanAlias = pivotSrc.fromAlias
else:
pivotSource = lowerSelect(Node(kind: nkSelect, selFrom: pivotSrc,
selResult: @[Node(kind: nkStar)],
selJoins: @[], selGroupBy: @[],
line: node.line, col: node.col))
let pivotPlan = IRPlan(kind: irpkPivot)
pivotPlan.pivotSource = pivotSource
pivotPlan.pivotAgg = lowerExpr(node.selFrom.pivotAgg)
pivotPlan.pivotForCol = node.selFrom.pivotForCol
pivotPlan.pivotInValues = node.selFrom.pivotInValues
result = pivotPlan
elif node.selFrom.kind == nkUnpivot:
let unpivotSource = lowerSelect(Node(kind: nkSelect, selFrom: node.selFrom.unpivotSource,
selResult: @[Node(kind: nkStar)],
selJoins: @[], selGroupBy: @[],
line: node.line, col: node.col))
let unpivotPlan = IRPlan(kind: irpkUnpivot)
unpivotPlan.unpivotSource = unpivotSource
unpivotPlan.unpivotValueCol = node.selFrom.unpivotValueCol
unpivotPlan.unpivotForCol = node.selFrom.unpivotForCol
unpivotPlan.unpivotInCols = node.selFrom.unpivotInCols
result = unpivotPlan
elif node.selFrom.kind == nkGraphTraversal:
let graphPlan = IRPlan(kind: irpkGraphTraversal)
graphPlan.graphName = node.selFrom.gtGraphName
graphPlan.graphAlgo = node.selFrom.gtAlgo.toLowerAscii()
if node.selFrom.gtStart != nil:
if node.selFrom.gtStart.kind == nkIdent:
graphPlan.graphStartNode = node.selFrom.gtStart.identName
elif node.selFrom.gtStart.kind == nkIntLit:
graphPlan.graphStartNode = $node.selFrom.gtStart.intVal
if node.selFrom.gtEnd != nil:
if node.selFrom.gtEnd.kind == nkIdent:
graphPlan.graphEndNode = node.selFrom.gtEnd.identName
elif node.selFrom.gtEnd.kind == nkIntLit:
graphPlan.graphEndNode = $node.selFrom.gtEnd.intVal
graphPlan.graphEdgeLabel = node.selFrom.gtEdge
graphPlan.graphMaxDepth = node.selFrom.gtMaxDepth
graphPlan.graphReturnCols = node.selFrom.gtReturnCols
result = graphPlan
elif node.selFrom.fromTable.len > 0:
result.scanTable = node.selFrom.fromTable
result.scanAlias = node.selFrom.fromAlias
# Build JOIN chain
for joinNode in node.selJoins:
if joinNode.kind == nkJoin:
let joinPlan = IRPlan(kind: irpkJoin)
case joinNode.joinKind
of jkInner: joinPlan.joinKind = irjkInner
of jkLeft: joinPlan.joinKind = irjkLeft
of jkRight: joinPlan.joinKind = irjkRight
of jkFull: joinPlan.joinKind = irjkFull
of jkCross: joinPlan.joinKind = irjkCross
joinPlan.joinLateral = joinNode.joinLateral
joinPlan.joinLeft = result
if joinNode.joinLateral and joinNode.joinTarget != nil and joinNode.joinTarget.kind == nkSubquery:
# LATERAL: right side is a full subquery plan
joinPlan.joinRight = lowerSelect(joinNode.joinTarget.subQuery)
else:
joinPlan.joinRight = IRPlan(kind: irpkScan)
if joinNode.joinTarget != nil and joinNode.joinTarget.kind == nkFrom:
joinPlan.joinRight.scanTable = joinNode.joinTarget.fromTable
joinPlan.joinRight.scanAlias = joinNode.joinTarget.fromAlias
else:
joinPlan.joinRight.scanTable = ""
joinPlan.joinAlias = joinNode.joinAlias
if joinNode.joinOn != nil:
joinPlan.joinCond = lowerExpr(joinNode.joinOn)
result = joinPlan
if node.selWhere != nil and node.selWhere.whereExpr != nil:
let filterPlan = IRPlan(kind: irpkFilter)
filterPlan.filterSource = result
filterPlan.filterCond = lowerExpr(node.selWhere.whereExpr)
result = filterPlan
if node.selGroupBy.len > 0 or node.selGroupingSetsKind != gskNone:
let groupPlan = IRPlan(kind: irpkGroupBy)
groupPlan.groupSource = result
groupPlan.groupKeys = @[]
for g in node.selGroupBy: groupPlan.groupKeys.add(lowerExpr(g))
# Collect aggregate expressions from SELECT list
groupPlan.groupAggs = @[]
for e in node.selResult:
let lowered = lowerExpr(e)
if lowered.kind == irekAggregate:
groupPlan.groupAggs.add(lowered)
if node.selHaving != nil:
groupPlan.groupHaving = lowerExpr(node.selHaving.havingExpr)
# Handle grouping sets
case node.selGroupingSetsKind
of gskNone:
groupPlan.groupingSetsKind = irgskNone
of gskGroupingSets:
groupPlan.groupingSetsKind = irgskGroupingSets
groupPlan.groupingSets = @[]
for s in node.selGroupingSets:
var setExprs: seq[IRExpr] = @[]
for e in s: setExprs.add(lowerExpr(e))
groupPlan.groupingSets.add(setExprs)
of gskRollup:
groupPlan.groupingSetsKind = irgskRollup
of gskCube:
groupPlan.groupingSetsKind = irgskCube
result = groupPlan
if node.selOrderBy.len > 0:
let sortPlan = IRPlan(kind: irpkSort)
sortPlan.sortSource = result
sortPlan.sortExprs = @[]
sortPlan.sortDirs = @[]
for o in node.selOrderBy:
sortPlan.sortExprs.add(lowerExpr(o.orderByExpr))
sortPlan.sortDirs.add(o.orderByDir == sdAsc)
result = sortPlan
let projectPlan = IRPlan(kind: irpkProject)
projectPlan.projectSource = result
projectPlan.projectExprs = @[]
projectPlan.projectAliases = @[]
var seenAliases = initTable[string, int]()
for i, e in node.selResult:
projectPlan.projectExprs.add(lowerExpr(e))
var alias = ""
if e.exprAlias.len > 0:
alias = e.exprAlias
elif e.kind == nkIdent:
alias = e.identName
elif e.kind == nkPath and e.pathParts.len > 0:
alias = e.pathParts.join(".")
elif e.kind == nkFuncCall:
var aliasArgs: seq[string] = @[]
for arg in e.funcArgs:
aliasArgs.add(exprToSql(arg))
alias = e.funcName & "(" & aliasArgs.join(", ") & ")"
elif e.kind == nkStar:
alias = "*"
else:
alias = "col" & $i
# Deduplicate aliases
if alias in seenAliases:
seenAliases[alias] += 1
alias = alias & "_" & $seenAliases[alias]
else:
seenAliases[alias] = 0
projectPlan.projectAliases.add(alias)
result = projectPlan
if node.selLimit != nil or node.selOffset != nil:
let limitPlan = IRPlan(kind: irpkLimit)
limitPlan.limitSource = result
limitPlan.limitCount = if node.selLimit != nil and node.selLimit.limitExpr.kind == nkIntLit:
node.selLimit.limitExpr.intVal else: 0
limitPlan.limitOffset = if node.selOffset != nil and node.selOffset.offsetExpr.kind == nkIntLit:
node.selOffset.offsetExpr.intVal else: 0
result = limitPlan
+88
View File
@@ -0,0 +1,88 @@
## Migration storage helpers — lock acquisition, applied/record keys, checksums.
##
## Extracted from `executor.nim` (Task 4 of the executor split).
## Internal module: imported by executor.nim but NOT re-exported.
import std/strutils
import std/times
import std/algorithm
import checksums/sha2
import ../../storage/lsm
import types
import context
# ----------------------------------------------------------------------
# Migration Helpers
# ----------------------------------------------------------------------
proc migrationLockKey(): string = "_schema:migrations:_lock"
proc acquireMigrationLock*(ctx: ExecutionContext): bool =
let lockKey = migrationLockKey()
let (locked, lockVal) = ctx.db.get(lockKey)
if locked:
# Check for stale lock (older than 1 hour)
let lockTime = try: parseInt(cast[string](lockVal)) except CatchableError: 0
if lockTime > 0 and (epochTime().int64 - lockTime) > 3600:
# Stale lock — force release
ctx.db.delete(lockKey)
else:
return false
ctx.db.put(lockKey, cast[seq[byte]]($epochTime().int64))
return true
proc releaseMigrationLock*(ctx: ExecutionContext) =
ctx.db.delete(migrationLockKey())
proc migrationAppliedKey*(name: string): string = "_schema:migrations:applied:" & name
proc migrationRecordKey(name: string): string = "_schema:migrations:record:" & name
proc isMigrationApplied*(ctx: ExecutionContext, name: string): bool =
let (applied, _) = ctx.db.get(migrationAppliedKey(name))
return applied
proc getMigrationRecord*(ctx: ExecutionContext, name: string): MigrationRecord =
let (found, val) = ctx.db.get(migrationRecordKey(name))
if found:
let parts = cast[string](val).split("|")
if parts.len >= 5:
return MigrationRecord(
name: parts[0],
checksum: parts[1],
appliedAt: parseInt(parts[2]),
appliedBy: parts[3],
durationMs: parseInt(parts[4]),
rolledBack: if parts.len >= 6: parts[5] == "true" else: false
)
return MigrationRecord(name: name)
proc setMigrationRecord*(ctx: ExecutionContext, rec: MigrationRecord) =
let val = rec.name & "|" & rec.checksum & "|" & $rec.appliedAt & "|" &
rec.appliedBy & "|" & $rec.durationMs & "|" & (if rec.rolledBack: "true" else: "false")
ctx.db.put(migrationRecordKey(rec.name), cast[seq[byte]](val))
proc computeChecksum*(body: string): string =
let h = secureHash(Sha_256, body)
return $h
proc listMigrations*(ctx: ExecutionContext): seq[string] =
result = @[]
for entry in ctx.db.scanMemTable():
if entry.deleted: continue
if entry.key.startsWith("_schema:migration:") and not entry.key.contains(":applied:") and
not entry.key.contains(":record:") and not entry.key.contains(":_lock"):
let name = entry.key["_schema:migration:".len..^1]
result.add(name)
sort(result)
proc getMigrationBody*(ctx: ExecutionContext, name: string): (bool, string, string) =
let migKey = "_schema:migration:" & name
let (found, val) = ctx.db.get(migKey)
if found:
let ddl = cast[string](val)
let parts = ddl.split("|DOWN|", 1)
if parts.len == 2:
return (true, parts[0], parts[1])
else:
return (true, ddl, "")
return (false, "", "")

Some files were not shown because too many files have changed in this diff Show More