phase2: COW append-only backend (segment diff + in-memory VFS)

- store: data.raw append-only / read-by-offset / fsync
- meta: Segment/VersionDiff/FileView/FileMeta + segment-list update (append/overwrite/truncate/extend) + in-memory directory tree
- fuse_ops: all callbacks route through COW backend; per-write Segment logging (op/phys_off/log_off/size)
- tests/phase2.sh: 5 write scenarios + sqlite + dir ops, 24/24 pass

Metadata in-memory (restart-lost; persistence phase 5); data.raw retains superseded bytes pending GC. cur cache / aggregation / multi-block land in phase 3.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jabberwocky238
2026-07-07 00:52:33 +08:00
parent 3b72884d9f
commit d7e4fc73b1
10 changed files with 976 additions and 186 deletions
+1 -1
View File
@@ -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)
+49 -63
View File
@@ -1,13 +1,18 @@
# etvd — 第阶段:FUSE 拦截验证
# etvd — 第阶段:内存段 DiffCOW 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] <op> <path> <detail>`),`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 <path> op=<Append|Overwrite|Truncate|Extend> phys=<off>+<len> off=.. size=.. data=<hex>`,满足「从底层观察每一次写入产生的 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) ── 段链表(当前视图)+ 目录树 + historyshared_mutex 保护
物理存储层 (src/store.cpp) ── data.rawAppend-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>+<len>` |
| 随机覆盖写 | `dd seek=` / SQLite UPDATE 改页 | `write op=Overwrite phys=<新off>+<len>`(旧字节仍在 `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 等)保持 NULLFUSE 返回 `-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` 返回 0SQLite fcntl 建议锁单进程正常运行。真实互斥留第十一阶段。
- **default_permissions**getattr 返回内存元数据的 mode/uid/gid,内核据此校验;以当前用户身份运行,文件 0644 / 目录 0755。
- **重启语义**`data.raw` 的 append 游标从文件大小恢复(继续追加、不覆写旧字节),但内存元数据丢失,旧字节成孤儿待 GC。
- **未实现 op**readlink / symlink / fallocate / xattr)保持 NULLFUSE 返回 `-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
```
+174 -104
View File
@@ -3,31 +3,36 @@
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <shared_mutex>
#include <string>
#include <utility>
#include <vector>
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#include <sys/types.h>
#include <unistd.h>
#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<std::shared_mutex> 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<long long>(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<std::shared_mutex> 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<std::shared_mutex> 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<std::shared_mutex> 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<std::shared_mutex> 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<std::shared_mutex> 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<std::shared_mutex> 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<std::shared_mutex> 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<std::shared_mutex> lk(g_meta->Lock());
rc = g_meta->Truncate(path, size);
}
LogOp("truncate", path,
"size=" + std::to_string(static_cast<long long>(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<uint64_t>(fd);
std::string sp = path;
int rc = 0;
{
std::shared_lock<std::shared_mutex> lk(g_meta->Lock());
if (!g_meta->Find(sp)) rc = -ENOENT;
}
if (rc == 0 && (fi->flags & O_TRUNC)) {
std::unique_lock<std::shared_mutex> lk(g_meta->Lock());
rc = g_meta->Truncate(sp, 0);
}
if (rc == 0) {
fi->fh = reinterpret_cast<uint64_t>(new OpenHandle{sp});
}
LogOp("open", path,
"flags=" + Hex(static_cast<unsigned long long>(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<uint64_t>(fd);
struct fuse_context* ctx = fuse_get_context();
std::string sp = path;
int rc;
{
std::unique_lock<std::shared_mutex> lk(g_meta->Lock());
rc = g_meta->Mknod(sp, mode, ctx->uid, ctx->gid);
}
if (rc == 0) {
fi->fh = reinterpret_cast<uint64_t>(new OpenHandle{sp});
}
char m[32];
std::snprintf(m, sizeof(m), "mode=0%o", static_cast<unsigned>(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<int>(fi->fh), buf, size, off);
if (rc < 0) rc = Err();
LogOp("read", path, OffSize(off, size) + " " + Rc(static_cast<long long>(rc)));
return static_cast<int>(rc);
auto* h = reinterpret_cast<OpenHandle*>(fi->fh);
std::string hp = h ? h->path : std::string(path);
size_t nread = 0;
int rc;
{
std::shared_lock<std::shared_mutex> 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<long long>(nread)));
return static_cast<int>(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<int>(fi->fh), buf, size, off);
if (rc < 0) rc = Err();
size_t shown = rc > 0 ? static_cast<size_t>(rc) : 0;
LogOp("write", path,
OffSize(off, size) + " " + Rc(static_cast<long long>(rc)) +
" data=" + HexPreview(buf, shown));
return static_cast<int>(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<OpenHandle*>(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<std::shared_mutex> 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<unsigned long long>(size)) + " " +
OffSize(off, size) + " " + Rc(static_cast<long long>(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<int>(fi->fh)) == 0 ? 0 : Err();
LogOp("release", path, Rc(rc));
return rc;
auto* h = reinterpret_cast<OpenHandle*>(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<int>(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<std::string> 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<std::shared_mutex> 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<enum fuse_fill_dir_flags>(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<enum fuse_fill_dir_flags>(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<std::shared_mutex> 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;
+9 -7
View File
@@ -5,17 +5,19 @@
#endif
#include <fuse3/fuse.h>
#include <string>
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
+28 -11
View File
@@ -6,9 +6,12 @@
#include <stddef.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include "fuse_ops.h" // brings in <fuse3/fuse.h> 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 <mountpoint> [fuse-options...] [-o backend=DIR]\n"
"\n"
"Mounts <mountpoint> and forwards every file operation to a backing\n"
"directory (passthrough), logging each call to stderr.\n"
"Mounts <mountpoint> 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);
+341
View File
@@ -0,0 +1,341 @@
#include "meta.h"
#include <algorithm>
#include <cstring>
#include <errno.h>
#include <fcntl.h>
#include <functional>
#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<ino_t>(std::hash<std::string>{}(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<off_t>(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<std::string> 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<uid_t>(-1)) m->uid = uid;
if (gid != static_cast<gid_t>(-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<uint64_t>(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<Segment> 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<int>(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<uint64_t>(size);
if (sz < m->view.total_size) {
std::vector<Segment> 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<uint64_t>(off);
if (lo < m->view.total_size) {
nread = std::min(size, static_cast<size_t>(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<size_t>(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
+119
View File
@@ -0,0 +1,119 @@
#pragma once
#include <atomic>
#include <cstdint>
#include <shared_mutex>
#include <string>
#include <sys/stat.h>
#include <sys/types.h>
#include <time.h>
#include <unordered_map>
#include <vector>
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<Segment> 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<Segment> 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<VersionDiff> history; // File (observation + future MVCC)
std::vector<std::string> 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<std::string, FileMeta> files_;
std::atomic<uint64_t> next_version_{1};
uid_t default_uid_;
gid_t default_gid_;
};
} // namespace etvd
+85
View File
@@ -0,0 +1,85 @@
#include "store.h"
#include <errno.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
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<uint64_t>(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<std::mutex> 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_t>(off + done));
if (n < 0) {
if (errno == EINTR) continue;
return UINT64_MAX;
}
done += static_cast<size_t>(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_t>(off + done));
if (n < 0) {
if (errno == EINTR) continue;
break;
}
if (n == 0) break; // EOF
done += static_cast<size_t>(n);
}
return done;
}
uint64_t Store::Size() {
std::lock_guard<std::mutex> lk(mu_);
return append_off_;
}
int Store::Sync() {
if (fd_ < 0) return -1;
return ::fsync(fd_);
}
} // namespace etvd
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <mutex>
#include <string>
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
+118
View File
@@ -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 ]