/* test_wal — iteration 9 Task 2: typed WAL + boot replay. * Round-trip through a replay, torn-tail drop, reopen-overwrites-tear, * and the commit-then-kill crash battery: a forked child inserts rows and * acks each COMMITTED id over a pipe; SIGKILL lands mid-stream; the parent * verifies with the offline oracle and a replay that every acked id is * present with the right contents. */ #define _POSIX_C_SOURCE 200809L #include #include #include #include #include #include #include #include #include #include "gc.h" #include "obj.h" #include "t.h" #include "table.h" #include "wal.h" /* class 0: Row { n: scalar, label: Text } */ static const uint8_t row_kinds[] = {WO_K_SCALAR, WO_K_TEXT}; static const wo_classdesc CLASSES[] = { {.name = 0, .flags = 0, .field_cnt = 2, .kinds = row_kinds}, }; static char g_dir[64]; static void test_roundtrip_replay(void) { char path[128]; snprintf(path, sizeof path, "%s/basic.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); const char *msg = ""; /* three inserts and one remove, RAM first, WAL second, one commit */ uint64_t ids[3]; 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, NULL); T_CHECK(ids[i] != 0); T_EQ(wo_wal_append_insert(&w, &db, 0, ids[i]), 0); wo_str_free(&rt, s); } T_EQ(wo_row_remove(&db, 0, ids[1]), 0); T_EQ(wo_wal_append_remove(&w, 0, ids[1]), 0); T_EQ(wo_wal_commit(&w), 0); wo_wal_close(&w); wo_db_destroy(&db); /* boot: fresh engine, replay, deep-compare */ wo_db db2; T_EQ(wo_db_init(&db2, CLASSES, 1, 0, 1), 0); T_EQ(wo_wal_replay(path, &db2), 4); uint64_t out[2]; T_EQ(wo_row_read(&db2, &rt, 0, ids[0], out, &msg), 0); T_EQ(out[0], 0); wo_str *s0 = (wo_str *)(uintptr_t)out[1]; T_CHECK(s0->len == 3 && memcmp(s0->data, "abc", 3) == 0); wo_str_free(&rt, s0); T_EQ(wo_row_read(&db2, &rt, 0, ids[1], out, &msg), -1); /* removed */ T_EQ(wo_row_read(&db2, &rt, 0, ids[2], out, &msg), 0); T_EQ(out[0], 20); 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, NULL); T_CHECK(fresh > ids[2]); wo_db_destroy(&db2); /* update record: re-log, replay replaces */ { char upath[128]; snprintf(upath, sizeof upath, "%s/upd.wal", g_dir); wo_db du; T_EQ(wo_db_init(&du, CLASSES, 1, 0, 1), 0); wo_wal wu; T_EQ(wo_wal_open(&wu, upath, 0), 0); wo_str *s1 = wo_str_new(&rt, "old", 3); uint64_t uv[2] = {7, (uint64_t)(uintptr_t)s1}; uint64_t uid = wo_row_insert(&du, 0, uv, &msg, NULL); T_EQ(wo_wal_append_insert(&wu, &du, 0, uid), 0); int ek = 0; wo_str *s2 = wo_str_new(&rt, "new!", 4); T_EQ(wo_row_update_field(&du, 0, uid, 1, (uint64_t)(uintptr_t)s2, &msg, &ek), 0); T_EQ(wo_row_update_field(&du, 0, uid, 0, 8, &msg, &ek), 0); T_EQ(wo_wal_append_update(&wu, &du, 0, uid), 0); T_EQ(wo_wal_commit(&wu), 0); wo_wal_close(&wu); wo_db_destroy(&du); wo_db db4; T_EQ(wo_db_init(&db4, CLASSES, 1, 0, 1), 0); T_EQ(wo_wal_replay(upath, &db4), 2); uint64_t uo[2]; T_EQ(wo_row_read(&db4, &rt, 0, uid, uo, &msg), 0); T_EQ(uo[0], 8); wo_str *us = (wo_str *)(uintptr_t)uo[1]; T_CHECK(us->len == 4 && memcmp(us->data, "new!", 4) == 0); wo_str_free(&rt, us); wo_drop_obj(&rt, (wo_hdr *)s1); wo_drop_obj(&rt, (wo_hdr *)s2); wo_db_destroy(&db4); } /* replay of a missing file is a fresh boot, not an error */ wo_db db3; T_EQ(wo_db_init(&db3, CLASSES, 1, 0, 1), 0); T_EQ(wo_wal_replay("/nonexistent/nope.wal", &db3), 0); wo_db_destroy(&db3); wo_rt_destroy(&rt); } /* databasev2 4 part A, Task 1: a failed barrier must be DETECTED, and the * caller must be able to tell WHICH operation failed — a pwrite failure and * an fdatasync failure are different operational problems and the diagnostic * has to name the right one. This proves detection only; the fatal exit that * follows it cannot be exercised in-process. */ static void test_commit_failure_detected(void) { char path[128]; snprintf(path, sizeof path, "%s/commitfail.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); const char *msg = ""; /* the WAL remembers where it lives — the abort diagnostic is worthless * without it */ T_CHECK(w.path != NULL && strstr(w.path, "commitfail.wal") != NULL); wo_str *s = wo_str_new(&rt, "abc", 3); uint64_t vals[2] = {7, (uint64_t)(uintptr_t)s}; uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(id != 0); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_CHECK(w.len > 0); /* something really is staged */ /* an unusable descriptor: pwrite reports EBADF. -1 is used rather than * closing the real fd so the close below cannot double-free it. */ int real = w.fd; w.fd = -1; T_EQ(wo_wal_commit(&w), WO_WAL_ERR_WRITE); T_CHECK(w.len > 0); /* a failed commit consumes nothing */ w.fd = real; wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* databasev2 3 Task 1: compaction rewrites the log as one record per LIVE row. * Asserts BOTH halves on purpose: "the file got shorter" is also true of a * truncating bug, so the replay comparison is what actually proves it. */ static void test_compact_shortens_and_replays_equal(void) { char path[128]; snprintf(path, sizeof path, "%s/compact.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); const char *msg = ""; uint64_t ids[3]; for (int i = 0; i < 3; i++) { wo_str *s = wo_str_new(&rt, "abc", 3); uint64_t vals[2] = {(uint64_t)(i * 10), (uint64_t)(uintptr_t)s}; 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); T_EQ(wo_wal_commit(&w), 0); } /* age it: the SAME row updated repeatedly, so HISTORY grows while the live * set does not — the exact case checkpoint exists for */ for (int k = 0; k < 40; k++) { int ek = 0; T_EQ(wo_row_update_field(&db, 0, ids[0], 0, (uint64_t)(500 + k), &msg, &ek), 0); T_EQ(wo_wal_append_update(&w, &db, 0, ids[0]), 0); T_EQ(wo_wal_commit(&w), 0); } uint64_t before_bytes = 0; int64_t before_recs = wo_wal_check(path, &before_bytes); T_CHECK(before_recs == 43); /* 3 inserts + 40 updates, all history */ T_EQ(wo_wal_compact(&w, &db), 0); uint64_t after_bytes = 0; int64_t after_recs = wo_wal_check(path, &after_bytes); T_CHECK(after_recs == 3); /* one record per LIVE row */ T_CHECK(after_bytes < before_bytes); /* and the file really shrank */ /* the WAL stays usable: the descriptor was reopened and the offset reset, * so a further write must land AFTER the compacted records, not over them */ wo_str *s4 = wo_str_new(&rt, "xyz", 3); uint64_t v4[2] = {99, (uint64_t)(uintptr_t)s4}; uint64_t id4 = wo_row_insert(&db, 0, v4, &msg, NULL); T_CHECK(id4 != 0); T_EQ(wo_wal_append_insert(&w, &db, 0, id4), 0); T_EQ(wo_wal_commit(&w), 0); T_CHECK(wo_wal_check(path, NULL) == 4); wo_wal_close(&w); /* the proof: a FRESH store replayed from the compacted log must hold the * same rows, the same ids, and the LAST value each row had */ wo_db db2; T_EQ(wo_db_init(&db2, CLASSES, 1, 0, 1), 0); T_EQ(wo_wal_replay(path, &db2), 4); uint64_t out[2]; T_EQ(wo_row_read(&db2, &rt, 0, ids[0], out, &msg), 0); T_CHECK(out[0] == 539); /* the 40th update won, not the original 0 */ wo_str_free(&rt, (wo_str *)(uintptr_t)out[1]); T_EQ(wo_row_read(&db2, &rt, 0, ids[1], out, &msg), 0); T_CHECK(out[0] == 10); wo_str_free(&rt, (wo_str *)(uintptr_t)out[1]); T_EQ(wo_row_read(&db2, &rt, 0, ids[2], out, &msg), 0); T_CHECK(out[0] == 20); wo_str_free(&rt, (wo_str *)(uintptr_t)out[1]); T_EQ(wo_row_read(&db2, &rt, 0, id4, out, &msg), 0); T_CHECK(out[0] == 99); wo_str_free(&rt, (wo_str *)(uintptr_t)out[1]); wo_db_destroy(&db2); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* databasev2 3 Task 2: a stale temp file is the one input that could be * mistaken for data — a crash before the rename leaves one behind, full of * well-formed records that are NOT yet authoritative. So the fixture uses * plausible records (a byte copy of a real log), not garbage: garbage would be * rejected by the CRC anyway and would prove nothing. */ static void test_stale_compact_temp_is_removed(void) { char path[128], tmp[160]; snprintf(path, sizeof path, "%s/stale.wal", g_dir); snprintf(tmp, sizeof tmp, "%s%s", path, WO_WAL_TMP_SUFFIX); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); const char *msg = ""; /* two live rows in the REAL log */ uint64_t ids[2]; for (int i = 0; i < 2; i++) { wo_str *s = wo_str_new(&rt, "abc", 3); uint64_t vals[2] = {(uint64_t)(i + 1), (uint64_t)(uintptr_t)s}; ids[i] = wo_row_insert(&db, 0, vals, &msg, NULL); T_EQ(wo_wal_append_insert(&w, &db, 0, ids[i]), 0); T_EQ(wo_wal_commit(&w), 0); } wo_wal_close(&w); /* forge a plausible stale temp: a byte copy of the real log */ { int src = open(path, O_RDONLY); int dst = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0644); T_CHECK(src >= 0 && dst >= 0); char buf[8192]; ssize_t n; while ((n = read(src, buf, sizeof buf)) > 0) T_CHECK(write(dst, buf, (size_t)n) == n); close(src); close(dst); T_EQ(access(tmp, F_OK), 0); /* it really is there before we open */ } wo_wal w2; T_EQ(wo_wal_open(&w2, path, 1 << 16), 0); T_CHECK(access(tmp, F_OK) != 0); /* gone, and never consulted */ wo_wal_close(&w2); /* and the live log still says exactly what it said */ wo_db db2; T_EQ(wo_db_init(&db2, CLASSES, 1, 0, 1), 0); T_EQ(wo_wal_replay(path, &db2), 2); uint64_t out[2]; T_EQ(wo_row_read(&db2, &rt, 0, ids[0], out, &msg), 0); T_CHECK(out[0] == 1); wo_str_free(&rt, (wo_str *)(uintptr_t)out[1]); T_EQ(wo_row_read(&db2, &rt, 0, ids[1], out, &msg), 0); T_CHECK(out[0] == 2); wo_str_free(&rt, (wo_str *)(uintptr_t)out[1]); wo_db_destroy(&db2); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* databasev2 7: WO_DATA names EITHER a directory (today's form, /shard-0.wal * byte for byte) or THE log file. Resolution is a pure function so main.c's * only branch is "print the refusal, exit 2" — and so every arm of the rule is * checkable here rather than by booting wovm against the filesystem. */ static void test_resolve_data_path(void) { char in[192], out[256], want[256]; /* an existing directory: exactly the bytes main.c always produced */ T_EQ(wo_wal_resolve_data_path(g_dir, out, sizeof out), 0); snprintf(want, sizeof want, "%s/shard-0.wal", g_dir); T_STREQ(out, want); /* a trailing slash keeps the directory form even when nothing exists there — including the doubled slash today's snprintf produced */ snprintf(in, sizeof in, "%s/nodir/", g_dir); T_EQ(wo_wal_resolve_data_path(in, out, sizeof out), 0); snprintf(want, sizeof want, "%s/nodir//shard-0.wal", g_dir); T_STREQ(out, want); /* absent file under an existing parent: the path IS the log; resolution itself creates nothing (wo_wal_open's O_CREAT does, later) */ snprintf(in, sizeof in, "%s/app.db", g_dir); T_EQ(wo_wal_resolve_data_path(in, out, sizeof out), 0); T_STREQ(out, in); T_CHECK(access(in, F_OK) != 0); /* an existing regular file: opened as the log */ { int fd = open(in, O_WRONLY | O_CREAT, 0644); T_CHECK(fd >= 0); close(fd); } T_EQ(wo_wal_resolve_data_path(in, out, sizeof out), 0); T_STREQ(out, in); /* a bare relative name: its parent is ".", which always exists */ T_EQ(wo_wal_resolve_data_path("app.db", out, sizeof out), 0); T_STREQ(out, "app.db"); /* missing parent: refused, the PARENT is handed back for the message, and nothing was mkdir'd on the way */ snprintf(in, sizeof in, "%s/nodir/app.db", g_dir); T_EQ(wo_wal_resolve_data_path(in, out, sizeof out), WO_WAL_PATH_NO_PARENT); snprintf(want, sizeof want, "%s/nodir", g_dir); T_STREQ(out, want); T_CHECK(access(want, F_OK) != 0); /* the parent exists but is a FILE (ENOTDIR): same refusal, same subject */ snprintf(in, sizeof in, "%s/app.db/x.db", g_dir); T_EQ(wo_wal_resolve_data_path(in, out, sizeof out), WO_WAL_PATH_NO_PARENT); snprintf(want, sizeof want, "%s/app.db", g_dir); T_STREQ(out, want); /* exists, but neither a regular file nor a directory */ snprintf(in, sizeof in, "%s/fifo.db", g_dir); T_EQ(mkfifo(in, 0600), 0); T_EQ(wo_wal_resolve_data_path(in, out, sizeof out), WO_WAL_PATH_NOT_A_FILE); /* a result that would not fit is refused, never truncated (today's snprintf into main.c's 512-byte buffer truncated silently) */ T_EQ(wo_wal_resolve_data_path(g_dir, out, 8), WO_WAL_PATH_TOO_LONG); snprintf(in, sizeof in, "%s/app.db", g_dir); T_EQ(wo_wal_resolve_data_path(in, out, strlen(in)), WO_WAL_PATH_TOO_LONG); } /* databasev2 3 Task 3: the trigger, tested as a pure decision. Kept pure * precisely so it CAN be tested — a policy only observable by writing megabytes * and waiting is a policy nobody checks. */ static void test_should_compact_policy(void) { /* below the floor, nothing fires however bad the ratio looks */ T_EQ(wo_wal_should_compact(1000, 10, 4096, 3), 0); T_EQ(wo_wal_should_compact(4095, 1, 4096, 3), 0); /* past the floor with no prior compaction: run once to learn the size */ T_EQ(wo_wal_should_compact(4096, 0, 4096, 3), 1); /* with a known denominator it is a straight ratio test */ T_EQ(wo_wal_should_compact(30000, 10000, 4096, 3), 0); /* exactly 3x is not MORE than 3x */ T_EQ(wo_wal_should_compact(30001, 10000, 4096, 3), 1); T_EQ(wo_wal_should_compact(19999, 10000, 4096, 2), 0); T_EQ(wo_wal_should_compact(20001, 10000, 4096, 2), 1); /* a zero ratio disables the policy rather than dividing by nothing */ T_EQ(wo_wal_should_compact(1u << 30, 10, 4096, 0), 0); } /* databasev2 3 Task 3: the ordering rule, asserted rather than trusted. * Compaction with records staged would write them into a file about to be * replaced, so it must be REFUSED — and refused without touching the log. */ static void test_compact_refuses_with_staged_records(void) { char path[128]; snprintf(path, sizeof path, "%s/staged.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); const char *msg = ""; wo_str *s1 = wo_str_new(&rt, "abc", 3); uint64_t v1[2] = {7, (uint64_t)(uintptr_t)s1}; uint64_t id1 = wo_row_insert(&db, 0, v1, &msg, NULL); T_EQ(wo_wal_append_insert(&w, &db, 0, id1), 0); T_EQ(wo_wal_commit(&w), 0); /* durable, buffer empty */ /* now stage WITHOUT committing */ wo_str *s2 = wo_str_new(&rt, "xyz", 3); uint64_t v2[2] = {8, (uint64_t)(uintptr_t)s2}; uint64_t id2 = wo_row_insert(&db, 0, v2, &msg, NULL); T_EQ(wo_wal_append_insert(&w, &db, 0, id2), 0); T_CHECK(w.len > 0); uint64_t before = 0; int64_t recs = wo_wal_check(path, &before); T_EQ(wo_wal_compact(&w, &db), -1); /* refused */ T_CHECK(w.len > 0); /* and the staged record is still there */ uint64_t after = 0; T_CHECK(wo_wal_check(path, &after) == recs && after == before); /* log untouched */ /* the staged record still commits normally afterwards */ T_EQ(wo_wal_commit(&w), 0); T_CHECK(wo_wal_check(path, NULL) == recs + 1); wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* databasev2 3 Task 4: kill -9 DURING compaction. * * The existing battery is insert-only, so its "records >= acks" oracle is * exactly what compaction is allowed to break: collapsing history is the point. * The invariant that survives is the ACKED LIVE SET — every id acked as * inserted and not later acked as deleted must be present with its acked value, * and every id acked as deleted must be absent. Both the pre-compaction and the * post-compaction log satisfy that identically, which is precisely the * "never a mixture" property the design is shaped around. * * The child deletes as it goes so HISTORY accumulates while the live set stays * small — without that, compaction would have nothing to collapse and the test * would prove nothing. */ #define CK_DELETED UINT64_MAX static void ck_ack(int fd, uint64_t id, uint64_t val) { uint64_t rec[2] = {id, val}; if (write(fd, rec, sizeof rec) != (ssize_t)sizeof rec) _exit(0); /* parent gone */ } static void compact_battery_child(const char *path, int ack_fd) { wo_rt rt; wo_db db; wo_wal w; if (wo_rt_init(&rt, 1 << 20, CLASSES, 1) != 0) _exit(9); if (wo_db_init(&db, CLASSES, 1, 0, 1) != 0) _exit(9); if (wo_wal_open(&w, path, 1 << 20) != 0) _exit(9); const char *msg = ""; uint64_t live[512]; size_t nlive = 0; for (uint64_t i = 0;; i++) { uint64_t val = i * 7 + 3; wo_str *s = wo_str_new(&rt, "r", 1); uint64_t vals[2] = {val, (uint64_t)(uintptr_t)s}; 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); if (wo_wal_commit(&w) != 0) _exit(9); /* durable BEFORE the ack */ ck_ack(ack_fd, id, val); if (nlive < 512) live[nlive++] = id; /* drop the oldest so history grows while the live set does not */ if (nlive > 16) { uint64_t victim = live[0]; memmove(live, live + 1, (nlive - 1) * sizeof live[0]); nlive--; /* INTENT FIRST, deliberately. An ack after the commit would race: * a kill between them leaves the row legitimately gone on disk * while the last ack still says "inserted", and the parent would * demand a row the engine was right to remove. Announcing intent * makes the row's fate simply UNKNOWN to the parent, which is the * honest thing to assert about it. */ ck_ack(ack_fd, victim, CK_DELETED); if (wo_row_remove(&db, 0, victim) != 0) _exit(9); if (wo_wal_append_remove(&w, 0, victim) != 0) _exit(9); if (wo_wal_commit(&w) != 0) _exit(9); } /* compact often, so a kill has a real chance of landing inside one */ if (i % 24 == 23) (void)wo_wal_compact(&w, &db); } } static void test_compact_crash_battery(void) { int rounds = 40; /* it is a RACE: one green run proves very little */ for (int round = 0; round < rounds; round++) { char path[128], tmp[160]; snprintf(path, sizeof path, "%s/ckcrash-%d.wal", g_dir, round); snprintf(tmp, sizeof tmp, "%s%s", path, WO_WAL_TMP_SUFFIX); int pipefd[2]; T_EQ(pipe(pipefd), 0); pid_t pid = fork(); T_CHECK(pid >= 0); if (pid == 0) { close(pipefd[0]); compact_battery_child(path, pipefd[1]); _exit(0); } close(pipefd[1]); /* vary the instant so kills land before, inside and after rewrites */ struct timespec ts = {0, (7 + round * 3) * 1000000L}; while (nanosleep(&ts, &ts) != 0) {} kill(pid, SIGKILL); int status; waitpid(pid, &status, 0); /* replay the acks into the expected live set, in order */ uint64_t ids[65536], vals[65536]; size_t n = 0; for (;;) { uint64_t rec[2]; ssize_t r = read(pipefd[0], rec, sizeof rec); if (r != (ssize_t)sizeof rec) break; if (n < 65536) { ids[n] = rec[0]; vals[n] = rec[1]; n++; } } close(pipefd[0]); T_CHECK(n > 0); /* the child got at least one commit out */ wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 22, CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, CLASSES, 1, 0, 1), 0); int64_t ck_recs = wo_wal_check(path, NULL); int64_t ck_applied = wo_wal_replay(path, &db); T_CHECK(ck_applied >= 0); /* never reported as corruption */ /* A stale temp may well EXIST after a kill inside compaction — that is * the expected debris. The guarantee is that the next OPEN removes it * and never reads it, so that is what gets asserted here; checking * merely for its absence after a replay would be asserting something * the design never promised (wo_wal_replay does not open the WAL). */ { wo_wal probe; T_EQ(wo_wal_open(&probe, path, 1 << 20), 0); T_CHECK(access(tmp, F_OK) != 0); wo_wal_close(&probe); } const char *msg = ""; int bad = 0, checked = 0; for (size_t k = 0; k < n && !bad; k++) { if (vals[k] == CK_DELETED) continue; /* intent: fate is unknown */ /* an id ever announced for deletion may legally be gone */ int doomed = 0; for (size_t j = 0; j < n; j++) if (ids[j] == ids[k] && vals[j] == CK_DELETED) { doomed = 1; break; } if (doomed) continue; uint64_t out[2]; int rc = wo_row_read(&db, &rt, 0, ids[k], out, &msg); if (0) { } else if (rc != 0 || out[0] != vals[k]) { bad = 1; /* an acked insert is missing or wrong */ fprintf(stderr, "CKDIAG round=%d id=%llu rc=%d got=%llu want=%llu ack#%zu/%zu " "log_records=%lld replay_applied=%lld\n", round, (unsigned long long)ids[k], rc, rc == 0 ? (unsigned long long)out[0] : 0ull, (unsigned long long)vals[k], k, n, (long long)ck_recs, (long long)ck_applied); } else { wo_str_free(&rt, (wo_str *)(uintptr_t)out[1]); } checked++; } T_CHECK(checked > 0); T_CHECK(!bad); wo_db_destroy(&db); wo_rt_destroy(&rt); } } /* databasev2 2 (5c): the keys-resident round trip. A row is inserted, its * record committed, its PAYLOAD DROPPED from the slab, and then read back out * of the log by offset — including its heap-valued column, which is the case * that would silently return garbage if the materialisation were wrong. */ static const uint8_t keys_kinds[] = {WO_K_SCALAR, WO_K_TEXT}; static const wo_classdesc KEYS_CLASSES[] = { {.name = 0, .flags = WO_CLASSF_RESIDENT_KEYS, .field_cnt = 2, .kinds = keys_kinds}, }; static void test_keys_resident_round_trip(void) { char path[128]; snprintf(path, sizeof path, "%s/keysres.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, KEYS_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; /* the loop a borrow reads the WAL through */ rt.wal = &w; rt.db = &db; const char *msg = ""; T_CHECK(wo_table_is_keys_resident(&db, 0) == 1); wo_str *s = wo_str_new(&rt, "hello", 5); uint64_t vals[2] = {4242, (uint64_t)(uintptr_t)s}; uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(id != 0); /* the offset this record WILL occupy — valid because the commit below * succeeds; a failed commit is fatal since databasev2 4 */ uint64_t off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_EQ(wo_wal_commit(&w), 0); /* while still resident, the row reads out of the slab */ db_row *res = wo_row_borrow(&db, 0, id, &msg); T_CHECK(res != NULL && res->slots[0] == 4242); wo_row_release(&db, 0, res); /* drop the payload: slot freed, id kept, indexes untouched, still live */ uint64_t before = db.tables[0].count; T_EQ(wo_row_drop_payload(&db, 0, id, off), 0); T_CHECK(db.tables[0].count == before); /* still LIVE, only unbacked */ /* and now it comes back out of the LOG */ db_row *r = wo_row_borrow(&db, 0, id, &msg); T_CHECK(r != NULL); T_CHECK(r->id == id); T_CHECK(r->slots[0] == 4242); /* engine-encoded, matching wo_row_ptr's contract (table.h's "a row stores NO VM pointer" doctrine) — db_text, not wo_str */ db_text *back = (db_text *)(uintptr_t)r->slots[1]; T_CHECK(back != NULL && back->len == 5 && memcmp(back->bytes, "hello", 5) == 0); wo_row_release(&db, 0, r); /* the scratch is reusable: a second borrow must succeed, which it cannot * if release failed to clear the busy flag */ db_row *again = wo_row_borrow(&db, 0, id, &msg); T_CHECK(again != NULL && again->slots[0] == 4242); wo_row_release(&db, 0, again); wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* databasev2 (task 1 of keys-resident delta updates): the delta record kind. * A keys-resident row cannot be rewritten whole to update one field (its * payload may already be gone from RAM), so a delta logs just the changed * field plus a back-pointer to the row's previous record. Nothing reads * deltas back yet — this only proves the encoder's bytes are what the format * says: kind, class, id, field index, back-pointer, value. * * All-scalar 3-field class, dedicated to this test (not the shared * KEYS_CLASSES): field_idx and back_off must each be a distinguishable * nonzero value or a transposition between the u32 field_idx and the u64 * back_off is invisible (both would print as zero bytes either way). A * scalar-only row keeps every field a fixed 8 bytes, so a third field gives * a nonzero field_idx without a Text value's variable-length encoding * complicating the fixed body-size assertion below. class_id stays 0: this * fixture registers exactly one class, so there is no other value to give it * without fabricating an unused second class purely to shift an index. */ static const uint8_t delta_kinds[] = {WO_K_SCALAR, WO_K_SCALAR, WO_K_SCALAR}; static const wo_classdesc DELTA_CLASSES[] = { {.name = 0, .flags = WO_CLASSF_RESIDENT_KEYS, .field_cnt = 3, .kinds = delta_kinds}, }; static void test_delta_record(void) { char path[128]; snprintf(path, sizeof path, "%s/delta.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, DELTA_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, DELTA_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; /* filler row+record so the TARGET row's insert lands at a nonzero * offset — a fresh WAL's first record is at offset 0, which would make * back_off indistinguishable from a zeroed field either way */ uint64_t filler_vals[3] = {1, 2, 3}; uint64_t filler_id = wo_row_insert(&db, 0, filler_vals, &msg, NULL); T_CHECK(filler_id != 0); T_EQ(wo_wal_append_insert(&w, &db, 0, filler_id), 0); T_EQ(wo_wal_commit(&w), 0); uint64_t vals[3] = {111, 222, 555}; uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(id != 0); uint64_t base_off = wo_wal_next_offset(&w); T_CHECK(base_off != 0); /* the filler pushed this past offset 0 */ T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_EQ(wo_wal_commit(&w), 0); /* field 2 (scalar) changes from 555 to 999; back-pointer is the insert * record this delta supersedes. field_idx=2 and back_off=base_off are * both nonzero and distinct from each other and from class_id=0, so a * field_idx/back_off transposition changes the read-back bytes. */ uint64_t delta_off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_delta(&w, &db, 0, id, 2, base_off, 999), 0); T_EQ(wo_wal_commit(&w), 0); /* payload: kind u8 | class u32 | id u64 | field_idx u32 | back_off u64 | * value u64 (scalar) — 33 bytes, after the 8-byte len+crc head */ uint8_t head[8], body[33]; T_EQ((int)pread(w.fd, head, 8, (off_t)delta_off), 8); uint32_t len; memcpy(&len, head, 4); T_EQ(len, 33u); T_EQ((int)pread(w.fd, body, 33, (off_t)(delta_off + 8)), 33); T_EQ(body[0], WO_WAL_DELTA); uint32_t cid; uint64_t rid, back, val; uint32_t fidx; memcpy(&cid, body + 1, 4); memcpy(&rid, body + 5, 8); memcpy(&fidx, body + 13, 4); memcpy(&back, body + 17, 8); memcpy(&val, body + 25, 8); T_EQ(cid, 0u); T_EQ(rid, id); T_EQ(fidx, 2u); T_EQ(back, base_off); T_EQ(val, 999u); wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* keys-resident delta updates, Task 2: the fold. A row's current record may * be a chain of deltas, not a base row — wo_row_borrow must walk back * through them, remembering one value per touched field, and overlay them * onto the base row it eventually reaches. Reuses DELTA_CLASSES (3 scalar * fields) so field 1 can stay untouched by any delta and prove the fold * does not clobber fields nobody changed. */ static void test_delta_fold_two_fields(void) { char path[128]; snprintf(path, sizeof path, "%s/foldtwo.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, DELTA_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, DELTA_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; uint64_t vals[3] = {10, 20, 30}; uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(id != 0); uint64_t base_off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, id, base_off), 0); /* field 0: 10 -> 111 */ uint64_t d1_off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_delta(&w, &db, 0, id, 0, base_off, 111), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_set_offset(&db, 0, id, d1_off), 0); /* field 2: 30 -> 333, chained off the first delta */ uint64_t d2_off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_delta(&w, &db, 0, id, 2, d1_off, 333), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_set_offset(&db, 0, id, d2_off), 0); db_row *r = wo_row_borrow(&db, 0, id, &msg); T_CHECK(r != NULL); T_CHECK(r->slots[0] == 111); /* changed */ T_CHECK(r->slots[1] == 20); /* untouched: original survives */ T_CHECK(r->slots[2] == 333); /* changed */ wo_row_release(&db, 0, r); wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* The ordering rule: two deltas to the SAME field. A fold walking the chain * in the wrong direction sees the OLDER delta first and stops there — a * plausible-looking but stale value, invisible unless a test pins the * direction explicitly. */ static void test_delta_fold_same_field_newest_wins(void) { char path[128]; snprintf(path, sizeof path, "%s/foldsame.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, DELTA_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, DELTA_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; uint64_t vals[3] = {1, 2, 3}; uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(id != 0); uint64_t base_off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, id, base_off), 0); /* field 1: 2 -> 20 (older) -> 200 (newer) */ uint64_t d1_off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_delta(&w, &db, 0, id, 1, base_off, 20), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_set_offset(&db, 0, id, d1_off), 0); uint64_t d2_off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_delta(&w, &db, 0, id, 1, d1_off, 200), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_set_offset(&db, 0, id, d2_off), 0); db_row *r = wo_row_borrow(&db, 0, id, &msg); T_CHECK(r != NULL); T_CHECK(r->slots[0] == 1); T_CHECK(r->slots[1] == 200); /* the NEWER delta wins, not the older */ T_CHECK(r->slots[2] == 3); wo_row_release(&db, 0, r); wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* keys-resident delta updates, Task 2 (review follow-up): a back-pointer * naming ITS OWN offset is the boundary case of the fold's invariant — * every hop must land on a STRICTLY earlier offset than the record naming * it. back_off == cur violates that on the very first hop and must be * refused immediately, not walked. */ static void test_fold_refuses_self_pointing_delta(void) { char path[128]; snprintf(path, sizeof path, "%s/foldself.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, DELTA_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, DELTA_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; uint64_t vals[3] = {10, 20, 30}; uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(id != 0); /* a delta whose back-pointer names ITS OWN offset */ uint64_t delta_off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_delta(&w, &db, 0, id, 0, delta_off, 999), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, id, delta_off), 0); T_CHECK(wo_row_borrow(&db, 0, id, &msg) == NULL); wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* The case a mere chain-length bound cannot rule out: a back-pointer that * points FORWARD to a real, valid record for the SAME row. Nothing about * this loops, so a cap on chain length would let it straight through in * one hop and return a plausible-but-wrong answer. Only checking that * every hop moves to a STRICTLY earlier offset catches it, immediately. */ static void test_fold_refuses_forward_pointing_delta(void) { char path[128]; snprintf(path, sizeof path, "%s/foldfwd.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, DELTA_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, DELTA_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; /* filler row/record, same reason as test_delta_record's: a fresh WAL's first record sits at offset 0, which would make the forged delta's own offset indistinguishable from a zeroed field either way. */ uint64_t filler_vals[3] = {1, 2, 3}; uint64_t filler_id = wo_row_insert(&db, 0, filler_vals, &msg, NULL); T_CHECK(filler_id != 0); T_EQ(wo_wal_append_insert(&w, &db, 0, filler_id), 0); T_EQ(wo_wal_commit(&w), 0); uint64_t vals[3] = {10, 20, 30}; uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(id != 0); /* forge a delta BEFORE the row's real base record exists, naming the offset the base record WILL occupy right after it. A scalar-field delta payload is kind|class|id|field_idx|back_off|value(u64) = 33 bytes (established by test_delta_record); the frame is 8+33+4 = 45. */ uint64_t delta_off = wo_wal_next_offset(&w); uint64_t insert_off = delta_off + 45u; T_EQ(wo_wal_append_delta(&w, &db, 0, id, 0, insert_off, 999), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_wal_next_offset(&w), insert_off); /* the hand-computed frame size held */ /* the row's TRUE base record, landing exactly where the forged delta claimed — the row is still in RAM, so this is an ordinary insert-log */ T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_EQ(wo_wal_commit(&w), 0); /* point the row at the forged, forward-pointing delta */ T_EQ(wo_row_drop_payload(&db, 0, id, delta_off), 0); /* the fold must refuse — not silently return {999, 20, 30} by walking forward into the base record the forged back-pointer named */ T_CHECK(wo_row_borrow(&db, 0, id, &msg) == NULL); wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* Task 3 (keys-resident delta updates): the plain case, through the real * API — wo_row_update_field, not a hand-rolled append+commit+set_offset like * the fold tests above. Before this task it refused outright with "update on * a `resident: keys` table is not implemented". * * Task 4 ruling: wo_row_update_field now only STAGES the delta and applies * the index swap — it does not commit and does not move the id map (that * mirrors insert, whose koff/commit/pend_drop live in the CALLER). So this * test now does the caller's half itself, exactly as db.c's inline arm * does: capture the offset before calling in, commit, then re-point. */ static void test_keys_resident_update_field(void) { char path[128]; snprintf(path, sizeof path, "%s/keysupd.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, KEYS_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; wo_str *s = wo_str_new(&rt, "hello", 5); uint64_t vals[2] = {111, (uint64_t)(uintptr_t)s}; uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(id != 0); uint64_t off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, id, off), 0); int ek = 0; uint64_t roff = wo_wal_next_offset(&w); T_EQ(wo_row_update_field(&db, 0, id, 0, 999, &msg, &ek), 0); T_EQ(ek, DB_ERR_NONE); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_set_offset(&db, 0, id, roff), 0); db_row *r = wo_row_borrow(&db, 0, id, &msg); T_CHECK(r != NULL); T_CHECK(r->slots[0] == 999); db_text *back = (db_text *)(uintptr_t)r->slots[1]; T_CHECK(back != NULL && back->len == 5 && memcmp(back->bytes, "hello", 5) == 0); wo_row_release(&db, 0, r); /* the scratch must be free again — a release that skipped clearing scratch_busy would wedge this second borrow */ db_row *again = wo_row_borrow(&db, 0, id, &msg); T_CHECK(again != NULL && again->slots[0] == 999); wo_row_release(&db, 0, again); wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* class 0: Row { n: scalar @index(non-unique), label: Text } — a keys-resident * table with a secondary index on the scalar column, dedicated to the test * below. The design deliberately allows a delta to change an indexed column * (a catalogue indexes exactly the columns that change, like `price`), so * this is the realistic case. */ static const uint8_t keys_idx_kinds[] = {WO_K_SCALAR, WO_K_TEXT}; static const uint32_t keys_idx_meta[] = {0 /*non-unique*/, 1, 0 /*col: n*/}; static const wo_classdesc KEYS_IDX_CLASSES[] = { {.name = 0, .flags = WO_CLASSF_RESIDENT_KEYS, .field_cnt = 2, .kinds = keys_idx_kinds, .idx_cnt = 1, .idx_meta = keys_idx_meta}, }; /* 2026-09-10 defect: the FIRST keys-resident row of a FRESH log. Boot adopts * the compiled schema (main.c: wo_wal_set_schema, never wo_wal_ensure_schema) * and the head record is staged lazily ahead of the first real record. * db.c's insert arm captures the row's offset with wo_wal_next_offset BEFORE * the append, so the head must already be staged by then — otherwise `koff` * names the schema record and the row's first read folds "record header is * malformed"; through wo_idx_probe (borrow with msg == NULL) that was a * zero-page write — the residency example's `seed` died rc 139. Call for * call the db.c:78 sequence, then the two reads `seed` performs. */ static void test_keys_resident_fresh_log_first_row(void) { char path[128]; snprintf(path, sizeof path, "%s/keysfresh.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_IDX_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, KEYS_IDX_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; static wo_schema_field f[] = { {(const uint8_t *)"n", 1, WO_K_SCALAR, WO_SCHEMA_NONE, WO_SCHEMA_NONE}, {(const uint8_t *)"label", 5, WO_K_TEXT, WO_SCHEMA_NONE, WO_SCHEMA_NONE}, }; static wo_schema_class cls[] = {{(const uint8_t *)"row", 3, WO_CLASSF_RESIDENT_KEYS, 2, f}}; wo_schema sc = {1, cls, NULL}; T_EQ(wo_wal_set_schema(&w, &sc), 0); T_EQ(wo_wal_read_schema(path, NULL, NULL), 1); /* still zero bytes: lazy */ /* db.c's insert arm, call for call */ wo_str *s = wo_str_new(&rt, "sku", 3); uint64_t vals[2] = {10, (uint64_t)(uintptr_t)s}; uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(id != 0); uint64_t koff = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_EQ(wo_wal_pend_drop(&w, 0, id, koff), 0); T_EQ(wo_wal_commit(&w), 0); wo_db_flush_drops(&db, &w); wo_str_free(&rt, s); /* the log describes itself AND koff names the row, not the head */ T_EQ(wo_wal_read_schema(path, NULL, NULL), 0); T_CHECK(koff != 0); uint32_t at_cid = 99; uint64_t at_id = 0, at[2]; T_EQ(wo_wal_read_row_at(&w, &db, &rt, koff, &at_cid, &at_id, at, &msg), 0); T_CHECK(at_cid == 0 && at_id == id); if (at_cid == 0 && at_id == id) wo_str_free(&rt, (wo_str *)(uintptr_t)at[1]); /* seed's first read: through the id map */ uint64_t out[2]; msg = ""; int rrc = wo_row_read(&db, &rt, 0, id, out, &msg); T_EQ(rrc, 0); if (rrc != 0) fprintf(stderr, " wo_row_read: %s\n", msg); else { T_EQ(out[0], 10); wo_str_free(&rt, (wo_str *)(uintptr_t)out[1]); } /* seed's second read: the index probe borrows with msg == NULL */ uint64_t *ids; uint32_t cnt; T_EQ(wo_idx_probe(&db, 0, 0, 10, NULL, 0, &ids, &cnt), 1); T_CHECK(cnt == 1 && ids[0] == id); free(ids); /* and a restart sees one row behind the head, readable by offset again */ wo_wal_close(&w); wo_db_destroy(&db); wo_db db2; T_EQ(wo_db_init(&db2, KEYS_IDX_CLASSES, 1, 0, 1), 0); db2.rt = &rt; rt.wal = NULL; rt.db = &db2; T_EQ(wo_wal_replay(path, &db2), 1); wo_wal w2; T_EQ(wo_wal_open(&w2, path, 1 << 16), 0); rt.wal = &w2; db_row *r = wo_row_borrow(&db2, 0, id, &msg); T_CHECK(r != NULL && r->slots[0] == 10); wo_row_release(&db2, 0, r); wo_wal_close(&w2); wo_db_destroy(&db2); wo_rt_destroy(&rt); } /* Task 3, the test that matters: updating an INDEXED column on a * keys-resident row must move the row in the index too, not just in the * log — queried through wo_idx_probe, the row is found by its NEW value and * gone from its OLD one. * * Task 4 ruling: wo_idx_probe's bucket hit is verified by folding the row * from the log (table.c's idx_cols_equal path), so the probes below must * run AFTER the caller's commit + re-point — mirroring db.c's inline arm — * not straight after wo_row_update_field, which now only stages. */ /* databasev2 11: helper — drive one update through the full commit/re-point * dance the request path performs, so a chain can be built in a loop. */ static void chain_update(wo_db *db, wo_wal *w, uint32_t cid, uint64_t id, uint32_t field, uint64_t v) { const char *msg = ""; int ek = 0; uint64_t roff = wo_wal_next_offset(w); T_EQ(wo_row_update_field(db, cid, id, field, v, &msg, &ek), 0); T_EQ(ek, DB_ERR_NONE); T_EQ(wo_wal_commit(w), 0); T_EQ(wo_row_set_offset(db, cid, id, roff), 0); } /* databasev2 11: the branch this iteration exists for. Past WO_DELTA_MAX_HOPS * an update must TERMINATE the chain with a full-row record rather than * lengthening it — otherwise read cost and replay cost grow without bound, * because compaction's trigger is a whole-log byte ratio and cannot see one * row's chain. * * The assertion is on the DEPTH the fold reports, not on timing: a test that * measured speed would pass on a slow box with an unbounded chain. */ /* databasev2 11 tier 2: the compaction policy's two new terms. A pure * function, so this is cheap and exact — no log, no timing. * * The trap being guarded: our `floor` SUPPRESSES compaction on a small log, * the opposite of PostgreSQL's vac_base_thresh, which TRIGGERS on a small * absolute problem the proportion would hide. Before this iteration we had the * proportion and the suppressor and neither real guard. */ static void test_should_compact_absolute_and_ceiling(void) { const uint64_t floor_b = 1024; /* unchanged behaviour: below the floor, never */ T_EQ(wo_wal_should_compact(512, 256, floor_b, 2), 0); /* unchanged: never compacted yet, past the floor -> once, to set a denominator */ T_EQ(wo_wal_should_compact(4096, 0, floor_b, 2), 1); /* unchanged: ratio 0 disables the policy rather than dividing by nothing */ T_EQ(wo_wal_should_compact(1u << 30, 1024, floor_b, 0), 0); /* THE ABSOLUTE TERM. A live set so large that the ratio will not trip for * a very long time, but with more than WO_CKPT_ABS_BYTES of garbage * already reclaimable. The old policy said no; the point of the term is * that garbage large in BYTES is worth reclaiming even when it is small in * PROPORTION. */ { uint64_t live = 4ull * 1024 * 1024 * 1024; /* 4 GiB live */ uint64_t used = live + WO_CKPT_ABS_BYTES + 1; /* just over the term */ T_CHECK(used < live * 2); /* ratio 2 would NOT fire */ T_EQ(wo_wal_should_compact(used, live, floor_b, 2), 1); } /* and just under it, the ratio still governs */ { uint64_t live = 4ull * 1024 * 1024 * 1024; uint64_t used = live + (WO_CKPT_ABS_BYTES / 2); T_EQ(wo_wal_should_compact(used, live, floor_b, 2), 0); } /* DEFERRAL IS CAPPED by the same term — no separate ceiling exists, and * one was removed as unreachable. With an 8 GiB live set, ratio 2 would * wait for the log to double; the absolute term fires long before that. */ { uint64_t live = 8ull * 1024 * 1024 * 1024; /* 8 GiB live */ uint64_t used = live + WO_CKPT_ABS_BYTES + 1; T_CHECK(used < live * 2); /* the ratio alone would defer */ T_EQ(wo_wal_should_compact(used, live, floor_b, 2), 1); } /* the ratio still governs BELOW the absolute term, which is what keeps the * two complementary rather than one subsuming the other: a small live set * trips the ratio with far less garbage than 64 MiB */ T_EQ(wo_wal_should_compact(3072, 1024, floor_b, 2), 1); /* 3 KiB > 1 KiB * 2 */ T_EQ(wo_wal_should_compact(2048, 1024, floor_b, 2), 0); /* not yet */ } /* ---- databasev2 12: the migration transcode ----------------------------- */ #define SF(nm, k) {(const uint8_t *)nm, (uint32_t)(sizeof nm - 1), k, WO_SCHEMA_NONE, WO_SCHEMA_NONE} #define SFC(nm, k, fc) {(const uint8_t *)nm, (uint32_t)(sizeof nm - 1), k, fc, WO_SCHEMA_NONE} #define SC(nm, fl, arr) {(const uint8_t *)nm, (uint32_t)(sizeof nm - 1), fl, \ (uint32_t)(sizeof arr / sizeof arr[0]), arr} /* every migrate test speaks both sides: a classdesc array for the engine and * a wo_schema for the diff, built from the same literals */ static const uint8_t mig_nt_kinds[] = {WO_K_SCALAR, WO_K_TEXT}; static const wo_classdesc MIG_NT[] = { {.name = 0, .flags = 0, .field_cnt = 2, .kinds = mig_nt_kinds}, }; static const uint8_t mig_nte_kinds[] = {WO_K_SCALAR, WO_K_TEXT, WO_K_SCALAR}; static const wo_classdesc MIG_NTE[] = { {.name = 0, .flags = 0, .field_cnt = 3, .kinds = mig_nte_kinds}, }; static const uint8_t mig_n_kinds[] = {WO_K_SCALAR}; static const wo_classdesc MIG_N[] = { {.name = 0, .flags = 0, .field_cnt = 1, .kinds = mig_n_kinds}, }; static wo_schema_field mig_sf_n[] = {SF("n", WO_K_SCALAR)}; static wo_schema_field mig_sf_nt[] = {SF("n", WO_K_SCALAR), SF("t", WO_K_TEXT)}; static wo_schema_field mig_sf_nte[] = {SF("n", WO_K_SCALAR), SF("t", WO_K_TEXT), SF("extra", WO_K_SCALAR)}; static db_text *mig_text(const char *sz) { size_t n = strlen(sz); db_text *t = malloc(sizeof(db_text) + n); t->len = (uint32_t)n; memcpy(t->bytes, sz, n); return t; } /* REORDER + OWNED FIXUP: the classes swap declaration order and one of them * embeds the other by value. The record's outer cid AND the cid inside the * stored owned value must both be renumbered — the outer one alone would * decode the embedded value against the wrong class. */ static void test_migrate_reorder_owned(void) { char path[128]; snprintf(path, sizeof path, "%s/migreorder.wal", g_dir); static const uint8_t x_kinds[] = {WO_K_SCALAR}; static const uint8_t c_kinds[] = {WO_K_OWNED, WO_K_SCALAR}; static const wo_classdesc OLD_XC[] = { {.name = 0, .flags = 0, .field_cnt = 1, .kinds = x_kinds}, {.name = 0, .flags = 0, .field_cnt = 2, .kinds = c_kinds}, }; static const wo_classdesc NEW_CX[] = { {.name = 0, .flags = 0, .field_cnt = 2, .kinds = c_kinds}, {.name = 0, .flags = 0, .field_cnt = 1, .kinds = x_kinds}, }; static wo_schema_field sx[] = {SF("v", WO_K_SCALAR)}; static wo_schema_field sc_old[] = {SFC("part", WO_K_OWNED, 0), SF("m", WO_K_SCALAR)}; static wo_schema_field sc_new[] = {SFC("part", WO_K_OWNED, 1), SF("m", WO_K_SCALAR)}; wo_schema_class oc[] = {SC("X", 0, sx), SC("C", 0, sc_old)}; wo_schema oldsc = {2, oc, NULL}; wo_schema_class nc[] = {SC("C", 0, sc_new), SC("X", 0, sx)}; wo_schema newsc = {2, nc, NULL}; { wo_db db; T_EQ(wo_db_init(&db, OLD_XC, 2, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db_row *rx = wo_row_create_raw(&db, 0, 3); /* an X row, old cid 0 */ rx->slots[0] = 7; T_EQ(wo_row_raw_commit(&db, 0, rx), 0); T_EQ(wo_wal_append_insert(&w, &db, 0, 3), 0); db_rec *part = malloc(sizeof(db_rec) + 8); /* embedded X, old cid 0 */ part->class_id = 0; part->_pad = 0; part->slots[0] = 42; db_row *rc = wo_row_create_raw(&db, 1, 5); /* a C row, old cid 1 */ rc->slots[0] = (uint64_t)(uintptr_t)part; rc->slots[1] = 9; T_EQ(wo_row_raw_commit(&db, 1, rc), 0); T_EQ(wo_wal_append_insert(&w, &db, 1, 5), 0); T_EQ(wo_wal_commit(&w), 0); wo_wal_close(&w); wo_db_destroy(&db); } wo_db db2; T_EQ(wo_db_init(&db2, NEW_CX, 2, 0, 1), 0); wo_mig_plan pl; T_EQ(wo_schema_diff(&oldsc, &newsc, &pl), 0); T_EQ(pl.identity, 0); T_EQ(pl.classes[0].new_cid, 1u); T_EQ(pl.classes[1].new_cid, 0u); T_CHECK(pl.classes[0].poison == NULL && pl.classes[1].poison == NULL); T_EQ(wo_wal_migrate(path, &db2, &oldsc, &pl, &newsc, 1 << 16, NULL), 0); wo_mig_plan_free(&pl); T_EQ(wo_wal_replay(path, &db2), 2); db_row *rx = wo_row_ptr(&db2, 1, 3); /* X lives at cid 1 now */ T_CHECK(rx != NULL && rx->slots[0] == 7); db_row *rc = wo_row_ptr(&db2, 0, 5); /* C lives at cid 0 now */ T_CHECK(rc != NULL && rc->slots[1] == 9); db_rec *part = (db_rec *)(uintptr_t)rc->slots[0]; T_CHECK(part != NULL && part->class_id == 1 && part->slots[0] == 42); wo_db_destroy(&db2); } /* DELTA SPLICE: a keys-resident row's chain carries deltas on a field that is * being DELETED. The spliced chain must still fold — later deltas re-point * around the dropped ones — and the surviving field's latest value wins. */ static void test_migrate_delta_splice(void) { char path[128]; snprintf(path, sizeof path, "%s/migsplice.wal", g_dir); static wo_schema_field sk_old[] = {SF("n", WO_K_SCALAR), SF("label", WO_K_TEXT)}; static wo_schema_field sk_new[] = {SF("n", WO_K_SCALAR)}; wo_schema_class oc[] = {SC("K", WO_CLASSF_RESIDENT_KEYS, sk_old)}; wo_schema oldsc = {1, oc, NULL}; wo_schema_class nc[] = {SC("K", WO_CLASSF_RESIDENT_KEYS, sk_new)}; wo_schema newsc = {1, nc, NULL}; static const uint8_t knew_kinds[] = {WO_K_SCALAR}; static const wo_classdesc KNEW[] = { {.name = 0, .flags = WO_CLASSF_RESIDENT_KEYS, .field_cnt = 1, .kinds = knew_kinds}, }; wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_CLASSES, 1), 0); uint64_t id; { wo_db db; T_EQ(wo_db_init(&db, KEYS_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 18), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; wo_str *sa = wo_str_new(&rt, "a", 1); uint64_t vals[2] = {1, (uint64_t)(uintptr_t)sa}; id = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(id != 0); uint64_t off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, id, off), 0); /* the chain: n=2, label="x" (doomed), n=3 — the last delta's back pointer crosses the doomed one */ chain_update(&db, &w, 0, id, 0, 2); wo_str *sx = wo_str_new(&rt, "x", 1); chain_update(&db, &w, 0, id, 1, (uint64_t)(uintptr_t)sx); chain_update(&db, &w, 0, id, 0, 3); wo_wal_close(&w); wo_db_destroy(&db); } wo_db db2; T_EQ(wo_db_init(&db2, KNEW, 1, 0, 1), 0); wo_mig_plan pl; T_EQ(wo_schema_diff(&oldsc, &newsc, &pl), 0); T_CHECK(pl.classes[0].poison == NULL && pl.classes[0].fmap[1] == -1); T_EQ(wo_wal_migrate(path, &db2, &oldsc, &pl, &newsc, 1 << 18, NULL), 0); wo_mig_plan_free(&pl); /* replay the migrated log the way boot does for a keys table, then read the row back through the fold: the chain must resolve to n=3 */ db2.rt = &rt; rt.wal = NULL; rt.db = &db2; T_CHECK(wo_wal_replay(path, &db2) >= 0); wo_wal w2; T_EQ(wo_wal_open(&w2, path, 1 << 18), 0); rt.wal = &w2; const char *msg = ""; db_row *r = wo_row_borrow(&db2, 0, id, &msg); T_CHECK(r != NULL && r->slots[0] == 3); wo_row_release(&db2, 0, r); wo_wal_close(&w2); wo_db_destroy(&db2); wo_rt_destroy(&rt); } /* ADD: a two-field log boots a three-field binary — rows survive, the new * field reads the kind's zero, the head record states the NEW shape, and a * stale compaction temp lying beside the log is discarded, not appended to */ static void test_migrate_add_field(void) { char path[128]; snprintf(path, sizeof path, "%s/migadd.wal", g_dir); wo_schema_class oc[] = {SC("row", 0, mig_sf_nt)}; wo_schema oldsc = {1, oc, NULL}; wo_schema_class nc[] = {SC("row", 0, mig_sf_nte)}; wo_schema newsc = {1, nc, NULL}; /* the OLD program writes its log, schema record at the head */ { wo_db db; T_EQ(wo_db_init(&db, MIG_NT, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); T_EQ(wo_wal_set_schema(&w, &oldsc), 0); T_EQ(wo_wal_ensure_schema(&w), 0); db_row *r1 = wo_row_create_raw(&db, 0, 1); r1->slots[0] = 7; r1->slots[1] = (uint64_t)(uintptr_t)mig_text("abc"); T_EQ(wo_row_raw_commit(&db, 0, r1), 0); T_EQ(wo_wal_append_insert(&w, &db, 0, 1), 0); db_row *r2 = wo_row_create_raw(&db, 0, 2); r2->slots[0] = 9; r2->slots[1] = 0; T_EQ(wo_row_raw_commit(&db, 0, r2), 0); T_EQ(wo_wal_append_insert(&w, &db, 0, 2), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_remove(&db, 0, 2), 0); T_EQ(wo_wal_append_remove(&w, 0, 2), 0); T_EQ(wo_wal_commit(&w), 0); wo_wal_close(&w); wo_db_destroy(&db); } /* a stale temp beside the log: never authoritative, must be discarded */ { char tmp[160]; snprintf(tmp, sizeof tmp, "%s.compact", path); FILE *f = fopen(tmp, "w"); T_CHECK(f != NULL); fputs("stale-not-a-record", f); fclose(f); } /* the NEW binary migrates it at boot */ wo_db db3; T_EQ(wo_db_init(&db3, MIG_NTE, 1, 0, 1), 0); wo_mig_plan pl; T_EQ(wo_schema_diff(&oldsc, &newsc, &pl), 0); T_EQ(pl.identity, 0); T_CHECK(pl.classes[0].poison == NULL); char *err = NULL; T_EQ(wo_wal_migrate(path, &db3, &oldsc, &pl, &newsc, 1 << 16, &err), 0); T_CHECK(err == NULL); wo_mig_plan_free(&pl); /* head record: the new three-field shape */ uint8_t *sp; uint32_t slen; T_EQ(wo_wal_read_schema(path, &sp, &slen), 0); wo_schema *head = wo_schema_decode(sp, slen); T_CHECK(head != NULL && head->classes[0].field_cnt == 3); wo_schema_free(head); free(sp); /* replay: row 1 intact with a zero-valued third field, row 2 gone */ T_EQ(wo_wal_replay(path, &db3), 3); /* insert, insert, remove */ db_row *r = wo_row_ptr(&db3, 0, 1); T_CHECK(r != NULL && r->slots[0] == 7); db_text *t = (db_text *)(uintptr_t)r->slots[1]; T_CHECK(t != NULL && t->len == 3 && memcmp(t->bytes, "abc", 3) == 0); T_EQ(r->slots[2], 0u); T_CHECK(wo_row_ptr(&db3, 0, 2) == NULL); wo_db_destroy(&db3); } /* DELETE: the Text column's stored values are freed (ASan holds the leash) * and the surviving field lands in its new slot */ static void test_migrate_delete_field(void) { char path[128]; snprintf(path, sizeof path, "%s/migdel.wal", g_dir); wo_schema_class oc[] = {SC("row", 0, mig_sf_nt)}; wo_schema oldsc = {1, oc, NULL}; wo_schema_class nc[] = {SC("row", 0, mig_sf_n)}; wo_schema newsc = {1, nc, NULL}; { wo_db db; T_EQ(wo_db_init(&db, MIG_NT, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); for (uint64_t id = 1; id <= 20; id++) { db_row *r = wo_row_create_raw(&db, 0, id); r->slots[0] = id * 10; r->slots[1] = (uint64_t)(uintptr_t)mig_text("payload-to-drop"); T_EQ(wo_row_raw_commit(&db, 0, r), 0); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); } T_EQ(wo_wal_commit(&w), 0); wo_wal_close(&w); wo_db_destroy(&db); } wo_db db2; T_EQ(wo_db_init(&db2, MIG_N, 1, 0, 1), 0); wo_mig_plan pl; T_EQ(wo_schema_diff(&oldsc, &newsc, &pl), 0); T_CHECK(pl.classes[0].poison == NULL && pl.classes[0].fmap[1] == -1); T_EQ(wo_wal_migrate(path, &db2, &oldsc, &pl, &newsc, 1 << 16, NULL), 0); wo_mig_plan_free(&pl); T_EQ(wo_wal_replay(path, &db2), 20); for (uint64_t id = 1; id <= 20; id++) { db_row *r = wo_row_ptr(&db2, 0, id); T_CHECK(r != NULL && r->slots[0] == id * 10); } wo_db_destroy(&db2); } /* CRASH BETWEEN WRITE AND RENAME: the sharpest point on the timeline — a * COMPLETE, VALID migrated log sits beside the original as the temp, the * rename never happened. The next boot must treat the temp as the nothing it * is (its records were never authoritative) and re-migrate from the intact * original. A garbage temp tests the unlink; a valid one tests the doctrine. */ static void test_migrate_crash_before_rename(void) { char pa[128], pb[160], tmp[160]; snprintf(pa, sizeof pa, "%s/migcrash.wal", g_dir); snprintf(pb, sizeof pb, "%s/migcrash-copy.wal", g_dir); snprintf(tmp, sizeof tmp, "%s.compact", pa); wo_schema_class oc[] = {SC("row", 0, mig_sf_nt)}; wo_schema oldsc = {1, oc, NULL}; wo_schema_class nc[] = {SC("row", 0, mig_sf_nte)}; wo_schema newsc = {1, nc, NULL}; { wo_db db; T_EQ(wo_db_init(&db, MIG_NT, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, pa, 0), 0); db_row *r = wo_row_create_raw(&db, 0, 1); r->slots[0] = 5; r->slots[1] = (uint64_t)(uintptr_t)mig_text("keep"); T_EQ(wo_row_raw_commit(&db, 0, r), 0); T_EQ(wo_wal_append_insert(&w, &db, 0, 1), 0); T_EQ(wo_wal_commit(&w), 0); wo_wal_close(&w); wo_db_destroy(&db); } wo_mig_plan pl; T_EQ(wo_schema_diff(&oldsc, &newsc, &pl), 0); /* produce the "crashed" state: migrate a COPY, then plant its result as the original's temp — exactly what a kill after fsync, before rename, leaves on disk */ { FILE *a = fopen(pa, "rb"), *b = fopen(pb, "wb"); T_CHECK(a && b); int ch; while ((ch = fgetc(a)) != EOF) fputc(ch, b); fclose(a); fclose(b); wo_db dbn; T_EQ(wo_db_init(&dbn, MIG_NTE, 1, 0, 1), 0); T_EQ(wo_wal_migrate(pb, &dbn, &oldsc, &pl, &newsc, 0, NULL), 0); wo_db_destroy(&dbn); T_EQ(rename(pb, tmp), 0); } /* the next boot: re-migrates from the intact original, result correct */ wo_db db2; T_EQ(wo_db_init(&db2, MIG_NTE, 1, 0, 1), 0); T_EQ(wo_wal_migrate(pa, &db2, &oldsc, &pl, &newsc, 0, NULL), 0); wo_mig_plan_free(&pl); T_EQ(wo_wal_replay(pa, &db2), 1); db_row *r = wo_row_ptr(&db2, 0, 1); T_CHECK(r != NULL && r->slots[0] == 5 && r->slots[2] == 0); db_text *t = (db_text *)(uintptr_t)r->slots[1]; T_CHECK(t != NULL && t->len == 4 && memcmp(t->bytes, "keep", 4) == 0); wo_db_destroy(&db2); } /* databasev2 7 Task 2: with WO_DATA naming a FILE, compaction (databasev2 3) * and migration (databasev2 12) must build their temp as `.compact` * beside it and take the parent they fsync from the FILE's path — both derive * everything from the log path today, and this pins that against a future * "derive it from WO_DATA". The proof is a blocker, not a listing: a * DIRECTORY planted at exactly `.compact` makes each rewrite refuse (-1) * with the log untouched, which a temp anywhere else could not produce; with * the blocker gone both succeed and the operator's file is the only artifact * in its directory (a sibling directory stands in as the decoy nothing may * land in). The parent derivation is one helper shared with the resolver * (parent_dir_of), so test_resolve_data_path's missing-parent arm already * pins what "the parent" of such a path is; fsync itself is not observable. */ static int dir_entries(const char *dir) { DIR *d = opendir(dir); if (!d) return -1; int n = 0; struct dirent *e; while ((e = readdir(d)) != NULL) if (strcmp(e->d_name, ".") != 0 && strcmp(e->d_name, "..") != 0) n++; closedir(d); return n; } static void test_file_form_temps_beside_log(void) { char dir[160], decoy[192], path[192], tmp[224], out[256]; snprintf(dir, sizeof dir, "%s/fileform", g_dir); snprintf(decoy, sizeof decoy, "%s/app.db.d", dir); /* sibling directory */ snprintf(path, sizeof path, "%s/app.db", dir); /* the operator's name */ snprintf(tmp, sizeof tmp, "%s%s", path, WO_WAL_TMP_SUFFIX); T_EQ(mkdir(dir, 0700), 0); T_EQ(mkdir(decoy, 0700), 0); T_EQ(wo_wal_resolve_data_path(path, out, sizeof out), 0); T_STREQ(out, path); /* the file form hands the path straight to the engine */ /* ---- compaction ---- */ wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); const char *msg = ""; uint64_t ids[2]; for (int i = 0; i < 2; i++) { wo_str *s = wo_str_new(&rt, "abc", 3); uint64_t vals[2] = {(uint64_t)(i + 1), (uint64_t)(uintptr_t)s}; ids[i] = wo_row_insert(&db, 0, vals, &msg, NULL); T_EQ(wo_wal_append_insert(&w, &db, 0, ids[i]), 0); T_EQ(wo_wal_commit(&w), 0); } for (int k = 0; k < 8; k++) { int ek = 0; T_EQ(wo_row_update_field(&db, 0, ids[0], 0, (uint64_t)(100 + k), &msg, &ek), 0); T_EQ(wo_wal_append_update(&w, &db, 0, ids[0]), 0); T_EQ(wo_wal_commit(&w), 0); } T_CHECK(wo_wal_check(path, NULL) == 10); T_EQ(mkdir(tmp, 0700), 0); /* the blocker */ T_EQ(wo_wal_compact(&w, &db), -1); /* the temp has exactly one home */ T_CHECK(wo_wal_check(path, NULL) == 10); /* refused = untouched */ T_EQ(rmdir(tmp), 0); /* still an empty dir: nothing went in */ T_EQ(wo_wal_compact(&w, &db), 0); T_CHECK(wo_wal_check(path, NULL) == 2); T_CHECK(access(tmp, F_OK) != 0); T_EQ(dir_entries(dir), 2); /* app.db + the decoy, nothing else */ T_EQ(dir_entries(decoy), 0); /* and the decoy saw nothing */ wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); /* ---- migration: same temp, same parent. The compacted log is two legacy INSERT records of (scalar, text) — MIG_NT's shape — so it migrates n,t → n,t,extra in place. ---- */ wo_schema_class oc[] = {SC("row", 0, mig_sf_nt)}; wo_schema oldsc = {1, oc, NULL}; wo_schema_class nc[] = {SC("row", 0, mig_sf_nte)}; wo_schema newsc = {1, nc, NULL}; wo_mig_plan pl; T_EQ(wo_schema_diff(&oldsc, &newsc, &pl), 0); wo_db dbn; T_EQ(wo_db_init(&dbn, MIG_NTE, 1, 0, 1), 0); T_EQ(mkdir(tmp, 0700), 0); T_EQ(wo_wal_migrate(path, &dbn, &oldsc, &pl, &newsc, 0, NULL), -1); T_CHECK(wo_wal_check(path, NULL) == 2); /* refused = untouched */ T_EQ(rmdir(tmp), 0); T_EQ(wo_wal_migrate(path, &dbn, &oldsc, &pl, &newsc, 0, NULL), 0); wo_mig_plan_free(&pl); T_CHECK(access(tmp, F_OK) != 0); T_EQ(dir_entries(dir), 2); T_EQ(dir_entries(decoy), 0); /* and the file, under the operator's name, replays into the new shape */ T_EQ(wo_wal_replay(path, &dbn), 2); db_row *r = wo_row_ptr(&dbn, 0, ids[0]); T_CHECK(r != NULL && r->slots[0] == 107 && r->slots[2] == 0); wo_db_destroy(&dbn); } /* POISON BITES ONLY WITH RECORDS: a retyped class with no stored rows never * blocks the boot; the same retype WITH a row refuses and names the field */ static void test_migrate_poison_needs_records(void) { char path[128]; snprintf(path, sizeof path, "%s/migpoison.wal", g_dir); static const uint8_t two_kinds0[] = {WO_K_SCALAR, WO_K_TEXT}; static const uint8_t two_kinds1[] = {WO_K_SCALAR}; static const wo_classdesc TWO[] = { {.name = 0, .flags = 0, .field_cnt = 2, .kinds = two_kinds0}, {.name = 0, .flags = 0, .field_cnt = 1, .kinds = two_kinds1}, }; static const uint8_t two_kinds1f[] = {WO_K_FLOAT}; static const wo_classdesc TWO_NEW[] = { {.name = 0, .flags = 0, .field_cnt = 2, .kinds = two_kinds0}, {.name = 0, .flags = 0, .field_cnt = 1, .kinds = two_kinds1f}, }; wo_schema_field b_old[] = {SF("x", WO_K_SCALAR)}; wo_schema_field b_new[] = {SF("x", WO_K_FLOAT)}; wo_schema_class oc[] = {SC("A", 0, mig_sf_nt), SC("B", 0, b_old)}; wo_schema oldsc = {2, oc, NULL}; /* the new side also ADDS a field to A, so the plan is not identity and the transcode genuinely runs */ wo_schema_class nc[] = {SC("A", 0, mig_sf_nte), SC("B", 0, b_new)}; wo_schema newsc = {2, nc, NULL}; /* log 1: rows of A only */ { wo_db db; T_EQ(wo_db_init(&db, TWO, 2, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db_row *r = wo_row_create_raw(&db, 0, 1); r->slots[0] = 1; r->slots[1] = 0; T_EQ(wo_row_raw_commit(&db, 0, r), 0); T_EQ(wo_wal_append_insert(&w, &db, 0, 1), 0); T_EQ(wo_wal_commit(&w), 0); wo_wal_close(&w); wo_db_destroy(&db); } wo_mig_plan pl; T_EQ(wo_schema_diff(&oldsc, &newsc, &pl), 0); T_CHECK(pl.classes[1].poison != NULL); /* B is poisoned... */ { wo_db dbn; T_EQ(wo_db_init(&dbn, TWO_NEW, 2, 0, 1), 0); char *err = NULL; T_EQ(wo_wal_migrate(path, &dbn, &oldsc, &pl, &newsc, 1 << 16, &err), 0); T_CHECK(err == NULL); /* ...but nothing of B is stored: boots fine */ wo_db_destroy(&dbn); } /* log 2: now with a B record — the poison bites and names the field */ { wo_db db; T_EQ(wo_db_init(&db, TWO, 2, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); /* migrated log: reopen fresh */ char p2[144]; snprintf(p2, sizeof p2, "%s2", path); wo_wal w2; T_EQ(wo_wal_open(&w2, p2, 1 << 16), 0); db_row *rb = wo_row_create_raw(&db, 1, 4); rb->slots[0] = 11; T_EQ(wo_row_raw_commit(&db, 1, rb), 0); T_EQ(wo_wal_append_insert(&w2, &db, 1, 4), 0); T_EQ(wo_wal_commit(&w2), 0); wo_wal_close(&w2); wo_wal_close(&w); wo_db_destroy(&db); wo_db dbn; T_EQ(wo_db_init(&dbn, TWO_NEW, 2, 0, 1), 0); char *err = NULL; T_EQ(wo_wal_migrate(p2, &dbn, &oldsc, &pl, &newsc, 1 << 16, &err), -2); T_CHECK(err != NULL && strstr(err, "`x`") != NULL); free(err); wo_db_destroy(&dbn); } wo_mig_plan_free(&pl); } /* CORRUPT INPUT: a torn tail ends the intact prefix — the transcode takes * the prefix (same rule as replay), never the tear */ static void test_migrate_corrupt_input(void) { char path[128]; snprintf(path, sizeof path, "%s/migcorrupt.wal", g_dir); wo_schema_class oc[] = {SC("row", 0, mig_sf_nt)}; wo_schema oldsc = {1, oc, NULL}; wo_schema_class nc[] = {SC("row", 0, mig_sf_nte)}; wo_schema newsc = {1, nc, NULL}; { wo_db db; T_EQ(wo_db_init(&db, MIG_NT, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 0), 0); for (uint64_t id = 1; id <= 3; id++) { db_row *r = wo_row_create_raw(&db, 0, id); r->slots[0] = id; r->slots[1] = 0; T_EQ(wo_row_raw_commit(&db, 0, r), 0); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); } T_EQ(wo_wal_commit(&w), 0); /* tear the LAST record's tail byte */ off_t end = lseek(w.fd, 0, SEEK_END); T_CHECK(end > 4); uint8_t junk = 0xFF; T_EQ((int)pwrite(w.fd, &junk, 1, end - 1), 1); wo_wal_close(&w); wo_db_destroy(&db); } wo_db db2; T_EQ(wo_db_init(&db2, MIG_NTE, 1, 0, 1), 0); wo_mig_plan pl; T_EQ(wo_schema_diff(&oldsc, &newsc, &pl), 0); T_EQ(wo_wal_migrate(path, &db2, &oldsc, &pl, &newsc, 0, NULL), 0); wo_mig_plan_free(&pl); T_EQ(wo_wal_replay(path, &db2), 2); /* rows 1 and 2; the torn third is gone */ T_CHECK(wo_row_ptr(&db2, 0, 1) != NULL && wo_row_ptr(&db2, 0, 3) == NULL); wo_db_destroy(&db2); } /* ---- databasev2 12: the boot diff --------------------------------------- */ static void test_schema_diff_verdicts(void) { /* base: A { n: scalar, t: text }, B { part: owned->A } */ wo_schema_field a_f[] = {SF("n", WO_K_SCALAR), SF("t", WO_K_TEXT)}; wo_schema_field b_f[] = {SFC("part", WO_K_OWNED, 0)}; wo_schema_class base_c[] = {SC("A", 0, a_f), SC("B", 0, b_f)}; wo_schema base = {2, base_c, NULL}; wo_mig_plan pl; /* 1. identical -> identity */ T_EQ(wo_schema_diff(&base, &base, &pl), 0); T_EQ(pl.identity, 1); T_CHECK(pl.classes[0].poison == NULL && pl.classes[1].poison == NULL); wo_mig_plan_free(&pl); /* 2. classes reordered -> not identity, cids remapped BY NAME, and the owned reference (a different NUMBER now) is recognised by name too */ { wo_schema_field b2_f[] = {SFC("part", WO_K_OWNED, 1)}; /* A is cid 1 now */ wo_schema_class swap_c[] = {SC("B", 0, b2_f), SC("A", 0, a_f)}; wo_schema swp = {2, swap_c, NULL}; T_EQ(wo_schema_diff(&base, &swp, &pl), 0); T_EQ(pl.identity, 0); T_EQ(pl.classes[0].new_cid, 1u); /* old A -> new cid 1 */ T_EQ(pl.classes[1].new_cid, 0u); /* old B -> new cid 0 */ T_CHECK(pl.classes[0].poison == NULL && pl.classes[1].poison == NULL); T_CHECK(pl.classes[0].changed == 0 && pl.classes[1].changed == 0); wo_mig_plan_free(&pl); } /* 3. added field -> changed, surviving map intact, B poisoned (embeds A) */ { wo_schema_field a3_f[] = {SF("n", WO_K_SCALAR), SF("t", WO_K_TEXT), SF("extra", WO_K_SCALAR)}; wo_schema_class c3[] = {SC("A", 0, a3_f), SC("B", 0, b_f)}; wo_schema n3 = {2, c3, NULL}; T_EQ(wo_schema_diff(&base, &n3, &pl), 0); T_EQ(pl.identity, 0); T_CHECK(pl.classes[0].poison == NULL && pl.classes[0].changed == 1); T_EQ(pl.classes[0].fmap[0], 0); T_EQ(pl.classes[0].fmap[1], 1); T_CHECK(pl.classes[1].poison != NULL); /* embeds a changed class */ wo_mig_plan_free(&pl); } /* 4. deleted field -> fmap -1; different-shape delete+add migrates */ { wo_schema_field a4_f[] = {SF("n", WO_K_SCALAR), SF("blob", WO_K_BYTES)}; wo_schema_class c4[] = {SC("A", 0, a4_f), SC("B", 0, b_f)}; wo_schema n4 = {2, c4, NULL}; /* t: Text deleted, blob: Bytes added */ T_EQ(wo_schema_diff(&base, &n4, &pl), 0); T_CHECK(pl.classes[0].poison == NULL && pl.classes[0].changed == 1); T_EQ(pl.classes[0].fmap[0], 0); T_EQ(pl.classes[0].fmap[1], -1); wo_mig_plan_free(&pl); } /* 5. SAME-shape delete+add -> the rename ambiguity poison */ { wo_schema_field a5_f[] = {SF("n", WO_K_SCALAR), SF("headline", WO_K_TEXT)}; wo_schema_class c5[] = {SC("A", 0, a5_f), SC("B", 0, b_f)}; wo_schema n5 = {2, c5, NULL}; T_EQ(wo_schema_diff(&base, &n5, &pl), 0); T_CHECK(pl.classes[0].poison != NULL); T_CHECK(strstr(pl.classes[0].poison, "two separate steps") != NULL); wo_mig_plan_free(&pl); } /* 6. retype -> poison naming the field */ { wo_schema_field a6_f[] = {SF("n", WO_K_FLOAT), SF("t", WO_K_TEXT)}; wo_schema_class c6[] = {SC("A", 0, a6_f), SC("B", 0, b_f)}; wo_schema n6 = {2, c6, NULL}; T_EQ(wo_schema_diff(&base, &n6, &pl), 0); T_CHECK(pl.classes[0].poison != NULL && strstr(pl.classes[0].poison, "`n`") != NULL); wo_mig_plan_free(&pl); } /* 7. vanished class -> poison; the other class (embedding nothing that changed shape) is untouched */ { wo_schema_class c7[] = {SC("A", 0, a_f)}; wo_schema n7 = {1, c7, NULL}; T_EQ(wo_schema_diff(&base, &n7, &pl), 0); T_CHECK(pl.classes[1].new_cid == WO_SCHEMA_NONE && pl.classes[1].poison != NULL); T_CHECK(pl.classes[0].poison == NULL); wo_mig_plan_free(&pl); } /* 8. flags change -> poison (v1 migrates fields, not storage modes) */ { wo_schema_class c8[] = {SC("A", WO_CLASSF_RESIDENT_KEYS, a_f), SC("B", 0, b_f)}; wo_schema n8 = {2, c8, NULL}; T_EQ(wo_schema_diff(&base, &n8, &pl), 0); T_CHECK(pl.classes[0].poison != NULL && strstr(pl.classes[0].poison, "storage") != NULL); wo_mig_plan_free(&pl); } /* 9. a NEW class in the binary does not break identity: it has no records */ { wo_schema_field n_f[] = {SF("x", WO_K_SCALAR)}; wo_schema_class c9[] = {SC("A", 0, a_f), SC("B", 0, b_f), SC("C", 0, n_f)}; wo_schema n9 = {3, c9, NULL}; T_EQ(wo_schema_diff(&base, &n9, &pl), 0); T_EQ(pl.identity, 1); wo_mig_plan_free(&pl); } /* 10. the embed closure is transitive: C owns B, B owns A, A changed -> both B and C poisoned */ { wo_schema_field cB[] = {SFC("a", WO_K_OWNED, 0)}; wo_schema_field cC[] = {SFC("b", WO_K_OWNED, 1)}; wo_schema_class oc[] = {SC("A", 0, a_f), SC("B", 0, cB), SC("C", 0, cC)}; wo_schema oldsc = {3, oc, NULL}; wo_schema_field a10[] = {SF("n", WO_K_SCALAR)}; /* t deleted */ wo_schema_class nc[] = {SC("A", 0, a10), SC("B", 0, cB), SC("C", 0, cC)}; wo_schema newsc = {3, nc, NULL}; T_EQ(wo_schema_diff(&oldsc, &newsc, &pl), 0); T_CHECK(pl.classes[0].poison == NULL && pl.classes[0].changed == 1); T_CHECK(pl.classes[1].poison != NULL); T_CHECK(pl.classes[2].poison != NULL); wo_mig_plan_free(&pl); } } /* ---- databasev2 12: the schema record ---------------------------------- */ /* a hand-built two-class schema exercising every payload field */ static wo_schema mig_schema_sample(void) { static wo_schema_field f0[] = { {(const uint8_t *)"n", 1, WO_K_SCALAR, WO_SCHEMA_NONE, WO_SCHEMA_NONE}, {(const uint8_t *)"label", 5, WO_K_TEXT, WO_SCHEMA_NONE, WO_SCHEMA_NONE}, }; static wo_schema_field f1[] = { {(const uint8_t *)"part", 4, WO_K_OWNED, 0, WO_SCHEMA_NONE}, {(const uint8_t *)"tags", 4, WO_K_MULTI, WO_SCHEMA_NONE, WO_K_TEXT}, }; static wo_schema_class cls[] = { {(const uint8_t *)"row", 3, 0, 2, f0}, {(const uint8_t *)"box", 3, WO_CLASSF_RESIDENT_KEYS, 2, f1}, }; wo_schema sc = {2, cls, NULL}; return sc; } static void test_schema_roundtrip(void) { wo_schema sc = mig_schema_sample(); uint8_t *p; uint32_t len; T_EQ(wo_schema_encode(&sc, &p, &len), 0); T_CHECK(len > 5 && p[0] == WO_WAL_SCHEMA); wo_schema *back = wo_schema_decode(p, len); T_CHECK(back != NULL); T_EQ(back->class_cnt, 2u); T_CHECK(back->classes[0].name_len == 3 && memcmp(back->classes[0].name, "row", 3) == 0); T_EQ(back->classes[0].field_cnt, 2u); T_CHECK(back->classes[0].fields[1].kind == WO_K_TEXT && back->classes[0].fields[1].name_len == 5 && memcmp(back->classes[0].fields[1].name, "label", 5) == 0); T_EQ(back->classes[1].flags, (uint32_t)WO_CLASSF_RESIDENT_KEYS); T_CHECK(back->classes[1].fields[0].fclass == 0 && back->classes[1].fields[1].felem == WO_K_TEXT); /* the decode owns its bytes: the encode buffer can die first */ free(p); T_CHECK(memcmp(back->classes[1].name, "box", 3) == 0); /* a truncated payload is malformed, not a crash */ uint8_t *p2; uint32_t len2; T_EQ(wo_schema_encode(&sc, &p2, &len2), 0); T_CHECK(wo_schema_decode(p2, len2 - 3) == NULL); free(p2); wo_schema_free(back); } /* a fresh log opened with a schema carries it as its FIRST record; replay * skips it without counting it, and rows behind it land intact */ static void test_schema_fresh_log(void) { char path[128]; snprintf(path, sizeof path, "%s/schemafresh.wal", g_dir); wo_db db; T_EQ(wo_db_init(&db, CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); wo_schema sc = mig_schema_sample(); T_EQ(wo_wal_set_schema(&w, &sc), 0); T_EQ(wo_wal_ensure_schema(&w), 0); /* head record is the schema */ uint8_t *p; uint32_t len; T_EQ(wo_wal_read_schema(path, &p, &len), 0); wo_schema *back = wo_schema_decode(p, len); T_CHECK(back != NULL && back->class_cnt == 2); wo_schema_free(back); free(p); /* a second ensure is a no-op: records exist now */ uint64_t before = wo_wal_next_offset(&w); T_EQ(wo_wal_ensure_schema(&w), 0); T_EQ(wo_wal_next_offset(&w), before); /* a row behind it replays; the schema record is not counted */ const char *msg = ""; uint64_t vals[2] = {7, 0}; uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(id != 0); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_EQ(wo_wal_commit(&w), 0); wo_wal_close(&w); wo_db_destroy(&db); wo_db db2; T_EQ(wo_db_init(&db2, CLASSES, 1, 0, 1), 0); T_EQ(wo_wal_replay(path, &db2), 1); /* one row, not two records */ db_row *r = wo_row_ptr(&db2, 0, id); T_CHECK(r != NULL && r->slots[0] == 7); wo_db_destroy(&db2); } /* a LEGACY log (rows, no schema record) reports 1 from read_schema, and its * first compaction with a schema set writes the record at the head */ static void test_schema_compaction_adopts_legacy(void) { char path[128]; snprintf(path, sizeof path, "%s/schemalegacy.wal", g_dir); wo_db db; T_EQ(wo_db_init(&db, CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); const char *msg = ""; uint64_t vals[2] = {1, 0}; uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(id != 0); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_wal_read_schema(path, NULL, NULL), 1); /* legacy: head is a row */ wo_schema sc = mig_schema_sample(); T_EQ(wo_wal_set_schema(&w, &sc), 0); T_EQ(wo_wal_ensure_schema(&w), 0); /* no-op: not empty */ T_EQ(wo_wal_read_schema(path, NULL, NULL), 1); T_EQ(wo_wal_compact(&w, &db), 0); uint8_t *p; uint32_t len; T_EQ(wo_wal_read_schema(path, &p, &len), 0); /* adopted at the head */ free(p); /* and the compacted log still replays its row */ wo_wal_close(&w); wo_db_destroy(&db); wo_db db2; T_EQ(wo_db_init(&db2, CLASSES, 1, 0, 1), 0); T_EQ(wo_wal_replay(path, &db2), 1); db_row *r = wo_row_ptr(&db2, 0, id); T_CHECK(r != NULL && r->slots[0] == 1); wo_db_destroy(&db2); } /* missing and empty files are legacy, not errors */ static void test_schema_read_absent(void) { char path[128]; snprintf(path, sizeof path, "%s/schemanone.wal", g_dir); T_EQ(wo_wal_read_schema(path, NULL, NULL), 1); /* no file */ FILE *f = fopen(path, "w"); T_CHECK(f != NULL); fclose(f); T_EQ(wo_wal_read_schema(path, NULL, NULL), 1); /* empty file */ } static void test_delta_chain_flattens_at_k(void) { char path[128]; snprintf(path, sizeof path, "%s/chainflat.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, KEYS_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 18), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; wo_str *sv = wo_str_new(&rt, "flat", 4); uint64_t vals[2] = {0, (uint64_t)(uintptr_t)sv}; uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(id != 0); uint64_t off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, id, off), 0); /* Walk the depth up one update at a time and watch it reset. Without the * flatten branch this climbs forever; with it, it must never exceed K. */ uint32_t peak = 0; int saw_reset = 0; for (uint32_t n = 1; n <= WO_DELTA_MAX_HOPS * 2u + 2u; n++) { chain_update(&db, &w, 0, id, 0, (uint64_t)n); uint32_t hops = 0; uint32_t got_cid = 0; uint64_t got_id = 0; uint64_t out[2] = {0, 0}; const char *fm = ""; uint64_t o1 = wo_row_offset1(&db, 0, id); T_CHECK(o1 != 0); T_EQ(wo_wal_fold_row_at(&w, &db, o1 - 1, &got_cid, &got_id, out, &hops, &fm), 0); T_CHECK(got_cid == 0 && got_id == id); T_CHECK(out[0] == (uint64_t)n); /* the value is still right */ for (uint32_t i = 0; i < 2; i++) wo_db_val_free(&db, KEYS_CLASSES[0].kinds[i], out[i]); if (hops > peak) peak = hops; if (n > 1 && hops == 0) saw_reset = 1; /* a chain was terminated */ } T_CHECK(peak <= WO_DELTA_MAX_HOPS); /* the bound holds */ T_CHECK(saw_reset); /* and it was actually reached */ wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* databasev2 11: a flattened row must survive a restart identically. Replay * meets a WO_WAL_INSERT where a chain used to be; if flattening wrote a shape * replay mishandled, this is where it shows. */ static void test_delta_chain_flatten_replays(void) { char path[128]; snprintf(path, sizeof path, "%s/chainflatreplay.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, KEYS_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 18), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; wo_str *sv = wo_str_new(&rt, "rep", 3); uint64_t vals[2] = {0, (uint64_t)(uintptr_t)sv}; uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); uint64_t off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, id, off), 0); /* enough updates to guarantee at least one flatten */ uint64_t last = 0; for (uint32_t n = 1; n <= WO_DELTA_MAX_HOPS + 3u; n++) { chain_update(&db, &w, 0, id, 0, (uint64_t)n); last = n; } wo_wal_close(&w); wo_db_destroy(&db); wo_db db2; T_EQ(wo_db_init(&db2, KEYS_CLASSES, 1, 0, 1), 0); db2.rt = &rt; rt.wal = NULL; rt.db = &db2; T_CHECK(wo_wal_replay(path, &db2) >= 0); wo_wal w2; T_EQ(wo_wal_open(&w2, path, 1 << 18), 0); rt.wal = &w2; db_row *r = wo_row_borrow(&db2, 0, id, &msg); T_CHECK(r != NULL); T_CHECK(r->slots[0] == last); /* the newest value survived */ db_text *back = (db_text *)(uintptr_t)r->slots[1]; T_CHECK(back != NULL && back->len == 3 && memcmp(back->bytes, "rep", 3) == 0); wo_row_release(&db2, 0, r); wo_wal_close(&w2); wo_db_destroy(&db2); wo_rt_destroy(&rt); } static void test_keys_resident_update_indexed(void) { char path[128]; snprintf(path, sizeof path, "%s/keysidx.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_IDX_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, KEYS_IDX_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; wo_str *sa = wo_str_new(&rt, "a", 1); wo_str *sb = wo_str_new(&rt, "b", 1); uint64_t va[2] = {100, (uint64_t)(uintptr_t)sa}; uint64_t vb[2] = {200, (uint64_t)(uintptr_t)sb}; uint64_t a = wo_row_insert(&db, 0, va, &msg, NULL); uint64_t b = wo_row_insert(&db, 0, vb, &msg, NULL); T_CHECK(a != 0 && b != 0); uint64_t off_a = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, a), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, a, off_a), 0); uint64_t off_b = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, b), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, b, off_b), 0); /* before the update: probing 100 finds a */ uint64_t *ids; uint32_t cnt; T_EQ(wo_idx_probe(&db, 0, 0, 100, NULL, 0, &ids, &cnt), 1); T_CHECK(cnt == 1 && ids[0] == a); free(ids); int ek = 0; uint64_t roff = wo_wal_next_offset(&w); T_EQ(wo_row_update_field(&db, 0, a, 0, 150, &msg, &ek), 0); T_EQ(ek, DB_ERR_NONE); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_set_offset(&db, 0, a, roff), 0); /* found by the NEW value */ T_EQ(wo_idx_probe(&db, 0, 0, 150, NULL, 0, &ids, &cnt), 1); T_CHECK(cnt == 1 && ids[0] == a); free(ids); /* gone from the OLD one */ T_EQ(wo_idx_probe(&db, 0, 0, 100, NULL, 0, &ids, &cnt), 1); T_CHECK(cnt == 0 && ids == NULL); /* b, untouched, still finds by its own value */ T_EQ(wo_idx_probe(&db, 0, 0, 200, NULL, 0, &ids, &cnt), 1); T_CHECK(cnt == 1 && ids[0] == b); free(ids); db_row *r = wo_row_borrow(&db, 0, a, &msg); T_CHECK(r != NULL && r->slots[0] == 150); wo_row_release(&db, 0, r); wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* databasev2 11: the third leg the story called out — a delta on an INDEXED * column, with flattening in play. The two are independent features that meet * on the same write path, and the meeting is where a bug would live: * `row_apply_field_keys` picks DELTA or full-row image AFTER the index has * already been re-pointed, so a flattened image that captured the wrong value * would leave the index pointing at a row the fold disagrees with. * * Drives enough updates on the indexed column to cross WO_DELTA_MAX_HOPS * several times, so at least one update lands on each branch, then checks the * index and the fold agree at the end and after replay. */ static void test_keys_resident_indexed_across_flatten(void) { char path[128]; snprintf(path, sizeof path, "%s/keysidxflat.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_IDX_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, KEYS_IDX_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; wo_str *sa = wo_str_new(&rt, "a", 1); uint64_t va[2] = {100, (uint64_t)(uintptr_t)sa}; uint64_t a = wo_row_insert(&db, 0, va, &msg, NULL); T_CHECK(a != 0); uint64_t off_a = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, a), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, a, off_a), 0); /* cross the bound several times over; ROUNDS is deliberately not a * multiple of the bound, so the run does not end on a reset */ const uint64_t ROUNDS = WO_DELTA_MAX_HOPS * 3 + 5; uint64_t val = 100; int saw_reset = 0; db_table *t = &db.tables[0]; for (uint64_t i = 0; i < ROUNDS; i++) { uint64_t prev = val; val = 200 + i; int ek = 0; uint64_t roff = wo_wal_next_offset(&w); T_EQ(wo_row_update_field(&db, 0, a, 0, val, &msg, &ek), 0); T_EQ(ek, DB_ERR_NONE); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_set_offset(&db, 0, a, roff), 0); /* every intermediate step, not only the last: the row is findable by * the value just written and absent from the one it replaced */ uint64_t *ids; uint32_t cnt; T_EQ(wo_idx_probe(&db, 0, 0, val, NULL, 0, &ids, &cnt), 1); T_CHECK(cnt == 1 && ids[0] == a); free(ids); T_EQ(wo_idx_probe(&db, 0, 0, prev, NULL, 0, &ids, &cnt), 1); T_CHECK(cnt == 0 && ids == NULL); db_row *r = wo_row_borrow(&db, 0, a, &msg); T_CHECK(r != NULL && r->slots[0] == val); if (t->scratch_hops == 0) saw_reset = 1; T_CHECK(t->scratch_hops <= WO_DELTA_MAX_HOPS); wo_row_release(&db, 0, r); } T_CHECK(saw_reset); /* flattening actually fired during the run */ /* the Text column, never updated, must survive every flatten: the image * is rebuilt from a borrowed row, which is exactly where a value of the * wrong representation would be written back */ db_row *r = wo_row_borrow(&db, 0, a, &msg); T_CHECK(r != NULL && r->slots[0] == val); db_text *back = (db_text *)(uintptr_t)r->slots[1]; /* engine repr, not wo_str */ T_CHECK(back != NULL && back->len == 1 && back->bytes[0] == 'a'); wo_row_release(&db, 0, r); wo_wal_close(&w); wo_db_destroy(&db); /* And the index rebuilds from the replayed log, flattened records and all. * A keys-resident table comes back offset-valued, so both the probe's * verification and any read need a live log: replay with rt.wal NULL (it * lends its own read-only view), then reopen before touching the rows. */ wo_db db2; T_EQ(wo_db_init(&db2, KEYS_IDX_CLASSES, 1, 0, 1), 0); db2.rt = &rt; rt.wal = NULL; rt.db = &db2; T_CHECK(wo_wal_replay(path, &db2) >= 0); wo_wal w2; T_EQ(wo_wal_open(&w2, path, 1 << 16), 0); rt.wal = &w2; uint64_t *ids; uint32_t cnt; T_EQ(wo_idx_probe(&db2, 0, 0, val, NULL, 0, &ids, &cnt), 1); T_CHECK(cnt == 1 && ids[0] == a); free(ids); T_EQ(wo_idx_probe(&db2, 0, 0, 100, NULL, 0, &ids, &cnt), 1); T_CHECK(cnt == 0 && ids == NULL); db_row *r2 = wo_row_borrow(&db2, 0, a, &msg); T_CHECK(r2 != NULL && r2->slots[0] == val); wo_row_release(&db2, 0, r2); wo_wal_close(&w2); wo_db_destroy(&db2); wo_rt_destroy(&rt); } /* class 0: Row { n: scalar, sku: Text @unique } — the Text-representation * gap flagged across Tasks 3, 4 and 5 and fixed in Task 6: every existing * keys-resident index test above indexes the SCALAR column, never * exercising idx_hash/idx_cols_equal/wo_idx_probe's WO_K_TEXT arm (nor * db.c's GET_FIELD/PROBE arms) against a keys-resident row. The root cause * was keys_fold_into handing back VM wo_str* where a borrowed row's slots * are supposed to hold engine db_text* — table.h's own "a row stores NO VM * pointer" doctrine, true for `resident: all` and silently false for * `resident: keys` until this task. This is the test that proves the fix: * index the TEXT column, update it, and probe by both the OLD and NEW * value — the same shape as test_keys_resident_update_indexed, on the * column that used to misread. */ static const uint8_t keys_text_idx_kinds[] = {WO_K_SCALAR, WO_K_TEXT}; static const uint32_t keys_text_idx_meta[] = {1 /*unique*/, 1, 1 /*col: sku (field 1)*/}; static const wo_classdesc KEYS_TEXT_IDX_CLASSES[] = { {.name = 0, .flags = WO_CLASSF_RESIDENT_KEYS, .field_cnt = 2, .kinds = keys_text_idx_kinds, .idx_cnt = 1, .idx_meta = keys_text_idx_meta}, }; static void test_keys_resident_update_indexed_text(void) { char path[128]; snprintf(path, sizeof path, "%s/keystextidx.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_TEXT_IDX_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, KEYS_TEXT_IDX_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; wo_str *sa = wo_str_new(&rt, "SKU-AAA", 7); wo_str *sb = wo_str_new(&rt, "SKU-BBB", 7); uint64_t va[2] = {1, (uint64_t)(uintptr_t)sa}; uint64_t vb[2] = {2, (uint64_t)(uintptr_t)sb}; uint64_t a = wo_row_insert(&db, 0, va, &msg, NULL); uint64_t b = wo_row_insert(&db, 0, vb, &msg, NULL); T_CHECK(a != 0 && b != 0); uint64_t off_a = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, a), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, a, off_a), 0); uint64_t off_b = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, b), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, b, off_b), 0); /* before the update: probing "SKU-AAA" finds a — through wo_idx_probe's verify step, which borrows the row and reads its Text slot, exactly the path the representation bug corrupted */ uint64_t *ids; uint32_t cnt; T_EQ(wo_idx_probe(&db, 0, 0, 0, "SKU-AAA", 7, &ids, &cnt), 1); T_CHECK(cnt == 1 && ids[0] == a); free(ids); /* update a's Text column: "SKU-AAA" -> "SKU-CCC" */ wo_str *sc = wo_str_new(&rt, "SKU-CCC", 7); int ek = 0; uint64_t roff = wo_wal_next_offset(&w); T_EQ(wo_row_update_field(&db, 0, a, 1, (uint64_t)(uintptr_t)sc, &msg, &ek), 0); T_EQ(ek, DB_ERR_NONE); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_set_offset(&db, 0, a, roff), 0); /* found by the NEW value */ T_EQ(wo_idx_probe(&db, 0, 0, 0, "SKU-CCC", 7, &ids, &cnt), 1); T_CHECK(cnt == 1 && ids[0] == a); free(ids); /* gone from the OLD one */ T_EQ(wo_idx_probe(&db, 0, 0, 0, "SKU-AAA", 7, &ids, &cnt), 1); T_CHECK(cnt == 0 && ids == NULL); /* b, untouched, still finds by its own value */ T_EQ(wo_idx_probe(&db, 0, 0, 0, "SKU-BBB", 7, &ids, &cnt), 1); T_CHECK(cnt == 1 && ids[0] == b); free(ids); /* a genuine duplicate is still refused: updating b's sku to a's NEW value must trip @unique — proving idx_cols_equal reads the correct engine bytes on BOTH sides, not a coincidental symmetric misread */ wo_str *sdupe = wo_str_new(&rt, "SKU-CCC", 7); T_EQ(wo_row_update_field(&db, 0, b, 1, (uint64_t)(uintptr_t)sdupe, &msg, &ek), -1); T_EQ(ek, DB_ERR_UNIQUE); /* the row itself reads back correctly through wo_row_borrow */ db_row *r = wo_row_borrow(&db, 0, a, &msg); T_CHECK(r != NULL); db_text *back = (db_text *)(uintptr_t)r->slots[1]; T_CHECK(back != NULL && back->len == 7 && memcmp(back->bytes, "SKU-CCC", 7) == 0); wo_row_release(&db, 0, r); wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* class 0: Row { n: scalar @unique, label: Text } — same shape as * KEYS_IDX_CLASSES, but the index is genuinely unique this time. */ static const uint8_t keys_uniq_kinds[] = {WO_K_SCALAR, WO_K_TEXT}; static const uint32_t keys_uniq_meta[] = {1 /*unique*/, 1, 0 /*col: n*/}; static const wo_classdesc KEYS_UNIQUE_CLASSES[] = { {.name = 0, .flags = WO_CLASSF_RESIDENT_KEYS, .field_cnt = 2, .kinds = keys_uniq_kinds, .idx_cnt = 1, .idx_meta = keys_uniq_meta}, }; /* Review finding (Task 3 follow-up): the unique shadow-check borrowed its * candidate through wo_row_borrow, which shares ONE scratch buffer per * table with the row already borrowed for the update itself — so the * candidate borrow always failed (NULL), clash was always false, and a * `resident: keys` table with a `@unique` index silently accepted * duplicates on update. This is the test that would have caught it: two * rows, update one's unique column to collide with the other's value, the * update must be REFUSED and the row left exactly as it was. */ static void test_keys_resident_update_unique_violation_refused(void) { char path[128]; snprintf(path, sizeof path, "%s/keysuniq.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_UNIQUE_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, KEYS_UNIQUE_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; wo_str *sa = wo_str_new(&rt, "a", 1); wo_str *sb = wo_str_new(&rt, "b", 1); uint64_t va[2] = {100, (uint64_t)(uintptr_t)sa}; uint64_t vb[2] = {200, (uint64_t)(uintptr_t)sb}; uint64_t a = wo_row_insert(&db, 0, va, &msg, NULL); uint64_t b = wo_row_insert(&db, 0, vb, &msg, NULL); T_CHECK(a != 0 && b != 0); uint64_t off_a = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, a), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, a, off_a), 0); uint64_t off_b = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, b), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, b, off_b), 0); /* a's n: 100 -> 200 collides with b's live value — must be refused */ int ek = 0; T_EQ(wo_row_update_field(&db, 0, a, 0, 200, &msg, &ek), -1); T_EQ(ek, DB_ERR_UNIQUE); /* a untouched: still 100, still the only hit for 100 */ db_row *r = wo_row_borrow(&db, 0, a, &msg); T_CHECK(r != NULL && r->slots[0] == 100); wo_row_release(&db, 0, r); uint64_t *ids; uint32_t cnt; T_EQ(wo_idx_probe(&db, 0, 0, 100, NULL, 0, &ids, &cnt), 1); T_CHECK(cnt == 1 && ids[0] == a); free(ids); /* b untouched: still the only hit for 200 */ T_EQ(wo_idx_probe(&db, 0, 0, 200, NULL, 0, &ids, &cnt), 1); T_CHECK(cnt == 1 && ids[0] == b); free(ids); wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* Task 4 (keys-resident delta updates): the request path's group-commit * shape, the test the brief asked for. Two updates to the SAME row through * wo_row_update_field_slot (the request-path entry point) with NEITHER * wo_wal_commit NOR the re-point called in between — exactly two requests * landing in the SAME drain before its one barrier. The re-point for each * is only RECORDED (wo_wal_pend_repoint), mirroring db.c's request arm; * the barrier commits once, then wo_db_flush_drops applies both. * * The failure this catches: a back_off read straight off the (still stale, * pre-barrier) durable map would have the second delta name the FIRST * request's insert-time offset instead of the first delta — skipping it. * Checked two ways: the final value must reflect BOTH updates in order, * and delta 2's back-pointer, read straight off disk, must equal delta 1's * own offset, not the base insert's. */ static void test_keys_resident_two_updates_one_drain(void) { char path[128]; snprintf(path, sizeof path, "%s/keys2upd.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, KEYS_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; wo_str *s = wo_str_new(&rt, "sku", 3); uint64_t vals[2] = {111, (uint64_t)(uintptr_t)s}; uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(id != 0); uint64_t base_off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, id, base_off), 0); /* "request" 1: field 0, 111 -> 222 — staged, NOT committed, the map NOT moved (only recorded as pending) */ int ek = 0; uint64_t roff1 = wo_wal_next_offset(&w); T_EQ(wo_row_update_field_slot(&db, 0, id, 0, 222, &msg, &ek), 0); T_EQ(ek, DB_ERR_NONE); T_EQ(wo_wal_pend_repoint(&w, 0, id, roff1), 0); /* "request" 2, SAME drain: field 0, 222 -> 333. The id map still names the base insert (the re-point above is only PENDING) — back_off must come from the pending list, not wo_row_offset1, or this chains to the wrong predecessor. */ uint64_t roff2 = wo_wal_next_offset(&w); T_EQ(wo_row_update_field_slot(&db, 0, id, 0, 333, &msg, &ek), 0); T_EQ(ek, DB_ERR_NONE); T_EQ(wo_wal_pend_repoint(&w, 0, id, roff2), 0); /* the drain's barrier: ONE commit for both staged deltas, then both pending re-points applied — db.c/vm.c's exact shape */ T_EQ(wo_wal_commit(&w), 0); wo_db_flush_drops(&db, &w); /* both updates visible, in order */ db_row *r = wo_row_borrow(&db, 0, id, &msg); T_CHECK(r != NULL && r->slots[0] == 333); db_text *back = (db_text *)(uintptr_t)r->slots[1]; T_CHECK(back != NULL && back->len == 3 && memcmp(back->bytes, "sku", 3) == 0); wo_row_release(&db, 0, r); /* the chain itself: delta 2's back-pointer names delta 1's OWN offset, not the base insert's — payload layout established by test_delta_record (kind|class|id|field_idx|back_off|value, 33 bytes for a scalar field) */ uint8_t body[33]; T_EQ((int)pread(w.fd, body, 33, (off_t)(roff2 + 8)), 33); T_EQ(body[0], WO_WAL_DELTA); uint64_t back_off; memcpy(&back_off, body + 17, 8); T_EQ(back_off, roff1); T_CHECK(back_off != base_off); /* the skip this test exists to catch */ wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* CRITICAL 2 (review finding): wo_row_borrow itself must prefer a pending * re-point over the durable map, the same reason back_off and the unique * shadow-check's candidate lookup already do. Without it, the SECOND (and * every later) update to one row in one drain borrows the row via `hget` — * the still-DURABLE, pre-drain offset — so row_apply_field_keys's * idx_remove_row hashes the row's ORIGINAL column value. That value was * already removed from the index by the FIRST update in this drain, so the * remove finds nothing, and idx_add_row adds a SECOND entry. N updates to * one row in one drain used to leave N entries for it; this asserts * exactly one, however many updates ran. */ static void test_keys_resident_repeat_updates_one_drain_index(void) { char path[128]; snprintf(path, sizeof path, "%s/keysrepeatidx.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_IDX_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, KEYS_IDX_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; wo_str *s = wo_str_new(&rt, "x", 1); uint64_t vals[2] = {100, (uint64_t)(uintptr_t)s}; uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(id != 0); uint64_t off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, id, off), 0); /* 5 updates to the SAME row, ALL staged behind the SAME barrier — db.c's request-path shape: stage each, remember its pending re-point, only commit + flush once at the end of the drain. */ int ek = 0; uint64_t next_v = 200; for (int i = 0; i < 5; i++) { uint64_t roff = wo_wal_next_offset(&w); T_EQ(wo_row_update_field_slot(&db, 0, id, 0, next_v, &msg, &ek), 0); T_EQ(ek, DB_ERR_NONE); T_EQ(wo_wal_pend_repoint(&w, 0, id, roff), 0); next_v += 100; } T_EQ(wo_wal_commit(&w), 0); wo_db_flush_drops(&db, &w); /* exactly one entry for this row, across EVERY bucket — a leaked entry would sit in a STALE bucket (the pre-first-update value's), not the current one, so this must scan the whole index, not just probe. */ db_table *t = &db.tables[0]; db_index *ix = &t->indexes[0]; uint32_t hits = 0; for (size_t bi = 0; bi < ix->bcap; bi++) { db_ibucket *b = &ix->buckets[bi]; for (uint32_t k = 0; k < b->len; k++) if (b->ids[k] == id) hits++; } T_EQ(hits, 1u); /* and it is reachable by its final value, 600 */ uint64_t *ids; uint32_t cnt; T_EQ(wo_idx_probe(&db, 0, 0, 600, NULL, 0, &ids, &cnt), 1); T_CHECK(cnt == 1 && ids[0] == id); free(ids); db_row *r = wo_row_borrow(&db, 0, id, &msg); T_CHECK(r != NULL && r->slots[0] == 600); wo_row_release(&db, 0, r); wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* Task 4 follow-up (review finding): the unique shadow-check's candidate * lookup must ALSO prefer a pending re-point over the durable map, for the * same reason back_off does. Before this task, two keys-resident updates * in one drain could not happen at all (the second crashed). Task 4 makes * it possible, which makes THIS reachable: request 1 updates row A's * unique-indexed column to a NEW value inside a drain (staged, not * committed, its index bucket already moved — that part is unconditional * RAM apply); request 2, same drain, updates a DIFFERENT row B to that * SAME new value. The shadow check finds A sitting in the target bucket * (correct — the bucket move is immediate) but, without the fix, verifies * A by folding it from its still-DURABLE (pre-update) offset — reading * A's OLD value, which does not match, so the real clash is missed and a * duplicate would be committed. Request 2 must be REFUSED. */ static void test_keys_resident_unique_clash_pending_repoint(void) { char path[128]; snprintf(path, sizeof path, "%s/keysuniqpend.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_UNIQUE_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, KEYS_UNIQUE_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; wo_str *sa = wo_str_new(&rt, "a", 1); wo_str *sb = wo_str_new(&rt, "b", 1); uint64_t va[2] = {100, (uint64_t)(uintptr_t)sa}; uint64_t vb[2] = {200, (uint64_t)(uintptr_t)sb}; uint64_t a = wo_row_insert(&db, 0, va, &msg, NULL); uint64_t b = wo_row_insert(&db, 0, vb, &msg, NULL); T_CHECK(a != 0 && b != 0); uint64_t off_a = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, a), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, a, off_a), 0); uint64_t off_b = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, b), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, b, off_b), 0); /* "request" 1: a's n 100 -> 300 — staged, NOT committed, map NOT moved (only pending), exactly db.c's request arm */ int ek = 0; uint64_t roff_a = wo_wal_next_offset(&w); T_EQ(wo_row_update_field_slot(&db, 0, a, 0, 300, &msg, &ek), 0); T_EQ(ek, DB_ERR_NONE); T_EQ(wo_wal_pend_repoint(&w, 0, a, roff_a), 0); /* "request" 2, SAME drain: b's n 200 -> 300 collides with a's NEW (still only staged) value — must be refused */ T_EQ(wo_row_update_field_slot(&db, 0, b, 0, 300, &msg, &ek), -1); T_EQ(ek, DB_ERR_UNIQUE); /* the drain's barrier: commit a's delta, flush its pending re-point */ T_EQ(wo_wal_commit(&w), 0); wo_db_flush_drops(&db, &w); /* b untouched: still 200, still the only hit for 200; a alone at 300 */ db_row *r = wo_row_borrow(&db, 0, b, &msg); T_CHECK(r != NULL && r->slots[0] == 200); wo_row_release(&db, 0, r); uint64_t *ids; uint32_t cnt; T_EQ(wo_idx_probe(&db, 0, 0, 300, NULL, 0, &ids, &cnt), 1); T_CHECK(cnt == 1 && ids[0] == a); free(ids); wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* databasev2 2 (5c): boot. A keys-resident store must come back from replay * with its rows readable FROM THE LOG — the map rebuilt to offsets, not slabs. * This is the half the round-trip test cannot cover: it runs in a fresh db, * exactly as a restart would. */ static void test_keys_resident_delete(void) { /* databasev2 2 (5d): deleting a keys-resident row. Before the fix this * read the id map's LOG OFFSET as a slot index and handed it to slot_row, * which does no bounds check — so it indexed t->slabs[] with a byte offset * and then freed whatever it found. ASan reports it as a wild read or a * bad free, not as a wrong answer, which is why the annotation stays * refused at the loader until every operation is honest. */ char path[128]; snprintf(path, sizeof path, "%s/keysdel.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, KEYS_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; uint64_t ids[3]; for (int i = 0; i < 3; i++) { wo_str *sv = wo_str_new(&rt, "del", 3); uint64_t vals[2] = {(uint64_t)(i + 500), (uint64_t)(uintptr_t)sv}; ids[i] = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(ids[i] != 0); uint64_t off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, ids[i]), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, ids[i], off), 0); } T_CHECK(db.tables[0].count == 3); /* the offset is far larger than any slot index, which is exactly what made * the old path walk off the slab array */ T_EQ(wo_row_remove(&db, 0, ids[1]), 0); T_CHECK(db.tables[0].count == 2); /* gone, and the survivors still read correctly through their own offsets */ T_CHECK(wo_row_borrow(&db, 0, ids[1], &msg) == NULL); for (int i = 0; i < 3; i += 2) { db_row *r = wo_row_borrow(&db, 0, ids[i], &msg); T_CHECK(r != NULL); T_CHECK(r->slots[0] == (uint64_t)(i + 500)); wo_row_release(&db, 0, r); } /* and a removed row must not come back through compaction */ T_EQ(wo_wal_compact(&w, &db), 0); T_CHECK(wo_row_borrow(&db, 0, ids[1], &msg) == NULL); T_CHECK(db.tables[0].count == 2); wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } static void test_keys_resident_delete_then_replay(void) { /* Does a keys-resident table survive a RESTART after a delete? The tombstone * has to replay, and replay reaches wo_row_remove, whose keys arm borrows * the row from the log to find its index entries. Replay runs BEFORE * rt->wal is wired (main.c sets it after), so the borrow has no log to read * and the remove fails — which replay reports as corruption. */ char path[128]; snprintf(path, sizeof path, "%s/keysdelreplay.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, KEYS_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; uint64_t ids[2]; for (int i = 0; i < 2; i++) { wo_str *sv = wo_str_new(&rt, "dr", 2); uint64_t vals[2] = {(uint64_t)(i + 900), (uint64_t)(uintptr_t)sv}; ids[i] = wo_row_insert(&db, 0, vals, &msg, NULL); uint64_t off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, ids[i]), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, ids[i], off), 0); } /* delete one, logging the tombstone the way the request path does */ T_EQ(wo_row_remove(&db, 0, ids[0]), 0); T_EQ(wo_wal_append_remove(&w, 0, ids[0]), 0); T_EQ(wo_wal_commit(&w), 0); wo_wal_close(&w); wo_db_destroy(&db); /* the restart: replay has no rt->wal yet, exactly as main.c orders it */ wo_db db2; T_EQ(wo_db_init(&db2, KEYS_CLASSES, 1, 0, 1), 0); db2.rt = &rt; rt.wal = NULL; rt.db = &db2; int64_t n = wo_wal_replay(path, &db2); T_CHECK(n >= 0); /* NOT corruption: a logged delete must replay */ T_CHECK(db2.tables[0].count == 1); /* one survivor */ wo_db_destroy(&db2); wo_rt_destroy(&rt); } static void test_keys_resident_survives_compaction(void) { /* databasev2 2 (5d): the obligation recorded at wo_wal_compact. Two ways * to fail it, both checked here: * 1. compaction walks the bitmap, so keys-resident rows — which hold no * bitmap bit — are never written to the new log and vanish; * 2. compaction writes them but leaves the id map naming OLD offsets. * Rows are written back in HASH order, not insertion order, so almost * every offset really does move: a map left un-repointed cannot pass by * coincidence, it lands on another row and fails the id check. */ char path[128]; snprintf(path, sizeof path, "%s/keyscompact.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, KEYS_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; enum { N = 5 }; uint64_t ids[N]; char texts[N][8]; for (int i = 0; i < N; i++) { /* varying lengths, so a record's position depends on what precedes it */ int tl = 1 + i; memset(texts[i], 'a' + i, (size_t)tl); texts[i][tl] = 0; wo_str *sv = wo_str_new(&rt, texts[i], (size_t)tl); uint64_t vals[2] = {(uint64_t)(i * 101 + 7), (uint64_t)(uintptr_t)sv}; ids[i] = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(ids[i] != 0); uint64_t off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, ids[i]), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, ids[i], off), 0); } T_EQ(wo_wal_compact(&w, &db), 0); /* every row still readable, with its own values, through the new log */ for (int i = 0; i < N; i++) { db_row *r = wo_row_borrow(&db, 0, ids[i], &msg); T_CHECK(r != NULL); T_CHECK(r->slots[0] == (uint64_t)(i * 101 + 7)); db_text *back = (db_text *)(uintptr_t)r->slots[1]; T_CHECK(back != NULL && back->len == (size_t)(1 + i)); T_CHECK(memcmp(back->bytes, texts[i], (size_t)(1 + i)) == 0); wo_row_release(&db, 0, r); } wo_wal_close(&w); wo_db_destroy(&db); /* and the compacted log replays to the same set in a fresh process */ wo_db db2; T_EQ(wo_db_init(&db2, KEYS_CLASSES, 1, 0, 1), 0); T_EQ(wo_wal_replay(path, &db2), N); wo_wal w2; T_EQ(wo_wal_open(&w2, path, 1 << 16), 0); db2.rt = &rt; rt.wal = &w2; rt.db = &db2; for (int i = 0; i < N; i++) { db_row *r = wo_row_borrow(&db2, 0, ids[i], &msg); T_CHECK(r != NULL); T_CHECK(r->slots[0] == (uint64_t)(i * 101 + 7)); wo_row_release(&db2, 0, r); } wo_wal_close(&w2); wo_db_destroy(&db2); wo_rt_destroy(&rt); } static void test_keys_resident_replay(void) { char path[128]; snprintf(path, sizeof path, "%s/keysboot.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, KEYS_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; uint64_t ids[3]; for (int i = 0; i < 3; i++) { wo_str *sv = wo_str_new(&rt, "abc", 3); uint64_t vals[2] = {(uint64_t)(i * 11 + 1), (uint64_t)(uintptr_t)sv}; ids[i] = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(ids[i] != 0); uint64_t off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, ids[i]), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, ids[i], off), 0); } wo_wal_close(&w); wo_db_destroy(&db); /* a fresh process would do exactly this */ wo_db db2; T_EQ(wo_db_init(&db2, KEYS_CLASSES, 1, 0, 1), 0); T_EQ(wo_wal_replay(path, &db2), 3); wo_wal w2; T_EQ(wo_wal_open(&w2, path, 1 << 16), 0); db2.rt = &rt; rt.wal = &w2; rt.db = &db2; T_CHECK(db2.tables[0].count == 3); /* live, though nothing is in a slab */ for (int i = 0; i < 3; i++) { db_row *r = wo_row_borrow(&db2, 0, ids[i], &msg); T_CHECK(r != NULL); T_CHECK(r->slots[0] == (uint64_t)(i * 11 + 1)); db_text *back = (db_text *)(uintptr_t)r->slots[1]; T_CHECK(back != NULL && back->len == 3 && memcmp(back->bytes, "abc", 3) == 0); wo_row_release(&db2, 0, r); } wo_wal_close(&w2); wo_db_destroy(&db2); wo_rt_destroy(&rt); } /* Task 5 (replay and compaction fold the same way): DELTA_CLASSES with a * non-unique index on field 0 — the chain-of-deltas tests below need THREE * touched fields (one delta each) but also an indexed column among them, * which DELTA_CLASSES (no index) and KEYS_IDX_CLASSES (only two fields) * don't together provide. */ static const uint8_t delta_idx_kinds[] = {WO_K_SCALAR, WO_K_SCALAR, WO_K_SCALAR}; static const uint32_t delta_idx_meta[] = {0 /*non-unique*/, 1, 0 /*col: field 0*/}; static const wo_classdesc DELTA_IDX_CLASSES[] = { {.name = 0, .flags = WO_CLASSF_RESIDENT_KEYS, .field_cnt = 3, .kinds = delta_idx_kinds, .idx_cnt = 1, .idx_meta = delta_idx_meta}, }; /* Task 5, step 1: a row with a chain of three deltas, replayed into a fresh * database, must read exactly as it did before the restart — including * through the secondary index on the column one of the deltas changed. * Before this task apply_record had no DELTA arm: a DELTA record in the log * made replay refuse the whole file as corruption. */ static void test_delta_chain_replay(void) { char path[128]; snprintf(path, sizeof path, "%s/deltareplay.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, DELTA_IDX_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, DELTA_IDX_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; uint64_t vals[3] = {10, 20, 30}; uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(id != 0); uint64_t base_off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, id, base_off), 0); /* field 0 (indexed): 10 -> 111 */ uint64_t d1_off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_delta(&w, &db, 0, id, 0, base_off, 111), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_set_offset(&db, 0, id, d1_off), 0); /* field 1: 20 -> 222, chained off the first delta */ uint64_t d2_off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_delta(&w, &db, 0, id, 1, d1_off, 222), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_set_offset(&db, 0, id, d2_off), 0); /* field 2: 30 -> 333, chained off the second delta — three deltas total */ uint64_t d3_off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_delta(&w, &db, 0, id, 2, d2_off, 333), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_set_offset(&db, 0, id, d3_off), 0); /* what the row reads as BEFORE the restart */ db_row *before = wo_row_borrow(&db, 0, id, &msg); T_CHECK(before != NULL); uint64_t want0 = before->slots[0], want1 = before->slots[1], want2 = before->slots[2]; T_EQ(want0, 111); T_EQ(want1, 222); T_EQ(want2, 333); wo_row_release(&db, 0, before); wo_wal_close(&w); wo_db_destroy(&db); /* a fresh process would do exactly this: rt.wal is NULL (main.c wires the real log in only AFTER replay) so a DELTA's fold, mid-replay, runs through the lent view wo_wal_replay_ex sets up on its own — db.rt itself must already be set, exactly as main.c sets DB.rt before calling wo_wal_replay_ex. */ wo_db db2; T_EQ(wo_db_init(&db2, DELTA_IDX_CLASSES, 1, 0, 1), 0); db2.rt = &rt; rt.wal = NULL; T_EQ(wo_wal_replay(path, &db2), 4); /* 1 insert + 3 deltas */ wo_wal w2; T_EQ(wo_wal_open(&w2, path, 1 << 16), 0); rt.wal = &w2; rt.db = &db2; db_row *after = wo_row_borrow(&db2, 0, id, &msg); T_CHECK(after != NULL); T_EQ(after->slots[0], want0); T_EQ(after->slots[1], want1); T_EQ(after->slots[2], want2); wo_row_release(&db2, 0, after); /* the index a delta changed: rebuilt at boot from the INSERT's value, then folded forward by the delta that touched field 0 — a fold that disagreed between reading and replaying would leave this probing the stale value */ uint64_t *ids; uint32_t cnt; T_EQ(wo_idx_probe(&db2, 0, 0, 111, NULL, 0, &ids, &cnt), 1); T_CHECK(cnt == 1 && ids[0] == id); free(ids); T_EQ(wo_idx_probe(&db2, 0, 0, 10, NULL, 0, &ids, &cnt), 1); T_CHECK(cnt == 0 && ids == NULL); /* the superseded value: no hits */ wo_wal_close(&w2); wo_db_destroy(&db2); wo_rt_destroy(&rt); } /* Task 5, step 2: the same chain, then compacted. The row must read * identically AND its chain must be length zero afterwards — the record its * offset points at must be a full row, not a delta. That second assertion * is the one the brief calls out as easy to skip: without it this test * would still pass if compaction merely copied the chain byte-for-byte * instead of flattening it, since a byte-for-byte copy still reads back * correctly — it just never shortens the chain, which is the entire point * of a checkpoint. */ static void test_delta_chain_compact_flattens(void) { char path[128]; snprintf(path, sizeof path, "%s/deltacompact.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, DELTA_IDX_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, DELTA_IDX_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; uint64_t vals[3] = {10, 20, 30}; uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(id != 0); uint64_t base_off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, id, base_off), 0); uint64_t d1_off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_delta(&w, &db, 0, id, 0, base_off, 111), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_set_offset(&db, 0, id, d1_off), 0); uint64_t d2_off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_delta(&w, &db, 0, id, 1, d1_off, 222), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_set_offset(&db, 0, id, d2_off), 0); uint64_t d3_off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_delta(&w, &db, 0, id, 2, d2_off, 333), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_set_offset(&db, 0, id, d3_off), 0); T_EQ(wo_wal_compact(&w, &db), 0); /* reads identically, through the compacted log */ db_row *r = wo_row_borrow(&db, 0, id, &msg); T_CHECK(r != NULL); T_EQ(r->slots[0], 111); T_EQ(r->slots[1], 222); T_EQ(r->slots[2], 333); wo_row_release(&db, 0, r); /* THE assertion the brief calls out: the offset now names a FULL ROW, not a delta — chain length zero, not merely "still readable" */ uint64_t o1 = wo_row_offset1(&db, 0, id); T_CHECK(o1 != 0); uint8_t kind_byte = 0xFF; T_EQ((int)pread(w.fd, &kind_byte, 1, (off_t)(o1 - 1 + 8)), 1); T_EQ(kind_byte, WO_WAL_INSERT); wo_wal_close(&w); wo_db_destroy(&db); /* the compacted log replays to the same, flattened, state */ wo_db db2; T_EQ(wo_db_init(&db2, DELTA_IDX_CLASSES, 1, 0, 1), 0); db2.rt = &rt; rt.wal = NULL; /* see the replay comment above: set before replaying */ T_EQ(wo_wal_replay(path, &db2), 1); /* one row, one INSERT — chain gone */ wo_wal w2; T_EQ(wo_wal_open(&w2, path, 1 << 16), 0); rt.wal = &w2; rt.db = &db2; db_row *r2 = wo_row_borrow(&db2, 0, id, &msg); T_CHECK(r2 != NULL); T_EQ(r2->slots[0], 111); T_EQ(r2->slots[1], 222); T_EQ(r2->slots[2], 333); wo_row_release(&db2, 0, r2); wo_wal_close(&w2); wo_db_destroy(&db2); wo_rt_destroy(&rt); } /* Task 5, step 3: the crash window commit-before-re-point ordering exists * for. Append a delta, commit it (durable), and deliberately do NOT * re-point the map — exactly the state a crash between the barrier and the * flush leaves behind (wo_db_flush_drops never got to run). Replaying the * log into a FRESH database, which never sees this process's map at all, * must still surface the update: the commit alone is what makes a delta * recoverable, not the in-RAM re-point, and this is the test that would * fail if that ordering were ever reversed. */ static void test_delta_crash_window(void) { char path[128]; snprintf(path, sizeof path, "%s/deltacrash.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, DELTA_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, DELTA_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db.rt = &rt; rt.wal = &w; rt.db = &db; const char *msg = ""; uint64_t vals[3] = {1, 2, 3}; uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(id != 0); uint64_t base_off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db, 0, id, base_off), 0); /* field 0: 1 -> 999. Committed (durable) but NEVER re-pointed: nothing after wo_wal_commit below runs — this IS the crash. */ T_EQ(wo_wal_append_delta(&w, &db, 0, id, 0, base_off, 999), 0); T_EQ(wo_wal_commit(&w), 0); wo_wal_close(&w); wo_db_destroy(&db); /* a fresh process, with no memory of this one's (never re-pointed) map */ wo_db db2; T_EQ(wo_db_init(&db2, DELTA_CLASSES, 1, 0, 1), 0); db2.rt = &rt; rt.wal = NULL; /* see the replay comment in test_delta_chain_replay */ T_EQ(wo_wal_replay(path, &db2), 2); /* insert + delta */ wo_wal w2; T_EQ(wo_wal_open(&w2, path, 1 << 16), 0); rt.wal = &w2; rt.db = &db2; db_row *r = wo_row_borrow(&db2, 0, id, &msg); T_CHECK(r != NULL); T_EQ(r->slots[0], 999); /* the update IS present */ T_EQ(r->slots[1], 2); T_EQ(r->slots[2], 3); wo_row_release(&db2, 0, r); wo_wal_close(&w2); wo_db_destroy(&db2); wo_rt_destroy(&rt); } static void test_torn_tail(void) { char path[128]; snprintf(path, sizeof path, "%s/torn.wal", g_dir); wo_db db; T_EQ(wo_db_init(&db, CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 0), 0); 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, NULL); T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0); T_EQ(wo_wal_commit(&w), 0); } uint64_t intact_end = w.off; /* tear: append half a record's worth of a valid-looking header + junk */ uint32_t fake_len = 40, fake_crc = 0xDEAD; uint8_t junk[20] = {7, 7, 7}; T_CHECK(pwrite(w.fd, &fake_len, 4, (off_t)intact_end) == 4); T_CHECK(pwrite(w.fd, &fake_crc, 4, (off_t)(intact_end + 4)) == 4); T_CHECK(pwrite(w.fd, junk, sizeof junk, (off_t)(intact_end + 8)) == (ssize_t)sizeof junk); wo_wal_close(&w); wo_db_destroy(&db); /* the oracle sees exactly the intact prefix */ uint64_t at = 0; T_EQ(wo_wal_check(path, &at), 5); T_EQ(at, intact_end); /* replay drops the tear whole */ wo_db db2; T_EQ(wo_db_init(&db2, CLASSES, 1, 0, 1), 0); T_EQ(wo_wal_replay(path, &db2), 5); T_EQ(db2.tables[0].count, 5); wo_db_destroy(&db2); /* reopen positions AT the tear: the next commit overwrites it */ wo_db db3; T_EQ(wo_db_init(&db3, CLASSES, 1, 0, 1), 0); T_EQ(wo_wal_replay(path, &db3), 5); wo_wal w2; 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, NULL); T_EQ(wo_wal_append_insert(&w2, &db3, 0, id), 0); T_EQ(wo_wal_commit(&w2), 0); wo_wal_close(&w2); T_EQ(wo_wal_check(path, NULL), 6); /* tear gone, record in its place */ wo_db_destroy(&db3); } /* ---- the crash battery -------------------------------------------------- */ /* Child: insert forever — RAM, WAL, COMMIT, and only then ack the id down * the pipe. Killed mid-stream by the parent. */ static void battery_child(const char *path, int ack_fd) { wo_rt rt; wo_db db; wo_wal w; if (wo_rt_init(&rt, 1 << 20, CLASSES, 1) != 0) _exit(9); if (wo_db_init(&db, CLASSES, 1, 0, 1) != 0) _exit(9); if (wo_wal_open(&w, path, 1 << 20) != 0) _exit(9); const char *msg = ""; for (uint64_t i = 0;; i++) { char label[32]; 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, NULL); wo_str_free(&rt, s); if (!id) _exit(9); if (wo_wal_append_insert(&w, &db, 0, id) != 0) _exit(9); if (wo_wal_commit(&w) != 0) _exit(9); /* durable BEFORE the ack */ ssize_t wr = write(ack_fd, &id, 8); if (wr != 8) _exit(0); /* parent went away */ } } static void test_crash_battery(void) { for (int round = 0; round < 5; round++) { char path[128]; snprintf(path, sizeof path, "%s/crash-%d.wal", g_dir, round); int pipefd[2]; T_EQ(pipe(pipefd), 0); pid_t pid = fork(); T_CHECK(pid >= 0); if (pid == 0) { close(pipefd[0]); battery_child(path, pipefd[1]); _exit(0); } close(pipefd[1]); /* collect acks for a few ms, then kill mid-stream — no sync with the child's commit loop, which is the point */ struct timespec ts = {0, (20 + round * 13) * 1000000L}; while (nanosleep(&ts, &ts) != 0) {} kill(pid, SIGKILL); int status; waitpid(pid, &status, 0); /* drain every ack that made it into the pipe */ uint64_t acked[65536]; size_t n_acked = 0; for (;;) { uint64_t id; ssize_t n = read(pipefd[0], &id, 8); if (n != 8) break; if (n_acked < 65536) acked[n_acked++] = id; } close(pipefd[0]); T_CHECK(n_acked > 0); /* the child got at least one commit out */ /* offline oracle: the file's intact prefix covers every ack */ int64_t intact = wo_wal_check(path, NULL); T_CHECK(intact >= (int64_t)n_acked); /* replay and verify: every acked id present, contents exact */ wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 22, CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, CLASSES, 1, 0, 1), 0); int64_t applied = wo_wal_replay(path, &db); T_CHECK(applied >= (int64_t)n_acked); const char *msg = ""; int bad = 0; for (size_t i = 0; i < n_acked; i++) { uint64_t out[2]; if (wo_row_read(&db, &rt, 0, acked[i], out, &msg) != 0) { bad++; continue; } /* id = i+1 (shard 0 of 1), field 0 = i*3+1, label = "row-i" */ char want[32]; int wl = snprintf(want, sizeof want, "row-%llu", (unsigned long long)(acked[i] - 1)); wo_str *s = (wo_str *)(uintptr_t)out[1]; if (out[0] != (acked[i] - 1) * 3 + 1 || s->len != (uint32_t)wl || memcmp(s->data, want, (size_t)wl) != 0) bad++; wo_str_free(&rt, s); } T_EQ(bad, 0); /* zero acked-but-missing, zero acked-but-wrong */ wo_db_destroy(&db); wo_rt_destroy(&rt); } } /* iteration 19: a Float column and a Bytes column survive a WAL round trip * BIT-EXACT. Bit-exact is the whole assertion — the durability path must not * render a float as decimal anywhere, or NaN, the infinities and -0.0 would * each come back as something else. Bytes goes through the same length- * prefixed blob a Text does and must come back as a Bytes, not a Text. */ static const uint8_t fb_kinds[] = {WO_K_FLOAT, WO_K_BYTES}; static const wo_classdesc FB_CLASSES[] = { {.name = 0, .flags = 0, .field_cnt = 2, .kinds = fb_kinds}, }; static void test_float_bytes_replay(void) { char path[128]; snprintf(path, sizeof path, "%s/floatbytes.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, FB_CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, FB_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); const char *msg = ""; /* the values a decimal round trip would destroy, plus a NUL-bearing blob * that a NUL-terminated string path would truncate */ const double vals_f[] = {9.99, 0.0 / 0.0, 1.0 / 0.0, -1.0 / 0.0, -0.0, 1e308}; const char blob[] = {'a', '\0', 'b'}; enum { N = sizeof vals_f / sizeof vals_f[0] }; uint64_t ids[N]; for (int i = 0; i < N; i++) { wo_str *b = wo_bytes_new(&rt, blob, sizeof blob); T_CHECK(b != NULL); uint64_t vals[2] = {wo_bits(vals_f[i]), (uint64_t)(uintptr_t)b}; 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, b); } T_EQ(wo_wal_commit(&w), 0); wo_wal_close(&w); wo_db_destroy(&db); wo_db db2; T_EQ(wo_db_init(&db2, FB_CLASSES, 1, 0, 1), 0); T_EQ(wo_wal_replay(path, &db2), N); for (int i = 0; i < N; i++) { uint64_t out[2]; T_EQ(wo_row_read(&db2, &rt, 0, ids[i], out, &msg), 0); /* BITS, not value: NaN != NaN and -0.0 == 0.0, so a value comparison * would pass while silently having lost the payload or the sign */ T_EQ(out[0], wo_bits(vals_f[i])); wo_str *b = (wo_str *)(uintptr_t)out[1]; T_CHECK(b != NULL); T_EQ(b->h.class_id, WO_CLS_BYTES); /* a Bytes column yields a Bytes */ T_CHECK(b->len == sizeof blob && memcmp(b->data, blob, sizeof blob) == 0); wo_str_free(&rt, b); } wo_db_destroy(&db2); wo_rt_destroy(&rt); } /* databasev2 2: offset capture. wo_wal_next_offset must name exactly where a * record lands, so a resident:keys table can read it back by that offset * later. A wrong offset is the worst possible bug here: it reads a * NEIGHBOURING record, which passes its own CRC and returns the wrong row * silently. So this asserts the recovered id per record, not just that a * record parses. * * Covers the two awkward cases the design called out: records straddling a * buffer growth (stage() doubles from 4096, so 400 rows with Text payloads * cross it repeatedly), and a batch spanning several commits. */ static void test_offset_capture(void) { char path[128]; snprintf(path, sizeof path, "%s/offsets.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); const char *msg = ""; enum { N = 400 }; uint64_t ids[N], offs[N]; /* commit in uneven batches so offsets are exercised both mid-buffer and * immediately after a flush reset len to 0 */ for (int i = 0; i < N; i++) { char lbl[32]; int ln = snprintf(lbl, sizeof lbl, "label-%d-padding", i); wo_str *s = wo_str_new(&rt, lbl, (uint32_t)ln); uint64_t vals[2] = {(uint64_t)i, (uint64_t)(uintptr_t)s}; ids[i] = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(ids[i] != 0); /* BEFORE the append: this is the contract */ offs[i] = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, ids[i]), 0); wo_str_free(&rt, s); if (i % 7 == 6) T_EQ(wo_wal_commit(&w), 0); } T_EQ(wo_wal_commit(&w), 0); /* offsets must be strictly increasing and inside the written region */ for (int i = 1; i < N; i++) T_CHECK(offs[i] > offs[i - 1]); /* read each record back BY ITS REPORTED OFFSET and check the id matches: * payload is [kind u8][class u32][id u64], after the 8-byte len+crc head */ int checked = 0; for (int i = 0; i < N; i++) { uint8_t head[8], body[13]; T_EQ((int)pread(w.fd, head, 8, (off_t)offs[i]), 8); T_EQ((int)pread(w.fd, body, 13, (off_t)(offs[i] + 8)), 13); T_EQ(body[0], WO_WAL_INSERT); uint32_t cid; uint64_t rid; memcpy(&cid, body + 1, 4); memcpy(&rid, body + 5, 8); T_EQ(cid, 0u); T_EQ(rid, ids[i]); checked++; } T_EQ(checked, N); wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* databasev2 2: an offset reported for a record whose commit FAILED must * never be trusted. Simulated by closing the fd under the wal so pwrite * fails: the offset accessor must not have advanced past the durable tail, * so a later successful commit reuses the same place. */ static void test_offset_after_failed_commit(void) { char path[128]; snprintf(path, sizeof path, "%s/offfail.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); const char *msg = ""; uint64_t vals[2] = {7u, 0u}; uint64_t id1 = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(id1 != 0); uint64_t at1 = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, id1), 0); /* break the fd, so the commit cannot succeed */ int saved = dup(w.fd); T_CHECK(saved >= 0); close(w.fd); w.fd = -1; T_CHECK(wo_wal_commit(&w) != 0); /* The DURABLE TAIL is what must not move. `next_offset` legitimately * points PAST the still-staged record (off unchanged, len still holding * it) — asserting otherwise was this test's own first mistake. The * invariant that matters: off is untouched, so the record still lands at * the offset already reported for it. */ T_EQ(w.off, at1); /* restore and commit for real: the record lands exactly where promised */ w.fd = saved; T_EQ(wo_wal_commit(&w), 0); uint8_t body[13]; T_EQ((int)pread(w.fd, body, 13, (off_t)(at1 + 8)), 13); uint64_t rid; memcpy(&rid, body + 5, 8); T_EQ(rid, id1); wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* databasev2 2 (5b): read rows back BY OFFSET and deep-compare. * * The point is not that a record parses — test_offset_capture already showed * the offsets are right. The point is that the VALUES come back intact, * including a nil Text, and that the two refusal paths refuse instead of * handing back something plausible. */ static void test_read_row_at(void) { char path[128]; snprintf(path, sizeof path, "%s/readat.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, CLASSES, 1), 0); wo_db db; T_EQ(wo_db_init(&db, CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); const char *msg = ""; enum { N = 24 }; uint64_t ids[N], offs[N]; const char *labels[N]; for (int i = 0; i < N; i++) { /* every third row has a NIL Text, so the nil path is covered */ wo_str *s = NULL; if (i % 3 != 0) { char lbl[24]; int ln = snprintf(lbl, sizeof lbl, "row-%d", i); s = wo_str_new(&rt, lbl, (uint32_t)ln); T_CHECK(s != NULL); } uint64_t vals[2] = {(uint64_t)(i * 3 + 1), (uint64_t)(uintptr_t)s}; ids[i] = wo_row_insert(&db, 0, vals, &msg, NULL); T_CHECK(ids[i] != 0); labels[i] = (i % 3 != 0) ? "set" : "nil"; offs[i] = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db, 0, ids[i]), 0); if (s) wo_str_free(&rt, s); } T_EQ(wo_wal_commit(&w), 0); /* read each row back by offset and compare field by field */ for (int i = 0; i < N; i++) { uint64_t got[2] = {0, 0}; uint32_t cid = 0xFFFFFFFFu; uint64_t id = 0; T_EQ(wo_wal_read_row_at(&w, &db, &rt, offs[i], &cid, &id, got, &msg), 0); T_EQ(cid, 0u); T_EQ(id, ids[i]); T_EQ(got[0], (uint64_t)(i * 3 + 1)); if (labels[i][0] == 'n') { T_EQ(got[1], 0u); /* nil Text stays nil through the round trip */ } else { wo_str *back = (wo_str *)(uintptr_t)got[1]; T_CHECK(back != NULL); char want[24]; int wl = snprintf(want, sizeof want, "row-%d", i); T_EQ((int)back->len, wl); T_EQ(memcmp(back->data, want, (size_t)wl), 0); wo_str_free(&rt, back); /* out-gate: the VM value is ours to free */ } } /* refusal 1: a tombstone is refused, not decoded as a live row */ T_EQ(wo_row_remove(&db, 0, ids[0]), 0); uint64_t tomb_off = wo_wal_next_offset(&w); T_EQ(wo_wal_append_remove(&w, 0, ids[0]), 0); T_EQ(wo_wal_commit(&w), 0); { uint64_t got[2] = {0, 0}; T_EQ(wo_wal_read_row_at(&w, &db, &rt, tomb_off, NULL, NULL, got, &msg), -1); } /* refusal 2: a wrong offset (mid-record) refuses rather than returning a * neighbouring row -- the silent-wrong-row failure this guards */ { uint64_t got[2] = {0, 0}; T_EQ(wo_wal_read_row_at(&w, &db, &rt, offs[5] + 3u, NULL, NULL, got, &msg), -1); } /* refusal 3: past the end of the intact prefix */ { uint64_t got[2] = {0, 0}; T_EQ(wo_wal_read_row_at(&w, &db, &rt, w.off + 4096u, NULL, NULL, got, &msg), -1); } wo_wal_close(&w); wo_db_destroy(&db); wo_rt_destroy(&rt); } /* Task 6, Step 5: the oracle test. `resident: all` never goes near a delta — * every update is a direct slab mutation — so running the SAME sequence of * updates against a `resident: all` table and a `resident: keys` table and * asserting the rows read identically at every step is the strongest * available proof that the fold agrees with ordinary storage: the resident * table is the oracle, exactly what an independent implementation would be, * without needing to write one. CLASSES (flags=0) and KEYS_CLASSES * (WO_CLASSF_RESIDENT_KEYS) share the same shape — {n: scalar, label: Text} * — already declared above for other tests. */ static void assert_rows_equal(wo_db *db_all, uint64_t id_all, wo_db *db_keys, uint64_t id_keys, wo_rt *rt, const char *step) { uint64_t out_all[2], out_keys[2]; const char *msg = ""; T_EQ(wo_row_read(db_all, rt, 0, id_all, out_all, &msg), 0); T_EQ(wo_row_read(db_keys, rt, 0, id_keys, out_keys, &msg), 0); T_CHECK(out_all[0] == out_keys[0]); wo_str *sa = (wo_str *)(uintptr_t)out_all[1]; wo_str *sk = (wo_str *)(uintptr_t)out_keys[1]; int text_eq = (!sa && !sk) || (sa && sk && sa->len == sk->len && memcmp(sa->data, sk->data, sa->len) == 0); if (!text_eq) fprintf(stderr, "oracle mismatch at %s\n", step); T_CHECK(text_eq); if (sa) wo_str_free(rt, sa); if (sk) wo_str_free(rt, sk); } static void test_oracle_all_vs_keys_same_update_sequence(void) { char path[128]; snprintf(path, sizeof path, "%s/oracle.wal", g_dir); wo_rt rt; T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_CLASSES, 1), 0); /* the oracle needs no WAL at all: row_apply_field_slot never touches one */ wo_db db_all; T_EQ(wo_db_init(&db_all, CLASSES, 1, 0, 1), 0); wo_db db_keys; T_EQ(wo_db_init(&db_keys, KEYS_CLASSES, 1, 0, 1), 0); wo_wal w; T_EQ(wo_wal_open(&w, path, 1 << 16), 0); db_keys.rt = &rt; rt.wal = &w; rt.db = &db_keys; const char *msg = ""; wo_str *s0a = wo_str_new(&rt, "start", 5); wo_str *s0k = wo_str_new(&rt, "start", 5); uint64_t va[2] = {10, (uint64_t)(uintptr_t)s0a}; uint64_t vk[2] = {10, (uint64_t)(uintptr_t)s0k}; uint64_t id_all = wo_row_insert(&db_all, 0, va, &msg, NULL); uint64_t id_keys = wo_row_insert(&db_keys, 0, vk, &msg, NULL); T_CHECK(id_all != 0 && id_keys != 0); /* drop the keys row's payload to the log now, exactly as a post-barrier flush would — every update below folds it back out of the WAL */ uint64_t koff = wo_wal_next_offset(&w); T_EQ(wo_wal_append_insert(&w, &db_keys, 0, id_keys), 0); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_drop_payload(&db_keys, 0, id_keys, koff), 0); assert_rows_equal(&db_all, id_all, &db_keys, id_keys, &rt, "insert"); /* the sequence: scalar and Text fields both move, more than once each, so the fold is exercised on a multi-hop chain the same shape a real catalogue would build one small update at a time */ struct { int field; uint64_t scalar; const char *text; } steps[] = { {0, 20, NULL}, {1, 0, "mid1"}, {0, 30, NULL}, {1, 0, "mid2"}, {0, 40, NULL}, {1, 0, "end"}, }; for (size_t i = 0; i < sizeof steps / sizeof steps[0]; i++) { int ek = 0; uint64_t val_all, val_keys; if (steps[i].field == 0) { val_all = steps[i].scalar; val_keys = steps[i].scalar; } else { uint32_t tl = (uint32_t)strlen(steps[i].text); val_all = (uint64_t)(uintptr_t)wo_str_new(&rt, steps[i].text, tl); val_keys = (uint64_t)(uintptr_t)wo_str_new(&rt, steps[i].text, tl); } T_EQ(wo_row_update_field(&db_all, 0, id_all, (uint32_t)steps[i].field, val_all, &msg, &ek), 0); T_EQ(ek, DB_ERR_NONE); uint64_t roff = wo_wal_next_offset(&w); T_EQ(wo_row_update_field(&db_keys, 0, id_keys, (uint32_t)steps[i].field, val_keys, &msg, &ek), 0); T_EQ(ek, DB_ERR_NONE); T_EQ(wo_wal_commit(&w), 0); T_EQ(wo_row_set_offset(&db_keys, 0, id_keys, roff), 0); char label[32]; snprintf(label, sizeof label, "step %zu", i); assert_rows_equal(&db_all, id_all, &db_keys, id_keys, &rt, label); } wo_wal_close(&w); wo_db_destroy(&db_all); wo_db_destroy(&db_keys); wo_rt_destroy(&rt); } int main(void) { snprintf(g_dir, sizeof g_dir, "/tmp/wo-wal-test-XXXXXX"); if (!mkdtemp(g_dir)) return 1; test_roundtrip_replay(); test_commit_failure_detected(); test_compact_shortens_and_replays_equal(); test_keys_resident_round_trip(); test_delta_record(); test_delta_fold_two_fields(); test_delta_fold_same_field_newest_wins(); test_fold_refuses_self_pointing_delta(); test_fold_refuses_forward_pointing_delta(); test_keys_resident_update_field(); test_keys_resident_update_indexed(); test_keys_resident_fresh_log_first_row(); test_keys_resident_indexed_across_flatten(); test_migrate_reorder_owned(); test_migrate_delta_splice(); test_migrate_add_field(); test_migrate_delete_field(); test_migrate_crash_before_rename(); test_migrate_poison_needs_records(); test_migrate_corrupt_input(); test_schema_diff_verdicts(); test_schema_roundtrip(); test_schema_fresh_log(); test_schema_compaction_adopts_legacy(); test_schema_read_absent(); test_should_compact_absolute_and_ceiling(); test_delta_chain_flattens_at_k(); test_delta_chain_flatten_replays(); test_keys_resident_update_indexed_text(); test_keys_resident_update_unique_violation_refused(); test_keys_resident_two_updates_one_drain(); test_keys_resident_repeat_updates_one_drain_index(); test_keys_resident_unique_clash_pending_repoint(); test_keys_resident_replay(); test_keys_resident_survives_compaction(); test_delta_chain_replay(); test_delta_chain_compact_flattens(); test_delta_crash_window(); test_keys_resident_delete(); test_keys_resident_delete_then_replay(); test_stale_compact_temp_is_removed(); test_resolve_data_path(); test_file_form_temps_beside_log(); test_should_compact_policy(); test_compact_refuses_with_staged_records(); test_torn_tail(); test_float_bytes_replay(); test_offset_capture(); test_offset_after_failed_commit(); test_read_row_at(); test_crash_battery(); test_compact_crash_battery(); test_oracle_all_vs_keys_same_update_sequence(); /* leave the dir for a failed run's forensics only */ if (!t_fail) { char cmd[128]; snprintf(cmd, sizeof cmd, "rm -rf %s", g_dir); if (system(cmd) != 0) fprintf(stderr, "cleanup failed, kept %s\n", g_dir); } else { fprintf(stderr, "kept %s\n", g_dir); } return t_report("test_wal"); }