From 587991124dab37a636b5eef907646518300c20bb Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Thu, 10 Sep 2026 14:18:36 +0200 Subject: [PATCH] test(db2-7): pin compaction + migration temps beside a file-form log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_file_form_temps_beside_log (runtime/test/test_wal.c): the log is `/app.db` — an operator's name, not shard-0.wal — with a sibling directory `app.db.d/` as the decoy nothing may land in. - Proof by blocker: a DIRECTORY planted at exactly `.compact` makes `wo_wal_compact` and `wo_wal_migrate` each return -1 with the log untouched (record count unchanged, blocker still an empty dir); a temp anywhere else would have let them succeed. - Blocker removed: compaction 10 → 2 records, migration n,t → n,t,extra succeeds, `.compact` gone after each rename, the directory holds exactly {app.db, app.db.d}, the decoy is empty, the migrated file replays into the new shape (slots[0] == 107, slots[2] == 0). - The parent fsync'd after a rename is `parent_dir_of()`, the helper the resolver shares (task 1), so its derivation is pinned there; fsync itself is not observable from a test. - Green on first run (57 assertions) as a pin must be; teeth shown by a mutation control — compaction's temp redirected into the decoy turned 14 assertions red (`wo_wal_compact(&w, &db) == 0, want -1`, …). - `make -C runtime test`: 21 suites, 8431 pass / 0 fail (was 8374/0). Co-Authored-By: Claude Fable 5.1 (cherry picked from commit ccee2d04fddfb96c7dfab1c17f235e3a9bbc69fb) --- runtime/test/test_wal.c | 100 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/runtime/test/test_wal.c b/runtime/test/test_wal.c index 33d23f1..6d31e10 100644 --- a/runtime/test/test_wal.c +++ b/runtime/test/test_wal.c @@ -6,6 +6,7 @@ * present with the right contents. */ #define _POSIX_C_SOURCE 200809L +#include #include #include #include @@ -1430,6 +1431,104 @@ static void test_migrate_crash_before_rename(void) { 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) { @@ -3512,6 +3611,7 @@ int main(void) { 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();