Skip to content

Commit 5f0a451

Browse files
TJKouryclaude
andcommitted
flatsqlrt: take the engine that cannot trap on a SQL error (flatsql b26ed45)
host-01's record catalog had NEVER finished hydrating. Every boot, ~2 min into the 1,344,427-frame replay: [error] execution failed: unreachable, Code: 0x40a [error] calling stack:3351, 3351, 3351, 325, 192, 574, 3351 When executing module name: "flatsql", function name: "flatsql_query_params" FlatSQL full record-catalog replay failed after 2m17.389s -> epoch 2 Four hypotheses had already been excluded by measurement (the data, the row count, AOT, the guest bytecode). The fifth — the runtime pin — is excluded here too: the real 450 MB journal replays ALL 1,344,427 frames clean under WasmEdge 0.16.4 on darwin-arm64 (32.1 s) AND under Docker linux/amd64, the host's exact pin (1479 s emulated), on the unmodified engine. The axis was none of those. It was the ERROR PATH ITSELF. The engine artifact is compiled -fignore-exceptions, so `throw` is `unreachable`; a plain SQL error inside a bound INSERT aborted the guest. flatsql b26ed45 routes every C-ABI query entry through an exception-free path. Reproduced deterministically in 0.07 s — no 450 MB journal, no host access — and the fixture is committed: sql_error_no_trap_test.go asserts that flatsql_query, _query_params, _query_many and _query_template all return a NAMED SQL error and leave the engine USABLE. Green on darwin-arm64 and in Docker linux/amd64 WasmEdge 0.16.4. Engine: 1c53398ae6dc76ec -> 4d17fc5f4936305a, in BOTH embeds (sdn-server and the kubo mirror) so the two hosts cannot drift on this. TestAOTCache asserted "exactly one file" in a machine-wide cache that retains the predecessor artifact on purpose; it now asserts the artifact for the engine bytes under test is present and the prefix stays inside the retention bound, so it catches a real cache miss instead of every engine rebuild. STILL OPEN, and now VISIBLE for the first time: with the guest no longer trapping, the replay reports the host-side cause in plain text — replay record index batch: flatsqlrt: query_params: SQL execution error: UNIQUE constraint failed: sdn_record_index.rowid reproduced off-host on the real journal at 285,000 frames by TestReproHost01JournalReplayWithLiveWrites (added here), which replays it WHILE live traffic publishes — host-01's actual condition and the one axis the plain repro never covered. replayRowIDState snapshots rowid ownership ONCE at replay start, but a chunked replay releases the store lock between windows, so live inserts draw from the same rowid space the replay is handing out. That is filed as sdn-replay-rowid-collides-with-live-writes; hydration completes only once both land. Suites: internal/flatsqlrt, internal/storage (492 s), internal/flatsqldrv, internal/wasmrt, kubo/sdn/flatsqlrt — all green. graph task: mod-flatsql-query-params-unreachable-trap (P1) Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VBJfFY5kwQjrwuWe3Kw5E7
1 parent 7bf1146 commit 5f0a451

9 files changed

Lines changed: 475 additions & 24 deletions

File tree

kubo/sdn/flatsqlrt/README.md

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ aligned size-prefixed FlatBuffer frames (`QueryRawFlatBufferStream`).
1616
`flatsql` repo (superproject submodule `repos/main-packages/flatsql`):
1717

1818
- source path: `flatsql/wasm/flatsql-wasi-noeh.wasm`
19-
- flatsql commit: `8e86fee` (v1.2.0 — sandboxed public query `flatsql_query_sandboxed`, gateway loop G.5)
20-
- sha256: `1c53398ae6dc76ec806a3e4724461c6ccc82b6fe8861c6a1a42efcfa4b4c7f64`
19+
- flatsql commit: `b26ed45` (no-eh query-error latch — a SQL error is a value, never `unreachable`)
20+
- sha256: `4d17fc5f4936305a005bfc5c63c550a58e448412900299ac7d6adc63ba0137e9`
2121

2222
Why no-exceptions (loop A.3/A.3b findings, measured): WasmEdge's AOT
2323
compiler (0.14–0.17) cannot parse wasm-exceptions (exnref) modules, and its
@@ -27,12 +27,30 @@ Wasmtime runs exnref natively but its C API/Go bindings do not expose the
2727
exceptions proposal yet. The no-EH build is export-identical and
2828
byte-parity-verified against the browser artifact (parity_test.go).
2929

30-
Error semantics (since flatsql A.3c no-throw refactor): user-triggerable
31-
failures — bad SQL, param-count mismatch, unknown template, duplicate
32-
source, bad schema — are pre-validated/latched in the engine WITHOUT
33-
throwing, return clean errors, and do NOT poison the runtime. Only a
34-
genuine trap (untouched internal throw path, OOM, unreachable) sets
35-
`Runtime.Poisoned()`; a poisoned runtime must be discarded and recreated.
30+
Error semantics: EVERY host-reachable query failure is a value, never a
31+
trap. Two layers get there:
32+
33+
- pre-validation (flatsql A.3c): bad SQL, param-count mismatch, unknown
34+
template, duplicate source, bad schema are latched before execution;
35+
- exception-free EXECUTION (flatsql no-eh query-error latch, graph task
36+
`mod-flatsql-query-params-unreachable-trap`): SQL errors raised while the
37+
statement RUNS — constraint violations, busy/locked-after-retries, bind
38+
and IO errors — return through `executeNoThrow`/`queryNoThrow` instead of
39+
`throw`.
40+
41+
The second layer is not cosmetic. This artifact is compiled
42+
`-fignore-exceptions`, so a `throw` on this build is not an exception, it
43+
is `unreachable`: the guest aborts and the whole engine instance is
44+
poisoned. Before the latch, an ordinary UNIQUE-constraint violation inside
45+
one bound INSERT aborted host-01's record-catalog hydration on every boot
46+
(`flatsql_query_params`, `calling stack:3351, 3351, 3351, 325, 192, 574,
47+
3351`), so its 1.34M-frame catalog never finished hydrating. The contract is
48+
asserted directly by `sql_error_no_trap_test.go` across every query entry
49+
the C ABI exposes.
50+
51+
Only a genuine trap (a remaining internal throw path, OOM, unreachable)
52+
sets `Runtime.Poisoned()`; a poisoned runtime must be discarded and
53+
recreated.
3654

3755
Production daemons should pass `WithPrecompiledAOTCache(dir)`: the portable
3856
module is AOT-compiled by an explicit release/prewarm step and loaded from
3.05 KB
Binary file not shown.

kubo/sdn/flatsqlrt/flatsqlrt_test.go

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"encoding/binary"
88
"encoding/hex"
99
"os"
10+
"strings"
1011
"testing"
1112
)
1213

@@ -132,7 +133,7 @@ func newOMMDatabase(t *testing.T, rt *Runtime, name string) *Database {
132133
func TestEmbeddedArtifact(t *testing.T) {
133134
sum := sha256.Sum256(EmbeddedWasm())
134135
// Must match the provenance block in README.md.
135-
const want = "1c53398ae6dc76ec806a3e4724461c6ccc82b6fe8861c6a1a42efcfa4b4c7f64"
136+
const want = "4d17fc5f4936305a005bfc5c63c550a58e448412900299ac7d6adc63ba0137e9"
136137
if got := hex.EncodeToString(sum[:]); got != want {
137138
t.Fatalf("embedded flatsql-wasi-noeh.wasm sha256 = %s, want %s (update README provenance if the pin moved)", got, want)
138139
}
@@ -197,9 +198,31 @@ func TestAOTCache(t *testing.T) {
197198
if !rt.AOT() {
198199
t.Fatal("expected AOT-compiled runtime (compiler unavailable?)")
199200
}
201+
// Assert the artifact for the engine bytes UNDER TEST is present, rather
202+
// than "exactly one file": the shared dev cache legitimately holds an
203+
// artifact per engine build, so a count assertion breaks on every
204+
// engine-bytes change instead of catching a real cache miss.
200205
entries, err := os.ReadDir(dir)
201-
if err != nil || len(entries) != 1 {
202-
t.Fatalf("AOT cache dir entries: %v err=%v", entries, err)
206+
if err != nil {
207+
t.Fatalf("read AOT cache dir: %v", err)
208+
}
209+
sum := sha256.Sum256(EmbeddedWasm())
210+
wantName := "flatsql-" + hex.EncodeToString(sum[:])[:16] + "-we" + RuntimeVersion() + ".aot.wasm"
211+
found := false
212+
engineArtifacts := 0
213+
for _, entry := range entries {
214+
if strings.HasPrefix(entry.Name(), "flatsql-") {
215+
engineArtifacts++
216+
}
217+
if entry.Name() == wantName {
218+
found = true
219+
}
220+
}
221+
if !found {
222+
t.Fatalf("AOT artifact %s missing; cache dir holds %v", wantName, entries)
223+
}
224+
if engineArtifacts == 0 {
225+
t.Fatalf("no engine AOT artifact in %s: %v", dir, entries)
203226
}
204227

205228
// Engine works end-to-end on the compiled artifact.
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package flatsqlrt
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
)
8+
9+
// A SQL RUNTIME ERROR IS A RESULT, NEVER A TRAP.
10+
//
11+
// graph task mod-flatsql-query-params-unreachable-trap. host-01's
12+
// record-catalog hydration aborted on every boot with
13+
//
14+
// [error] execution failed: unreachable, Code: 0x40a
15+
// [error] calling stack:3351, 3351, 3351, 325, 192, 574, 3351
16+
// When executing module name: "flatsql", function name: "flatsql_query_params"
17+
//
18+
// and had therefore NEVER finished hydrating its 1,344,427-frame catalog. The
19+
// cause was not the journal, the row count, AOT, the guest bytecode or the
20+
// WasmEdge pin (all measured clean): the engine artifact the servers execute,
21+
// flatsql-wasi-noeh.wasm, is compiled `-fignore-exceptions`, so
22+
// SQLiteEngine::execute's `throw std::runtime_error("SQL execution error: ...")`
23+
// lowered to `unreachable`. The try/catch in flatsql_capi.cpp was dead code on
24+
// that artifact. Any ordinary constraint violation reaching the guest — which
25+
// live traffic writing to sdn_record_index during a replay can produce — aborted
26+
// the guest and poisoned the whole engine.
27+
//
28+
// This fixture asserts the ABI contract directly, so the defect cannot come back
29+
// through a different SQL error class or a different entry point: every
30+
// host-reachable query entry must return an error and leave the engine USABLE.
31+
func TestSQLErrorNeverTrapsTheGuest(t *testing.T) {
32+
rt, err := New()
33+
if err != nil {
34+
t.Fatalf("New: %v", err)
35+
}
36+
defer rt.Close()
37+
38+
db, err := rt.CreateDatabase(ommTestSchema, "sql-error-no-trap")
39+
if err != nil {
40+
t.Fatalf("CreateDatabase: %v", err)
41+
}
42+
if _, err := db.Query(`CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT NOT NULL)`); err != nil {
43+
t.Fatalf("create table: %v", err)
44+
}
45+
if _, err := db.Query(`INSERT INTO t (id, v) VALUES (?, ?)`, 1, "seed"); err != nil {
46+
t.Fatalf("seed row: %v", err)
47+
}
48+
49+
cases := []struct {
50+
name string
51+
want string
52+
run func() error
53+
}{
54+
{
55+
// The host-01 class: the replay assigns explicit rowids into
56+
// sdn_record_index, so a rowid that collides with one live traffic
57+
// already wrote is a PRIMARY KEY violation on a bound INSERT.
58+
name: "flatsql_query_params: rowid/PRIMARY KEY collision",
59+
want: "UNIQUE constraint failed",
60+
run: func() error {
61+
_, err := db.Query(`INSERT INTO t (id, v) VALUES (?, ?)`, 1, "collide")
62+
return err
63+
},
64+
},
65+
{
66+
name: "flatsql_query_params: NOT NULL violation",
67+
want: "NOT NULL constraint failed",
68+
run: func() error {
69+
_, err := db.Query(`INSERT INTO t (id, v) VALUES (?, ?)`, 2, nil)
70+
return err
71+
},
72+
},
73+
{
74+
name: "flatsql_query_params: unknown table",
75+
want: "no such table",
76+
run: func() error {
77+
_, err := db.Query(`INSERT INTO missing_table (id) VALUES (?)`, 3)
78+
return err
79+
},
80+
},
81+
{
82+
name: "flatsql_query: unparameterized statement error",
83+
want: "no such table",
84+
run: func() error {
85+
_, err := db.Query(`INSERT INTO missing_table (id) VALUES (7)`)
86+
return err
87+
},
88+
},
89+
{
90+
name: "flatsql_query_many: error inside a batch",
91+
want: "UNIQUE constraint failed",
92+
run: func() error {
93+
_, err := db.QueryMany([]QueryRequest{
94+
{SQL: `INSERT INTO t (id, v) VALUES (?, ?)`, Params: []interface{}{10, "ok"}},
95+
{SQL: `INSERT INTO t (id, v) VALUES (?, ?)`, Params: []interface{}{10, "dup"}},
96+
})
97+
return err
98+
},
99+
},
100+
{
101+
name: "flatsql_query_template: error inside a registered template",
102+
want: "UNIQUE constraint failed",
103+
run: func() error {
104+
if err := db.RegisterQueryTemplate("dup-insert",
105+
`INSERT INTO t (id, v) VALUES (?, ?)`, false); err != nil {
106+
return fmt.Errorf("register: %w", err)
107+
}
108+
if _, err := db.QueryTemplate("dup-insert", 20, "first"); err != nil {
109+
return fmt.Errorf("first template run: %w", err)
110+
}
111+
_, err := db.QueryTemplate("dup-insert", 20, "second")
112+
return err
113+
},
114+
},
115+
}
116+
117+
for _, tc := range cases {
118+
t.Run(tc.name, func(t *testing.T) {
119+
err := tc.run()
120+
if err == nil {
121+
t.Fatalf("expected an error from %s", tc.name)
122+
}
123+
if strings.Contains(err.Error(), "unreachable") || strings.Contains(err.Error(), "poisoned") {
124+
t.Fatalf("GUEST TRAPPED on a plain SQL error (this is the defect): %v", err)
125+
}
126+
if !strings.Contains(err.Error(), tc.want) {
127+
t.Fatalf("error %q does not carry the SQL diagnosis %q", err, tc.want)
128+
}
129+
if rt.Poisoned() {
130+
t.Fatalf("engine poisoned by a plain SQL error")
131+
}
132+
// The engine must still be USABLE afterwards — a trap would have
133+
// left it refused for every later caller.
134+
res, err := db.Query(`SELECT COUNT(*) FROM t`)
135+
if err != nil {
136+
t.Fatalf("engine unusable after a SQL error: %v", err)
137+
}
138+
if len(res.Rows) != 1 {
139+
t.Fatalf("SELECT COUNT(*) returned %d rows", len(res.Rows))
140+
}
141+
})
142+
}
143+
}

sdn-server/internal/flatsqlrt/README.md

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ aligned size-prefixed FlatBuffer frames (`QueryRawFlatBufferStream`).
1616
`flatsql` repo (superproject submodule `repos/main-packages/flatsql`):
1717

1818
- source path: `flatsql/wasm/flatsql-wasi-noeh.wasm`
19-
- flatsql commit: `8e86fee` (v1.2.0 — sandboxed public query `flatsql_query_sandboxed`, gateway loop G.5)
20-
- sha256: `1c53398ae6dc76ec806a3e4724461c6ccc82b6fe8861c6a1a42efcfa4b4c7f64`
19+
- flatsql commit: `b26ed45` (no-eh query-error latch — a SQL error is a value, never `unreachable`)
20+
- sha256: `4d17fc5f4936305a005bfc5c63c550a58e448412900299ac7d6adc63ba0137e9`
2121

2222
Why no-exceptions (loop A.3/A.3b findings, measured): WasmEdge's AOT
2323
compiler (0.14–0.17) cannot parse wasm-exceptions (exnref) modules, and its
@@ -27,12 +27,30 @@ Wasmtime runs exnref natively but its C API/Go bindings do not expose the
2727
exceptions proposal yet. The no-EH build is export-identical and
2828
byte-parity-verified against the browser artifact (parity_test.go).
2929

30-
Error semantics (since flatsql A.3c no-throw refactor): user-triggerable
31-
failures — bad SQL, param-count mismatch, unknown template, duplicate
32-
source, bad schema — are pre-validated/latched in the engine WITHOUT
33-
throwing, return clean errors, and do NOT poison the runtime. Only a
34-
genuine trap (untouched internal throw path, OOM, unreachable) sets
35-
`Runtime.Poisoned()`; a poisoned runtime must be discarded and recreated.
30+
Error semantics: EVERY host-reachable query failure is a value, never a
31+
trap. Two layers get there:
32+
33+
- pre-validation (flatsql A.3c): bad SQL, param-count mismatch, unknown
34+
template, duplicate source, bad schema are latched before execution;
35+
- exception-free EXECUTION (flatsql no-eh query-error latch, graph task
36+
`mod-flatsql-query-params-unreachable-trap`): SQL errors raised while the
37+
statement RUNS — constraint violations, busy/locked-after-retries, bind
38+
and IO errors — return through `executeNoThrow`/`queryNoThrow` instead of
39+
`throw`.
40+
41+
The second layer is not cosmetic. This artifact is compiled
42+
`-fignore-exceptions`, so a `throw` on this build is not an exception, it
43+
is `unreachable`: the guest aborts and the whole engine instance is
44+
poisoned. Before the latch, an ordinary UNIQUE-constraint violation inside
45+
one bound INSERT aborted host-01's record-catalog hydration on every boot
46+
(`flatsql_query_params`, `calling stack:3351, 3351, 3351, 325, 192, 574,
47+
3351`), so its 1.34M-frame catalog never finished hydrating. The contract is
48+
asserted directly by `sql_error_no_trap_test.go` across every query entry
49+
the C ABI exposes.
50+
51+
Only a genuine trap (a remaining internal throw path, OOM, unreachable)
52+
sets `Runtime.Poisoned()`; a poisoned runtime must be discarded and
53+
recreated.
3654

3755
Production daemons should pass `WithPrecompiledAOTCache(dir)`: the portable
3856
module is AOT-compiled by an explicit release/prewarm step and loaded from
3.05 KB
Binary file not shown.

sdn-server/internal/flatsqlrt/flatsqlrt_test.go

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"encoding/binary"
88
"encoding/hex"
99
"os"
10+
"strings"
1011
"testing"
1112
)
1213

@@ -132,7 +133,7 @@ func newOMMDatabase(t *testing.T, rt *Runtime, name string) *Database {
132133
func TestEmbeddedArtifact(t *testing.T) {
133134
sum := sha256.Sum256(EmbeddedWasm())
134135
// Must match the provenance block in README.md.
135-
const want = "1c53398ae6dc76ec806a3e4724461c6ccc82b6fe8861c6a1a42efcfa4b4c7f64"
136+
const want = "4d17fc5f4936305a005bfc5c63c550a58e448412900299ac7d6adc63ba0137e9"
136137
if got := hex.EncodeToString(sum[:]); got != want {
137138
t.Fatalf("embedded flatsql-wasi-noeh.wasm sha256 = %s, want %s (update README provenance if the pin moved)", got, want)
138139
}
@@ -197,9 +198,33 @@ func TestAOTCache(t *testing.T) {
197198
if !rt.AOT() {
198199
t.Fatal("expected AOT-compiled runtime (compiler unavailable?)")
199200
}
201+
// The cache retains the current artifact AND its predecessor on purpose
202+
// (aotRetainedArtifactsPerPrefix — a rollback must come up AOT, not
203+
// silently interpreted), so assert the POLICY, not "exactly one file":
204+
// the artifact for the engine bytes under test must be present, and the
205+
// prefix must stay inside the retention bound.
200206
entries, err := os.ReadDir(dir)
201-
if err != nil || len(entries) != 1 {
202-
t.Fatalf("AOT cache dir entries: %v err=%v", entries, err)
207+
if err != nil {
208+
t.Fatalf("read AOT cache dir: %v", err)
209+
}
210+
sum := sha256.Sum256(EmbeddedWasm())
211+
wantName := "flatsql-" + hex.EncodeToString(sum[:])[:16] + "-we" + RuntimeVersion() + ".aot.wasm"
212+
found := false
213+
engineArtifacts := 0
214+
for _, entry := range entries {
215+
if strings.HasPrefix(entry.Name(), "flatsql-") {
216+
engineArtifacts++
217+
}
218+
if entry.Name() == wantName {
219+
found = true
220+
}
221+
}
222+
if !found {
223+
t.Fatalf("AOT artifact %s missing; cache dir holds %v", wantName, entries)
224+
}
225+
if engineArtifacts > aotRetainedArtifactsPerPrefix {
226+
t.Fatalf("engine AOT artifacts = %d, retention bound is %d: %v",
227+
engineArtifacts, aotRetainedArtifactsPerPrefix, entries)
203228
}
204229

205230
// Engine works end-to-end on the compiled artifact.

0 commit comments

Comments
 (0)