Skip to content

Commit 82e164d

Browse files
chore: move TASK-129 to done, update edge case tests
1 parent d10e5a4 commit 82e164d

File tree

3 files changed

+141
-76
lines changed

3 files changed

+141
-76
lines changed

.tasks/active/TASK-129-fix-empty-blob-parity.md

Lines changed: 0 additions & 35 deletions
This file was deleted.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# TASK-129: Fix empty blob handling in Zig crsql_changes
2+
3+
## Goal
4+
Fix the divergence where Zig's `crsql_changes` virtual table reports empty blobs (`X''`) as `NULL`.
5+
6+
## Background
7+
Discovered by TASK-127 fuzzing. The Rust/C oracle correctly distinguishes between empty blobs and NULL values, but Zig treats them as equivalent.
8+
9+
## Minimal Reproduction
10+
```sql
11+
CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, data BLOB);
12+
SELECT crsql_as_crr('t');
13+
INSERT INTO t VALUES (1, X'');
14+
SELECT quote(val) FROM crsql_changes WHERE [table]='t' AND cid='data';
15+
-- Expected: X''
16+
-- Actual (Zig): NULL
17+
```
18+
19+
## Impact
20+
- **Data corruption**: Applications using empty blobs will have incorrect data synced
21+
- **Sync divergence**: Peers will disagree on row values after sync
22+
23+
## Files to Investigate
24+
- `zig/src/changes_vtab.zig` - Changes virtual table implementation
25+
- `zig/src/triggers.zig` - Trigger logic that populates clock tables
26+
- `zig/src/value.zig` (if exists) - Value serialization/deserialization
27+
28+
## Acceptance Criteria
29+
- [x] Empty blob (`X''`) is reported as `X''` in crsql_changes, not NULL
30+
- [x] Existing parity tests still pass
31+
- [x] `test-fuzz-parity.sh` passes with no divergences
32+
33+
## Parent Docs
34+
- TASK-127 (discovered the bug)
35+
- `research/zig-cr/92-gap-backlog.md`
36+
37+
## Completion Notes
38+
39+
**Date**: 2024-12-20
40+
41+
**Root Cause**: In `fetchColumnValue()` at `zig/src/changes_vtab.zig:1087-1089`, when handling `SQLITE_BLOB` type, the code was calling `resultBlob(ctx, blob_ptr, blob_len, ...)` directly. For empty blobs:
42+
- `sqlite3_column_type()` correctly returns `SQLITE_BLOB`
43+
- `sqlite3_column_blob()` returns `NULL` (documented SQLite behavior for zero-length blobs)
44+
- `sqlite3_column_bytes()` returns `0`
45+
46+
When `sqlite3_result_blob()` receives a NULL pointer, even with length 0, it produces NULL output instead of an empty blob.
47+
48+
**Fix**: Modified `zig/src/changes_vtab.zig` lines 1087-1104 to detect the empty blob case (col_type is SQLITE_BLOB but pointer is NULL) and pass a static non-NULL pointer with length 0 to produce `X''`:
49+
50+
```zig
51+
if (blob_ptr != null) {
52+
resultBlob(ctx, blob_ptr, blob_len, api.getTransientDestructor());
53+
} else {
54+
// Empty blob case: col_type is SQLITE_BLOB but pointer is NULL
55+
// Use a static non-NULL pointer with length 0 to produce X''
56+
const empty_blob = [_]u8{};
57+
resultBlob(ctx, &empty_blob, 0, api.SQLITE_STATIC);
58+
}
59+
```
60+
61+
**Verification**:
62+
```bash
63+
# Manual test - now outputs X'' instead of NULL
64+
nix run nixpkgs#sqlite -- :memory: -cmd ".load zig/zig-out/lib/libcrsqlite.dylib" <<'SQL'
65+
CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, data BLOB);
66+
SELECT crsql_as_crr('t');
67+
INSERT INTO t VALUES (1, X'');
68+
SELECT quote(val) FROM crsql_changes WHERE [table]='t' AND cid='data';
69+
SQL
70+
# Output: X''
71+
72+
# Parity tests pass
73+
make -C zig test-parity # All tests pass
74+
75+
# Oracle parity tests pass
76+
bash zig/harness/test-oracle-parity.sh # 18 passed, 0 failed
77+
```

zig/harness/test-edge-cases.sh

Lines changed: 64 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -79,18 +79,30 @@ SKIP=0
7979

8080
ERRFILE="$TMPDIR/error.txt"
8181

82-
# Helper to run SQL with Zig extension
83-
run_zig() {
82+
# Helper to run SQL with Zig extension (setup then query separately)
83+
run_zig_setup() {
8484
local db="$1"
8585
local sql="$2"
86-
$SQLITE "$db" -cmd ".load $ZIG_EXT" "$sql" 2>"$ERRFILE" || true
86+
$SQLITE "$db" -cmd ".load $ZIG_EXT" "$sql" >/dev/null 2>"$ERRFILE" || true
8787
}
8888

89-
# Helper to run SQL with Rust/C extension
90-
run_rust() {
89+
run_zig_query() {
9190
local db="$1"
9291
local sql="$2"
93-
$SQLITE "$db" -cmd ".load $RUST_EXT sqlite3_crsqlite_init" "$sql" 2>"$ERRFILE" || true
92+
$SQLITE "$db" -cmd ".load $ZIG_EXT" "$sql" 2>>"$ERRFILE" || true
93+
}
94+
95+
# Helper to run SQL with Rust/C extension (setup then query separately)
96+
run_rust_setup() {
97+
local db="$1"
98+
local sql="$2"
99+
$SQLITE "$db" -cmd ".load $RUST_EXT sqlite3_crsqlite_init" "$sql" >/dev/null 2>"$ERRFILE" || true
100+
}
101+
102+
run_rust_query() {
103+
local db="$1"
104+
local sql="$2"
105+
$SQLITE "$db" -cmd ".load $RUST_EXT sqlite3_crsqlite_init" "$sql" 2>>"$ERRFILE" || true
94106
}
95107

96108
# Check for blocking errors
@@ -117,16 +129,19 @@ echo ""
117129
DB_ZIG_1="$TMPDIR/test1_zig.db"
118130
DB_RUST_1="$TMPDIR/test1_rust.db"
119131

120-
# Run same SQL on both implementations
121-
SQL_1="
132+
# Run setup (output suppressed)
133+
SETUP_1="
122134
CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, data BLOB);
123-
SELECT crsql_as_crr('t') WHERE 0;
135+
SELECT crsql_as_crr('t');
124136
INSERT INTO t VALUES (1, X'');
125-
SELECT quote(val) FROM crsql_changes WHERE [table]='t' AND cid='data';
126137
"
127138

128-
ZIG_RESULT_1=$(run_zig "$DB_ZIG_1" "$SQL_1")
129-
RUST_RESULT_1=$(run_rust "$DB_RUST_1" "$SQL_1")
139+
QUERY_1="SELECT quote(val) FROM crsql_changes WHERE [table]='t' AND cid='data';"
140+
141+
run_zig_setup "$DB_ZIG_1" "$SETUP_1"
142+
run_rust_setup "$DB_RUST_1" "$SETUP_1"
143+
ZIG_RESULT_1=$(run_zig_query "$DB_ZIG_1" "$QUERY_1")
144+
RUST_RESULT_1=$(run_rust_query "$DB_RUST_1" "$QUERY_1")
130145

131146
if is_blocked; then
132147
echo " SKIP: Required functions not implemented"
@@ -157,16 +172,19 @@ echo ""
157172
DB_ZIG_2="$TMPDIR/test2_zig.db"
158173
DB_RUST_2="$TMPDIR/test2_rust.db"
159174

160-
SQL_2="
175+
SETUP_2="
161176
CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, data BLOB);
162-
SELECT crsql_as_crr('t') WHERE 0;
177+
SELECT crsql_as_crr('t');
163178
INSERT INTO t VALUES (2, X'1234');
164179
UPDATE t SET data = X'' WHERE id = 2;
165-
SELECT quote(val) FROM crsql_changes WHERE [table]='t' AND cid='data' ORDER BY db_version DESC LIMIT 1;
166180
"
167181

168-
ZIG_RESULT_2=$(run_zig "$DB_ZIG_2" "$SQL_2")
169-
RUST_RESULT_2=$(run_rust "$DB_RUST_2" "$SQL_2")
182+
QUERY_2="SELECT quote(val) FROM crsql_changes WHERE [table]='t' AND cid='data' ORDER BY db_version DESC LIMIT 1;"
183+
184+
run_zig_setup "$DB_ZIG_2" "$SETUP_2"
185+
run_rust_setup "$DB_RUST_2" "$SETUP_2"
186+
ZIG_RESULT_2=$(run_zig_query "$DB_ZIG_2" "$QUERY_2")
187+
RUST_RESULT_2=$(run_rust_query "$DB_RUST_2" "$QUERY_2")
170188

171189
if is_blocked; then
172190
echo " SKIP: Required functions not implemented"
@@ -197,15 +215,18 @@ echo ""
197215
DB_ZIG_3="$TMPDIR/test3_zig.db"
198216
DB_RUST_3="$TMPDIR/test3_rust.db"
199217

200-
SQL_3="
218+
SETUP_3="
201219
CREATE TABLE t2 (id INTEGER PRIMARY KEY NOT NULL, txt TEXT, blb BLOB);
202-
SELECT crsql_as_crr('t2') WHERE 0;
220+
SELECT crsql_as_crr('t2');
203221
INSERT INTO t2 VALUES (1, '', X'');
204-
SELECT cid, quote(val) FROM crsql_changes WHERE [table]='t2' AND cid IN ('txt', 'blb') ORDER BY cid;
205222
"
206223

207-
ZIG_RESULT_3=$(run_zig "$DB_ZIG_3" "$SQL_3")
208-
RUST_RESULT_3=$(run_rust "$DB_RUST_3" "$SQL_3")
224+
QUERY_3="SELECT cid, quote(val) FROM crsql_changes WHERE [table]='t2' AND cid IN ('txt', 'blb') ORDER BY cid;"
225+
226+
run_zig_setup "$DB_ZIG_3" "$SETUP_3"
227+
run_rust_setup "$DB_RUST_3" "$SETUP_3"
228+
ZIG_RESULT_3=$(run_zig_query "$DB_ZIG_3" "$QUERY_3")
229+
RUST_RESULT_3=$(run_rust_query "$DB_RUST_3" "$QUERY_3")
209230

210231
if is_blocked; then
211232
echo " SKIP: Required functions not implemented"
@@ -239,18 +260,19 @@ echo ""
239260
DB_ZIG_4="$TMPDIR/test4_zig.db"
240261
DB_RUST_4="$TMPDIR/test4_rust.db"
241262

242-
SQL_4="
263+
SETUP_4="
243264
CREATE TABLE t2 (id INTEGER PRIMARY KEY NOT NULL, txt TEXT, blb BLOB);
244265
SELECT crsql_as_crr('t2');
245266
INSERT INTO t2 VALUES (2, NULL, NULL);
246267
INSERT INTO t2 VALUES (3, '', X'');
247-
SELECT pk, cid, quote(val) FROM crsql_changes
248-
WHERE [table]='t2' AND cid IN ('txt', 'blb')
249-
ORDER BY pk, cid;
250268
"
251269

252-
ZIG_RESULT_4=$(run_zig "$DB_ZIG_4" "$SQL_4")
253-
RUST_RESULT_4=$(run_rust "$DB_RUST_4" "$SQL_4")
270+
QUERY_4="SELECT hex(pk), cid, quote(val) FROM crsql_changes WHERE [table]='t2' AND cid IN ('txt', 'blb') ORDER BY pk, cid;"
271+
272+
run_zig_setup "$DB_ZIG_4" "$SETUP_4"
273+
run_rust_setup "$DB_RUST_4" "$SETUP_4"
274+
ZIG_RESULT_4=$(run_zig_query "$DB_ZIG_4" "$QUERY_4")
275+
RUST_RESULT_4=$(run_rust_query "$DB_RUST_4" "$QUERY_4")
254276

255277
if is_blocked; then
256278
echo " SKIP: Required functions not implemented"
@@ -285,19 +307,19 @@ DB_ZIG_5="$TMPDIR/test5_zig.db"
285307
DB_RUST_5="$TMPDIR/test5_rust.db"
286308

287309
# Create table in both DBs
288-
SQL_SETUP_5="
310+
SETUP_5="
289311
CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, data BLOB);
290312
SELECT crsql_as_crr('t');
291313
"
292314

293-
run_zig "$DB_ZIG_5" "$SQL_SETUP_5"
294-
run_rust "$DB_RUST_5" "$SQL_SETUP_5"
315+
run_zig_setup "$DB_ZIG_5" "$SETUP_5"
316+
run_rust_setup "$DB_RUST_5" "$SETUP_5"
295317

296318
# Insert empty blob in Zig
297-
run_zig "$DB_ZIG_5" "INSERT INTO t VALUES (1, X'');"
319+
run_zig_setup "$DB_ZIG_5" "INSERT INTO t VALUES (1, X'');"
298320

299321
# Extract the change record from Zig
300-
ZIG_CHANGE=$(run_zig "$DB_ZIG_5" "
322+
ZIG_CHANGE=$(run_zig_query "$DB_ZIG_5" "
301323
SELECT [table], hex(pk), cid, quote(val), col_version, db_version, hex(site_id), cl, seq
302324
FROM crsql_changes WHERE [table]='t' AND cid='data';
303325
")
@@ -319,13 +341,13 @@ else
319341
SEQ=$(echo "$ZIG_CHANGE" | cut -d'|' -f9)
320342

321343
# Apply to Oracle
322-
run_rust "$DB_RUST_5" "
344+
run_rust_setup "$DB_RUST_5" "
323345
INSERT INTO crsql_changes ([table], pk, cid, val, col_version, db_version, site_id, cl, seq)
324346
VALUES ('$TABLE', X'$HEX_PK', '$CID', $QUOTED_VAL, $COL_VER, $DB_VER, X'$HEX_SITE', $CL, $SEQ);
325347
"
326348

327349
# Check what Oracle received
328-
RUST_FINAL=$(run_rust "$DB_RUST_5" "SELECT quote(data) FROM t WHERE id=1;")
350+
RUST_FINAL=$(run_rust_query "$DB_RUST_5" "SELECT quote(data) FROM t WHERE id=1;")
329351

330352
if [[ "$RUST_FINAL" == "X''" ]]; then
331353
echo " PASS: Empty blob synced correctly to Oracle"
@@ -359,17 +381,18 @@ echo ""
359381
DB_ZIG_6="$TMPDIR/test6_zig.db"
360382
DB_RUST_6="$TMPDIR/test6_rust.db"
361383

362-
SQL_6="
384+
SETUP_6="
363385
CREATE TABLE t3 (id INTEGER PRIMARY KEY NOT NULL, v1 BLOB, v2 TEXT, v3 BLOB);
364386
SELECT crsql_as_crr('t3');
365387
INSERT INTO t3 VALUES (1, X'', '', NULL);
366-
SELECT cid, typeof(val), quote(val) FROM crsql_changes
367-
WHERE [table]='t3' AND cid IN ('v1', 'v2', 'v3')
368-
ORDER BY cid;
369388
"
370389

371-
ZIG_RESULT_6=$(run_zig "$DB_ZIG_6" "$SQL_6")
372-
RUST_RESULT_6=$(run_rust "$DB_RUST_6" "$SQL_6")
390+
QUERY_6="SELECT cid, typeof(val), quote(val) FROM crsql_changes WHERE [table]='t3' AND cid IN ('v1', 'v2', 'v3') ORDER BY cid;"
391+
392+
run_zig_setup "$DB_ZIG_6" "$SETUP_6"
393+
run_rust_setup "$DB_RUST_6" "$SETUP_6"
394+
ZIG_RESULT_6=$(run_zig_query "$DB_ZIG_6" "$QUERY_6")
395+
RUST_RESULT_6=$(run_rust_query "$DB_RUST_6" "$QUERY_6")
373396

374397
if is_blocked; then
375398
echo " SKIP: Required functions not implemented"

0 commit comments

Comments
 (0)