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:
parent
82efb498d4
commit
c19a01564f
4 changed files with 147 additions and 8 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue