diff --git a/compiler/src/disasm.ml b/compiler/src/disasm.ml index af5bd55..fcf74a7 100644 --- a/compiler/src/disasm.ml +++ b/compiler/src/disasm.ml @@ -184,7 +184,7 @@ let dump (img : string) : string = opcodes 34-41). The disassembler tracks the emitter, not a range: an old image is a different format and reading it as this one would misrender. *) (* tracks emit.ml's wob_version and wob.h's WOB_VERSION *) - if ver <> 7 then raise (Bad (Printf.sprintf "unsupported version %d" ver)); + if ver <> 8 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 @@ -264,6 +264,7 @@ let dump (img : string) : string = (if flags land 1 <> 0 then [ "gc" ] else []) @ (if flags land 2 <> 0 then [ "volatile" ] else []) @ (if flags land 4 <> 0 then [ "resident=keys" ] else []) + @ (if flags land 8 <> 0 then [ "table" ] else []) in if parts = [] then "-" else String.concat "+" parts) (String.concat ", " fields)) diff --git a/compiler/src/emit.ml b/compiler/src/emit.ml index 45dae28..f5d974c 100644 --- a/compiler/src/emit.ml +++ b/compiler/src/emit.ml @@ -156,8 +156,9 @@ let wob_magic = 0x31424F57 (* "WOB1" read as an LE u32 *) builtins 70-83. v4 (iteration 7b): RC opcodes retired; gc mask = GC roots *) (* MUST track runtime/src/wob.h's WOB_VERSION — the loader is an exact-match check, so a drift here is not a warning, it is every image refused. - v7 (databasev2 2): two class flag bits, no layout change. *) -let wob_version = 7 + v7 (databasev2 2): two class flag bits, no layout change. + v8 (databasev2 2 task 6a): the table bit, no layout change. *) +let wob_version = 8 let wob_hdr_size = 44 let wob_none = 0xFFFFFFFF @@ -173,6 +174,9 @@ let classf_gc = 0x01 (* databasev2 2: spare bits of the same flags word — see runtime/src/wob.h *) let classf_volatile = 0x02 let classf_resident_keys = 0x04 +(* v8: has @table. The runtime's durability rules apply to these classes only; + the two bits above are meaningful — and loader-accepted — only with it. *) +let classf_table = 0x08 let op_nop = 0 let op_loadk = 1 @@ -5093,7 +5097,8 @@ let emit ?(entry_ok : string -> bool = fun _ -> true) ~(syms : Types.symbols) Buf.u32 cls ((if c.cr_gc then classf_gc else 0) lor (if c.cr_durable then 0 else classf_volatile) - lor (if c.cr_resident_keys then classf_resident_keys else 0)); + lor (if c.cr_resident_keys then classf_resident_keys else 0) + lor (if c.cr_is_table then classf_table else 0)); Buf.u32 cls (Array.length c.cr_fields); Array.iter (fun (_, ty) -> Buf.u8 cls (field_kind p ty)) c.cr_fields; let pad = (4 - (Array.length c.cr_fields mod 4)) mod 4 in diff --git a/compiler/test/runner.ml b/compiler/test/runner.ml index 305a510..63e1817 100644 --- a/compiler/test/runner.ml +++ b/compiler/test/runner.ml @@ -2402,7 +2402,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 <> 7 then fail "unsupported version"; (* v7: databasev2 2 *) + if u32 4 <> 8 then fail "unsupported version"; (* v8: databasev2 2 task 6a *) 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 @@ -2439,13 +2439,16 @@ let validate_image (img : string) : string list = let nm = u32 !o and flags = u32 (!o + 4) and fcnt = u32 (!o + 8) in o := !o + 12; if not (text_const nm) then fail (Printf.sprintf "class %d: bad name constant" i); - (* v7 (databasev2 2): bit1 VOLATILE, bit2 RESIDENT_KEYS. This battery is a - deliberately independent reimplementation of runtime/src/loader.c's - validation, so it tracks the same contract — including refusing the pair - that would leave rows neither logged nor resident. *) - if flags land lnot 0x07 <> 0 then fail (Printf.sprintf "class %d: unknown flags" i); + (* v7 (databasev2 2): bit1 VOLATILE, bit2 RESIDENT_KEYS; v8 (task 6a): bit3 + TABLE. This battery is a deliberately independent reimplementation of + runtime/src/loader.c's validation, so it tracks the same contract — + including refusing the pair that would leave rows neither logged nor + resident, and storage bits on a class that is not a @table. *) + if flags land lnot 0x0f <> 0 then fail (Printf.sprintf "class %d: unknown flags" i); if flags land 0x02 <> 0 && flags land 0x04 <> 0 then fail (Printf.sprintf "class %d: durable:false with resident:keys" i); + if flags land 0x06 <> 0 && flags land 0x08 = 0 then + fail (Printf.sprintf "class %d: storage flags on a class that is not a @table" 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 *) diff --git a/docs/plan/oop-vm/00-wob-format.md b/docs/plan/oop-vm/00-wob-format.md index ff1fcaf..e9a593c 100644 --- a/docs/plan/oop-vm/00-wob-format.md +++ b/docs/plan/oop-vm/00-wob-format.md @@ -11,11 +11,11 @@ All integers little-endian; offsets are absolute file offsets. -**Header (44 bytes):** magic `"WOB1"`, version 7 (databasev2 2; see "v7: table storage flags" below — v6 was iteration 36's "v6: the Int bitwise set", v5 iteration 19's "v5: Float and Bytes"), then offset/count u32 pairs for the constant pool, class table, interface section, and method table, then a u32 entry-method index (all-ones = none). +**Header (44 bytes):** magic `"WOB1"`, version 8 (databasev2 2 task 6a; see "v8: the table bit" below — v7 was databasev2 2's "v7: table storage flags", v6 was iteration 36's "v6: the Int bitwise set", v5 iteration 19's "v5: Float and Bytes"), then offset/count u32 pairs for the constant pool, class table, interface section, and method table, then a u32 entry-method index (all-ones = none). **Constant pool** — sequential entries: one tag byte; tag 0 = i64 follows; tag 1 = text (u32 length + bytes, no NUL); tag 2 = f64 as its IEEE 754 bit pattern in an LE u64 (v5). There is no Bytes tag: Bytes has no literal form. -**Class table** — per class: name constant index, flags u32 (bit0 = instances are `@gc`; **bit1 = `@table(durable: false)`, bit2 = `@table(resident: keys)`** — v7, and 0 in both means the pre-v7 behaviour of durable-and-fully-resident), field count, then one kind byte per field padded to a 4-byte boundary, then **three u32 arrays of per-field metadata** (v2), one entry per field each, in declaration order: +**Class table** — per class: name constant index, flags u32 (bit0 = instances are `@gc`; **bit1 = `@table(durable: false)`, bit2 = `@table(resident: keys)`** — v7, and 0 in both means the pre-v7 behaviour of durable-and-fully-resident; **bit3 = the class has `@table`** — v8, required by bit1/bit2, clear on every class that is not a table), field count, then one kind byte per field padded to a 4-byte boundary, then **three u32 arrays of per-field metadata** (v2), one entry per field each, in declaration order: 1. `field_names[i]` — constant index of the field's name, or all-ones for "not recorded" (what a hand-built test image writes). 2. `field_class[i]` — the class id the field refers to: its own class for an OWNED/GCREF field, its *element's* class for a container of records; `0xFFFFFFFE` marks a `json.Value` field, whose Text holds a raw JSON slice; `0xFFFFFFFD` a nullable scalar (`WO_NIL_SCALAR` nil); `0xFFFFFFFC` a plain `Bool` (json encodes `true`/`false`); `0xFFFFFFFB` a `?Bool` (both); `0xFFFFFFFA` a `?Float` (v5 — nil is `WO_NIL_FLOAT`, not `WO_NIL_SCALAR`); all-ones for none. @@ -338,3 +338,29 @@ refuses this at compile time (WO-E102), and the loader refuses it again on the standing principle that what the loader accepts, the interpreter trusts. Both paths are gate-verified — the loader's by forging the flags word in an otherwise valid image, since `woc` will not emit one. + +## v8: the table bit (databasev2 2 task 6a) + +Again no layout change: one more spare bit of the class descriptor's `flags` +u32. + +**What v8 adds** + +- `flags` bit3 — `WO_CLASSF_TABLE`: the class has `@table`; its instances are + row ids and the runtime's durability rules apply to it. `woc` sets it from + the class record's `cr_is_table`. +- bit1 and bit2 now **require** bit3. A plain class, a variant class and a + predeclared record (`Error`, `Stat`, …) have all three clear. + +**Why.** `durable: true` is the default for a `@table`, and v7 spelled it as +the *absence* of bit1 — which every non-table class also has. When the runtime +started refusing a durable table without `WO_DATA` (task 6a) it had no way to +tell `@table class Notes` from `class Tick`, and every class-bearing program +refused. The bit is the missing fact, recorded where the other two are. + +**Refused by the loader, independently of the compiler:** bit1 or bit2 set +with bit3 clear ("storage flags on a class that is not a @table"). A v7 image +is refused by the exact-match version check rather than read with bit3 clear — +read that way it would contain no tables at all and silently skip every +durability rule, the mirror image of the failure the v7 bump guarded against. +Gate-verified by forging the flags word (`runtime/test/test_loader.c`). diff --git a/runtime/src/loader.c b/runtime/src/loader.c index 4792a2a..5cf6348 100644 --- a/runtime/src/loader.c +++ b/runtime/src/loader.c @@ -205,6 +205,16 @@ int wo_load_buf(wo_module *m, const uint8_t *buf, size_t len, char *err, if ((flags & WO_CLASSF_VOLATILE) && (flags & WO_CLASSF_RESIDENT_KEYS)) BAIL("class %u: durable:false with resident:keys — rows would have " "nowhere to be read from", (unsigned)i); + /* v8 (databasev2 2 task 6a): the storage bits describe a @table's + * rows, so on a class without WO_CLASSF_TABLE they describe nothing — + * refuse rather than let main.c's refusal loops see a table that is + * not one. A v7 image (no table bit) is refused by the version check + * above, the same way task 3's v7 refused v6: read with the bit clear + * it would have no tables and silently skip every durability rule. */ + if ((flags & (WO_CLASSF_VOLATILE | WO_CLASSF_RESIDENT_KEYS)) && + !(flags & WO_CLASSF_TABLE)) + BAIL("class %u: storage flags on a class that is not a @table", + (unsigned)i); if (fcnt > 65535) BAIL("class %u: too many fields", (unsigned)i); if (fcnt > k.len - k.off) BAIL("class %u: truncated kinds", (unsigned)i); for (uint32_t j = 0; j < fcnt; j++) diff --git a/runtime/src/main.c b/runtime/src/main.c index aaeafa4..fc58687 100644 --- a/runtime/src/main.c +++ b/runtime/src/main.c @@ -243,10 +243,13 @@ int main(int argc, char **argv) { * serving program writes to sockets whose peers vanish. */ signal(SIGPIPE, SIG_IGN); /* The database engine boots with the VM: every class IS a table. - * Durability is opt-in — WO_DATA= opens /shard-0.wal, + * Durability is the default — WO_DATA= opens /shard-0.wal, * replays it before the entry runs (boot-before-listeners doctrine), - * and every insert commits before it acknowledges. Without WO_DATA - * the engine runs RAM-only, which is what the corpus expects. + * and every insert commits before it acknowledges. A program with any + * durable @table (the default) refuses to start without WO_DATA; + * WO_EPHEMERAL=1 opts into a RAM-only run (the corpus's mode), and + * @table(durable: false) opts a table out. A class without @table is + * storage for the engine but never a durable table (v8 WO_CLASSF_TABLE). * Arc stage 3 obligation: this whole block runs BEFORE the worker * shards spawn — replay completes before anything can serve, and the * engine's immutable class-table pointer is published to the worker @@ -262,6 +265,16 @@ int main(int argc, char **argv) { VM.rt.db = &DB; DB.rt = &VM.rt; /* databasev2 2 (5c): the loop a borrow reads the WAL through */ const char *data_dir = getenv("WO_DATA"); + const char *ephemeral = getenv("WO_EPHEMERAL"); + if (data_dir && data_dir[0] && ephemeral) { + /* databasev2 2 (6a): the two knobs answer the same question with + * opposite answers — refuse rather than pick one silently. */ + fprintf(stderr, "wovm: WO_EPHEMERAL=1 is incompatible with WO_DATA\n"); + wo_db_destroy(&DB); + wo_vm_destroy(&VM); + wo_module_free(&mod); + return 2; + } if (!data_dir || !data_dir[0]) { /* databasev2 3: the loader used to refuse `resident: keys` outright; * now it is accepted because UPDATE landed, but every row still @@ -269,6 +282,9 @@ int main(int argc, char **argv) { * durable:false+resident:keys refusal does, rather than let reads * silently misbehave with no WAL to fold from. */ for (uint32_t i = 0; i < mod.class_cnt; i++) { + /* v8: the storage bits are a @table's; the loader refuses them + * on anything else, so the table check here is belt and braces */ + if (!(mod.classes[i].flags & WO_CLASSF_TABLE)) continue; if (!(mod.classes[i].flags & WO_CLASSF_RESIDENT_KEYS)) continue; const char *cname = "?"; int cnlen = 1; @@ -286,6 +302,58 @@ int main(int argc, char **argv) { wo_module_free(&mod); return 2; } + /* databasev2 2 (6a): a durable table (the default) without WO_DATA + * used to run RAM-only and drop every write at exit — the one + * outcome `durable: true` promises against. Refuse by name unless + * the developer opted into RAM-only explicitly. Startup-only: + * db.c's `w && table_is_durable` guards are untouched, so the RAM + * path under WO_EPHEMERAL=1 is byte-for-byte the old one. After the + * keys loop on purpose: a keys-resident table has nowhere to read + * from at all, which is the more specific refusal and is not + * rescued by WO_EPHEMERAL. `durable: true` is a @table property (v8 + * WO_CLASSF_TABLE): a plain class, variant or predeclared record is + * not a table, so a program with no durable table starts as it + * always did and WO_EPHEMERAL is not consulted at all. */ + uint32_t first_durable = mod.class_cnt; + for (uint32_t i = 0; i < mod.class_cnt; i++) { + if (!(mod.classes[i].flags & WO_CLASSF_TABLE)) continue; + if (mod.classes[i].flags & WO_CLASSF_VOLATILE) continue; + first_durable = i; + break; + } + if (first_durable < mod.class_cnt && ephemeral && strcmp(ephemeral, "1") != 0) { + fprintf(stderr, + "wovm: WO_EPHEMERAL=%s is not accepted — the only accepted " + "value is WO_EPHEMERAL=1.\n", + ephemeral); + wo_db_destroy(&DB); + wo_vm_destroy(&VM); + wo_module_free(&mod); + return 2; + } + if (first_durable < mod.class_cnt && ephemeral) { + fprintf(stderr, + "wovm: WO_EPHEMERAL=1 — durable tables served from RAM, " + "nothing is written.\n"); + } else if (first_durable < mod.class_cnt) { + const char *cname = "?"; + int cnlen = 1; + uint32_t k = mod.classes[first_durable].name; + if (k < mod.const_cnt && mod.consts[k].s) { + cname = mod.consts[k].s->data; + cnlen = (int)mod.consts[k].s->len; + } + fprintf(stderr, + "wovm: `%.*s` is a durable table (the default) and WO_DATA " + "is not set — set WO_DATA= to keep its rows, " + "WO_EPHEMERAL=1 to run RAM-only, or declare it " + "@table(durable: false).\n", + cnlen, cname); + wo_db_destroy(&DB); + wo_vm_destroy(&VM); + wo_module_free(&mod); + return 2; + } } wo_schema compiled_schema; int have_schema = 0; diff --git a/runtime/src/obj.h b/runtime/src/obj.h index 12f37cf..49474df 100644 --- a/runtime/src/obj.h +++ b/runtime/src/obj.h @@ -63,7 +63,9 @@ typedef struct wo_rt { void *out; /* FILE*; kept void* so obj.h needn't pull in stdio */ /* the database engine's handles (database/src), opaque here so the VM core needn't include engine headers: db = wo_db*, wal = wo_wal*. - NULL = engine absent (test binaries) / durability off (no WO_DATA). + NULL = engine absent (test binaries) / RAM-only under WO_EPHEMERAL=1 + (a program with any durable table — the default — refuses to start + without WO_DATA; @table(durable: false) opts a table out). Set by main.c at boot; db.c casts. */ void *db; void *wal; diff --git a/runtime/src/wob.h b/runtime/src/wob.h index dee48d8..205760e 100644 --- a/runtime/src/wob.h +++ b/runtime/src/wob.h @@ -13,7 +13,14 @@ /* ---- file header (44 bytes, absolute offsets) ---- */ #define WOB_MAGIC 0x31424F57u /* "WOB1" read as LE u32 */ -#define WOB_VERSION 7u /* v7 (databasev2 2): two class flag bits — +#define WOB_VERSION 8u /* v8 (databasev2 2 task 6a): one class flag bit — + * WO_CLASSF_TABLE (the class has @table: a row-bearing class). No layout + * change. Needed because `durable: true` is a property of @table classes + * only, and v7 gave the runtime no way to tell a table from a plain class: + * the no-WO_DATA refusal fired on every class-bearing program. A v7 image is + * refused by the exact-match check, as v6 was by v7 (task 3): read with the + * bit clear it would have no tables at all and silently skip every refusal. + * v7 (databasev2 2): two class flag bits — * WO_CLASSF_VOLATILE (@table durable: false) and WO_CLASSF_RESIDENT_KEYS * (@table resident: keys). No layout change: both ride spare bits of the * class descriptor's existing `flags` u32, so the serialized shape is @@ -655,7 +662,11 @@ typedef struct wo_classdesc { flags word means today's behaviour: durable, every row resident. */ #define WO_CLASSF_VOLATILE 0x02u /* @table(durable: false) — never logged */ #define WO_CLASSF_RESIDENT_KEYS 0x04u /* @table(resident: keys) — rows read from the log */ -#define WO_CLASSF_ALL 0x07u +/* v8 (databasev2 2 task 6a): has @table — a row-bearing class. The two storage + bits above require it; a plain class, variant or predeclared record has all + three clear and is never a durable table. */ +#define WO_CLASSF_TABLE 0x08u +#define WO_CLASSF_ALL 0x0fu /* runtime object layout: 16-byte header then one 8-byte slot per field */ static inline size_t wo_obj_size(const wo_classdesc *c) { diff --git a/runtime/test/test_loader.c b/runtime/test/test_loader.c index ef7c966..42e1917 100644 --- a/runtime/test/test_loader.c +++ b/runtime/test/test_loader.c @@ -109,8 +109,44 @@ static void test_rejects(void) { free(img); } +/* a valid one-class image whose class descriptor carries `flags` */ +static uint8_t *image_with_class_flags(uint32_t flags, size_t *len) { + wb_t *b = wb_new(); + wb_const_int(b, 42); + uint32_t kname = wb_const_text(b, "main"); + uint8_t kinds[] = {WO_K_SCALAR, WO_K_SCALAR}; + wb_class(b, kname, flags, kinds, 2); + uint32_t code[] = {wo_ins_abc(WOP_RET, 0, 0, 0)}; + wb_method(b, kname, WOB_NONE, 0, 1, code, 1, NULL, 0, NULL, 0); + return wb_finish(b, len); +} + +/* databasev2 2 (6a, .wob v8): the storage bits describe a @table's rows, so + * they are meaningless — and refused — on a class without WO_CLASSF_TABLE. + * woc never emits the combination; the loader refuses it independently. */ +static void test_storage_flags_need_table(void) { + size_t len; + uint8_t *img = image_with_class_flags(WO_CLASSF_VOLATILE, &len); + expect_reject(img, len, "volatile without table"); + free(img); + + img = image_with_class_flags(WO_CLASSF_RESIDENT_KEYS, &len); + expect_reject(img, len, "resident:keys without table"); + free(img); + + /* the same bits WITH the table bit load */ + img = image_with_class_flags(WO_CLASSF_TABLE | WO_CLASSF_VOLATILE, &len); + wo_module m; + char err[256] = ""; + T_EQ(wo_load_buf(&m, img, len, err, sizeof err), 0); + T_EQ(m.classes[0].flags, WO_CLASSF_TABLE | WO_CLASSF_VOLATILE); + wo_module_free(&m); + free(img); +} + int main(void) { test_happy_path(); test_rejects(); + test_storage_flags_need_table(); return t_report("test_loader"); }