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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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] <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) ── 段链表(当前视图)+ 目录树 + 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>+<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 等)保持 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/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 预览
|
||||
src/fuse_ops.h/.cpp FUSE3 回调:日志 + 直接 syscall 透传 + ops 表
|
||||
src/main.cpp 参数解析(fuse_opt)、绝对路径处理、挂载入口
|
||||
tests/phase2.sh 五种场景 + SQLite 验收脚本
|
||||
Makefile pkg-config fuse3
|
||||
```
|
||||
|
||||
+170
-100
@@ -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);
|
||||
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);
|
||||
}
|
||||
|
||||
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));
|
||||
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();
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
for (const std::string& c : m->children) filler(buf, c.c_str(), nullptr, 0, kFlags);
|
||||
n = m->children.size();
|
||||
rc = 0;
|
||||
}
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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 ]
|
||||
Reference in New Issue
Block a user