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
This commit is contained in:
2026-07-30 21:25:58 +03:00
parent 50f827f8cf
commit 095698ba82
10 changed files with 180 additions and 41 deletions
+8
View File
@@ -488,3 +488,11 @@ proc applyReplicatedDelete*(ctx: ExecutionContext, fullKey: string) =
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
+11
View File
@@ -179,6 +179,17 @@ proc isDDL*(stmt: Node): bool =
else:
result = false
proc isRaftDdl*(stmt: Node): bool =
## Schema changes that go through the Raft log when clustering is on.
## CREATE/DROP DATABASE are excluded — multi-DB is out of scope for v1 raft
## (state machine is wired only to the default database).
if not isDDL(stmt): return false
case stmt.kind
of nkCreateDatabase, nkDropDatabase:
result = false
else:
result = true
proc isWrite*(stmt: Node): bool =
## True for statements that mutate stored data. `nkCommitTxn` is included
## because COMMIT emits the transaction's buffered kvPairs.
+33 -2
View File
@@ -317,8 +317,23 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
row[colName] = w.binRight.strVal
rows.add(row)
return okResult(rows, coveredCols)
# Fetch actual row data from LSM
let rows = execPointRead(ctx, stmt.selFrom.fromTable, colName & "=" & w.binRight.strVal)
# Fetch full rows via the LSM keys stored in the index — never
# reconstruct the primary key from the filter column (secondary
# indexes are not the PK).
var rows: seq[Row] = @[]
for entry in entries:
if entry.lsmKey.len == 0: continue
let (found, val) = ctx.db.get(entry.lsmKey)
if found:
var row = parseRowDataToValueRow(cast[string](val))
let prefix = stmt.selFrom.fromTable & "."
if entry.lsmKey.startsWith(prefix):
let rest = entry.lsmKey[prefix.len..^1]
row["$key"] = rest
let eqPos = rest.find('=')
if eqPos >= 0:
row[rest[0..<eqPos]] = rest[eqPos+1..^1]
rows.add(row)
let tbl = ctx.getTableDef(stmt.selFrom.fromTable)
var cols: seq[string] = @[]
for c in tbl.columns: cols.add(c.name)
@@ -1749,6 +1764,22 @@ proc restoreEngines*(ctx: ExecutionContext) =
except CatchableError as e:
warn("restoreEngines: graph rebuild failed for '" & name & "': " & e.msg)
proc applyReplicatedDdl*(ctx: ExecutionContext, sql: string) {.gcsafe.} =
## Raft "ddl" log entry: re-execute the original SQL on this node's context.
## Called from applyCommand (no server/raft layer — must not re-append).
## Leader double-apply failures (already exists / does not exist) are ignored
## so the state machine keeps advancing. {.cast(gcsafe).} is required because
## executeQuery touches the registry factory (same pattern as other engine
## callbacks under the storage gate on the single-threaded apply path).
{.cast(gcsafe).}:
try:
let tokens = qlex.tokenize(sql)
let astNode = qpar.parse(tokens)
if astNode.stmts.len == 0: return
discard executeQuery(ctx, astNode)
except CatchableError:
discard
# ----------------------------------------------------------------------
# Hook wiring — breaks the module cycle between executor and the exec/*
# submodules: eval.nim calls back into the engine for subqueries, hybrid