From e44341e47cd7708551d30d53504dd6684a6a3025 Mon Sep 17 00:00:00 2001 From: dimgigov Date: Sun, 2 Aug 2026 23:12:47 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20audit=20batch=202=20=E2=80=94=20semi-syn?= =?UTF-8?q?c,=20DISTINCT,=20set=20ops,=20MERGE,=20storage=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address 12 deep-audit findings: semi-sync fail-closed on partial ack, COUNT/SUM/AVG(DISTINCT), UNION/INTERSECT/EXCEPT dedup, MERGE THEN DELETE, WAL torn-record recovery, MVCC/checkpoint/flush/compaction/WAL rewrite safety, mmap overflow bounds; remove stray protocol/scram ELF. --- .gitignore | 1 + BUG_AUDIT_2026-08.md | 50 +++++++------- CHANGELOG.md | 17 ++++- PLAN.md | 6 +- src/barabadb/core/mvcc.nim | 6 +- src/barabadb/core/replication.nim | 14 +++- src/barabadb/protocol/scram | Bin 94104 -> 0 bytes src/barabadb/query/exec/lower.nim | 1 + src/barabadb/query/exec/plan_exec.nim | 86 +++++++++++++++++++---- src/barabadb/query/executor.nim | 81 ++++++++++++++++------ src/barabadb/storage/compaction.nim | 7 +- src/barabadb/storage/lsm.nim | 76 +++++++++++++------- src/barabadb/storage/mmap.nim | 12 ++-- src/barabadb/storage/recovery.nim | 4 ++ src/barabadb/storage/wal.nim | 9 ++- tests/bugfix_test.nim | 96 ++++++++++++++++++++++++++ tests/test_all.nim | 24 +++++-- 17 files changed, 384 insertions(+), 106 deletions(-) delete mode 100755 src/barabadb/protocol/scram diff --git a/.gitignore b/.gitignore index 6dac124..a9d115b 100644 --- a/.gitignore +++ b/.gitignore @@ -71,5 +71,6 @@ src/barabadb/storage/lsm src/barabadb/storage/wal src/barabadb/storage/btree src/barabadb/storage/gate +src/barabadb/protocol/scram clients/nim/tests/test_pool clients/nim/tests/test_wire diff --git a/BUG_AUDIT_2026-08.md b/BUG_AUDIT_2026-08.md index eadbf3e..3f321cb 100644 --- a/BUG_AUDIT_2026-08.md +++ b/BUG_AUDIT_2026-08.md @@ -3,7 +3,7 @@ > Дата: 2026-08-02 > Метод: 4 паралелни одит-агента по слоеве (Storage / Query / Core / Protocol), всеки чете всички файлове в обхвата си и проверява находките срещу реалния код. > Обхват: **само нови дефекти** — 80-те вече оправени в `BUGS.md` / `BUG_AUDIT.md` / `BARADB_CLIENT_BUGS.md` са изключени. -> **Общо: ~28 находки | Поправени (батч 1): 5 | Остават: 23** +> **Общо: ~28 находки | Поправени: 17 (батч 1: 5 + батч 2: 12, вкл. hygiene) | Остават: 12** --- @@ -17,54 +17,56 @@ | H4 | 🟠 HIGH | **`**` и `++` се lower-ваха към equality** — `bkPow`/`bkConcat` липсваха в op-mapping case-а и попадаха в `else: irEq` (`2 ** 3` → `false`, `'a' ++ 'b'` → `false`). | `query/exec/lower.nim:79` | `of bkPow: irOp = irPow`, `of bkConcat: irOp = irAdd`; 2 регресионни теста | | H5 | 🟠 HIGH | **`!=` не е отрицание на `=`** — `irNeq` short-circuit-ваше на string inequality, така че `5 != 5.0` → true, но `5 = 5.0` → true. | `query/exec/eval.nim:438` | `irNeq` numeric-first (точно допълнение на `irEq`); регресионен тест | -**Верификация:** `baradadb` build чист; `tests/test_all.nim` (пълен suite) и `tests/bugfix_test.nim` минават без `[FAILED]`. +## Поправени — батч 2 (12) + +| # | Severity | Проблем | Файл | Fix | +|---|----------|---------|------|-----| +| H3 | 🟠 HIGH | **Semi-sync partial/zero ack** — връщаше LSN дори при 0 acks | `core/replication.nim` | `return 0` когато connected replicas < `syncReplicaCount` acks; 0 connected → local-only (като sync) | +| H6 | 🟠 HIGH | **`COUNT/SUM/AVG(DISTINCT)` игнорира DISTINCT** | `query/exec/lower.nim`, `plan_exec.nim` | `aggDistinct = node.funcDistinct`; dedup с `HashSet` в agg пътищата | +| H7 | 🟠 HIGH | **`UNION/INTERSECT/EXCEPT` KeyError** | `query/executor.nim` | Dedup fingerprint от projected cols, не `row["$value"]` | +| H8 | 🟠 HIGH | **`MERGE … THEN DELETE` / matched condition no-op** | `query/executor.nim` | Honor `mergeMatchedDelete` + `mergeMatchedCondition` | +| H9 | 🟠 HIGH | **WAL recovery crash на torn record** | `storage/lsm.nim`, `wal.nim`, `recovery.nim` | Bound key/val ≤ 64 MB; validate kind преди enum cast | +| M1 | 🟡 MEDIUM | **MVCC `write` delete-during-iteration** | `core/mvcc.nim` | Collect-then-delete stale txn ids | +| M3 | 🟡 MEDIUM | **`checkpoint` lock leak** | `storage/lsm.nim` | try/finally около write lock + walLock | +| M4 | 🟡 MEDIUM | **`flushUnsafe` clear-before-write** | `storage/lsm.nim` | Clear memtable едва след успешен `writeSSTable` | +| M5 | 🟡 MEDIUM | **Compaction empty-key skip** | `storage/compaction.nim` | `haveLast` флаг вместо `lastKey = ""` sentinel | +| M6 | 🟡 MEDIUM | **`rewriteLive` remove-before-move** | `storage/wal.nim` | Само атомен `moveFile` (rename replace) | +| L3 | 🟢 LOW | **mmap `offset+size` overflow** | `storage/mmap.nim` | Overflow-safe: `offset > size - length` | +| — | hygiene | **Stray ELF `protocol/scram`** | `.gitignore` | Премахнат binary + ignore entry | + +**Верификация (батч 2):** `baradadb` build чист; `tests/bugfix_test.nim` (вкл. batch-2 suite) и `tests/test_all.nim` (501 OK) минават без `[FAILED]`. `tests/prop_test.nim` B-Tree suite OK (H10 *не* е в този батч — naive left-max fix чупи interleaved remove). --- -## Остават (23) +## Остават (12) -### 🟠 HIGH (8) +### 🟠 HIGH (2) | # | Проблем | Файл | Предложен fix | |---|---------|------|---------------| | H2 | **TLS client връзките между възли не верифицират сертификата** — `forwardQueryToLeader` ползва `verifyMode = CVerifyNone` → MITM на клъстър линка. Raft client dials са със същия default (`raftTlsVerifyPeer: false`). | `core/server.nim:70` | Verify peer cert срещу CA при client handshake (fail-closed при enabled TLS) | -| H3 | **Semi-sync репликация връща durable LSN при partial/zero ack** — `rmSync` връща 0 при partial ack, но `rmSemiSync` връща LSN безусловно (само debug echo при 0 acks). | `core/replication.nim:217` | `if syncReplicaCount > 0 and ackCount < syncReplicaCount: return 0` | -| H6 | **`COUNT/SUM/AVG(DISTINCT)` игнорира DISTINCT** — `funcDistinct` се set-ва в парсера (BUG-017), но `aggDistinct` никога не се копира/чете; няма dedup в aggregate пътищата. | `query/exec/lower.nim:116`, `plan_exec.nim` | Копирай `aggDistinct = node.funcDistinct`; dedup чрез `HashSet[string]` преди count/sum/avg | -| H7 | **`UNION/INTERSECT/EXCEPT` (без ALL) чупят с KeyError** — dedup ключът чете `row["$value"]`, но projected редове нямат този ключ. Само `UNION ALL` работи. | `query/executor.nim:459` | Dedup ключ от projected колоните (join `valueToString` по ред на `cols`), не `row["$value"]` | -| H8 | **`MERGE ... WHEN MATCHED THEN DELETE` / `AND ` не се изпълняват** — AST/parser полетата (BUG-032) съществуват, но executor-ът не ги реферира; DELETE е no-op, condition се игнорира. | `query/executor.nim:752` | В matched клона: провери `mergeMatchedCondition`, после honor-вай `mergeMatchedDelete` | -| H9 | **WAL recovery чупи процеса при torn record** — recovery parser-ът вярва на `keyLen`/`valLen` (до ~4 GiB alloc) и `kind` (out-of-range enum → `CaseStmtError` Defect, не се catch-ва). | `storage/lsm.nim:704` | Bound lengths + валидирай `kind` преди use; дългосрочно per-record CRC32 | -| H10 | **B-tree `remove` пише separator с грешна конвенция** — `splitChild` ползва left child max, `removeRec` пише right child min (`child.keys[0]`) → ключове стават ненамираеми при internal nodes (silent data loss). | `storage/btree.nim:377` | Separator = max ключ на left child, преизчислен след rebalance | +| H10 | **B-tree `remove` separator convention** — audit: `splitChild` left-max vs `removeRec` right-min. Naive left-max rewrite of separators/borrows **fails** `prop_test` interleaved insert/remove; needs careful multi-level fix + more targeted repro first. | `storage/btree.nim:377` | Repro + full-tree separator invariant; keep borrow/merge/search consistent | -### 🟡 MEDIUM (11) +### 🟡 MEDIUM (7) | # | Проблем | Файл | Предложен fix | |---|---------|------|---------------| -| M1 | **MVCC `write` трие от `activeTxns` по време на итерация** — `delete` proc-ът ползва collect-then-delete, но `write` трие inline (unsafe, пропуска timed-out txns). | `core/mvcc.nim:180` | Collect stale ids в seq, трий след loop-а | | M2 | **disttxn `connectWithTimeout` без SO_ERROR + uncaught RPC** — refused connect е "writable" → връща true; `sendDistTxnRpc` няма try/except → OSError wedge-ва 2PC състояние. (BUG-042 fix-нат в replication, не тук.) | `core/disttxn.nim:88` | `getsockopt(SO_ERROR)` + try/except около per-participant RPC | -| M3 | **`checkpoint` leaking write lock при exception** — `acquireWrite` без try/finally; IOError от flush/rotate пропуска `releaseWrite` → постоянен hang. | `storage/lsm.nim:932` | try/finally около lock-а (и walLock) | -| M4 | **`flushUnsafe` празни memtable преди SSTable write** — при IOError на `writeSSTable` данните са загубени от memory (остават само в WAL, невидими за live reads). | `storage/lsm.nim:870` | Първо `writeSSTable`, после clear на memtable | -| M5 | **Compaction пропуска empty-string ключа** — dedup sentinel `lastKey = ""` skip-ва ключ `""` → data loss при compact на празен ключ. | `storage/compaction.nim:130` | `haveLast` флаг вместо sentinel стойност | -| M6 | **`rewriteLive` data-loss window** — `removeFile(wal.path)` преди `moveFile`; crash между тях губи unflushed записи. `rename(2)` и без това е атомен replace. | `storage/wal.nim:329` | Махни `removeFile`, остави атомния `moveFile` | -| M7 | **Compaction unlink-ва input-ите преди output-ът да е loadable в каталога** — `applyCompactionResult` re-load-ва output в try/except (само warning); ако fail-не след unlink → загуба на ключове. | `storage/compaction.nim:150` | Load/verify output в каталога ПРЕДИ unlink на input-ите | +| M7 | **Compaction unlink-ва input-ите преди output-ът да е loadable в каталога** — verifySSTable вече е преди unlink; остава catalog re-load ordering в caller. | `storage/compaction.nim` / LSM apply | Load/verify output в каталога ПРЕДИ unlink на input-ите | | M8 | **`OFFSET n` без `LIMIT` връща 0 реда; negative `LIMIT` чупи** — `limitCount = 0` е sentinel и за "няма limit", и за "LIMIT 0"; `sourceRows[start.. region.size` с native int wrap-ва негативно при corrupt offset/size → OOB read. v3 SSTable-ите са CRC-защитени (reachable само през legacy v1/v2). | `storage/mmap.nim` | `offset > region.size - size` (без overflow) | | L4 | **NULL equality semantics** — `NULL = NULL` и `col = NULL` → true (string sentinel сравнение), не unknown/false. Системно за string-based value модела. | `query/exec/eval.nim:431` | Three-valued logic за NULL (по-голям рефакторинг) | -### Хигиена - -- **Stray 94 KB компилиран ELF binary** в `src/barabadb/protocol/scram` — случайно commit-нат в source tree-то; да се премахне (+ `.gitignore`). - --- ## Проверени и чисти (не са бъгове) diff --git a/CHANGELOG.md b/CHANGELOG.md index dafab0c..edfe6c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,13 +14,28 @@ All notable changes to BaraDB are documented in this file. - **Raft commit quorum (CRITICAL)** — commit now requires a strict majority (`N div 2 + 1`), matching the election check; the previous `(N+1) div 2` formula committed at a minority for even-sized clusters (`core/raft.nim`) - **`**` / `++` operators (HIGH)** — power and concat are no longer lowered to equality: `2 ** 3` → 8, `'a' ++ 'b'` → `'ab'` (`query/exec/lower.nim`) - **`!=` semantics (HIGH)** — `!=` is now the exact complement of `=` for numerically-equal values (`5 != 5.0` is false) (`query/exec/eval.nim`) +- **Semi-sync partial ack (HIGH)** — `writeLsn` in `rmSemiSync` returns `0` when connected replicas fail to meet `syncReplicaCount`; zero connected peers still succeed local-only (like sync) (`core/replication.nim`) +- **`COUNT/SUM/AVG(DISTINCT …)` (HIGH)** — `funcDistinct` is copied to `aggDistinct` and applied via `HashSet` dedup in aggregate paths (`query/exec/lower.nim`, `plan_exec.nim`) +- **`UNION` / `INTERSECT` / `EXCEPT` (HIGH)** — set-op dedup fingerprints projected columns instead of missing `row["$value"]` (KeyError crash) (`query/executor.nim`) +- **`MERGE … WHEN MATCHED THEN DELETE` (HIGH)** — executor honors `mergeMatchedDelete` and optional `mergeMatchedCondition` (`query/executor.nim`) +- **WAL recovery torn records (HIGH)** — recovery bounds key/value to 64 MB and rejects out-of-range entry kinds before enum cast (avoids multi-GiB alloc / `CaseStmtError` Defect) (`storage/lsm.nim`, `wal.nim`, `recovery.nim`) +- **MVCC `write` timeout cleanup** — stale active transactions are collected then deleted (no mutation during `activeTxns` iteration) (`core/mvcc.nim`) +- **`checkpoint` lock leak** — write lock and `walLock` released in `try/finally` (`storage/lsm.nim`) +- **`flushUnsafe` data-loss window** — memtable is cleared only after a successful SSTable write (`storage/lsm.nim`) +- **Compaction empty-string key** — dedup uses a `haveLast` flag so key `""` is not skipped (`storage/compaction.nim`) +- **`rewriteLive` crash window** — atomic `moveFile` replace only (no `removeFile` before rename) (`storage/wal.nim`) +- **mmap OOB on overflow** — length checks use `offset > size - length` instead of wrapping `offset + size` (`storage/mmap.nim`) - **REP replication put/delete encoding** — the legacy REP payload carries an explicit op tag so PK-only inserts (empty value) replicate as puts instead of vanishing as deletes (`core/replication.nim`, `core/server.nim`) - **REP receiver secondary indexes** — the legacy REP receiver applies via `applyReplicatedPut/Delete` under the storage gate, keeping B-tree/FTS/HNSW/graph indexes consistent on the replica (`core/server.nim`) - **Snapshot send stall (partial)** — the leader's snapshot send runs gzip off the event loop on a worker thread (`gzipFileAsync`), so heartbeats keep firing during compression; tar (send) and the restore path still run on the loop (`core/backup.nim`, `core/raft.nim`) +### Removed + +- Stray compiled ELF `src/barabadb/protocol/scram` from the source tree (added to `.gitignore`) + ### Added -- Deep audit report `BUG_AUDIT_2026-08.md` (~28 findings; 5 fixed in this batch, 23 tracked) +- Deep audit report `BUG_AUDIT_2026-08.md` (~28 findings; 17 fixed across batches 1–2, ~12 tracked) --- diff --git a/PLAN.md b/PLAN.md index bf5d4d4..a0a7ea6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -163,7 +163,9 @@ **Батч 1 — поправени (5):** MIGRATE auth bypass (CRITICAL), raft commit strict-majority за even-N (CRITICAL), pre-auth wire-length DoS (HIGH), `**`/`++` lowering към equality (HIGH), `!=` не е отрицание на `=` (HIGH). -**Остават (~23):** вж. `BUG_AUDIT_2026-08.md` — TLS peer verify, semi-sync partial-ack, COUNT(DISTINCT), UNION/INTERSECT/EXCEPT crash, MERGE THEN DELETE, WAL recovery crash, B-tree separator convention, MVCC delete-during-iteration, disttxn SO_ERROR, checkpoint lock leak, flushUnsafe data-loss, compaction empty-key, wal rewriteLive window, OFFSET-без-LIMIT, window агрегати, WebSocket (3), SCRAM (2), mmap overflow. +**Батч 2 — поправени (12):** semi-sync partial-ack (H3), COUNT/SUM/AVG(DISTINCT) (H6), UNION/INTERSECT/EXCEPT (H7), MERGE THEN DELETE (H8), WAL torn-record recovery (H9), MVCC write iteration (M1), checkpoint lock leak (M3), flushUnsafe order (M4), compaction empty-key (M5), rewriteLive atomic replace (M6), mmap overflow (L3), stray `protocol/scram` ELF. + +**Остават (~12):** вж. `BUG_AUDIT_2026-08.md` — TLS peer verify (H2), B-tree separator (H10, needs careful repro), disttxn SO_ERROR (M2), compaction catalog order (M7), OFFSET-без-LIMIT (M8), window агрегати (M9), WebSocket (M10–M12), SCRAM (L1–L2), NULL equality (L4). --- @@ -179,7 +181,7 @@ | **Този план** — Сесии 10, 11, 12 | ✅ Завършен | | Raft C3a/C3b + DDL/forward/compact/metrics (2026-07-30) | ✅ Завършен на `main` — `docs/superpowers/specs/2026-07-30-raft-cluster-status.md` | | **Production GA v1.2.0** (single-node) | ✅ `docs/superpowers/plans/2026-07-30-production-ga.md` | -| **Сесия 13** — Stabilization & Deep Audit (2026-08) | 🔄 В процес — батч 1 завършен (5 поправки); `BUG_AUDIT_2026-08.md` | +| **Сесия 13** — Stabilization & Deep Audit (2026-08) | 🔄 В процес — батч 1+2 (17 поправки); остават ~12; `BUG_AUDIT_2026-08.md` | --- diff --git a/src/barabadb/core/mvcc.nim b/src/barabadb/core/mvcc.nim index 839e830..a42f4db 100644 --- a/src/barabadb/core/mvcc.nim +++ b/src/barabadb/core/mvcc.nim @@ -178,12 +178,16 @@ proc write*(tm: TxnManager, txn: Transaction, key: string, value: seq[byte]): bo return false # Timeout-based deadlock detection: abort stale transactions + # Collect then delete — never mutate activeTxns while iterating it. let now = getMonoTime().ticks() + var staleIds: seq[TxnId] = @[] for otherId, otherTxn in tm.activeTxns: if otherId != txn.id and otherTxn.state == tsActive: if now - otherTxn.startTime > tm.txnTimeoutMs * 1_000_000: otherTxn.state = tsAborted - tm.activeTxns.del(otherId) + staleIds.add(otherId) + for id in staleIds: + tm.activeTxns.del(id) # Check for write-write conflict against other active transactions' write sets for otherId, otherTxn in tm.activeTxns: diff --git a/src/barabadb/core/replication.nim b/src/barabadb/core/replication.nim index cf813ca..45ab7c5 100644 --- a/src/barabadb/core/replication.nim +++ b/src/barabadb/core/replication.nim @@ -213,10 +213,18 @@ proc writeLsn*(rm: ReplicationManager, data: seq[byte]): uint64 = rm.pendingAcks[lsn].excl(id) if rm.pendingAcks[lsn].len == 0: rm.pendingAcks.del(lsn) + # Semi-sync requires at least syncReplicaCount acks when replicas are + # connected. With zero connected peers (nothing to ship) the write is + # local-only — same as sync mode with an empty replica set. + if rm.syncReplicaCount > 0 and replicasToShip.len > 0 and + ackCount < rm.syncReplicaCount: + # Drop the LSN from pendingAcks — write is not durable + rm.pendingAcks.del(lsn) + release(rm.lock) + echo "[ERROR] Semi-sync replication failed: only ", ackCount, "/", + rm.syncReplicaCount, " replicas acked for LSN ", lsn + return 0 release(rm.lock) - if replicasToShip.len > 0 and ackCount == 0 and rm.syncReplicaCount > 0: - when defined(debug): - echo "Replication semi-sync: no replicas acked for LSN ", lsn return lsn proc ackLsn*(rm: ReplicationManager, replicaId: string, lsn: uint64) = diff --git a/src/barabadb/protocol/scram b/src/barabadb/protocol/scram deleted file mode 100755 index cec9518155c449ab7d77a03c4aec4630836c99d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 94104 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 V9?bsS#Y&T}k!y9CUh4rj{|$^#R)+up diff --git a/src/barabadb/query/exec/lower.nim b/src/barabadb/query/exec/lower.nim index 735a2a0..c4be989 100644 --- a/src/barabadb/query/exec/lower.nim +++ b/src/barabadb/query/exec/lower.nim @@ -122,6 +122,7 @@ proc lowerExpr*(node: Node): IRExpr = else: discard result.aggArgs = @[] for arg in node.funcArgs: result.aggArgs.add(lowerExpr(arg)) + result.aggDistinct = node.funcDistinct if node.funcFilter != nil: result.aggFilter = lowerExpr(node.funcFilter) else: diff --git a/src/barabadb/query/exec/plan_exec.nim b/src/barabadb/query/exec/plan_exec.nim index ef103e0..fffb3db 100644 --- a/src/barabadb/query/exec/plan_exec.nim +++ b/src/barabadb/query/exec/plan_exec.nim @@ -5,6 +5,7 @@ ## executor split). Pure code motion — no behavior changes. import std/strutils import std/tables +import std/sets import std/sequtils import std/algorithm import ../ir @@ -19,6 +20,19 @@ import eval import scan import window +# ---------------------------------------------------------------------- +# Aggregate DISTINCT helpers +# ---------------------------------------------------------------------- + +proc shouldKeepDistinct(seen: var HashSet[string], s: string, doDistinct: bool): bool = + ## Returns true if `s` should be counted/included (first occurrence when distinct). + if not doDistinct: + return true + if s in seen: + return false + seen.incl(s) + return true + # ---------------------------------------------------------------------- # IR Plan Execution (with actual filter/sort/projection) # ---------------------------------------------------------------------- @@ -113,49 +127,71 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] = newRow[alias] = $filteredRows.len else: var count = 0 + var seen: HashSet[string] for row in filteredRows: let v = evalExpr(expr.aggArgs[0], row, ctx) - if v.kind != vkNull: count += 1 + if v.kind != vkNull: + let s = valueToString(v) + if shouldKeepDistinct(seen, s, expr.aggDistinct): + count += 1 newRow[alias] = $count of irSum: var sum = 0.0 + var seen: HashSet[string] for row in filteredRows: let v = evalExpr(expr.aggArgs[0], row, ctx) - try: sum += parseFloat(valueToString(v)) except CatchableError: discard + let s = valueToString(v) + if shouldKeepDistinct(seen, s, expr.aggDistinct): + try: sum += parseFloat(s) except CatchableError: discard newRow[alias] = $sum of irAvg: var sum = 0.0 var count = 0 + var seen: HashSet[string] for row in filteredRows: let v = evalExpr(expr.aggArgs[0], row, ctx) - try: sum += parseFloat(valueToString(v)); count += 1 except CatchableError: discard + let s = valueToString(v) + if shouldKeepDistinct(seen, s, expr.aggDistinct): + try: sum += parseFloat(s); count += 1 except CatchableError: discard newRow[alias] = if count > 0: $(sum / float(count)) else: "0" of irMin: var minVal = "" + var seen: HashSet[string] for row in filteredRows: let v = evalExpr(expr.aggArgs[0], row, ctx) if v.kind == vkNull: continue - if minVal == "" or cmpMin(valueToString(v), minVal): minVal = valueToString(v) + let s = valueToString(v) + if shouldKeepDistinct(seen, s, expr.aggDistinct): + if minVal == "" or cmpMin(s, minVal): minVal = s newRow[alias] = minVal of irMax: var maxVal = "" + var seen: HashSet[string] for row in filteredRows: let v = evalExpr(expr.aggArgs[0], row, ctx) if v.kind == vkNull: continue - if maxVal == "" or cmpMax(valueToString(v), maxVal): maxVal = valueToString(v) + let s = valueToString(v) + if shouldKeepDistinct(seen, s, expr.aggDistinct): + if maxVal == "" or cmpMax(s, maxVal): maxVal = s newRow[alias] = maxVal of irArrayAgg: var arr: seq[string] + var seen: HashSet[string] for row in filteredRows: if expr.aggArgs.len > 0: - arr.add(valueToString(evalExpr(expr.aggArgs[0], row, ctx))) + let s = valueToString(evalExpr(expr.aggArgs[0], row, ctx)) + if shouldKeepDistinct(seen, s, expr.aggDistinct): + arr.add(s) newRow[alias] = "[" & arr.join(", ") & "]" of irStringAgg: var parts: seq[string] + var seen: HashSet[string] let delim = if expr.aggArgs.len > 1: evalExpr(expr.aggArgs[1], initTable[string, Value](), ctx) else: Value(kind: vkString, strVal: ",") for row in filteredRows: if expr.aggArgs.len > 0: - parts.add(valueToString(evalExpr(expr.aggArgs[0], row, ctx))) + let s = valueToString(evalExpr(expr.aggArgs[0], row, ctx)) + if shouldKeepDistinct(seen, s, expr.aggDistinct): + parts.add(s) newRow[alias] = parts.join(valueToString(delim)) else: let val = evalExpr(expr, if sourceRows.len > 0: sourceRows[0] else: initTable[string, Value](), ctx) @@ -292,49 +328,71 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] = aggRow[aggKey] = $filteredRows.len else: var count = 0 + var seen: HashSet[string] for row in filteredRows: let v = evalExpr(aggExpr.aggArgs[0], row, ctx) - if v.kind != vkNull: count += 1 + if v.kind != vkNull: + let s = valueToString(v) + if shouldKeepDistinct(seen, s, aggExpr.aggDistinct): + count += 1 aggRow[aggKey] = $count of irSum: var sum = 0.0 + var seen: HashSet[string] for row in filteredRows: let v = evalExpr(aggExpr.aggArgs[0], row, ctx) - try: sum += parseFloat(valueToString(v)) except CatchableError: discard + let s = valueToString(v) + if shouldKeepDistinct(seen, s, aggExpr.aggDistinct): + try: sum += parseFloat(s) except CatchableError: discard aggRow[aggKey] = $sum of irAvg: var sum = 0.0 var count = 0 + var seen: HashSet[string] for row in filteredRows: let v = evalExpr(aggExpr.aggArgs[0], row, ctx) - try: sum += parseFloat(valueToString(v)); count += 1 except CatchableError: discard + let s = valueToString(v) + if shouldKeepDistinct(seen, s, aggExpr.aggDistinct): + try: sum += parseFloat(s); count += 1 except CatchableError: discard aggRow[aggKey] = if count > 0: $(sum / float(count)) else: "0" of irMin: var minVal = "" + var seen: HashSet[string] for row in filteredRows: let v = evalExpr(aggExpr.aggArgs[0], row, ctx) if v.kind == vkNull: continue - if minVal == "" or cmpMin(valueToString(v), minVal): minVal = valueToString(v) + let s = valueToString(v) + if shouldKeepDistinct(seen, s, aggExpr.aggDistinct): + if minVal == "" or cmpMin(s, minVal): minVal = s aggRow[aggKey] = minVal of irMax: var maxVal = "" + var seen: HashSet[string] for row in filteredRows: let v = evalExpr(aggExpr.aggArgs[0], row, ctx) if v.kind == vkNull: continue - if maxVal == "" or cmpMax(valueToString(v), maxVal): maxVal = valueToString(v) + let s = valueToString(v) + if shouldKeepDistinct(seen, s, aggExpr.aggDistinct): + if maxVal == "" or cmpMax(s, maxVal): maxVal = s aggRow[aggKey] = maxVal of irArrayAgg: var arr: seq[string] + var seen: HashSet[string] for row in filteredRows: if aggExpr.aggArgs.len > 0: - arr.add(valueToString(evalExpr(aggExpr.aggArgs[0], row, ctx))) + let s = valueToString(evalExpr(aggExpr.aggArgs[0], row, ctx)) + if shouldKeepDistinct(seen, s, aggExpr.aggDistinct): + arr.add(s) aggRow[aggKey] = "[" & arr.join(", ") & "]" of irStringAgg: var parts: seq[string] + var seen: HashSet[string] let delim = if aggExpr.aggArgs.len > 1: evalExpr(aggExpr.aggArgs[1], initTable[string, Value](), ctx) else: Value(kind: vkString, strVal: ",") for row in filteredRows: if aggExpr.aggArgs.len > 0: - parts.add(valueToString(evalExpr(aggExpr.aggArgs[0], row, ctx))) + let s = valueToString(evalExpr(aggExpr.aggArgs[0], row, ctx)) + if shouldKeepDistinct(seen, s, aggExpr.aggDistinct): + parts.add(s) aggRow[aggKey] = parts.join(valueToString(delim)) # Apply HAVING filter if plan.groupHaving != nil: diff --git a/src/barabadb/query/executor.nim b/src/barabadb/query/executor.nim index 4f1dcdb..79cfc3d 100644 --- a/src/barabadb/query/executor.nim +++ b/src/barabadb/query/executor.nim @@ -448,6 +448,26 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu if cols.len == 0: cols = rightRes.columns + # Fingerprint a projected row for set-op dedup. Prefer declared columns; + # fall back to non-system keys so UNION/INTERSECT/EXCEPT work without `$value`. + proc setOpRowKey(row: Row, colNames: seq[string]): string = + var parts: seq[string] = @[] + if colNames.len > 0: + for c in colNames: + if c in row: + parts.add(valueToString(row[c])) + else: + parts.add("") + else: + var keys: seq[string] = @[] + for k, _ in row: + if not k.startsWith("$"): + keys.add(k) + keys.sort() + for k in keys: + parts.add(k & "=" & valueToString(row[k])) + return parts.join("\x1f") + var rows: seq[Row] = @[] case stmt.setOpKind of sdkUnion: @@ -460,28 +480,30 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu # UNION: deduplicate var seen: Table[string, bool] for row in leftRes.rows: - seen[valueToString(row["$value"])] = true + seen[setOpRowKey(row, cols)] = true for row in rightRes.rows: - if not seen.getOrDefault(valueToString(row["$value"]), false): - seen[valueToString(row["$value"])] = true + let k = setOpRowKey(row, cols) + if not seen.getOrDefault(k, false): + seen[k] = true rows.add(row) of sdkIntersect: var leftSet: Table[string, bool] for row in leftRes.rows: - leftSet[valueToString(row["$value"])] = true + leftSet[setOpRowKey(row, cols)] = true for row in rightRes.rows: - if leftSet.getOrDefault(valueToString(row["$value"]), false): + let k = setOpRowKey(row, cols) + if leftSet.getOrDefault(k, false): rows.add(row) if not stmt.setOpAll: - leftSet.del(valueToString(row["$value"])) # remove to prevent duplicates for INTERSECT (not ALL) + leftSet.del(k) # remove to prevent duplicates for INTERSECT (not ALL) of sdkExcept: var rightSet: Table[string, bool] for row in rightRes.rows: - rightSet[valueToString(row["$value"])] = true + rightSet[setOpRowKey(row, cols)] = true for row in leftRes.rows: - if not rightSet.getOrDefault(valueToString(row["$value"]), false): + if not rightSet.getOrDefault(setOpRowKey(row, cols), false): rows.add(row) return okResult(rows, cols) @@ -759,21 +781,34 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu let onExpr = lowerExpr(stmt.mergeOn) if valueToString(evalExpr(onExpr, rowWithTarget, ctx)) == "true": matched = true - if stmt.mergeMatchedUpdate.len > 0 and "$key" in tgtRow: - var updateSets = initTable[string, string]() - for s in stmt.mergeMatchedUpdate: - if s.kind == nkBinOp and s.binOp == bkAssign: - if s.binLeft.kind == nkIdent: - let valExpr = lowerExpr(s.binRight) - updateSets[s.binLeft.identName] = valueToString(evalExpr(valExpr, rowWithTarget, ctx)) - var newRow = tgtRow - for col, val in updateSets: - newRow[col] = Value(kind: vkString, strVal: val) - fireTriggers(ctx, stmt.mergeTarget, "before", "update", tgtRow) - count += execUpdateRow(ctx, stmt.mergeTarget, valueToString(tgtRow["$key"]), updateSets, kvPairs) - fireTriggers(ctx, stmt.mergeTarget, "after", "update", newRow) - if ctx.onChange != nil: - ctx.onChange(ChangeEvent(table: stmt.mergeTarget, kind: ckUpdate, key: valueToString(tgtRow["$key"]), data: "")) + # Optional AND after WHEN MATCHED + var applyMatched = true + if stmt.mergeMatchedCondition != nil: + let condExpr = lowerExpr(stmt.mergeMatchedCondition) + applyMatched = valueToString(evalExpr(condExpr, rowWithTarget, ctx)) == "true" + if applyMatched and "$key" in tgtRow: + if stmt.mergeMatchedDelete: + fireTriggers(ctx, stmt.mergeTarget, "before", "delete", tgtRow) + count += execDelete(ctx, stmt.mergeTarget, valueToString(tgtRow["$key"]), kvPairs) + fireTriggers(ctx, stmt.mergeTarget, "after", "delete", tgtRow) + if ctx.onChange != nil: + ctx.onChange(ChangeEvent(table: stmt.mergeTarget, kind: ckDelete, + key: valueToString(tgtRow["$key"]), data: "")) + elif stmt.mergeMatchedUpdate.len > 0: + var updateSets = initTable[string, string]() + for s in stmt.mergeMatchedUpdate: + if s.kind == nkBinOp and s.binOp == bkAssign: + if s.binLeft.kind == nkIdent: + let valExpr = lowerExpr(s.binRight) + updateSets[s.binLeft.identName] = valueToString(evalExpr(valExpr, rowWithTarget, ctx)) + var newRow = tgtRow + for col, val in updateSets: + newRow[col] = Value(kind: vkString, strVal: val) + fireTriggers(ctx, stmt.mergeTarget, "before", "update", tgtRow) + count += execUpdateRow(ctx, stmt.mergeTarget, valueToString(tgtRow["$key"]), updateSets, kvPairs) + fireTriggers(ctx, stmt.mergeTarget, "after", "update", newRow) + if ctx.onChange != nil: + ctx.onChange(ChangeEvent(table: stmt.mergeTarget, kind: ckUpdate, key: valueToString(tgtRow["$key"]), data: "")) break if not matched and stmt.mergeNotMatchedInsert.len > 0: diff --git a/src/barabadb/storage/compaction.nim b/src/barabadb/storage/compaction.nim index 9404c1e..6ec15db 100644 --- a/src/barabadb/storage/compaction.nim +++ b/src/barabadb/storage/compaction.nim @@ -125,13 +125,16 @@ proc compact*(cs: CompactionStrategy, level: int): CompactionResult = return cmp(b.timestamp, a.timestamp) # newest first ) - # Deduplicate: keep only the newest version of each key + # Deduplicate: keep only the newest version of each key. + # Use a haveLast flag — sentinel lastKey="" would skip the empty-string key. var merged: seq[Entry] = @[] var lastKey = "" + var haveLast = false for entry in allEntries: - if entry.key != lastKey: + if not haveLast or entry.key != lastKey: merged.add(entry) lastKey = entry.key + haveLast = true # Keep tombstones to prevent deleted keys from resurrecting in lower levels var final: seq[Entry] = @[] diff --git a/src/barabadb/storage/lsm.nim b/src/barabadb/storage/lsm.nim index c310a88..7316706 100644 --- a/src/barabadb/storage/lsm.nim +++ b/src/barabadb/storage/lsm.nim @@ -696,6 +696,10 @@ proc newLSMTree*( var version: uint32 = 0 if stream.readData(addr magic, 4) == 4 and magic == WALMagic: if stream.readData(addr version, 4) == 4: + # Cap per-record sizes to avoid multi-GiB alloc on torn/corrupt WAL. + # Kind must be a known WalEntryKind value (1..4) — out-of-range casts + # raise CaseStmtError (Defect) and crash the process. + const MaxWalRecordField = 64 * 1024 * 1024 # 64 MB while not stream.atEnd(): var kind: uint8 = 0 var timestamp: uint64 = 0 @@ -704,10 +708,20 @@ proc newLSMTree*( if stream.readData(addr kind, 1) != 1: break if stream.readData(addr timestamp, 8) != 8: break if stream.readData(addr keyLen, 4) != 4: break + if keyLen.int > MaxWalRecordField: + echo "[WARN] WAL recovery: torn/corrupt record (keyLen=", keyLen, ") — stopping replay" + break + # Validate kind before allocating or branching (avoids CaseStmtError Defect) + if kind < uint8(wekPut) or kind > uint8(wekCommit): + echo "[WARN] WAL recovery: invalid entry kind ", kind, " — stopping replay" + break var key = newString(keyLen.int) if keyLen > 0: if stream.readData(addr key[0], keyLen.int) != keyLen.int: break if stream.readData(addr valLen, 4) != 4: break + if valLen.int > MaxWalRecordField: + echo "[WARN] WAL recovery: torn/corrupt record (valLen=", valLen, ") — stopping replay" + break var value = newSeq[byte](valLen.int) if valLen > 0: if stream.readData(addr value[0], valLen.int) != valLen.int: break @@ -866,26 +880,36 @@ proc flushUnsafe(db: LSMTree) = if db.immutableMem.len == 0 and db.memTable.len == 0: return - # Flush immutable memtable if present, otherwise flush current memtable - var toFlush = db.immutableMem - if toFlush.len == 0: - toFlush = db.memTable - db.memTable = newMemTable(db.memMaxSize) + # Flush immutable memtable if present, otherwise flush current memtable. + # Do NOT clear the source memtable until the SSTable is written — an IOError + # mid-write must leave the data still visible to live reads (WAL still has it). + var flushingImmutable = false + var toFlush: MemTable + if db.immutableMem.len > 0: + toFlush = db.immutableMem + flushingImmutable = true else: - db.immutableMem = newMemTable(0) + toFlush = db.memTable if toFlush.len == 0: return let path = db.dir / "sstables" / ($db.nextSSTableId & ".sst") + let sstId = db.nextSSTableId inc db.nextSSTableId # Sort once at flush time (O(n log n)) — put/get stay O(1) var sst = writeSSTable(toFlush.sortedEntries(), path, level = 0) - sst.id = db.nextSSTableId - 1 + sst.id = sstId db.sstables.add(sst) # SSTables are kept in insertion order (newest last) so getUnsafe can search newest-first + # Only now drop the in-memory copy — SSTable is durable on disk + if flushingImmutable: + db.immutableMem = newMemTable(0) + else: + db.memTable = newMemTable(db.memMaxSize) + # Update MANIFEST atomically inc db.manifestSequence try: @@ -930,27 +954,29 @@ proc checkpoint*(db: LSMTree) = ## rotate WAL, and write MANIFEST. This provides a clean boundary ## for online backup without stopping the server. acquireWrite(db.lock) + try: + # Flush any pending immutable memtable first + if db.immutableMem.len > 0: + flushUnsafe(db) - # Flush any pending immutable memtable first - if db.immutableMem.len > 0: - flushUnsafe(db) + # Freeze current memtable so writes can continue on a new one + if db.memTable.len > 0: + db.immutableMem = db.memTable + db.memTable = newMemTable(db.memMaxSize) - # Freeze current memtable so writes can continue on a new one - if db.memTable.len > 0: - db.immutableMem = db.memTable - db.memTable = newMemTable(db.memMaxSize) + # Flush the frozen memtable + if db.immutableMem.len > 0: + flushUnsafe(db) - # Flush the frozen memtable - if db.immutableMem.len > 0: - flushUnsafe(db) - - # Rotate WAL for a clean backup boundary - acquire(db.walLock) - db.wal.maybeRotate() - db.wal.sync() - release(db.walLock) - - releaseWrite(db.lock) + # Rotate WAL for a clean backup boundary + acquire(db.walLock) + try: + db.wal.maybeRotate() + db.wal.sync() + finally: + release(db.walLock) + finally: + releaseWrite(db.lock) proc close*(db: LSMTree) = acquireWrite(db.lock) diff --git a/src/barabadb/storage/mmap.nim b/src/barabadb/storage/mmap.nim index af3612d..c13bd2d 100644 --- a/src/barabadb/storage/mmap.nim +++ b/src/barabadb/storage/mmap.nim @@ -80,7 +80,8 @@ proc readAt*(mf: MmapFile, offset: int, size: int): seq[byte] = if mf.regions.len == 0: return @[] let region = mf.regions[0] - if offset < 0 or size < 0 or offset + size > region.size: + # overflow-safe bound: offset > size - length (not offset + length > size) + if offset < 0 or size < 0 or size > region.size or offset > region.size - size: return @[] result = newSeq[byte](size) copyMem(addr result[0], unsafeAddr region.data[offset], size) @@ -91,21 +92,24 @@ proc readByte*(mf: MmapFile, offset: int): byte = return mf.regions[0].data[offset] proc readUint32*(mf: MmapFile, offset: int): uint32 = - if mf.regions.len == 0 or offset < 0 or offset + 4 > mf.regions[0].size: + if mf.regions.len == 0 or offset < 0 or 4 > mf.regions[0].size or + offset > mf.regions[0].size - 4: return 0 var val: uint32 copyMem(addr val, unsafeAddr mf.regions[0].data[offset], 4) return val proc readUint64*(mf: MmapFile, offset: int): uint64 = - if mf.regions.len == 0 or offset < 0 or offset + 8 > mf.regions[0].size: + if mf.regions.len == 0 or offset < 0 or 8 > mf.regions[0].size or + offset > mf.regions[0].size - 8: return 0 var val: uint64 copyMem(addr val, unsafeAddr mf.regions[0].data[offset], 8) return val proc readString*(mf: MmapFile, offset: int, size: int): string = - if mf.regions.len == 0 or offset < 0 or size < 0 or offset + size > mf.regions[0].size: + if mf.regions.len == 0 or offset < 0 or size < 0 or + size > mf.regions[0].size or offset > mf.regions[0].size - size: return "" result = newString(size) copyMem(addr result[0], unsafeAddr mf.regions[0].data[offset], size) diff --git a/src/barabadb/storage/recovery.nim b/src/barabadb/storage/recovery.nim index daed440..488c0d1 100644 --- a/src/barabadb/storage/recovery.nim +++ b/src/barabadb/storage/recovery.nim @@ -68,6 +68,7 @@ proc scanWAL*(rec: CrashRecovery): seq[RecoveredEntry] = var txnId: uint64 = 0 var entryCount = 0 + const MaxWalRecordField = 64 * 1024 * 1024 # 64 MB while not stream.atEnd(): var kind: uint8 = 0 var timestamp: uint64 = 0 @@ -77,12 +78,15 @@ proc scanWAL*(rec: CrashRecovery): seq[RecoveredEntry] = if stream.readData(addr kind, 1) != 1: break if stream.readData(addr timestamp, 8) != 8: break if stream.readData(addr keyLen, 4) != 4: break + if keyLen.int > MaxWalRecordField: break + if kind < uint8(wekPut) or kind > uint8(wekCommit): break var key = newString(keyLen.int) if keyLen > 0: if stream.readData(addr key[0], keyLen.int) != keyLen.int: break if stream.readData(addr valLen, 4) != 4: break + if valLen.int > MaxWalRecordField: break var value = newSeq[byte](valLen.int) if valLen > 0: if stream.readData(addr value[0], valLen.int) != valLen.int: break diff --git a/src/barabadb/storage/wal.nim b/src/barabadb/storage/wal.nim index 1d4cab2..8419e17 100644 --- a/src/barabadb/storage/wal.nim +++ b/src/barabadb/storage/wal.nim @@ -326,8 +326,9 @@ proc rewriteLive*(wal: var WriteAheadLog, if wal.stream != nil: wal.stream.close() - if fileExists(wal.path): - removeFile(wal.path) + wal.stream = nil + # Atomic replace: moveFile overwrites the destination on POSIX rename(2). + # Do not removeFile first — a crash between unlink and rename would lose the WAL. moveFile(tmpPath, wal.path) wal.stream = newFileStream(wal.path, fmAppend) if wal.stream == nil: @@ -363,6 +364,7 @@ proc readEntries*(walPath: string, untilTimestamp: uint64 = 0): seq[WalEntry] = if s.readData(addr magic, 4) != 4: return if s.readData(addr version, 4) != 4: return if magic != WALMagic: return + const MaxWalRecordField = 64 * 1024 * 1024 # 64 MB while not s.atEnd: var kind: uint8 if s.readData(addr kind, 1) != 1: break @@ -372,11 +374,14 @@ proc readEntries*(walPath: string, untilTimestamp: uint64 = 0): seq[WalEntry] = break var keyLen: uint32 if s.readData(addr keyLen, 4) != 4: break + if keyLen.int > MaxWalRecordField: break + if kind < uint8(wekPut) or kind > uint8(wekCommit): break var key = newSeq[byte](keyLen) if keyLen > 0: if s.readData(addr key[0], int(keyLen)) != int(keyLen): break var valLen: uint32 if s.readData(addr valLen, 4) != 4: break + if valLen.int > MaxWalRecordField: break var value = newSeq[byte](valLen) if valLen > 0: if s.readData(addr value[0], int(valLen)) != int(valLen): break diff --git a/tests/bugfix_test.nim b/tests/bugfix_test.nim index a18d27f..7204d72 100644 --- a/tests/bugfix_test.nim +++ b/tests/bugfix_test.nim @@ -625,3 +625,99 @@ suite "Query operator correctness — audit batch 1": let neq = executeQuery(ctx, parse("SELECT * FROM users WHERE id != 1.0")) check eq.rows.len == 1 # 1 = 1.0 -> true check neq.rows.len == 0 # 1 != 1.0 -> false (old bug returned the row) + + +suite "Query correctness — audit batch 2": + + test "COUNT(DISTINCT) deduplicates values": + ## Regression: funcDistinct was parsed but never copied to aggDistinct / + ## never consulted during aggregation. + var ctx = setupCtx() + defer: teardown(ctx) + discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'alice')")) + discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (2, 'bob')")) + discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (3, 'alice')")) + let r = executeQuery(ctx, parse("SELECT COUNT(DISTINCT name) AS c FROM users")) + check r.success + check r.rows.len == 1 + check valueToString(r.rows[0]["c"]) == "2" + + test "SUM(DISTINCT) sums unique values only": + var ctx = setupCtx() + defer: teardown(ctx) + discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'a')")) + discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (2, 'b')")) + discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (5, 'c')")) + # ids 1, 2, 5 — insert another row with id-like values via a number col + discard executeQuery(ctx, parse("CREATE TABLE nums (id INTEGER PRIMARY KEY, n INTEGER)")) + discard executeQuery(ctx, parse("INSERT INTO nums (id, n) VALUES (1, 10)")) + discard executeQuery(ctx, parse("INSERT INTO nums (id, n) VALUES (2, 10)")) + discard executeQuery(ctx, parse("INSERT INTO nums (id, n) VALUES (3, 20)")) + let r = executeQuery(ctx, parse("SELECT SUM(DISTINCT n) AS s FROM nums")) + check r.success + check r.rows.len == 1 + check parseFloat(valueToString(r.rows[0]["s"])) == 30.0 + + test "UNION deduplicates without KeyError": + ## Regression: set-op dedup used row["$value"] which projected rows lack. + var ctx = setupCtx() + defer: teardown(ctx) + discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'alice')")) + discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (2, 'bob')")) + discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (3, 'alice')")) + let r = executeQuery(ctx, parse( + "SELECT name FROM users WHERE id = 1 UNION SELECT name FROM users WHERE id = 3")) + check r.success + check r.rows.len == 1 + check valueToString(r.rows[0]["name"]) == "alice" + + test "INTERSECT returns common rows": + var ctx = setupCtx() + defer: teardown(ctx) + discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'alice')")) + discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (2, 'bob')")) + let r = executeQuery(ctx, parse( + "SELECT name FROM users WHERE id <= 2 INTERSECT SELECT name FROM users WHERE id = 1")) + check r.success + check r.rows.len == 1 + check valueToString(r.rows[0]["name"]) == "alice" + + test "EXCEPT removes right-side rows": + var ctx = setupCtx() + defer: teardown(ctx) + discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'alice')")) + discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (2, 'bob')")) + let r = executeQuery(ctx, parse( + "SELECT name FROM users EXCEPT SELECT name FROM users WHERE id = 1")) + check r.success + check r.rows.len == 1 + check valueToString(r.rows[0]["name"]) == "bob" + + test "MERGE WHEN MATCHED THEN DELETE removes the row": + ## Regression: mergeMatchedDelete was parsed but never executed. + var ctx = setupCtx() + defer: teardown(ctx) + discard executeQuery(ctx, parse("CREATE TABLE inv (id INTEGER PRIMARY KEY, qty INTEGER)")) + discard executeQuery(ctx, parse("INSERT INTO inv (id, qty) VALUES (1, 10)")) + discard executeQuery(ctx, parse("INSERT INTO inv (id, qty) VALUES (2, 20)")) + discard executeQuery(ctx, parse("CREATE TABLE deltas (id INTEGER PRIMARY KEY, qty INTEGER)")) + discard executeQuery(ctx, parse("INSERT INTO deltas (id, qty) VALUES (1, 0)")) + let r = executeQuery(ctx, parse(""" + MERGE INTO inv AS t + USING deltas AS s + ON t.id = s.id + WHEN MATCHED THEN DELETE + """)) + check r.success + check r.affectedRows >= 1 + let left = executeQuery(ctx, parse("SELECT id FROM inv ORDER BY id")) + check left.success + check left.rows.len == 1 + check valueToString(left.rows[0]["id"]) == "2" + + test "semi-sync writeLsn returns 0 when replicas do not ack": + var rm = newReplicationManager(rmSemiSync, syncCount = 1) + rm.addReplica(newReplica("r1", "10.0.0.1", 9472)) + rm.connectReplica("r1") + let lsn = rm.writeLsn(@[1'u8, 2, 3]) + check lsn == 0 diff --git a/tests/test_all.nim b/tests/test_all.nim index 0f829dc..3d4b040 100644 --- a/tests/test_all.nim +++ b/tests/test_all.nim @@ -1659,14 +1659,28 @@ suite "Replication": rm.connectReplica("r2") rm.connectReplica("r3") + # Unreachable replicas cannot ack — semi-sync must fail closed (return 0) let lsn = rm.writeLsn(@[1'u8]) - check not rm.isFullyAcked(lsn) # needs 2 acks + check lsn == 0 - rm.ackLsn("r1", lsn) - check not rm.isFullyAcked(lsn) # still needs 1 more + # No connected replicas → nothing to wait for; write succeeds + var rm2 = newReplicationManager(rmSemiSync, syncCount = 2) + rm2.addReplica(newReplica("r1", "10.0.0.1", 9472)) + # not connected + let lsn2 = rm2.writeLsn(@[1'u8]) + check lsn2 > 0 + check rm2.isFullyAcked(lsn2) - rm.ackLsn("r2", lsn) - check rm.isFullyAcked(lsn) # 2 acks received + # ackLsn bookkeeping still clears pendingAcks at the required quorum + var rm3 = newReplicationManager(rmSemiSync, syncCount = 2) + rm3.pendingAcks[1'u64] = initHashSet[string]() + rm3.pendingAcks[1'u64].incl("r1") + rm3.pendingAcks[1'u64].incl("r2") + check not rm3.isFullyAcked(1) + rm3.ackLsn("r1", 1) + check not rm3.isFullyAcked(1) + rm3.ackLsn("r2", 1) + check rm3.isFullyAcked(1) test "Replica status": var rm = newReplicationManager(rmAsync)