From 6f2f9b6f0af24abbf5053904bacb22ec7d77bd2c Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Sat, 15 Aug 2026 12:57:02 +0200 Subject: [PATCH] feat: secondary indexes + @unique trap (iteration 9, Task 4; wob v3) - .wob v3: class records carry an index tail (flags bit0 = unique, col_cnt, columns) -- @table(index:[a,b]) entries plus one unique single-column entry per @unique field; loader validates columns in range and scalar/Text-kinded; emitter validates the declarations (unknown column, un-indexable kind => diagnostic) - engine: db_index hash multimap per table, built from the class table at first touch, maintained ONLY inside wo_row_insert/ wo_row_remove; unique checks re-compare actual column values (a hash is a hint); replay re-indexes via wo_row_raw_commit AFTER slots are filled, so recovered tables carry their indexes - WO_T_UNIQUE = 10; a violating insert is un-applied whole (bitmap, hash, count, and the never-observable id reclaimed) and traps catchably -- the employee SEED-DUP pattern - wo_row_insert gains err_kind so db.c maps UNIQUE/OOM/other to the right trap; test images and the runner's loader mirror speak v3 - fixtures: trap/db-unique-violation (code 10 exact) and run/db-unique-catch (catchable dup, composite index accepts duplicates, next id dense after a refusal) - gates: oop-e2e 73/0, all 15 runtime suites, woc-test green, log-watcher 7/0 Co-Authored-By: Claude Opus 5 (1M context) --- compiler/src/disasm.ml | 10 +- compiler/src/emit.ml | 98 +++++++++- compiler/test/runner.ml | 22 ++- database/src/db.c | 8 +- database/src/table.c | 176 +++++++++++++++++- database/src/table.h | 34 +++- database/src/wal.c | 12 +- runtime/src/loader.c | 35 +++- runtime/src/loader.h | 1 + runtime/src/wob.h | 11 +- runtime/test/test_table.c | 22 +-- runtime/test/test_wal.c | 10 +- runtime/test/wob_build.c | 1 + tests/corpus/run/db-unique-catch/fixture.out | 4 + tests/corpus/run/db-unique-catch/fixture.wo | 26 +++ .../trap/db-unique-violation/fixture.trap | 1 + .../trap/db-unique-violation/fixture.wo | 12 ++ 17 files changed, 441 insertions(+), 42 deletions(-) create mode 100644 tests/corpus/run/db-unique-catch/fixture.out create mode 100644 tests/corpus/run/db-unique-catch/fixture.wo create mode 100644 tests/corpus/trap/db-unique-violation/fixture.trap create mode 100644 tests/corpus/trap/db-unique-violation/fixture.wo diff --git a/compiler/src/disasm.ml b/compiler/src/disasm.ml index a30ca0c..ecd0f86 100644 --- a/compiler/src/disasm.ml +++ b/compiler/src/disasm.ml @@ -164,7 +164,7 @@ let dump (img : string) : string = let line fmt = Buffer.add_string out (fmt ^ "\n") in if u32 img 0 <> magic then raise (Bad "bad magic"); let ver = u32 img 4 in - if ver <> 2 then raise (Bad (Printf.sprintf "unsupported version %d" ver)); + if ver <> 3 then raise (Bad (Printf.sprintf "unsupported version %d" ver)); let coff = u32 img 8 and ccnt = u32 img 12 in let koff = u32 img 16 and kcnt = u32 img 20 in let ioff = u32 img 24 and icnt = u32 img 28 in @@ -213,6 +213,14 @@ let dump (img : string) : string = renders as keys, so a wrong one is worth seeing. *) let names = List.init fcnt (fun j -> u32 img (!o + (j * 4))) in o := !o + (fcnt * 12); + (* v3 index tail: walk past (the disassembly prints class shape, not + indexes — dump goldens stay byte-stable across the version bump) *) + let icnt = u32 img !o in + o := !o + 4; + for _ = 1 to icnt do + let ccnt = u32 img (!o + 4) in + o := !o + 8 + (ccnt * 4) + done; let fields = List.map2 (fun nmk k -> if nmk = 0xFFFFFFFF then k else Printf.sprintf "%s:%s" (kname nmk) k) diff --git a/compiler/src/emit.ml b/compiler/src/emit.ml index 519cc56..136b40b 100644 --- a/compiler/src/emit.ml +++ b/compiler/src/emit.ml @@ -152,7 +152,7 @@ let stdlib_not_linked_code = Diag.emitter_prefix ^ "06" ============================================================ *) let wob_magic = 0x31424F57 (* "WOB1" read as an LE u32 *) -let wob_version = 2 (* v2: per-field class-table metadata *) +let wob_version = 3 (* v3: v2 + per-class secondary-index metadata *) let wob_hdr_size = 44 let wob_none = 0xFFFFFFFF let k_int = 0 @@ -349,6 +349,11 @@ type clsrec = { cr_gc : bool; cr_fields : (string * Ast.field_ty) array; cr_methods : string list; (* method names, declaration order *) + (* iteration 9 Task 4: (unique, column indices) per secondary index — + `@table(index: [a, b])` entries (non-unique, composite) plus one + unique single-column entry per `@unique` field. Serialized as the v3 + class-record tail; the engine builds its runtime indexes from this. *) + cr_indexes : (bool * int array) list; } type ifacerec = { @@ -3923,6 +3928,18 @@ let emit ~(syms : Types.symbols) ~(module_of : string -> string) ~(module_syms : (string, Types.symbols) Hashtbl.t) (coll : Diag.Collector.t) (units : input list) : string = let colliding = compute_colliding_fn_names ~module_of units in + (* iteration 9 Task 4: index-declaration problems found while building + clsrecs — reported once a file/pos-bearing context exists below *) + let index_col_err : (Ast.pos * string) option ref = ref None in + let index_err_file = ref "" in + let ref_index_of_name (fnames : string list) (n : string) : int = + let rec go i = function + | [] -> 0 (* unknown column: the caller records the diagnostic *) + | x :: tl -> if x = n then i else go (i + 1) tl + in + go 0 fnames + in + let p_syms_for_indexes = syms in (* ---- pass 1: declarations, in discovery then declaration order ---- *) let classes = ref [] and class_id = ref SM.empty and nclasses = ref 0 in let ifaces = ref [] and iface_id = ref SM.empty and nifaces = ref 0 and nslots = ref 0 in @@ -3961,6 +3978,7 @@ let emit ~(syms : Types.symbols) ~(module_of : string -> string) (function | Ast.Class (c : Ast.class_decl) -> if not (SM.mem c.name !class_id) then begin + (if !index_col_err = None then index_err_file := u.file); let shape = if c.is_record then Some (record_shape_key c) else None in let alias_of = match shape with Some key -> Hashtbl.find_opt record_shape key | None -> None @@ -3978,10 +3996,58 @@ let emit ~(syms : Types.symbols) ~(module_of : string -> string) | Some key -> Hashtbl.replace record_shape key cid | None -> ()); classes := - { cr_name = c.name; cr_gc = c.is_gc; - cr_fields = - Array.of_list (List.map (fun (fl : Ast.field) -> (fl.name, fl.ty)) c.fields); - cr_methods = List.map (fun (m : Ast.method_decl) -> m.name) c.methods } + (let fnames = List.map (fun (fl : Ast.field) -> fl.Ast.name) c.fields in + let col_of n = ref_index_of_name fnames n in + let is_indexable (fl : Ast.field) = + match Types.wob_kind_of_typ p_syms_for_indexes (Types.typ_of_field_ty (unwrap fl.Ast.ty)) with + | Types.WO_K_SCALAR | Types.WO_K_TEXT -> true + | _ -> false + in + let table_indexes = + match c.Ast.table with + | None -> [] + | Some cfg -> + List.map + (fun cols -> (false, Array.of_list (List.map col_of cols))) + cfg.Ast.indexes + in + let unique_indexes = + List.concat_map + (fun (fl : Ast.field) -> + if List.mem "unique" fl.Ast.annotations then begin + if not (is_indexable fl) then + index_col_err := Some (c.Ast.pos, Printf.sprintf + "`@unique` on `%s.%s`: only scalar and Text fields can be indexed" + c.Ast.name fl.Ast.name); + [ (true, [| col_of fl.Ast.name |]) ] + end + else []) + c.fields + in + (match c.Ast.table with + | Some cfg -> + List.iter + (fun cols -> + List.iter + (fun cn -> + match List.find_opt (fun (fl : Ast.field) -> fl.Ast.name = cn) c.fields with + | None -> + index_col_err := Some (c.Ast.pos, Printf.sprintf + "`@table(index: ...)` on `%s` names `%s`, which is not a field" + c.Ast.name cn) + | Some fl -> + if not (is_indexable fl) then + index_col_err := Some (c.Ast.pos, Printf.sprintf + "`@table(index: ...)` on `%s`: `%s` is not a scalar or Text field" + c.Ast.name cn)) + cols) + cfg.Ast.indexes + | None -> ()); + { cr_name = c.name; cr_gc = c.is_gc; + cr_fields = + Array.of_list (List.map (fun (fl : Ast.field) -> (fl.name, fl.ty)) c.fields); + cr_methods = List.map (fun (m : Ast.method_decl) -> m.name) c.methods; + cr_indexes = table_indexes @ unique_indexes }) :: !classes end | Ast.Union (ud : Ast.union_decl) -> @@ -4001,7 +4067,7 @@ let emit ~(syms : Types.symbols) ~(module_of : string -> string) class_id := SM.add key cid !class_id; incr nclasses; classes := - { cr_name = key; cr_gc = false; + { cr_name = key; cr_gc = false; cr_indexes = []; cr_fields = Array.of_list vd.Ast.v_fields; cr_methods = [] } :: !classes @@ -4044,11 +4110,18 @@ let emit ~(syms : Types.symbols) ~(module_of : string -> string) class_id := SM.add name cid !class_id; incr nclasses; classes := - { cr_name = name; cr_gc = false; cr_fields = Array.of_list fields; cr_methods = [] } + { cr_name = name; cr_gc = false; cr_fields = Array.of_list fields; cr_methods = []; + cr_indexes = [] } :: !classes end) Types.predeclared_records; let class_id = !class_id in + (match !index_col_err with + | Some (pos, msg) -> + Diag.Collector.add coll + (Diag.error ~code:cannot_lower_code ~file:!index_err_file ~line:pos.Ast.line + ~col:pos.Ast.col ~message:msg ()) + | None -> ()); let p_classes = Array.of_list (List.rev !classes) in let p_ifaces = Array.of_list (List.rev !ifaces) in List.iter @@ -4220,7 +4293,16 @@ let emit ~(syms : Types.symbols) ~(module_of : string -> string) needs when decode creates one. *) Array.iter (fun kidx -> Buf.u32 cls kidx) class_field_names.(cid); Array.iter (fun (_, ty) -> Buf.u32 cls (field_class_meta p ty)) c.cr_fields; - Array.iter (fun (_, ty) -> Buf.u32 cls (field_elem_meta p ty)) c.cr_fields) + Array.iter (fun (_, ty) -> Buf.u32 cls (field_elem_meta p ty)) c.cr_fields; + (* v3 tail (iteration 9 Task 4): the class's secondary indexes — + index_cnt, then per index: flags (bit0 unique), col_cnt, cols *) + Buf.u32 cls (List.length c.cr_indexes); + List.iter + (fun (uniq, cols) -> + Buf.u32 cls (if uniq then 1 else 0); + Buf.u32 cls (Array.length cols); + Array.iter (fun ci -> Buf.u32 cls ci) cols) + c.cr_indexes) p_classes; let ifs = Buf.create () in Array.iteri diff --git a/compiler/test/runner.ml b/compiler/test/runner.ml index 0291c31..ba3cfd0 100644 --- a/compiler/test/runner.ml +++ b/compiler/test/runner.ml @@ -2382,7 +2382,7 @@ let validate_image (img : string) : string list = let u64 o = if ok 8 o then String.get_int64_le img o else 0L in let none = 0xFFFFFFFF in if u32 0 <> 0x31424F57 then fail "bad magic"; - if u32 4 <> 2 then fail "unsupported version"; + if u32 4 <> 3 then fail "unsupported version"; let coff = u32 8 and ccnt = u32 12 in let koff = u32 16 and kcnt = u32 20 in let ioff = u32 24 and icnt = u32 28 in @@ -2419,6 +2419,7 @@ let validate_image (img : string) : string list = if flags land lnot 0x01 <> 0 then fail (Printf.sprintf "class %d: unknown flags" i); if fcnt > 65535 then fail (Printf.sprintf "class %d: too many fields" i); class_fields.(i) <- fcnt; + let kco = !o in (* the kind bytes' offset: the v3 index walk re-reads them *) for j = 0 to fcnt - 1 do if u8 (!o + j) > 5 then fail (Printf.sprintf "class %d field %d: bad kind" i j) done; @@ -2435,6 +2436,25 @@ let validate_image (img : string) : string list = fail (Printf.sprintf "class %d field %d: field class out of range" i j) done; o := !o + (fcnt * 12); + (* v3: the index tail — flags (bit0 only), col_cnt 1..8, columns in + range and scalar/Text-kinded. Mirrors loader.c's checks. *) + let icnt_x = u32 !o in + o := !o + 4; + if icnt_x > 64 then fail (Printf.sprintf "class %d: too many indexes" i); + for x = 0 to icnt_x - 1 do + let ifl = u32 !o and ccnt = u32 (!o + 4) in + o := !o + 8; + if ifl land lnot 1 <> 0 then fail (Printf.sprintf "class %d index %d: unknown flags" i x); + if ccnt = 0 || ccnt > 8 then fail (Printf.sprintf "class %d index %d: bad column count" i x); + for c = 0 to ccnt - 1 do + let col = u32 !o in + o := !o + 4; + if col >= fcnt then fail (Printf.sprintf "class %d index %d: column out of range" i x); + let kind = u8 (kco + col) in + if kind <> 0 && kind <> 3 then + fail (Printf.sprintf "class %d index %d: column %d is not scalar or Text" i x c) + done + done; if !o > len then fail (Printf.sprintf "class %d: truncated" i) done; (* interfaces + vtable rows *) diff --git a/database/src/db.c b/database/src/db.c index abe8931..4347fbb 100644 --- a/database/src/db.c +++ b/database/src/db.c @@ -13,8 +13,12 @@ int wo_builtin_db(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) { switch (C) { case WO_B_DB_INSERT: { uint32_t cid = (uint32_t)R[B]; - uint64_t id = wo_row_insert(db, cid, &R[B + 1], msg); - if (!id) return WO_T_DB; /* *msg already set (OOM / bad kind) */ + int ek = 0; + uint64_t id = wo_row_insert(db, cid, &R[B + 1], msg, &ek); + if (!id) + return ek == DB_ERR_UNIQUE ? WO_T_UNIQUE + : ek == DB_ERR_OOM ? WO_T_OOM + : WO_T_DB; wo_wal *w = (wo_wal *)vm->rt.wal; if (w) { /* RAM applied, record staged, ONE commit before the ack (the diff --git a/database/src/table.c b/database/src/table.c index a9217b9..5da75c0 100644 --- a/database/src/table.c +++ b/database/src/table.c @@ -266,6 +266,120 @@ static void hdel(db_table *t, uint64_t id) { /* ---- tables and rows ---------------------------------------------------- */ +/* ---- secondary indexes (Task 4) ---------------------------------------- */ + +/* hash of one row's index columns: kind-driven, never trusted for equality */ +static uint64_t idx_hash(const wo_classdesc *c, const db_index *ix, const db_row *r) { + uint64_t h = 0x9e3779b97f4a7c15ull; + for (uint32_t i = 0; i < ix->col_cnt; i++) { + uint32_t col = ix->cols[i]; + uint64_t v = r->slots[col]; + if (c->kinds[col] == WO_K_TEXT) { + const db_text *t = (const db_text *)(uintptr_t)v; + uint64_t th = 1469598103934665603ull; /* FNV-1a over bytes; nil = 0 */ + if (t) + for (uint32_t b = 0; b < t->len; b++) th = (th ^ (uint8_t)t->bytes[b]) * 1099511628211ull; + else th = 0; + v = th; + } + h ^= hmix(v + i); + } + return h ? h : 1; /* 0 marks an empty bucket */ +} + +static int idx_cols_equal(const wo_classdesc *c, const db_index *ix, const db_row *a, + const db_row *b) { + for (uint32_t i = 0; i < ix->col_cnt; i++) { + uint32_t col = ix->cols[i]; + if (c->kinds[col] == WO_K_TEXT) { + const db_text *x = (const db_text *)(uintptr_t)a->slots[col]; + const db_text *y = (const db_text *)(uintptr_t)b->slots[col]; + if (!x || !y) { + if (x != y) return 0; + } else if (x->len != y->len || memcmp(x->bytes, y->bytes, x->len) != 0) + return 0; + } else if (a->slots[col] != b->slots[col]) + return 0; + } + return 1; +} + +static db_ibucket *idx_bucket(db_index *ix, uint64_t h, int create) { + if (ix->bcap == 0) { + if (!create) return NULL; + ix->buckets = calloc(64, sizeof(db_ibucket)); + if (!ix->buckets) return NULL; + ix->bcap = 64; + } + if (create && ix->blen * 10 >= ix->bcap * 7) { + size_t ncap = ix->bcap * 2; + db_ibucket *nb = calloc(ncap, sizeof(db_ibucket)); + if (!nb) return NULL; + for (size_t i = 0; i < ix->bcap; i++) { + if (!ix->buckets[i].hash) continue; + size_t j = ix->buckets[i].hash & (ncap - 1); + while (nb[j].hash) j = (j + 1) & (ncap - 1); + nb[j] = ix->buckets[i]; + } + free(ix->buckets); + ix->buckets = nb; + ix->bcap = ncap; + } + size_t j = h & (ix->bcap - 1); + while (ix->buckets[j].hash) { + if (ix->buckets[j].hash == h) return &ix->buckets[j]; + j = (j + 1) & (ix->bcap - 1); + } + if (!create) return NULL; + ix->buckets[j].hash = h; + ix->blen++; + return &ix->buckets[j]; +} + +/* Add [r] to every index; unique violation reports which without mutating + * anything (checks run before any add). 0 ok, DB_ERR_* otherwise. */ +static int idx_add_row(wo_db *db, db_table *t, db_row *r) { + const wo_classdesc *c = &db->classes[t->class_id]; + for (uint32_t x = 0; x < t->index_cnt; x++) { + db_index *ix = &t->indexes[x]; + if (!(ix->flags & 1u)) continue; + db_ibucket *b = idx_bucket(ix, idx_hash(c, ix, r), 0); + if (!b) continue; + for (uint32_t i = 0; i < b->len; i++) { + db_row *other = wo_row_ptr(db, t->class_id, b->ids[i]); + if (other && idx_cols_equal(c, ix, r, other)) return DB_ERR_UNIQUE; + } + } + for (uint32_t x = 0; x < t->index_cnt; x++) { + db_index *ix = &t->indexes[x]; + db_ibucket *b = idx_bucket(ix, idx_hash(c, ix, r), 1); + if (!b) return DB_ERR_OOM; + if (b->len == b->cap) { + uint32_t ncap = b->cap ? b->cap * 2 : 4; + uint64_t *ni = realloc(b->ids, (size_t)ncap * 8u); + if (!ni) return DB_ERR_OOM; + b->ids = ni; + b->cap = ncap; + } + b->ids[b->len++] = r->id; + } + return 0; +} + +static void idx_remove_row(wo_db *db, db_table *t, db_row *r) { + const wo_classdesc *c = &db->classes[t->class_id]; + for (uint32_t x = 0; x < t->index_cnt; x++) { + db_index *ix = &t->indexes[x]; + db_ibucket *b = idx_bucket(ix, idx_hash(c, ix, r), 0); + if (!b) continue; + for (uint32_t i = 0; i < b->len; i++) + if (b->ids[i] == r->id) { + b->ids[i] = b->ids[--b->len]; + break; + } + } +} + int wo_db_init(wo_db *db, const wo_classdesc *classes, uint32_t class_cnt, uint32_t shard, uint32_t nshards) { if (!nshards || shard >= nshards) return -1; @@ -298,6 +412,11 @@ static void table_destroy(wo_db *db, db_table *t) { free(t->free_slots); free(t->hkeys); free(t->hvals); + for (uint32_t x = 0; x < t->index_cnt; x++) { + for (size_t b = 0; b < t->indexes[x].bcap; b++) free(t->indexes[x].buckets[b].ids); + free(t->indexes[x].buckets); + } + free(t->indexes); } void wo_db_destroy(wo_db *db) { @@ -312,9 +431,22 @@ static db_table *table_of(wo_db *db, uint32_t class_id) { if (class_id >= db->class_cnt) return NULL; db_table *t = &db->tables[class_id]; if (!t->row_size) { /* lazy init on first touch */ + const wo_classdesc *c = &db->classes[class_id]; t->class_id = class_id; - t->row_size = sizeof(db_row) + (size_t)db->classes[class_id].field_cnt * 8u; + t->row_size = sizeof(db_row) + (size_t)c->field_cnt * 8u; t->next_id = db->shard + 1; /* S+1, then += N: interleaved, local-only */ + if (c->idx_cnt) { + t->indexes = calloc(c->idx_cnt, sizeof(db_index)); + if (!t->indexes) return NULL; + const uint32_t *im = c->idx_meta; + for (uint32_t x = 0; x < c->idx_cnt; x++) { + t->indexes[x].flags = im[0]; + t->indexes[x].col_cnt = im[1]; + t->indexes[x].cols = im + 2; + im += 2 + im[1]; + } + t->index_cnt = c->idx_cnt; + } } return t; } @@ -356,7 +488,8 @@ static uint32_t slot_alloc(db_table *t) { } uint64_t wo_row_insert(wo_db *db, uint32_t class_id, const uint64_t *vals, - const char **msg) { + const char **msg, int *err_kind) { + if (err_kind) *err_kind = DB_ERR_MISC; db_table *t = table_of(db, class_id); if (!t) { *msg = "no such class"; @@ -375,7 +508,10 @@ uint64_t wo_row_insert(wo_db *db, uint32_t class_id, const uint64_t *vals, uint32_t i = 0; for (; i < c->field_cnt; i++) { r->slots[i] = db_val_encode(db->classes, c->kinds[i], vals[i], &ok, msg); - if (!ok) break; + if (!ok) { + if (err_kind) *err_kind = DB_ERR_BADKIND; + break; + } } if (!ok) { for (uint32_t j = 0; j < i; j++) db_val_free(c->kinds[j], r->slots[j]); @@ -395,13 +531,28 @@ uint64_t wo_row_insert(wo_db *db, uint32_t class_id, const uint64_t *vals, t->next_id += db->nshards; if (hput(t, r->id, (uint64_t)g + 1) != 0) { for (uint32_t j = 0; j < c->field_cnt; j++) db_val_free(c->kinds[j], r->slots[j]); + if (err_kind) *err_kind = DB_ERR_OOM; *msg = "out of memory indexing a row"; return 0; } t->bitmap[g >> 6] |= 1ull << (g & 63); t->count++; - /* INDEX HOOK (Task 4): secondary indexes update here, inside the choke - point, never anywhere else. */ + /* THE index hook (Task 4): inside the choke point, never anywhere else. + A unique violation un-applies the row entirely — id never handed out + twice matters less than the row never having existed. */ + int irc = idx_add_row(db, t, r); + if (irc != 0) { + t->bitmap[g >> 6] &= ~(1ull << (g & 63)); + hdel(t, r->id); + t->count--; + t->next_id -= db->nshards; /* the id was never observable: reclaim it */ + for (uint32_t j = 0; j < c->field_cnt; j++) db_val_free(c->kinds[j], r->slots[j]); + if (t->free_cnt < t->free_cap) t->free_slots[t->free_cnt++] = g; + if (err_kind) *err_kind = irc; + *msg = irc == DB_ERR_UNIQUE ? "unique index violation" : "out of memory indexing a row"; + return 0; + } + if (err_kind) *err_kind = DB_ERR_NONE; return r->id; } @@ -444,12 +595,16 @@ db_row *wo_row_create_raw(wo_db *db, uint32_t class_id, uint64_t id) { /* keep the interleave: only ids this shard owns move its counter */ if ((id - 1) % db->nshards == db->shard && id >= t->next_id) t->next_id = id + db->nshards; - /* INDEX HOOK (Task 4): replayed rows re-index here, same as inserts — - the caller fills slots BEFORE indexes exist on them (Task 4 will move - the hook to a post-fill call, recorded in the binding doc). */ + /* indexes: NOT here — the slots are still zero. wal.c fills them and + then calls wo_row_raw_commit, which is where replayed rows re-index. */ return r; } +int wo_row_raw_commit(wo_db *db, uint32_t class_id, db_row *r) { + db_table *t = &db->tables[class_id]; + return idx_add_row(db, t, r) == 0 ? 0 : -1; +} + void wo_db_val_free(wo_db *db, uint8_t kind, uint64_t v) { (void)db; db_val_free(kind, v); @@ -463,8 +618,9 @@ int wo_row_remove(wo_db *db, uint32_t class_id, uint64_t id) { if (!s1) return -1; uint32_t g = (uint32_t)(s1 - 1); db_row *r = slot_row(t, g); - /* INDEX HOOK (Task 4): secondary indexes remove here, before the row's - values die. */ + /* the index hook's remove side: before the row's values die, while the + columns are still comparable */ + idx_remove_row(db, t, r); const wo_classdesc *c = &db->classes[class_id]; for (uint32_t i = 0; i < c->field_cnt; i++) db_val_free(c->kinds[i], r->slots[i]); t->bitmap[g >> 6] &= ~(1ull << (g & 63)); diff --git a/database/src/table.h b/database/src/table.h index 78588f2..3890957 100644 --- a/database/src/table.h +++ b/database/src/table.h @@ -78,6 +78,29 @@ typedef struct db_row { #define DB_SLAB_ROWS 256u +/* Secondary index (iteration 9, Task 4): built from the class table's v3 + * metadata at first touch, maintained ONLY inside the row choke points. + * Hash multimap: bucket per column-value hash, ids within; equality is + * re-checked against the actual rows on the unique path (a hash is a hint, + * never an answer). */ +typedef struct db_ibucket { + uint64_t hash; + uint64_t *ids; + uint32_t len, cap; +} db_ibucket; + +typedef struct db_index { + uint32_t flags; /* bit0 = unique */ + uint32_t col_cnt; + const uint32_t *cols; /* into the loader's idx pool */ + db_ibucket *buckets; /* open addressing by hash; hash==0 stored as 1 */ + size_t bcap, blen; +} db_index; + +/* wo_row_insert failure classes — *msg carries the sentence, this carries + * the machine-readable kind so db.c maps to the right trap. */ +enum { DB_ERR_NONE = 0, DB_ERR_OOM = 1, DB_ERR_BADKIND = 2, DB_ERR_UNIQUE = 3, DB_ERR_MISC = 4 }; + typedef struct db_table { uint32_t class_id; size_t row_size; /* 16 + field_cnt * 8 */ @@ -94,6 +117,9 @@ typedef struct db_table { uint64_t *hkeys; uint64_t *hvals; size_t hcap, hlen; + /* secondary indexes, from the class table's v3 metadata */ + db_index *indexes; + uint32_t index_cnt; } db_table; typedef struct wo_db { @@ -112,7 +138,7 @@ void wo_db_destroy(wo_db *db); * table) into a fresh row. Returns the new id, or 0 with *msg set (OOM, or * a GCREF field — which the compiler should have refused upstream). */ uint64_t wo_row_insert(wo_db *db, uint32_t class_id, const uint64_t *vals, - const char **msg); + const char **msg, int *err_kind); /* Read: decode the row's fields into VM values freshly allocated from * [rt] — always copies, never a pointer into the slab (the out-gate). @@ -140,4 +166,10 @@ db_row *wo_row_create_raw(wo_db *db, uint32_t class_id, uint64_t id); * decode error paths). */ void wo_db_val_free(wo_db *db, uint8_t kind, uint64_t v); +/* Engine-internal, replay only: after wal.c fills a raw row's slots, this + * runs the index maintenance the normal insert runs inline — including the + * unique check, whose violation during replay is corruption, not data + * (0 ok, -1). */ +int wo_row_raw_commit(wo_db *db, uint32_t class_id, db_row *r); + #endif /* WO_TABLE_H */ diff --git a/database/src/wal.c b/database/src/wal.c index 3bea4df..2e8858c 100644 --- a/database/src/wal.c +++ b/database/src/wal.c @@ -403,7 +403,17 @@ static int apply_record(wo_db *db, const uint8_t *payload, uint32_t len) { return -1; } } - return (size_t)(r.end - r.p) == 0 ? 0 : -1; /* trailing bytes = corrupt */ + if ((size_t)(r.end - r.p) != 0) { /* trailing bytes = corrupt */ + wo_row_remove(db, cid, id); + return -1; + } + /* slots are real now: re-index (Task 4). A unique violation during + * replay is corruption — the live insert would have refused it. */ + if (wo_row_raw_commit(db, cid, row) != 0) { + wo_row_remove(db, cid, id); + return -1; + } + return 0; } int64_t wo_wal_replay(const char *path, wo_db *db) { diff --git a/runtime/src/loader.c b/runtime/src/loader.c index 3804bf8..ecb3985 100644 --- a/runtime/src/loader.c +++ b/runtime/src/loader.c @@ -147,7 +147,7 @@ int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err, m->classes = calloc(kcnt, sizeof(wo_classdesc)); if (!m->classes) BAIL("out of memory"); } - size_t pool_len = 0, meta_pool = 0; + size_t pool_len = 0, meta_pool = 0, idx_pool = 0; for (uint32_t i = 0; i < kcnt; i++) { uint32_t name, flags, fcnt; if (rd_u32(&k, &name) || rd_u32(&k, &flags) || rd_u32(&k, &fcnt)) @@ -203,6 +203,36 @@ int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err, m->classes[i].field_elem = (const uint32_t *)(uintptr_t)(meta_pool + 2u * (size_t)fcnt); pool_len += fcnt ? fcnt : 1; meta_pool += meta_words ? meta_words : 1; + /* v3 tail: secondary indexes — flags/col_cnt/cols per index, columns + bounded and scalar/Text-kinded (the only indexable kinds) */ + uint32_t icnt; + if (rd_u32(&k, &icnt)) BAIL("class %u: truncated index count", (unsigned)i); + if (icnt > 64) BAIL("class %u: too many indexes", (unsigned)i); + m->classes[i].idx_cnt = icnt; + m->classes[i].idx_meta = (const uint32_t *)(uintptr_t)idx_pool; + for (uint32_t x = 0; x < icnt; x++) { + uint32_t iflags, ccnt; + if (rd_u32(&k, &iflags) || rd_u32(&k, &ccnt)) + BAIL("class %u index %u: truncated", (unsigned)i, (unsigned)x); + if (iflags & ~1u) BAIL("class %u index %u: unknown flags", (unsigned)i, (unsigned)x); + if (ccnt == 0 || ccnt > 8) + BAIL("class %u index %u: bad column count", (unsigned)i, (unsigned)x); + uint32_t *ip = realloc(m->idxpool, (idx_pool + 2 + ccnt) * sizeof(uint32_t)); + if (!ip) BAIL("out of memory"); + m->idxpool = ip; + m->idxpool[idx_pool++] = iflags; + m->idxpool[idx_pool++] = ccnt; + for (uint32_t cix = 0; cix < ccnt; cix++) { + uint32_t col; + if (rd_u32(&k, &col)) BAIL("class %u index %u: truncated column", (unsigned)i, (unsigned)x); + if (col >= fcnt) BAIL("class %u index %u: column out of range", (unsigned)i, (unsigned)x); + uint8_t kind = m->kindpool[(uintptr_t)m->classes[i].kinds + col]; + if (kind != WO_K_SCALAR && kind != WO_K_TEXT) + BAIL("class %u index %u: column %u is not scalar or Text", (unsigned)i, + (unsigned)x, (unsigned)col); + m->idxpool[idx_pool++] = col; + } + } m->class_cnt = i + 1; } for (uint32_t i = 0; i < m->class_cnt; i++) { @@ -210,6 +240,8 @@ int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err, m->classes[i].field_names = m->metapool + (uintptr_t)m->classes[i].field_names; m->classes[i].field_class = m->metapool + (uintptr_t)m->classes[i].field_class; m->classes[i].field_elem = m->metapool + (uintptr_t)m->classes[i].field_elem; + m->classes[i].idx_meta = + m->idxpool ? m->idxpool + (uintptr_t)m->classes[i].idx_meta : NULL; } /* ---- interfaces + vtable rows (expanded to sorted triples) ---- */ @@ -532,6 +564,7 @@ void wo_module_free(wo_module *m) { free(m->classes); free(m->kindpool); free(m->metapool); + free(m->idxpool); free(m->vtabs); for (uint32_t i = 0; i < m->method_cnt; i++) { free(m->methods[i].code); diff --git a/runtime/src/loader.h b/runtime/src/loader.h index 36a885f..47ee734 100644 --- a/runtime/src/loader.h +++ b/runtime/src/loader.h @@ -54,6 +54,7 @@ typedef struct wo_module { /* pooled per-field metadata (v2): names, referenced class ids, element kinds — see wob.h's "class-table field metadata" note */ uint32_t *metapool; + uint32_t *idxpool; /* v3 pooled per-class index metadata (flags/cols) */ uint32_t slot_cnt; /* total interface slots across all interfaces */ wo_vtabent *vtabs; uint32_t vtab_cnt; diff --git a/runtime/src/wob.h b/runtime/src/wob.h index 69d0ce7..757f3fc 100644 --- a/runtime/src/wob.h +++ b/runtime/src/wob.h @@ -12,7 +12,7 @@ /* ---- file header (44 bytes, absolute offsets) ---- */ #define WOB_MAGIC 0x31424F57u /* "WOB1" read as LE u32 */ -#define WOB_VERSION 2u /* v2 adds per-field names/types to the class table */ +#define WOB_VERSION 3u /* v3: v2's field metadata + per-class index metadata */ #define WOB_HDR_SIZE 44u #define WOB_OFF_MAGIC 0u #define WOB_OFF_VERSION 4u @@ -121,6 +121,10 @@ enum { * message rides along in the error record, and `try ... catch` is how * a program that expects the failure handles it. */ WO_T_IO = 9, + /* iteration 9 Task 4: a unique-index violation on insert/update — + raised by the engine at the row choke point, catchable like any + trap (the employee sample's SEED-DUP line) */ + WO_T_UNIQUE = 10, }; /* ---- opcodes (spec section 5; semantics in the format doc) ---- */ @@ -330,6 +334,11 @@ typedef struct wo_classdesc { const uint32_t *field_names; /* constant index of each field's name */ const uint32_t *field_class; /* referenced class id / JSON_RAW / NONE */ const uint32_t *field_elem; /* container element kinds */ + /* v3 (iteration 9 Task 4): the class's secondary indexes, flat-encoded + [flags, col_cnt, col...]* — flags bit0 = unique. idx_cnt entries. + Columns are field indices, scalar/Text kinds only (loader-checked). */ + uint32_t idx_cnt; + const uint32_t *idx_meta; } wo_classdesc; #define WO_CLASSF_GC 0x01u diff --git a/runtime/test/test_table.c b/runtime/test/test_table.c index aa54ad4..90c5941 100644 --- a/runtime/test/test_table.c +++ b/runtime/test/test_table.c @@ -45,7 +45,7 @@ static void test_roundtrip_all_kinds(void) { uint64_t vals[5] = {(uint64_t)(uintptr_t)name, 9200000, (uint64_t)(uintptr_t)addr, (uint64_t)(uintptr_t)tags, (uint64_t)(uintptr_t)meta}; - uint64_t id = wo_row_insert(&db, 1, vals, &msg); + uint64_t id = wo_row_insert(&db, 1, vals, &msg, NULL); T_EQ(id, 1); /* shard 0 of 1: first id is 1 */ /* the row stored COPIES: mutate the VM originals, then read back */ @@ -74,7 +74,7 @@ static void test_roundtrip_all_kinds(void) { /* nil TEXT / nil OWNED / WO_NIL_SCALAR round-trip */ uint64_t nilvals[5] = {0, WO_NIL_SCALAR, 0, 0, 0}; - uint64_t id2 = wo_row_insert(&db, 1, nilvals, &msg); + uint64_t id2 = wo_row_insert(&db, 1, nilvals, &msg, NULL); T_EQ(id2, 2); uint64_t out2[5] = {(uint64_t)-1, 0, (uint64_t)-1, (uint64_t)-1, (uint64_t)-1}; T_EQ(wo_row_read(&db, &rt, 1, id2, out2, &msg), 0); @@ -103,12 +103,12 @@ static void test_id_interleave_across_shards(void) { T_EQ(wo_db_init(&b, CLASSES, 3, 1, 3), 0); T_EQ(wo_db_init(&c, CLASSES, 3, 2, 3), 0); uint64_t v[1] = {42}; - T_EQ(wo_row_insert(&a, 2, v, &msg), 1); /* shard 0: 1, 4, 7 */ - T_EQ(wo_row_insert(&a, 2, v, &msg), 4); - T_EQ(wo_row_insert(&b, 2, v, &msg), 2); /* shard 1: 2, 5 */ - T_EQ(wo_row_insert(&b, 2, v, &msg), 5); - T_EQ(wo_row_insert(&c, 2, v, &msg), 3); /* shard 2: 3, 6 */ - T_EQ(wo_row_insert(&c, 2, v, &msg), 6); + T_EQ(wo_row_insert(&a, 2, v, &msg, NULL), 1); /* shard 0: 1, 4, 7 */ + T_EQ(wo_row_insert(&a, 2, v, &msg, NULL), 4); + T_EQ(wo_row_insert(&b, 2, v, &msg, NULL), 2); /* shard 1: 2, 5 */ + T_EQ(wo_row_insert(&b, 2, v, &msg, NULL), 5); + T_EQ(wo_row_insert(&c, 2, v, &msg, NULL), 3); /* shard 2: 3, 6 */ + T_EQ(wo_row_insert(&c, 2, v, &msg, NULL), 6); /* owner-shard discipline: (id-1) % N names the shard */ T_EQ((4 - 1) % 3, 0); T_EQ((5 - 1) % 3, 1); @@ -133,7 +133,7 @@ static void test_slab_growth_and_reuse(void) { uint64_t ids[N]; for (uint32_t i = 0; i < N; i++) { uint64_t v[1] = {i}; - ids[i] = wo_row_insert(&db, 2, v, &msg); + ids[i] = wo_row_insert(&db, 2, v, &msg, NULL); T_CHECK(ids[i] == i + 1); } T_EQ(db.tables[2].slab_cnt, 4); @@ -152,7 +152,7 @@ static void test_slab_growth_and_reuse(void) { T_EQ(wo_row_read(&db, &rt, 2, ids[100], out, &msg), -1); /* gone */ T_EQ(wo_row_remove(&db, 2, ids[100]), -1); /* twice = miss */ uint64_t v[1] = {777}; - uint64_t fresh = wo_row_insert(&db, 2, v, &msg); + uint64_t fresh = wo_row_insert(&db, 2, v, &msg, NULL); T_CHECK(fresh > (uint64_t)N); /* ids never reused ... */ db_row *fresh_row = wo_row_ptr(&db, 2, fresh); T_CHECK(fresh_row == victim); /* ... but the SLOT is */ @@ -166,7 +166,7 @@ static void test_misuse(void) { wo_db db; T_EQ(wo_db_init(&db, CLASSES, 3, 0, 1), 0); uint64_t v[1] = {1}; - T_EQ(wo_row_insert(&db, 99, v, &msg), 0); /* unknown class */ + T_EQ(wo_row_insert(&db, 99, v, &msg, NULL), 0); /* unknown class */ T_CHECK(wo_row_ptr(&db, 99, 1) == NULL); T_CHECK(wo_row_ptr(&db, 2, 1) == NULL); /* table never touched */ T_EQ(wo_row_remove(&db, 2, 1), -1); diff --git a/runtime/test/test_wal.c b/runtime/test/test_wal.c index 701c46a..fc6e4ac 100644 --- a/runtime/test/test_wal.c +++ b/runtime/test/test_wal.c @@ -43,7 +43,7 @@ static void test_roundtrip_replay(void) { for (int i = 0; i < 3; i++) { wo_str *s = wo_str_new(&rt, "abcXYZ" + i, 3); /* "abc","bcX","cXY" */ uint64_t vals[2] = {(uint64_t)(i * 10), (uint64_t)(uintptr_t)s}; - ids[i] = wo_row_insert(&db, 0, vals, &msg); + ids[i] = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(ids[i] != 0); T_EQ(wo_wal_append_insert(&w, &db, 0, ids[i]), 0); wo_str_free(&rt, s); @@ -70,7 +70,7 @@ static void test_roundtrip_replay(void) { wo_str_free(&rt, (wo_str *)(uintptr_t)out[1]); /* next_id advanced past the replayed ids: a fresh insert never collides */ uint64_t vals[2] = {99, 0}; - uint64_t fresh = wo_row_insert(&db2, 0, vals, &msg); + uint64_t fresh = wo_row_insert(&db2, 0, vals, &msg, NULL); T_CHECK(fresh > ids[2]); wo_db_destroy(&db2); wo_rt_destroy(&rt); @@ -92,7 +92,7 @@ static void test_torn_tail(void) { const char *msg = ""; for (int i = 0; i < 5; i++) { uint64_t vals[2] = {(uint64_t)i, 0}; - uint64_t id = wo_row_insert(&db, 0, vals, &msg); + uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_EQ(wo_wal_commit(&w), 0); } @@ -126,7 +126,7 @@ static void test_torn_tail(void) { T_EQ(wo_wal_open(&w2, path, 0), 0); T_EQ(w2.off, intact_end); uint64_t vals[2] = {100, 0}; - uint64_t id = wo_row_insert(&db3, 0, vals, &msg); + uint64_t id = wo_row_insert(&db3, 0, vals, &msg, NULL); T_EQ(wo_wal_append_insert(&w2, &db3, 0, id), 0); T_EQ(wo_wal_commit(&w2), 0); wo_wal_close(&w2); @@ -151,7 +151,7 @@ static void battery_child(const char *path, int ack_fd) { int n = snprintf(label, sizeof label, "row-%llu", (unsigned long long)i); wo_str *s = wo_str_new(&rt, label, (uint32_t)n); uint64_t vals[2] = {i * 3 + 1, (uint64_t)(uintptr_t)s}; - uint64_t id = wo_row_insert(&db, 0, vals, &msg); + uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); wo_str_free(&rt, s); if (!id) _exit(9); if (wo_wal_append_insert(&w, &db, 0, id) != 0) _exit(9); diff --git a/runtime/test/wob_build.c b/runtime/test/wob_build.c index c6c9ca2..a40907d 100644 --- a/runtime/test/wob_build.c +++ b/runtime/test/wob_build.c @@ -65,6 +65,7 @@ uint32_t wb_class(wb_t *b, uint32_t name_const, uint32_t flags, for (uint32_t j = 0; j < field_cnt; j++) put_u32(&b->classes, WOB_NONE); for (uint32_t j = 0; j < field_cnt; j++) put_u32(&b->classes, WOB_NONE); for (uint32_t j = 0; j < field_cnt; j++) put_u32(&b->classes, 0); + put_u32(&b->classes, 0); /* v3: no secondary indexes in hand-built images */ return b->class_cnt++; } diff --git a/tests/corpus/run/db-unique-catch/fixture.out b/tests/corpus/run/db-unique-catch/fixture.out new file mode 100644 index 0000000..72c43d0 --- /dev/null +++ b/tests/corpus/run/db-unique-catch/fixture.out @@ -0,0 +1,4 @@ +1 +DUP-REFUSED +EVENTS-OK +2 diff --git a/tests/corpus/run/db-unique-catch/fixture.wo b/tests/corpus/run/db-unique-catch/fixture.wo new file mode 100644 index 0000000..1f40472 --- /dev/null +++ b/tests/corpus/run/db-unique-catch/fixture.wo @@ -0,0 +1,26 @@ +-- iteration 9 Task 4, the other half: the unique trap is catchable (the +-- employee sample's SEED-DUP pattern), a NON-unique @table composite index +-- accepts duplicates, and a distinct key inserts freely beside the taken +-- one. (Key release after delete joins the corpus with Task 5's delete.) +@table(name: "events", index: [kind, at]) +class Event { + kind: Text + at: Int +} + +class Account { + email: Text @unique + balance: Int +} + +fn main() { + let a = insert Account { email: "a@x", balance: 100 } + print_int(a) + let dup = try insert Account { email: "a@x", balance: 200 } catch (e) nil + if dup == nil { print("DUP-REFUSED") } + insert Event { kind: "warn", at: 1 } + insert Event { kind: "warn", at: 1 } + print("EVENTS-OK") + let b = insert Account { email: "b@x", balance: 300 } + print_int(b) +} diff --git a/tests/corpus/trap/db-unique-violation/fixture.trap b/tests/corpus/trap/db-unique-violation/fixture.trap new file mode 100644 index 0000000..9a03714 --- /dev/null +++ b/tests/corpus/trap/db-unique-violation/fixture.trap @@ -0,0 +1 @@ +10 \ No newline at end of file diff --git a/tests/corpus/trap/db-unique-violation/fixture.wo b/tests/corpus/trap/db-unique-violation/fixture.wo new file mode 100644 index 0000000..9d0e77c --- /dev/null +++ b/tests/corpus/trap/db-unique-violation/fixture.wo @@ -0,0 +1,12 @@ +-- iteration 9 Task 4: a duplicate insert into a @unique column traps with +-- the unique-violation code (WO_T_UNIQUE = 10), raised at the engine's row +-- choke point. Uncaught here on purpose: the trap code is the assertion. +class Account { + email: Text @unique + balance: Int +} + +fn main() { + insert Account { email: "a@x", balance: 100 } + insert Account { email: "a@x", balance: 200 } +}