feat(db2-7): WO_DATA=<file> — the store as one file, two refusals

- `wo_wal_resolve_data_path` (database/src/wal.c|h): an existing directory
  or a trailing `/` → `<dir>/shard-0.wal` byte for byte (the `//` after a
  trailing slash included); otherwise the path IS the log — opened if a
  regular file, created by `wo_wal_open` if absent under an existing parent.
- Refusals as codes for main.c: WO_WAL_PATH_NO_PARENT (out = the parent, so
  the line names it), WO_WAL_PATH_NOT_A_FILE (fifo/socket/device),
  WO_WAL_PATH_TOO_LONG (today's snprintf truncated silently).
- `parent_dir_of` shared by the resolver and `sync_parent_dir`: the parent
  checked at boot IS the parent fsync'd after a compaction/migration rename.
- runtime/src/main.c: the resolver replaces the unconditional
  `"%s/shard-0.wal"`; each refusal is one stderr line, exit 2 through the
  6a destroy sequence; never mkdir -p. The 6a block is untouched.
- Failing first: test_resolve_data_path — 4× -Werror (implicit declaration
  + three undeclared codes); green after: 21 assertions (dir, trailing
  slash, absent file, regular file, bare name, missing parent, parent is a
  file, fifo, two too-long).
- `make -C runtime test`: 21 suites, 8374 pass / 0 fail (was 8353/0).
  `make -C runtime wovm-asan` clean; smoke: file form seeds + replays with
  `app.db` the only artifact; missing parent and fifo refuse rc 2 naming
  path + parent; dir and trailing slash unchanged; WO_EPHEMERAL conflict
  inherited from 6a.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit b31bd4052624be460de1c18cf208661539397e09)
This commit is contained in:
shoney.arickathil 2026-09-10 14:14:21 +02:00
parent 82efb498d4
commit c19a01564f
4 changed files with 147 additions and 8 deletions

View file

@ -10,6 +10,7 @@
#include <stdarg.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
/* ---- crc32 (poly 0xEDB88320) — ported from runtime/wo-rt.c ------------- */
@ -366,6 +367,45 @@ int wo_wal_open(wo_wal *w, const char *path, uint64_t prealloc) {
return 0;
}
/* dirname(3) without its allocation and locale baggage: "a/b" → "a", "/b" →
* "/", "b" → ".". The boot-time parent check (wo_wal_resolve_data_path) and
* the post-rename fsync (sync_parent_dir) MUST agree on what the parent is;
* this is the one place that decides. 0 ok, -1 when [path] does not fit. */
static int parent_dir_of(const char *path, char *dir, size_t cap) {
size_t n = strlen(path);
if (n >= cap) return -1;
memcpy(dir, path, n + 1);
char *slash = strrchr(dir, '/');
if (slash == dir) dir[1] = '\0';
else if (slash) *slash = '\0';
else memcpy(dir, ".", 2);
return 0;
}
int wo_wal_resolve_data_path(const char *wo_data, char *out, size_t cap) {
size_t n = strlen(wo_data);
struct stat st;
int have = stat(wo_data, &st) == 0;
if ((n && wo_data[n - 1] == '/') || (have && S_ISDIR(st.st_mode))) {
/* the directory form: byte for byte what main.c produced before this
* function existed, doubled slash after a trailing '/' included */
if ((size_t)snprintf(out, cap, "%s/shard-0.wal", wo_data) >= cap)
return WO_WAL_PATH_TOO_LONG;
return 0;
}
if (have && !S_ISREG(st.st_mode)) return WO_WAL_PATH_NOT_A_FILE;
if (!have) {
/* absent: wo_wal_open creates it, but only where a directory already
* is — the parent is handed back so the refusal can name it */
if (parent_dir_of(wo_data, out, cap) != 0) return WO_WAL_PATH_TOO_LONG;
struct stat pst;
if (stat(out, &pst) != 0 || !S_ISDIR(pst.st_mode)) return WO_WAL_PATH_NO_PARENT;
}
if (n >= cap) return WO_WAL_PATH_TOO_LONG;
memcpy(out, wo_data, n + 1);
return 0;
}
int wo_wal_pend_drop(wo_wal *w, uint32_t cid, uint64_t id, uint64_t off) {
if (w->pend_len == w->pend_cap) {
size_t nc = w->pend_cap ? w->pend_cap * 2 : 16;
@ -1015,13 +1055,7 @@ int wo_wal_should_compact(uint64_t used, uint64_t last, uint64_t floor, uint32_t
* cut. */
static void sync_parent_dir(const char *path) {
char dir[4096];
size_t n = strlen(path);
if (n >= sizeof dir) return;
memcpy(dir, path, n + 1);
char *slash = strrchr(dir, '/');
if (slash == dir) dir[1] = '\0';
else if (slash) *slash = '\0';
else memcpy(dir, ".", 2);
if (parent_dir_of(path, dir, sizeof dir) != 0) return;
int fd = open(dir, O_RDONLY);
if (fd < 0) return;
(void)fsync(fd);

View file

@ -247,6 +247,22 @@ static inline uint64_t wo_wal_next_offset(const wo_wal *w) { return w->off + w->
int wo_wal_open(wo_wal *w, const char *path, uint64_t prealloc);
void wo_wal_close(wo_wal *w);
/* databasev2 7: WO_DATA names the store as EITHER a directory or the log file.
* An existing directory or a trailing '/' resolves to "<dir>/shard-0.wal" —
* the bytes every deployment before this iteration used, unchanged. Anything
* else IS the log: an existing regular file is opened, an absent path is
* created by wo_wal_open — but only under a parent directory that exists NOW.
* The resolver never mkdirs: a typo must not plant a store somewhere
* unexpected. Pure — stats, creates nothing. 0 ok, [out] = the log path;
* WO_WAL_PATH_NO_PARENT, [out] = the parent that is not an existing directory
* (for the refusal line); WO_WAL_PATH_NOT_A_FILE: exists, neither a regular
* file nor a directory (fifo, socket, device); WO_WAL_PATH_TOO_LONG: the
* result would not fit [cap] — refused, never truncated. */
#define WO_WAL_PATH_NO_PARENT -1
#define WO_WAL_PATH_NOT_A_FILE -2
#define WO_WAL_PATH_TOO_LONG -3
int wo_wal_resolve_data_path(const char *wo_data, char *out, size_t cap);
/* Stage a record for the row that MUST already be applied to RAM (the
* commit-order doctrine). Insert/update read the row via wo_row_ptr.
* 0 ok, -1 OOM / no such row. */

View file

@ -291,7 +291,29 @@ int main(int argc, char **argv) {
int have_schema = 0;
if (data_dir && data_dir[0]) {
char wal_path[512];
snprintf(wal_path, sizeof wal_path, "%s/shard-0.wal", data_dir);
/* databasev2 7: WO_DATA is a directory (→ <dir>/shard-0.wal, as it
* always was) or THE log file, created if absent. Refuse rather than
* guess: a missing parent is never created, and a path that is
* neither a regular file nor a directory is not a store. */
int prc = wo_wal_resolve_data_path(data_dir, wal_path, sizeof wal_path);
if (prc != 0) {
if (prc == WO_WAL_PATH_NO_PARENT)
fprintf(stderr,
"wovm: WO_DATA=%s — its parent %s is not an existing "
"directory; create it first (wovm never runs mkdir -p).\n",
data_dir, wal_path);
else if (prc == WO_WAL_PATH_NOT_A_FILE)
fprintf(stderr,
"wovm: WO_DATA=%s exists but is neither a regular file "
"nor a directory.\n",
data_dir);
else
fprintf(stderr, "wovm: WO_DATA=%s is too long for a path.\n", data_dir);
wo_db_destroy(&DB);
wo_vm_destroy(&VM);
wo_module_free(&mod);
return 2;
}
/* databasev2 12: the log's head record states the shape that wrote
* it. Diff it against the compiled classes BEFORE replay: a matching
* shape replays as-is, add/delete migrates the log in place through

View file

@ -10,6 +10,7 @@
#include <signal.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
@ -297,6 +298,71 @@ static void test_stale_compact_temp_is_removed(void) {
wo_rt_destroy(&rt);
}
/* databasev2 7: WO_DATA names EITHER a directory (today's form, <dir>/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. */
@ -3445,6 +3511,7 @@ int main(void) {
test_keys_resident_delete();
test_keys_resident_delete_then_replay();
test_stale_compact_temp_is_removed();
test_resolve_data_path();
test_should_compact_policy();
test_compact_refuses_with_staged_records();
test_torn_tail();