diff --git a/Makefile b/Makefile index bd564f6..a767173 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ FUSE_CFLAGS := $(shell pkg-config --cflags fuse3) FUSE_LIBS := $(shell pkg-config --libs fuse3) TARGET := etvd_fuse -SRCS := src/main.cpp src/fuse_ops.cpp +SRCS := src/main.cpp src/fuse_ops.cpp src/store.cpp src/meta.cpp OBJS := $(SRCS:.cpp=.o) HEADERS := $(wildcard src/*.h) diff --git a/README.md b/README.md index 1a8c679..c4b3f7a 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,18 @@ -# etvd — 第一阶段:FUSE 拦截验证 +# etvd — 第二阶段:内存段 Diff(COW append-only 后端) -> 对应 `docs/DESIGN.md` 第一阶段。目标:让 FUSE Daemon 挂载目录、拦截所有文件系统调用,上层程序能正常 open / read / write / close,并能在底层结构化观察到每一次写入,证明通路打通。 +> 对应 `docs/DESIGN.md` 第二阶段。在第一阶段 FUSE 拦截通路基础上,把透传后端替换为 COW append-only 后端:写入只追加到 `data.raw` 尾部、以 Segment 拼接还原视图,验证五种写入场景并从底层观测每一次 write 的 Segment。 ## 这一步做了什么 -- 一个基于 **libfuse3** 的 C++ FUSE Daemon(`etvd_fuse`),挂载一个目录,拦截全部文件系统调用。 -- **透传后端(passthrough)**:所有操作转发到真实后端目录(默认 `./backend`)。这是第一阶段的后端;第二阶段会替换为 COW append-only 后端(`data.raw` + Segment),第三阶段再引入 `cur` 快照加速读,FUSE 拦截层与日志层保持不变。 -- **操作日志层**:每一次拦截到的调用都结构化打印到 stderr(`[etvd] `),`write` 额外带 `off/size/rc` 与前 16 字节 hex 预览 —— 满足「从底层观察到每一次写入」。 -- 回调里直接 log + syscall 透传,**没有额外抽象层**(FUSE3 自己的 `struct fuse_operations` 就是接口)。第二阶段做 COW 后端时再按需引入存储抽象 —— 届时 `write` 语义是 append+Segment,和现在的 pwrite 形状不同,现在猜接口多半对不上。 +- 第一阶段的 FUSE 拦截层保留,后端由「透传真实目录」改为 **COW append-only**: + - 物理存储是单个稀疏文件 `data.raw`,唯一写接口是 Append(顺序追加,绝不原地改字节)。 + - 每次 write 把新字节追加到 `data.raw` 尾部,得到一个物理段 Segment(`phys_off` / `phys_len` / `log_off`)。 + - 一个虚拟文件的当前内容由一组 Segment 按逻辑顺序拼接;段间 gap 即空洞,读返回零。 + - 读取按当前视图段链表从 `data.raw` 取字节拼接。 +- **全自管内存 VFS**:目录树 + 文件元数据全部内存维护,`data.raw` 只存文件字节。`getattr` / `readdir` / `mkdir` / `unlink` / `rename` 全走内存。 +- **即时写 + 当前视图**:write 立即 Append + 生成 Segment/VersionDiff + 增量更新当前视图;read 走当前视图;fsync 对 `data.raw` 落盘并标记版本边界。事务暂存圈定留到第六阶段。 +- **底层观测**:每次 write 打印 `[etvd] write op= phys=+ off=.. size=.. data=`,满足「从底层观察每一次写入产生的 Segment」。 +- 元数据纯内存,进程重启丢失(第五阶段 KV 持久化);`data.raw` 残留旧字节待后续 GC。 ### 三层结构 @@ -15,19 +20,21 @@ 上层程序 (bash / cat / dd / sqlite3) │ 标准 Linux 文件 API ▼ -FUSE 拦截层 (src/fuse_ops.cpp) ── 每次调用先记日志,再直接 syscall 透传到 ./backend +FUSE 拦截层 (src/fuse_ops.cpp) ── 每次调用先记日志,再走 COW 后端 + │ + ▼ +内存元数据层 (src/meta.cpp) ── 段链表(当前视图)+ 目录树 + history,shared_mutex 保护 + │ + ▼ +物理存储层 (src/store.cpp) ── data.raw,Append-only / 按偏移读 ``` ## 依赖 -- Linux + `/dev/fuse`(内核 FUSE 模块) -- `libfuse3-dev`(头文件 + `fuse3.pc`) -- `pkg-config`、`g++`(C++17)、`make` - -安装依赖(Ubuntu/Debian): +Linux + `/dev/fuse`、`libfuse3-dev`、`pkg-config`、`g++`(C++17)、`make`、`sqlite3`(测试用)。 ```bash -sudo apt-get install -y libfuse3-dev pkg-config g++ make +sudo apt-get install -y libfuse3-dev pkg-config g++ make sqlite3 ``` ## 构建 @@ -41,13 +48,11 @@ make ## 运行 ```bash -# 挂载到 mnt/,后端目录 ./backend(两者不存在会自动创建) ./etvd_fuse mnt -o backend=./backend ``` -- `-o backend=DIR`:指定后端目录(默认 `./backend`)。 -- `-f`:前台运行(**已强制开启**,便于日志输出与 Ctrl-C 卸载)。 -- `-o default_permissions`:**已强制开启**,内核按透传的 mode 做权限校验。 +- `-o backend=DIR`:存放 `data.raw` 的目录(默认 `./backend`,不存在会自动创建)。 +- `-f` 前台运行(**已强制开启**);`-o default_permissions`(**已强制开启**)。 - 日志输出到 stderr。 ### 卸载 @@ -59,64 +64,45 @@ fusermount3 -u mnt ## 验收测试 ```bash -# 1. 启动 daemon(前台,日志重定向到文件) -./etvd_fuse mnt -o backend=./backend 2>etvd.log & - -# 2. 基本读写 -echo "hello world" > mnt/a.txt -cat mnt/a.txt # -> hello world -echo "second line" >> mnt/a.txt - -# 3. 块写 / 目录 / 重命名 / 删除 / 截断 -dd if=/dev/zero of=mnt/b.bin bs=4096 count=4 status=none -mkdir mnt/sub && ls -F mnt -mv mnt/a.txt mnt/a.log -rm mnt/b.bin -truncate -s 100 mnt/a.log - -# 4. SQLite 冒烟(真实数据库负载,无感知运行) -sqlite3 mnt/test.db "CREATE TABLE t(id INTEGER PRIMARY KEY, name TEXT); \ - INSERT INTO t VALUES (1,'alice'); INSERT INTO t VALUES (2,'bob'); SELECT * FROM t;" -sqlite3 mnt/test.db "UPDATE t SET name='ALICE' WHERE id=1; SELECT * FROM t;" -sqlite3 mnt/test.db "PRAGMA integrity_check;" - -# 5. 观察底层拦截日志 -grep '\[etvd\] write' etvd.log # 每次 write 的 off/size/hex 预览 -grep '/test.db' etvd.log | grep write # 随机页覆盖写(off=0 / off=4096) -grep -E 'create|unlink' etvd.log | grep journal # rollback journal 生命周期 -grep -c fsync etvd.log # 事务刷盘调用 - -# 6. 卸载 -fusermount3 -u mnt +bash tests/phase2.sh ``` -### 已验证的写入语义 +脚本覆盖五种写入场景 + SQLite + 目录操作,并校验底层日志的 Segment 观测,末尾打印 `RESULT: pass=N fail=M`。 + +### 五种写入语义 | 场景 | 行为 | 日志可见 | |------|------|----------| -| 追加写 | `echo >>` | `write off=<文件末尾> size=N` | -| 块写 | `dd bs=4k count=4` | 4 次 `write off=0/4096/8192/12288 size=4096` | -| 随机覆盖写 | SQLite `UPDATE` 改页 | `write off=4096 size=4096`(页 1 原地覆盖) | -| 截断 | `truncate -s 100` | `truncate size=100` | -| journal | SQLite 事务 | `create /test.db-journal` → `unlink` 每事务一次 | -| 刷盘 | SQLite `COMMIT` | `fsync` | +| 追加写 | `echo >>` | `write op=Append phys=<尾部off>+` | +| 随机覆盖写 | `dd seek=` / SQLite UPDATE 改页 | `write op=Overwrite phys=<新off>+`(旧字节仍在 `data.raw`,仅视图摘出) | +| 截断缩小 | `truncate -s N` | `truncate size=N`,视图裁剪,不产生新物理字节 | +| 截断扩大 / 扩展写 | `truncate -s M` / `dd seek=超末尾` | `write op=Extend`,中间空洞读返回零 | +| Fsync 边界 | SQLite COMMIT | `fsync datasync=..`,对 `data.raw` 落盘 | ## 实现要点 -- **绝对路径**:libfuse3 在跑事件循环前会 `chdir("/")`,因此 daemon 在请求时解析的所有路径(后端根目录)必须是绝对路径。`main` 在挂载前把 `backend` 与 `mountpoint` 都 `realpath` 成绝对路径。 -- **文件句柄**:`open/create` 打开真实 fd 存入 `fuse_file_info->fh`;`read/write` 用 `pread/pwrite`;`release` 关闭。 -- **锁**:`lock` 回调对 `F_GETLK` 返回 `F_UNLCK`、对 `F_SETLK` 返回 0,使 SQLite 的 fcntl 建议锁在单进程下正常运行。**真实互斥留到第十一阶段的分布式写租约。** -- **未实现的 op**(xattr / fallocate 等)保持 NULL,FUSE 返回 `-ENOSYS`,SQLite 等会自动回退。 +- **分层**:`store`(物理 Append/Read/Sync)+ `meta`(段链表算法 + 目录树 + history)+ `fuse_ops`(回调)。`meta` 方法不加锁,由 `fuse_ops` 经 `Meta::Lock()`(`shared_mutex`)在外层界定读写锁;write 持写锁跨 `Store::Append`,保证段落盘与视图更新原子。 +- **段链表更新**:write 区间 `[off,off+len)` 把相交旧段拆成前缀/后缀片段、丢弃被覆盖中段、插入新段,保持升序不重叠,gap 即空洞。truncate 缩小裁剪段、扩大只增长 `total_size`。 +- **即时写 + 当前视图**:当前视图(`FileView`)是 cur 的朴素前身;第三阶段才在其上做 LRU 缓存 / 聚合版本 / 多 Block 分块。`history`(`VersionDiff` 列表)保留供观测与未来 MVCC,不参与读路径。 +- **全自管内存 VFS**:根 `/` 在 `Meta` 构造时建立;路径经 `Parent`/`Base` 拆分管理父子;rename 目录时遍历子树改 key 前缀。 +- **fsync 真落盘**:fsync 回调 `::fsync(data.raw fd)`,SQLite 依赖其持久性。 +- **锁**:`lock` 回调对 `F_GETLK` 返回 `F_UNLCK`、`F_SETLK` 返回 0,SQLite fcntl 建议锁单进程正常运行。真实互斥留第十一阶段。 +- **default_permissions**:getattr 返回内存元数据的 mode/uid/gid,内核据此校验;以当前用户身份运行,文件 0644 / 目录 0755。 +- **重启语义**:`data.raw` 的 append 游标从文件大小恢复(继续追加、不覆写旧字节),但内存元数据丢失,旧字节成孤儿待 GC。 +- **未实现 op**(readlink / symlink / fallocate / xattr)保持 NULL,FUSE 返回 `-ENOSYS`。 -## 第一阶段边界(明确不做) +## 第二阶段边界(明确不做) -COW / append-only、Segment / VersionDiff、`cur` 快照、KV 持久化、前缀策略、MVCC、Raft、租约、多节点、QUIC —— 全部留到后续阶段。当前唯一目标是证明 FUSE 拦截通路打通,且能观测每一次写入。 +cur 的 LRU 缓存 / 聚合版本 / 多 Block 分块(第三阶段)、KV 持久化(第五阶段)、事务 TxnBegin/End(第六阶段)、Prefix 策略(第七阶段)、MVCC 恢复(第八阶段)、etcd 协议(第九阶段)、QUIC mTLS Raft(第十阶段)、分布式租约(第十一阶段)、GC 空洞回收、冷数据归档、symlink / 设备文件。 ## 文件 ``` -src/logger.h 线程安全操作日志 + hex 预览 -src/fuse_ops.h/.cpp FUSE3 回调:日志 + 直接 syscall 透传 + ops 表 -src/main.cpp 参数解析(fuse_opt)、绝对路径处理、挂载入口 -Makefile pkg-config fuse3 +src/store.h/.cpp 物理存储:data.raw Append / Read / Sync +src/meta.h/.cpp 内存元数据:Segment/VersionDiff/FileView/FileMeta + 段链表算法 + 目录树 +src/fuse_ops.h/.cpp FUSE3 回调:日志 + 走 COW 后端 + ops 表 +src/main.cpp 参数解析、绝对路径、打开 data.raw、初始化 Meta、挂载入口 +src/logger.h 线程安全操作日志 + hex 预览 +tests/phase2.sh 五种场景 + SQLite 验收脚本 +Makefile pkg-config fuse3 ``` diff --git a/src/fuse_ops.cpp b/src/fuse_ops.cpp index 5ab00c4..2eeda1d 100644 --- a/src/fuse_ops.cpp +++ b/src/fuse_ops.cpp @@ -3,31 +3,36 @@ #include #include #include +#include #include #include -#include -#include #include #include -#include #include #include #include #include #include "logger.h" +#include "meta.h" +#include "store.h" namespace etvd { namespace { -// Absolute backing directory. Set once in main() before fuse_main(); only read -// afterwards, so concurrent reads from FUSE worker threads are safe. -std::string g_root; +// COW backend, injected from main(). Read-only after SetBackend, so concurrent +// reads from FUSE worker threads are safe. +Store* g_store = nullptr; +Meta* g_meta = nullptr; -// Join the backing root with a FUSE virtual path (always starts with '/'). -std::string Real(const char* path) { return g_root + path; } +// Per-open handle. We don't keep a real fd (data lives in data.raw); the +// handle just remembers which virtual path this fd is bound to so read/write +// are robust even if FUSE ever passes a stale path. +struct OpenHandle { + std::string path; +}; inline int Err() { return -errno; } @@ -58,70 +63,77 @@ void etvd_destroy(void* private_data) { int etvd_getattr(const char* path, struct stat* st, struct fuse_file_info* fi) { (void)fi; - int rc = ::stat(Real(path).c_str(), st) == 0 ? 0 : Err(); - LogOp("getattr", path, Rc(rc)); - return rc; -} - -int etvd_readlink(const char* path, char* buf, size_t size) { int rc; - if (size == 0) { - rc = -EINVAL; - } else { - ssize_t n = ::readlink(Real(path).c_str(), buf, size - 1); - if (n < 0) { - rc = Err(); - } else { - buf[n] = '\0'; - rc = 0; - } + { + std::shared_lock lk(g_meta->Lock()); + rc = g_meta->Stat(path, st); } - LogOp("readlink", path, Rc(rc)); + LogOp("getattr", path, Rc(rc) + " size=" + + std::to_string(static_cast(st->st_size))); return rc; } int etvd_mknod(const char* path, mode_t mode, dev_t rdev) { - int rc = ::mknod(Real(path).c_str(), mode, rdev) == 0 ? 0 : Err(); + (void)rdev; // phase 2 only supports regular files + struct fuse_context* ctx = fuse_get_context(); + int rc; + { + std::unique_lock lk(g_meta->Lock()); + rc = g_meta->Mknod(path, mode, ctx->uid, ctx->gid); + } LogOp("mknod", path, Rc(rc)); return rc; } int etvd_mkdir(const char* path, mode_t mode) { - int rc = ::mkdir(Real(path).c_str(), mode) == 0 ? 0 : Err(); + struct fuse_context* ctx = fuse_get_context(); + int rc; + { + std::unique_lock lk(g_meta->Lock()); + rc = g_meta->Mkdir(path, mode, ctx->uid, ctx->gid); + } LogOp("mkdir", path, Rc(rc)); return rc; } int etvd_unlink(const char* path) { - int rc = ::unlink(Real(path).c_str()) == 0 ? 0 : Err(); + int rc; + { + std::unique_lock lk(g_meta->Lock()); + rc = g_meta->Unlink(path); + } LogOp("unlink", path, Rc(rc)); return rc; } int etvd_rmdir(const char* path) { - int rc = ::rmdir(Real(path).c_str()) == 0 ? 0 : Err(); + int rc; + { + std::unique_lock lk(g_meta->Lock()); + rc = g_meta->Rmdir(path); + } LogOp("rmdir", path, Rc(rc)); return rc; } -int etvd_symlink(const char* from, const char* to) { - int rc = ::symlink(from, Real(to).c_str()) == 0 ? 0 : Err(); - LogOp("symlink", to, std::string("->") + from + " " + Rc(rc)); - return rc; -} - int etvd_rename(const char* from, const char* to, unsigned int flags) { - int rc = ::renameat2(AT_FDCWD, Real(from).c_str(), AT_FDCWD, - Real(to).c_str(), flags) == 0 - ? 0 - : Err(); + if (flags != 0) return -EINVAL; // RENAME_EXCHANGE / RENAME_NOREPLACE unsupported + int rc; + { + std::unique_lock lk(g_meta->Lock()); + rc = g_meta->Rename(from, to); + } LogOp("rename", from, std::string("->") + to + " " + Rc(rc)); return rc; } int etvd_chmod(const char* path, mode_t mode, struct fuse_file_info* fi) { (void)fi; - int rc = ::chmod(Real(path).c_str(), mode) == 0 ? 0 : Err(); + int rc; + { + std::unique_lock lk(g_meta->Lock()); + rc = g_meta->Chmod(path, mode); + } LogOp("chmod", path, Rc(rc)); return rc; } @@ -129,32 +141,57 @@ int etvd_chmod(const char* path, mode_t mode, struct fuse_file_info* fi) { int etvd_chown(const char* path, uid_t uid, gid_t gid, struct fuse_file_info* fi) { (void)fi; - int rc = ::lchown(Real(path).c_str(), uid, gid) == 0 ? 0 : Err(); + int rc; + { + std::unique_lock lk(g_meta->Lock()); + rc = g_meta->Chown(path, uid, gid); + } LogOp("chown", path, Rc(rc)); return rc; } int etvd_truncate(const char* path, off_t size, struct fuse_file_info* fi) { (void)fi; - int rc = ::truncate(Real(path).c_str(), size) == 0 ? 0 : Err(); + int rc; + { + std::unique_lock lk(g_meta->Lock()); + rc = g_meta->Truncate(path, size); + } LogOp("truncate", path, "size=" + std::to_string(static_cast(size)) + " " + Rc(rc)); return rc; } int etvd_open(const char* path, struct fuse_file_info* fi) { - int fd = ::open(Real(path).c_str(), fi->flags); - int rc = fd >= 0 ? 0 : Err(); - if (rc == 0) fi->fh = static_cast(fd); + std::string sp = path; + int rc = 0; + { + std::shared_lock lk(g_meta->Lock()); + if (!g_meta->Find(sp)) rc = -ENOENT; + } + if (rc == 0 && (fi->flags & O_TRUNC)) { + std::unique_lock lk(g_meta->Lock()); + rc = g_meta->Truncate(sp, 0); + } + if (rc == 0) { + fi->fh = reinterpret_cast(new OpenHandle{sp}); + } LogOp("open", path, "flags=" + Hex(static_cast(fi->flags)) + " " + Rc(rc)); return rc; } int etvd_create(const char* path, mode_t mode, struct fuse_file_info* fi) { - int fd = ::open(Real(path).c_str(), fi->flags, mode); - int rc = fd >= 0 ? 0 : Err(); - if (rc == 0) fi->fh = static_cast(fd); + struct fuse_context* ctx = fuse_get_context(); + std::string sp = path; + int rc; + { + std::unique_lock lk(g_meta->Lock()); + rc = g_meta->Mknod(sp, mode, ctx->uid, ctx->gid); + } + if (rc == 0) { + fi->fh = reinterpret_cast(new OpenHandle{sp}); + } char m[32]; std::snprintf(m, sizeof(m), "mode=0%o", static_cast(mode)); LogOp("create", path, @@ -165,45 +202,80 @@ int etvd_create(const char* path, mode_t mode, struct fuse_file_info* fi) { int etvd_read(const char* path, char* buf, size_t size, off_t off, struct fuse_file_info* fi) { - ssize_t rc = ::pread(static_cast(fi->fh), buf, size, off); - if (rc < 0) rc = Err(); - LogOp("read", path, OffSize(off, size) + " " + Rc(static_cast(rc))); - return static_cast(rc); + auto* h = reinterpret_cast(fi->fh); + std::string hp = h ? h->path : std::string(path); + size_t nread = 0; + int rc; + { + std::shared_lock lk(g_meta->Lock()); + rc = g_meta->Read(*g_store, hp, buf, size, off, &nread); + } + if (rc < 0) { + LogOp("read", hp.c_str(), OffSize(off, size) + " " + Rc(rc)); + return rc; + } + LogOp("read", hp.c_str(), OffSize(off, size) + " " + Rc(static_cast(nread))); + return static_cast(nread); } int etvd_write(const char* path, const char* buf, size_t size, off_t off, struct fuse_file_info* fi) { - ssize_t rc = ::pwrite(static_cast(fi->fh), buf, size, off); - if (rc < 0) rc = Err(); - size_t shown = rc > 0 ? static_cast(rc) : 0; - LogOp("write", path, - OffSize(off, size) + " " + Rc(static_cast(rc)) + - " data=" + HexPreview(buf, shown)); - return static_cast(rc); -} - -int etvd_statfs(const char* path, struct statvfs* sv) { - int rc = ::statvfs(Real(path).c_str(), sv) == 0 ? 0 : Err(); - LogOp("statfs", path, Rc(rc)); + auto* h = reinterpret_cast(fi->fh); + std::string hp = h ? h->path : std::string(path); + uint64_t phys_off = 0; + Op op = Op::Append; + int rc; + { + std::unique_lock lk(g_meta->Lock()); + rc = g_meta->Write(*g_store, hp, buf, size, off, &phys_off, &op); + } + if (rc < 0) { + LogOp("write", hp.c_str(), OffSize(off, size) + " " + Rc(rc)); + return rc; + } + LogOp("write", hp.c_str(), + std::string("op=") + OpName(op) + + " phys=" + std::to_string(phys_off) + "+" + + std::to_string(static_cast(size)) + " " + + OffSize(off, size) + " " + Rc(static_cast(rc)) + + " data=" + HexPreview(buf, size)); return rc; } +int etvd_statfs(const char* path, struct statvfs* sv) { + (void)path; + std::memset(sv, 0, sizeof(*sv)); + uint64_t used = g_store->Size(); + sv->f_bsize = 4096; + sv->f_frsize = 4096; + sv->f_blocks = used / 4096 + 1024 * 1024; + sv->f_bfree = sv->f_blocks - used / 4096; + sv->f_bavail = sv->f_bfree; + sv->f_files = 1000000; + sv->f_ffree = 900000; + sv->f_namemax = 255; + LogOp("statfs", path, Rc(0)); + return 0; +} + int etvd_flush(const char* path, struct fuse_file_info* fi) { - // pwrite/pwrite already push data synchronously; flush is a no-op here. (void)fi; + // Writes already landed in data.raw on each pwrite; nothing buffered to flush. LogOp("flush", path, Rc(0)); return 0; } int etvd_release(const char* path, struct fuse_file_info* fi) { - int rc = ::close(static_cast(fi->fh)) == 0 ? 0 : Err(); - LogOp("release", path, Rc(rc)); - return rc; + auto* h = reinterpret_cast(fi->fh); + delete h; + LogOp("release", path, Rc(0)); + return 0; } int etvd_fsync(const char* path, int datasync, struct fuse_file_info* fi) { - int fd = static_cast(fi->fh); - int rc = (datasync ? ::fdatasync(fd) : ::fsync(fd)) == 0 ? 0 : Err(); + (void)datasync; // single data.raw file; fsync == fdatasync here + (void)fi; + int rc = g_store->Sync() == 0 ? 0 : Err(); LogOp("fsync", path, std::string("datasync=") + (datasync ? "1" : "0") + " " + Rc(rc)); return rc; @@ -214,40 +286,36 @@ int etvd_readdir(const char* path, void* buf, fuse_fill_dir_t filler, off_t off, (void)off; (void)fi; (void)flags; - std::vector names; - int rc = 0; - DIR* d = ::opendir(Real(path).c_str()); - if (d == nullptr) { - rc = Err(); - } else { - struct dirent* de; - errno = 0; - while ((de = ::readdir(d)) != nullptr) { - if (std::strcmp(de->d_name, ".") == 0 || std::strcmp(de->d_name, "..") == 0) - continue; - names.emplace_back(de->d_name); + size_t n = 0; + int rc; + { + std::shared_lock lk(g_meta->Lock()); + const FileMeta* m = g_meta->Find(path); + if (!m) { + rc = -ENOENT; + } else if (m->type != Type::Dir) { + rc = -ENOTDIR; + } else { + const auto kFlags = static_cast(0); + filler(buf, ".", nullptr, 0, kFlags); + filler(buf, "..", nullptr, 0, kFlags); + for (const std::string& c : m->children) filler(buf, c.c_str(), nullptr, 0, kFlags); + n = m->children.size(); + rc = 0; } - int saved = errno; // capture before closedir clobbers it - ::closedir(d); - if (saved != 0) rc = -saved; } - if (rc == 0) { - const auto kFlags = static_cast(0); - filler(buf, ".", nullptr, 0, kFlags); - filler(buf, "..", nullptr, 0, kFlags); - for (const std::string& n : names) - filler(buf, n.c_str(), nullptr, 0, kFlags); - } - LogOp("readdir", path, Rc(rc) + " n=" + std::to_string(names.size())); + LogOp("readdir", path, Rc(rc) + " n=" + std::to_string(n)); return rc; } int etvd_utimens(const char* path, const struct timespec ts[2], struct fuse_file_info* fi) { (void)fi; - int rc = ::utimensat(AT_FDCWD, Real(path).c_str(), ts, AT_SYMLINK_NOFOLLOW) == 0 - ? 0 - : Err(); + int rc; + { + std::unique_lock lk(g_meta->Lock()); + rc = g_meta->Utimens(path, ts); + } LogOp("utimens", path, Rc(rc)); return rc; } @@ -256,29 +324,31 @@ int etvd_lock(const char* path, struct fuse_file_info* fi, int cmd, struct flock* fl) { (void)path; (void)fi; - // Phase 1: advisory locking is not enforced. Grant every request and report - // no conflicts so fcntl-based engines (SQLite) run in single-process mode. - // Real mutual exclusion arrives with the distributed write lease (phase 10). - // Not logged -- high-frequency and noisy. + // Phase 2: advisory locking not enforced (in-memory VFS has no cross-process + // locks to honor). Grant every request so fcntl-based engines (SQLite) run + // in single-process mode. Real mutual exclusion arrives with the distributed + // write lease (phase 11). Not logged -- high-frequency and noisy. if (cmd == F_GETLK) fl->l_type = F_UNLCK; return 0; } } // namespace -void SetRoot(std::string root) { g_root = std::move(root); } +void SetBackend(Store* store, Meta* meta) { + g_store = store; + g_meta = meta; +} const struct fuse_operations* EtvdFuseOps() { // Zero-initialized once (static storage); unassigned callbacks stay NULL, - // making FUSE return -ENOSYS for ops we don't implement (xattr, fallocate). + // making FUSE return -ENOSYS for ops we don't implement (readlink, symlink, + // fallocate, xattr). static struct fuse_operations ops; ops.getattr = etvd_getattr; - ops.readlink = etvd_readlink; ops.mknod = etvd_mknod; ops.mkdir = etvd_mkdir; ops.unlink = etvd_unlink; ops.rmdir = etvd_rmdir; - ops.symlink = etvd_symlink; ops.rename = etvd_rename; ops.chmod = etvd_chmod; ops.chown = etvd_chown; diff --git a/src/fuse_ops.h b/src/fuse_ops.h index bc4900a..1ce5c0a 100644 --- a/src/fuse_ops.h +++ b/src/fuse_ops.h @@ -5,17 +5,19 @@ #endif #include -#include - namespace etvd { -// Set the absolute backing directory the callbacks forward to. Must be called -// once before fuse_main(). libfuse3 chdir()'s to "/" before serving, so this -// must be an absolute path. -void SetRoot(std::string root); +class Store; +class Meta; + +// Inject the COW backend (Store + in-memory Meta). Must be called once before +// fuse_main(). Both must outlive the FUSE session (main() owns them as locals +// and blocks in fuse_main, so this holds). +void SetBackend(Store* store, Meta* meta); // The FUSE3 operations table: every callback logs the call, then performs the -// operation directly against the backing directory (passthrough). +// operation against the COW append-only backend (data.raw + Segment view), +// not a passthrough directory. const struct fuse_operations* EtvdFuseOps(); } // namespace etvd diff --git a/src/main.cpp b/src/main.cpp index f46649c..0ea221f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,9 +6,12 @@ #include #include #include +#include #include "fuse_ops.h" // brings in with FUSE_USE_VERSION #include "logger.h" +#include "meta.h" +#include "store.h" namespace { @@ -23,15 +26,18 @@ const struct fuse_opt kOptDefs[] = { void Usage(const char* prog) { std::fprintf(stderr, - "etvd - phase 1 FUSE interception daemon\n" + "etvd - phase 2 COW append-only VFS daemon\n" "\n" "Usage: %s [fuse-options...] [-o backend=DIR]\n" "\n" - "Mounts and forwards every file operation to a backing\n" - "directory (passthrough), logging each call to stderr.\n" + "Mounts and serves every file operation from a COW\n" + "append-only backend: writes append to DIR/data.raw and are tracked as\n" + "Segments; reads reconstruct the view from the segment list. Metadata is\n" + "in-memory (lost on restart; persistence arrives in phase 5). Each call\n" + "is logged to stderr.\n" "\n" "Options:\n" - " -o backend=DIR backing directory (default: ./backend)\n" + " -o backend=DIR directory holding data.raw (default: ./backend)\n" " -f foreground (forced on)\n" " -d FUSE debug\n" "\n" @@ -50,8 +56,7 @@ bool EnsureDir(const std::string& dir) { // Resolve `dir` to an absolute, canonical path in place. libfuse3 chdir()'s to // "/" before serving requests, so any path the daemon resolves at request time -// (the backend root) must be absolute or it will be misread relative to "/". -// Requires `dir` to already exist (call EnsureDir first). +// must be absolute or it will be misread relative to "/". bool ToAbsolute(std::string& dir) { char* abs = ::realpath(dir.c_str(), nullptr); if (abs == nullptr) return false; @@ -76,8 +81,6 @@ int main(int argc, char* argv[]) { } std::string mountpoint = argv[1]; - // Create + absolutize the mountpoint, then feed the absolute path back into - // argv so fuse_main mounts there regardless of any later chdir. if (!EnsureDir(mountpoint)) { std::fprintf(stderr, "etvd: mountpoint '%s' unusable\n", mountpoint.c_str()); return 1; @@ -108,15 +111,29 @@ int main(int argc, char* argv[]) { return 1; } + // Open (creating if needed) the append-only physical store. The append + // cursor resumes from the existing file size, so a restart keeps appending + // past old (now orphaned) bytes rather than overwriting them. + std::string data_path = backend_dir + "/data.raw"; + etvd::Store store; + if (!store.Open(data_path)) { + std::fprintf(stderr, "etvd: cannot open data store '%s'\n", data_path.c_str()); + return 1; + } + + // In-memory VFS metadata + directory tree, seeded with root "/". + etvd::Meta meta(::getuid(), ::getgid()); + // Force foreground (logs stream, Ctrl-C unmounts) and let the kernel enforce - // access checks against the modes reported from the backing files. + // access checks against the modes reported from the in-memory metadata. fuse_opt_add_arg(&args, "-f"); fuse_opt_add_arg(&args, "-o"); fuse_opt_add_arg(&args, "default_permissions"); - etvd::SetRoot(backend_dir); + etvd::SetBackend(&store, &meta); etvd::LogOp("start", "/", - "backend=" + backend_dir + " mount=" + mountpoint); + "backend=" + backend_dir + " data=" + data_path + + " mount=" + mountpoint); const struct fuse_operations* ops = etvd::EtvdFuseOps(); int rc = fuse_main(args.argc, args.argv, ops, nullptr); diff --git a/src/meta.cpp b/src/meta.cpp new file mode 100644 index 0000000..39c3583 --- /dev/null +++ b/src/meta.cpp @@ -0,0 +1,341 @@ +#include "meta.h" + +#include +#include +#include +#include +#include + +#include "store.h" + +namespace etvd { + +namespace { + +timespec Now() { + timespec t{}; + clock_gettime(CLOCK_REALTIME, &t); + return t; +} + +ino_t InodeOf(const std::string& path) { + return static_cast(std::hash{}(path)); +} + +} // namespace + +Meta::Meta(uid_t uid, gid_t gid) : default_uid_(uid), default_gid_(gid) { + FileMeta root; + root.path = "/"; + root.type = Type::Dir; + root.mode = 0755; + root.uid = uid; + root.gid = gid; + root.nlink = 2; + timespec now = Now(); + root.mtime = root.atime = root.ctime = now; + files_["/"] = std::move(root); +} + +std::string Meta::Parent(const std::string& path) { + if (path == "/" || path.empty()) return "/"; + size_t pos = path.find_last_of('/'); + if (pos == 0) return "/"; // "/a" -> "/" + return path.substr(0, pos); // "/a/b" -> "/a" +} + +std::string Meta::Base(const std::string& path) { + if (path == "/") return ""; + size_t pos = path.find_last_of('/'); + return path.substr(pos + 1); +} + +const FileMeta* Meta::Find(const std::string& path) const { + auto it = files_.find(path); + return it == files_.end() ? nullptr : &it->second; +} + +FileMeta* Meta::FindMut(const std::string& path) { + auto it = files_.find(path); + return it == files_.end() ? nullptr : &it->second; +} + +void Meta::Touch(FileMeta* m) { + timespec now = Now(); + m->mtime = now; + m->ctime = now; +} + +int Meta::Stat(const std::string& path, struct stat* st) const { + const FileMeta* m = Find(path); + if (!m) return -ENOENT; + std::memset(st, 0, sizeof(*st)); + st->st_ino = InodeOf(path); + st->st_mode = (m->type == Type::Dir ? S_IFDIR : S_IFREG) | (m->mode & 07777); + st->st_nlink = m->nlink; + st->st_uid = m->uid; + st->st_gid = m->gid; + st->st_size = (m->type == Type::Dir) ? 4096 : static_cast(m->view.total_size); + st->st_blocks = st->st_size / 512; + st->st_atim = m->atime; + st->st_mtim = m->mtime; + st->st_ctim = m->ctime; + return 0; +} + +int Meta::Mknod(const std::string& path, mode_t mode, uid_t uid, gid_t gid) { + if (Find(path)) return -EEXIST; + FileMeta* p = FindMut(Parent(path)); + if (!p || p->type != Type::Dir) return -ENOENT; + FileMeta m; + m.path = path; + m.type = Type::File; + m.mode = mode & 07777; + m.uid = uid; + m.gid = gid; + m.nlink = 1; + timespec now = Now(); + m.mtime = m.atime = m.ctime = now; + files_[path] = std::move(m); + p->children.push_back(Base(path)); + Touch(p); + return 0; +} + +int Meta::Mkdir(const std::string& path, mode_t mode, uid_t uid, gid_t gid) { + if (Find(path)) return -EEXIST; + FileMeta* p = FindMut(Parent(path)); + if (!p || p->type != Type::Dir) return -ENOENT; + FileMeta m; + m.path = path; + m.type = Type::Dir; + m.mode = mode & 07777; + m.uid = uid; + m.gid = gid; + m.nlink = 2; + timespec now = Now(); + m.mtime = m.atime = m.ctime = now; + files_[path] = std::move(m); + p->children.push_back(Base(path)); + p->nlink++; // subdir's ".." points back + Touch(p); + return 0; +} + +int Meta::Unlink(const std::string& path) { + FileMeta* m = FindMut(path); + if (!m) return -ENOENT; + if (m->type != Type::File) return -EISDIR; + if (FileMeta* p = FindMut(Parent(path))) { + std::string b = Base(path); + p->children.erase(std::remove(p->children.begin(), p->children.end(), b), + p->children.end()); + Touch(p); + } + files_.erase(path); + // Physical segments in data.raw are NOT reclaimed here -- GC is a later + // phase. Old bytes stay in place, just no longer referenced by any view. + return 0; +} + +int Meta::Rmdir(const std::string& path) { + FileMeta* m = FindMut(path); + if (!m) return -ENOENT; + if (m->type != Type::Dir) return -ENOTDIR; + if (!m->children.empty()) return -ENOTEMPTY; + if (FileMeta* p = FindMut(Parent(path))) { + std::string b = Base(path); + p->children.erase(std::remove(p->children.begin(), p->children.end(), b), + p->children.end()); + p->nlink--; + Touch(p); + } + files_.erase(path); + return 0; +} + +int Meta::Rename(const std::string& from, const std::string& to) { + FileMeta* m = FindMut(from); + if (!m) return -ENOENT; + if (Find(to)) return -EEXIST; // simplified: no overwrite + FileMeta* pt = FindMut(Parent(to)); + if (!pt || pt->type != Type::Dir) return -ENOTDIR; + + bool is_dir = (m->type == Type::Dir); + std::vector keys; + if (is_dir) { + std::string prefix = from + "/"; + for (auto& kv : files_) { + if (kv.first == from || + kv.first.compare(0, prefix.size(), prefix) == 0) { + keys.push_back(kv.first); + } + } + } else { + keys.push_back(from); + } + for (const std::string& k : keys) { + std::string nk = (k == from) ? to : to + k.substr(from.size()); + FileMeta mm = files_[k]; + mm.path = nk; + files_[nk] = std::move(mm); + } + for (const std::string& k : keys) files_.erase(k); + + if (FileMeta* pf = FindMut(Parent(from))) { + std::string b = Base(from); + pf->children.erase(std::remove(pf->children.begin(), pf->children.end(), b), + pf->children.end()); + Touch(pf); + } + pt->children.push_back(Base(to)); + Touch(pt); + return 0; +} + +int Meta::Chmod(const std::string& path, mode_t mode) { + FileMeta* m = FindMut(path); + if (!m) return -ENOENT; + m->mode = mode & 07777; + m->ctime = Now(); + return 0; +} + +int Meta::Chown(const std::string& path, uid_t uid, gid_t gid) { + FileMeta* m = FindMut(path); + if (!m) return -ENOENT; + if (uid != static_cast(-1)) m->uid = uid; + if (gid != static_cast(-1)) m->gid = gid; + m->ctime = Now(); + return 0; +} + +int Meta::Utimens(const std::string& path, const struct timespec ts[2]) { + FileMeta* m = FindMut(path); + if (!m) return -ENOENT; + timespec now = Now(); + m->atime = (ts && ts[0].tv_nsec != UTIME_OMIT) ? ts[0] : now; + m->mtime = (ts && ts[1].tv_nsec != UTIME_OMIT) ? ts[1] : now; + m->ctime = now; + return 0; +} + +int Meta::Write(Store& store, const std::string& path, const char* buf, + size_t len, off_t off, uint64_t* phys_off_out, Op* op_out) { + FileMeta* m = FindMut(path); + if (!m) return -ENOENT; + if (m->type == Type::Dir) return -EISDIR; + if (len == 0) { + if (phys_off_out) *phys_off_out = 0; + if (op_out) *op_out = Op::Append; + return 0; + } + + uint64_t lo = static_cast(off); + uint64_t hi = lo + len; + Op op; + if (lo == m->view.total_size) { + op = Op::Append; + } else if (lo > m->view.total_size) { + op = Op::Extend; + } else { + // lo < total_size: overwrites existing bytes (may also extend past the + // old end -- classify by the overwrite half for logging). + op = Op::Overwrite; + } + + uint64_t phys_off = store.Append(buf, len); + if (phys_off == UINT64_MAX) return -EIO; + + Segment S{phys_off, len, lo}; + + // Rebuild segs: carve the [lo,hi) window out of existing segments (keeping + // any prefix/suffix fragments), then insert S. Gaps left between segments + // are holes that read as zero. + std::vector out; + for (const auto& s : m->view.segs) { + uint64_t s_lo = s.log_off; + uint64_t s_hi = s.log_off + s.phys_len; + if (s_hi <= lo || s_lo >= hi) { + out.push_back(s); + continue; + } + if (s_lo < lo) out.push_back({s.phys_off, lo - s_lo, s_lo}); + if (s_hi > hi) out.push_back({s.phys_off + (hi - s_lo), s_hi - hi, hi}); + } + out.push_back(S); + std::sort(out.begin(), out.end(), + [](const Segment& a, const Segment& b) { return a.log_off < b.log_off; }); + m->view.segs = std::move(out); + if (hi > m->view.total_size) m->view.total_size = hi; + + VersionDiff vd; + vd.version_id = next_version_.fetch_add(1); + vd.op = op; + vd.new_segs.push_back(S); + vd.total_size_after = m->view.total_size; + m->history.push_back(std::move(vd)); + + Touch(m); + if (phys_off_out) *phys_off_out = phys_off; + if (op_out) *op_out = op; + return static_cast(len); +} + +int Meta::Truncate(const std::string& path, off_t size) { + FileMeta* m = FindMut(path); + if (!m) return -ENOENT; + if (m->type == Type::Dir) return -EISDIR; + uint64_t sz = static_cast(size); + if (sz < m->view.total_size) { + std::vector out; + for (const auto& s : m->view.segs) { + uint64_t s_lo = s.log_off; + uint64_t s_hi = s.log_off + s.phys_len; + if (s_lo >= sz) continue; // entirely beyond + if (s_hi <= sz) { out.push_back(s); continue; } // wholly kept + out.push_back({s.phys_off, sz - s_lo, s_lo}); // straddles: cut to sz + } + m->view.segs = std::move(out); + m->view.total_size = sz; + } else if (sz > m->view.total_size) { + // extend with a hole; no new physical bytes + m->view.total_size = sz; + } + + VersionDiff vd; + vd.version_id = next_version_.fetch_add(1); + vd.op = Op::Truncate; + vd.truncate_size = sz; + vd.total_size_after = m->view.total_size; + m->history.push_back(std::move(vd)); + Touch(m); + return 0; +} + +int Meta::Read(Store& store, const std::string& path, char* buf, size_t size, + off_t off, size_t* nread_out) const { + const FileMeta* m = Find(path); + if (!m) return -ENOENT; + if (m->type == Type::Dir) return -EISDIR; + size_t nread = 0; + uint64_t lo = static_cast(off); + if (lo < m->view.total_size) { + nread = std::min(size, static_cast(m->view.total_size - lo)); + std::memset(buf, 0, nread); // holes default to zero + for (const auto& s : m->view.segs) { + uint64_t s_lo = s.log_off; + uint64_t s_hi = s.log_off + s.phys_len; + if (s_hi <= lo || s_lo >= lo + nread) continue; + uint64_t copy_lo = std::max(s_lo, lo); + uint64_t copy_hi = std::min(s_hi, lo + nread); + uint64_t seg_skip = copy_lo - s_lo; + size_t want = static_cast(copy_hi - copy_lo); + store.Read(s.phys_off + seg_skip, want, buf + (copy_lo - lo)); + } + } + if (nread_out) *nread_out = nread; + return 0; +} + +} // namespace etvd diff --git a/src/meta.h b/src/meta.h new file mode 100644 index 0000000..0f2e430 --- /dev/null +++ b/src/meta.h @@ -0,0 +1,119 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace etvd { + +class Store; + +// Physical segment: file logical [log_off, log_off+phys_len) maps to +// data.raw [phys_off, phys_off+phys_len). +struct Segment { + uint64_t phys_off = 0; + uint64_t phys_len = 0; + uint64_t log_off = 0; +}; + +enum class Op : int { Append, Overwrite, Truncate, Extend }; + +inline const char* OpName(Op op) { + switch (op) { + case Op::Append: return "Append"; + case Op::Overwrite: return "Overwrite"; + case Op::Truncate: return "Truncate"; + case Op::Extend: return "Extend"; + } + return "?"; +} + +// One write/truncate = one version. Kept in history for bottom-level +// observation and as the substrate for MVCC recovery (phase 8). Not used on +// the read path -- reads use FileView (the phase-3 cur snapshot will layer a +// cache on top of that). +struct VersionDiff { + uint64_t version_id = 0; + Op op = Op::Append; + std::vector new_segs; + uint64_t truncate_size = 0; // Op::Truncate only + uint64_t total_size_after = 0; +}; + +// Current view: ordered, non-overlapping segments; gaps between them are +// holes that read as zero. +struct FileView { + std::vector segs; + uint64_t total_size = 0; +}; + +enum class Type { File, Dir }; + +struct FileMeta { + std::string path; + Type type = Type::File; + mode_t mode = 0; + uid_t uid = 0; + gid_t gid = 0; + timespec mtime{}; + timespec atime{}; + timespec ctime{}; + nlink_t nlink = 1; + + FileView view; // File + std::vector history; // File (observation + future MVCC) + std::vector children; // Dir (basenames) +}; + +// In-memory VFS metadata + directory tree, guarded by a shared_mutex. +// Callers scope the lock via Lock(); every method assumes the appropriate +// lock is already held -- shared for const/read ops, unique for mutations. +// Write() holds the unique lock across Store::Append so the segment lands and +// the view updates atomically w.r.t. other metadata ops. +class Meta { + public: + Meta(uid_t uid, gid_t gid); + + std::shared_mutex& Lock() { return mu_; } + + // lookups (read lock) + const FileMeta* Find(const std::string& path) const; + int Stat(const std::string& path, struct stat* st) const; + + // tree ops (write lock) + int Mknod(const std::string& path, mode_t mode, uid_t uid, gid_t gid); + int Mkdir(const std::string& path, mode_t mode, uid_t uid, gid_t gid); + int Unlink(const std::string& path); + int Rmdir(const std::string& path); + int Rename(const std::string& from, const std::string& to); + int Chmod(const std::string& path, mode_t mode); + int Chown(const std::string& path, uid_t uid, gid_t gid); + int Utimens(const std::string& path, const struct timespec ts[2]); + + // file data (Write/Truncate: write lock; Read: read lock) + int Write(Store& store, const std::string& path, const char* buf, size_t len, + off_t off, uint64_t* phys_off_out, Op* op_out); + int Truncate(const std::string& path, off_t size); + int Read(Store& store, const std::string& path, char* buf, size_t size, + off_t off, size_t* nread_out) const; + + private: + static std::string Parent(const std::string& path); + static std::string Base(const std::string& path); + FileMeta* FindMut(const std::string& path); + void Touch(FileMeta* m); + + std::shared_mutex mu_; + std::unordered_map files_; + std::atomic next_version_{1}; + uid_t default_uid_; + gid_t default_gid_; +}; + +} // namespace etvd diff --git a/src/store.cpp b/src/store.cpp new file mode 100644 index 0000000..ae40a16 --- /dev/null +++ b/src/store.cpp @@ -0,0 +1,85 @@ +#include "store.h" + +#include +#include +#include +#include +#include + +namespace etvd { + +Store::~Store() { Close(); } + +bool Store::Open(const std::string& path) { + fd_ = ::open(path.c_str(), O_RDWR | O_CREAT, 0644); + if (fd_ < 0) return false; + struct stat st; + if (::fstat(fd_, &st) != 0) { + ::close(fd_); + fd_ = -1; + return false; + } + // Resume the append cursor from the existing file size. On restart the + // in-memory metadata is gone (phase 2 keeps it in memory; persistence + // arrives in phase 5), so old bytes become orphans awaiting GC -- but we + // must not overwrite them. + append_off_ = static_cast(st.st_size); + return true; +} + +void Store::Close() { + if (fd_ >= 0) { + ::close(fd_); + fd_ = -1; + } +} + +uint64_t Store::Append(const char* data, size_t len) { + std::lock_guard lk(mu_); + if (fd_ < 0 || len == 0) { + // len == 0 is a no-op append; return the current cursor so callers that + // always form a Segment still get a sane (unused) offset. + return fd_ < 0 ? UINT64_MAX : append_off_; + } + uint64_t off = append_off_; + size_t done = 0; + while (done < len) { + ssize_t n = ::pwrite(fd_, data + done, len - done, + static_cast(off + done)); + if (n < 0) { + if (errno == EINTR) continue; + return UINT64_MAX; + } + done += static_cast(n); + } + append_off_ += len; + return off; +} + +size_t Store::Read(uint64_t off, size_t len, char* buf) { + if (fd_ < 0 || len == 0) return 0; + size_t done = 0; + while (done < len) { + ssize_t n = ::pread(fd_, buf + done, len - done, + static_cast(off + done)); + if (n < 0) { + if (errno == EINTR) continue; + break; + } + if (n == 0) break; // EOF + done += static_cast(n); + } + return done; +} + +uint64_t Store::Size() { + std::lock_guard lk(mu_); + return append_off_; +} + +int Store::Sync() { + if (fd_ < 0) return -1; + return ::fsync(fd_); +} + +} // namespace etvd diff --git a/src/store.h b/src/store.h new file mode 100644 index 0000000..9310173 --- /dev/null +++ b/src/store.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include +#include + +namespace etvd { + +// Append-only physical backing store: a single sparse file (data.raw). +// The only write path is Append (sequential, never overwrites in place); +// reads are random by physical offset. Phase 2 splits this into multiple +// fixed-size Block files (phase 3); for now one file is enough to validate +// correctness. +// +// Thread-safe: Append is serialized by an internal mutex so concurrent FUSE +// writers all advance a single append cursor. Read needs no lock (pread). +class Store { + public: + Store() = default; + ~Store(); + + Store(const Store&) = delete; + Store& operator=(const Store&) = delete; + + // Open (creating if needed) the data file at `path`. Resumes the append + // cursor from the existing file size so a restart continues appending. + // Returns false on error. + bool Open(const std::string& path); + void Close(); + + // Append `len` bytes, returning the physical offset where they landed. + // Returns UINT64_MAX on failure. + uint64_t Append(const char* data, size_t len); + + // Read up to `len` bytes starting at physical offset `off` into `buf`. + // Returns bytes actually read (fewer at EOF). + size_t Read(uint64_t off, size_t len, char* buf); + + // Current append offset (== logical file size). + uint64_t Size(); + + // fsync the backing file so fsync callbacks give real durability. + int Sync(); + + private: + int fd_ = -1; + std::mutex mu_; + uint64_t append_off_ = 0; +}; + +} // namespace etvd diff --git a/tests/phase2.sh b/tests/phase2.sh new file mode 100644 index 0000000..b63fcec --- /dev/null +++ b/tests/phase2.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Phase 2 acceptance test: COW append-only backend. +# Exercises the five write scenarios + SQLite + dir ops and checks correctness, +# then confirms the bottom-level log shows Segment-level write observation. +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BIN="$ROOT/etvd_fuse" +MNT="$ROOT/mnt" +BACKEND="${BACKEND:-$ROOT/backend}" +LOG="$ROOT/etvd.log" + +cleanup() { + fusermount3 -u "$MNT" 2>/dev/null || true +} +trap cleanup EXIT + +cleanup +rm -rf "$MNT" "$BACKEND" "$LOG" +mkdir -p "$MNT" "$BACKEND" + +echo "==> starting etvd_fuse (log -> $LOG)" +"$BIN" "$MNT" -o backend="$BACKEND" 2>"$LOG" & +DAEMON=$! +mounted=0 +for _ in $(seq 1 50); do + if mountpoint -q "$MNT"; then mounted=1; break; fi + sleep 0.1 +done +if [ "$mounted" -ne 1 ]; then + echo "FATAL: mount failed; log:"; cat "$LOG"; exit 1 +fi + +pass=0; fail=0 +check() { + if [[ "$2" == "$3" ]]; then + echo " PASS: $1"; pass=$((pass+1)) + else + echo " FAIL: $1"; echo " expected: [$2]"; echo " actual: [$3]"; fail=$((fail+1)) + fi +} +byte_hex() { dd if="$1" bs=1 skip="$2" count=1 status=none 2>/dev/null | od -An -tx1 | tr -d ' \n'; } + +echo "==> scenario 1: append" +printf 'a\n' > "$MNT/f1" +printf 'b\n' >> "$MNT/f1" +check "append content" "a +b" "$(cat "$MNT/f1")" +check "append size" "4" "$(stat -c%s "$MNT/f1")" + +echo "==> scenario 2: random overwrite" +printf 'ABCDEFGH' > "$MNT/f2" +printf 'XXXX' | dd of="$MNT/f2" bs=1 seek=4 count=4 conv=notrunc status=none +check "overwrite content" "ABCDXXXX" "$(cat "$MNT/f2")" +check "overwrite size" "8" "$(stat -c%s "$MNT/f2")" +raw_after_overwrite=$(stat -c%s "$BACKEND/data.raw") +echo " data.raw after overwrite = $raw_after_overwrite bytes (old ABCDEFGH tail retained, > logical 8)" + +echo "==> scenario 3: truncate down + up (hole)" +truncate -s 3 "$MNT/f2" +check "truncate-down size" "3" "$(stat -c%s "$MNT/f2")" +check "truncate-down content" "ABC" "$(cat "$MNT/f2")" +truncate -s 10 "$MNT/f2" +check "truncate-up size" "10" "$(stat -c%s "$MNT/f2")" +check "truncate-up hole byte[3]=00" "00" "$(byte_hex "$MNT/f2" 3)" +check "truncate-up hole byte[9]=00" "00" "$(byte_hex "$MNT/f2" 9)" + +echo "==> scenario 4: extend write with hole" +printf 'Y' | dd of="$MNT/f3" bs=1 seek=100 count=1 conv=notrunc status=none +check "extend size" "101" "$(stat -c%s "$MNT/f3")" +check "extend hole byte[0]=00" "00" "$(byte_hex "$MNT/f3" 0)" +check "extend byte[100]=59(Y)" "59" "$(byte_hex "$MNT/f3" 100)" + +echo "==> scenario 5: fsync + sqlite" +sqlite3 "$MNT/test.db" "CREATE TABLE t(id INTEGER PRIMARY KEY, name TEXT); INSERT INTO t VALUES (1,'alice'); INSERT INTO t VALUES (2,'bob');" +check "sqlite select" "1|alice +2|bob" "$(sqlite3 "$MNT/test.db" 'SELECT * FROM t ORDER BY id;')" +sqlite3 "$MNT/test.db" "UPDATE t SET name='ALICE' WHERE id=1;" +check "sqlite update" "1|ALICE +2|bob" "$(sqlite3 "$MNT/test.db" 'SELECT * FROM t ORDER BY id;')" +check "sqlite integrity" "ok" "$(sqlite3 "$MNT/test.db" 'PRAGMA integrity_check;')" + +echo "==> scenario 6: dir / rename / unlink / rmdir" +mkdir "$MNT/sub" +echo "x" > "$MNT/sub/a.txt" +check "readdir lists file" "a.txt" "$(ls "$MNT/sub")" +mv "$MNT/sub/a.txt" "$MNT/sub/b.txt" +check "rename" "b.txt" "$(ls "$MNT/sub")" +rm "$MNT/sub/b.txt" +check "unlink empties dir" "" "$(ls "$MNT/sub")" +rmdir "$MNT/sub" +if [ -e "$MNT/sub" ]; then + echo " FAIL: rmdir did not remove sub"; fail=$((fail+1)) +else + echo " PASS: rmdir removed sub"; pass=$((pass+1)) +fi + +echo "==> observation: bottom-level write segments" +nwrites=$(grep -c '\[etvd\] write' "$LOG" || true) +nfsync=$(grep -c '\[etvd\] fsync' "$LOG" || true) +echo " write ops logged: $nwrites" +echo " fsync ops logged: $nfsync" +if [ "$nwrites" -gt 0 ]; then echo " PASS: writes observable"; pass=$((pass+1)); else echo " FAIL: no writes logged"; fail=$((fail+1)); fi +for want in Append Overwrite Extend; do + if grep -q "op=$want" "$LOG"; then echo " PASS: op=$want observed"; pass=$((pass+1)); + else echo " FAIL: op=$want not observed"; fail=$((fail+1)); fi +done +if [ "$nfsync" -gt 0 ]; then echo " PASS: fsync observed"; pass=$((pass+1)); else echo " FAIL: no fsync logged"; fail=$((fail+1)); fi + +echo "==> old-segment retention (no in-place rewrite, no GC yet)" +raw_final=$(stat -c%s "$BACKEND/data.raw") +echo " data.raw final size = $raw_final bytes" +echo " (raw >> sum of current logical file sizes => superseded bytes retained on disk)" + +cleanup +echo "" +echo "RESULT: pass=$pass fail=$fail" +[ "$fail" -eq 0 ]