Convert all ASCII art diagrams to Mermaid

Replace 20 ASCII box-drawing diagrams with Mermaid equivalents:
- System architecture → flowchart with subgraphs
- Multi-protocol cache → flowchart LR
- Write-back decision matrix → flowchart with branches
- SD card import decision tree → flowchart
- Read cache validation → markdown table (cleaner than ASCII grid)
- 5 scenario walkthroughs → flowcharts with timeline context
- 4-table ER diagram → erDiagram
- Deletion detection flow → flowchart
- Write-back dual-pipeline → flowchart with subgraphs
- Import state machine → stateDiagram-v2
- Tiered polling strategy → flowchart
- NAS agent push → flowchart LR
- Read/write flows → flowcharts
- Cache eviction → flowchart
- Headscale infrastructure → flowchart BT
- Cloud backup → flowchart with subgraph
- TM write-back strategy → flowchart LR

Kept directory tree structure as plain text (standard convention).
Cache protection measures converted to structured markdown list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
grabbit 2026-02-16 21:28:03 +08:00
parent ddcfb87b36
commit aaf947859f

View File

@ -42,28 +42,36 @@
### 3.1 整体架构
```
局域网 (高速) 公网 / Tailscale (低速)
┌──────────────────────────────┐ ┌──────────────────────┐
│ │ │ │
┌──────────┐ SMB │ ┌─────────────┐ │ SFTP │ ┌────────────────┐ │
│ Lightroom│──────┼─▶│ Samba Server│ │ │ │ │ │
│ macOS │ │ └──────┬──────┘ │ │ │ 任意品牌 NAS │ │
└──────────┘ │ │ │ │ │ (SFTP) │ │
│ ▼ │ │ │ │ │
┌──────────┐ NFS │ ┌─────────────────────────┐ │ ┌────────┐│ └────────────────┘ │
│ Linux │──────┼─▶│ │─┼▶│Tailscal││ │
│ 客户端 │ │ │ rclone VFS mount │ │ │/WireGrd││ │
└──────────┘ │ │ + Write-back Controller│ │ └────────┘│ │
│ │ │ │ │ │
┌──────────┐WebDAV│ └────────────┬────────────┘ │ │ │
│ iPad │──────┼─▶ │ │ │ │
│ 移动端 │ │ ┌────────────▼────────────┐ │ │ │
└──────────┘ │ │ SSD 缓存 + 元数据 DB │ │ │ │
│ │ (btrfs/ZFS) │ │ │ │
│ └─────────────────────────┘ │ │ │
│ Linux Proxy │ │ │
└──────────────────────────────┘ └──────────────────────┘
```mermaid
graph LR
subgraph clients ["客户端设备"]
LR["Lightroom<br/>macOS"]
Linux["Linux<br/>客户端"]
iPad["iPad<br/>移动端"]
end
subgraph proxy ["Linux Proxy局域网·高速"]
Samba["Samba Server"]
NFS_S["NFS Server"]
WebDAV_S["WebDAV Server"]
VFS["rclone VFS mount<br/>+ Write-back Controller"]
SSD["SSD 缓存 + 元数据 DB<br/>(btrfs/ZFS)"]
Samba --> VFS
NFS_S --> VFS
WebDAV_S --> VFS
VFS --> SSD
end
subgraph remote ["远程(公网 / Tailscale·低速"]
TS["Tailscale /<br/>WireGuard"]
NAS["任意品牌 NAS<br/>(SFTP)"]
TS --- NAS
end
LR -- "SMB" --> Samba
Linux -- "NFS" --> NFS_S
iPad -- "WebDAV" --> WebDAV_S
VFS -- "SFTP" --> TS
```
### 3.2 协议选择说明
@ -83,10 +91,12 @@
**设计决策**:所有对外协议服务共享同一个 rclone FUSE 挂载点。缓存层只有一份,不会因为多协议而重复缓存。
```
SMB Server ───┐
NFS Server ───┼──▶ /mnt/nas-photos (rclone FUSE mount) ──▶ SSD 缓存
WebDAV ───┘ 唯一缓存层
```mermaid
graph LR
SMB["SMB Server"] --> FUSE["/mnt/nas-photos<br/>(rclone FUSE mount)"]
NFS["NFS Server"] --> FUSE
WebDAV --> FUSE
FUSE --> SSD["SSD 缓存<br/>唯一缓存层"]
```
**注意事项**SMB 和 NFS 的文件锁机制不同,同一文件不建议多协议同时写入。产品层面通过文档告知用户"多协议是为不同设备类型服务,非同时并发写同一文件"。
@ -190,13 +200,16 @@ Time Machine 使用 sparsebundle 格式(一个目录包含数千个 8MB band
- TM 备份持续 10-30 分钟,持续写入不同 band 文件,通用计时器会不断重置
- 每次 TM 备份只修改部分 band 文件,不需要传输整个 sparsebundle
```mermaid
flowchart LR
TM["macOS Time Machine<br/>写入 sparsebundle"] --> Monitor["① 会话结束检测<br/>连续 5min 无新写入"]
Monitor --> Sync["② band 文件级增量同步<br/>rsync/rclone 只传 mtime 变化的 band"]
Sync --> NAS["③ SFTP 直传到<br/>TIMEMACHINE_PATH<br/>(NAS 独立目录)"]
style NAS fill:#e8f5e9
```
TM 回写策略:
① TM 备份会话结束检测:监控 sparsebundle 目录,连续 5 分钟无新写入 → 视为会话结束
② band 文件级增量同步rsync/rclone sync 只传输 mtime 变化的 band 文件到 NAS
③ 目标路径TIMEMACHINE_PATHNAS 上独立目录,如 /volume1/timemachine/
④ 不走通用 Write-back Controller 的三时间戳冲突检测TM 数据只有一个写入源)
```
> **注**:④ TM 回写不走通用 Write-back Controller 的三时间戳冲突检测TM 数据只有一个写入源)。
**TIMEMACHINE_PATH 与 NAS_REMOTE_PATH 的关系**:两者是 NAS 上的不同目录互不关联。NAS_REMOTE_PATH 是用户的照片/文件目录(如 `/volume1/photos`TIMEMACHINE_PATH 是 TM 备份专用目录(如 `/volume1/timemachine`。TM 回写通过独立的 SFTP 连接直接传输到 TIMEMACHINE_PATH不经过 rclone VFS。
@ -396,61 +409,43 @@ remote_mtime ── 回写/读取时实时查询远程的当前 mtime
当 Write-back Controller 准备回写一个脏文件时,先通过 SFTP stat 查询远程 mtime然后按以下矩阵决策
```
远程没变 远程也改了 远程被删了
(remote == origin) (remote != origin) (文件不存在)
┌────────────────────┬───────────────────────┬──────────────────┐
│ │ │ │
│ ✅ 正常回写 │ ⚠️ 比较 mtime │ ⚠️ 不回写 │
│ │ │ │
本地改过 │ 远程自上次同步后 │ cache > remote: │ 尊重远程删除 │
(dirty) │ 没人动过 │ → 本地更新,回写 │ 本地脏文件移到 │
│ 安全回写 │ remote > cache: │ conflict 目录 │
│ 更新 origin_mtime │ → 远程更新,远程胜 │ 通知用户 │
│ │ → 拉远程新版本 │ │
│ │ → 本地存冲突副本 │ │
│ │ 相等: │ │
│ │ → 内容可能不同 │ │
│ │ → 本地胜(写入者优先)│ │
└────────────────────┴───────────────────────┴──────────────────┘
```mermaid
flowchart TD
Start["Write-back Controller<br/>准备回写脏文件"] --> Check["SFTP stat 查远程 mtime"]
Check --> NoChange{"remote == origin?<br/>远程没变"}
Check --> Changed{"remote != origin?<br/>远程也改了"}
Check --> Deleted{"远程文件不存在?<br/>远程被删了"}
NoChange -->|"✅ 安全回写"| WB["回写到远程<br/>更新 origin_mtime"]
Changed --> CmpMtime{"比较 mtime"}
CmpMtime -->|"cache > remote<br/>本地更新"| WB2["本地胜,回写"]
CmpMtime -->|"remote > cache<br/>远程更新"| Conflict1["远程胜<br/>拉远程新版本<br/>本地存冲突副本"]
CmpMtime -->|"mtime 相等<br/>内容可能不同"| LocalWins["本地胜<br/>(写入者优先)"]
Deleted -->|"⚠️ 不回写"| Respect["尊重远程删除<br/>脏文件移到 conflict/<br/>通知用户"]
```
### 5.4b SD 卡导入文件的回写决策(补充)
从 SD 卡导入的文件,本质上等同于「本地新创建的脏文件」。在 metadata.db 的 cache_files 表中,`origin_mtime = NULL` 表示此文件从未存在于远程 NAS。
```
origin_mtime = NULLSD 卡导入的新文件)
├── SFTP stat 查远程:文件不存在
│ → 直接上传(新建),更新 origin_mtimestate → clean
├── SFTP stat 查远程:文件已存在(说明之前某次导入已成功上传,或远程碰巧有同名文件)
│ → 比较 cache_mtime vs remote_mtime
│ → 按 5.4 正常冲突逻辑处理
└── 上传失败
→ 重试(指数退避),保持 dirty 状态
```mermaid
flowchart TD
Start["origin_mtime = NULL<br/>SD 卡导入的新文件"] --> Stat["SFTP stat 查远程"]
Stat -->|"文件不存在"| Upload["直接上传(新建)<br/>更新 origin_mtime<br/>state → clean"]
Stat -->|"文件已存在<br/>(之前导入已上传 / 远程碰巧同名)"| Compare["比较 cache_mtime vs remote_mtime<br/>按 5.4 正常冲突逻辑处理"]
Stat -->|"上传失败"| Retry["重试(指数退避)<br/>保持 dirty 状态"]
```
### 5.5 读取时的缓存验证
当应用读取一个已缓存的文件时:
```
远程没变 远程也改了 远程被删了
(remote == origin) (remote != origin) (文件不存在)
┌────────────────────┬───────────────────────┬──────────────────┐
干净缓存 │ │ │ │
(clean) │ ✅ 直接用缓存 │ 🔄 拉新版本 │ 🗑️ 删除本地缓存 │
│ │ 更新 origin_mtime │ 返回文件不存在 │
├────────────────────┼───────────────────────┼──────────────────┤
本地改过 │ │ │ │
(dirty) │ ✅ 用本地版本 │ ⚠️ 标记冲突 │ ⚠️ 用本地版本 │
│ 等待回写 │ 两边版本都保留 │ 但标记为冲突 │
│ │ 通知用户 │ 通知用户 │
└────────────────────┴───────────────────────┴──────────────────┘
```
| | 远程没变 (remote == origin) | 远程也改了 (remote != origin) | 远程被删了 (文件不存在) |
|---|---|---|---|
| **干净缓存 (clean)** | ✅ 直接用缓存 | 🔄 拉新版本,更新 origin_mtime | 🗑️ 删除本地缓存,返回文件不存在 |
| **本地改过 (dirty)** | ✅ 用本地版本,等待回写 | ⚠️ 标记冲突,两边版本都保留,通知用户 | ⚠️ 用本地版本,但标记为冲突,通知用户 |
**注意**:读取时不是每次都查远程 mtime那样太慢。远程 mtime 信息由后台轮询线程定期更新(见第六章)。热路径上读取命中缓存直接返回,只有当轮询发现 mtime 变化时才触发重新验证。
@ -458,133 +453,69 @@ origin_mtime = NULLSD 卡导入的新文件)
#### 场景 1Cache 关机几天NAS 上文件被修改
```
Day 1: Cache 缓存了 photo.cr3 (origin_mtime = Day1, 状态 clean)
Day 1: Cache 关机
Day 3: 有人在 NAS 上修改了 photo.cr3 (NAS mtime → Day3)
Day 5: Cache 开机
rclone 启动,后台轮询线程开始工作
轮询发现 photo.cr3 的 remote_mtime(Day3) != origin_mtime(Day1)
文件是 clean 的(本地没改过)
标记本地缓存失效
下次应用访问时拉新版本,更新 origin_mtime = Day3
✅ 结果:用户读到 NAS 上的最新版本,符合预期
```mermaid
flowchart TD
D1["Day 1: Cache 缓存 photo.cr3<br/>(origin_mtime=Day1, clean)"] --> Off["Day 1: Cache 关机"]
Off --> D3["Day 3: NAS 上 photo.cr3 被修改<br/>(NAS mtime → Day3)"]
D3 --> D5["Day 5: Cache 开机<br/>rclone 启动,轮询线程开始"]
D5 --> Detect["轮询发现 remote_mtime(Day3)<br/>!= origin_mtime(Day1)"]
Detect --> Clean["文件是 clean本地没改过"]
Clean --> Invalidate["标记缓存失效<br/>下次访问时拉新版本"]
Invalidate --> Result["✅ 用户读到 NAS 最新版本"]
```
#### 场景 2Cache 关机几天本地有脏数据NAS 也被更新
```
Day 1: 用户在 Cache 上修改了 photo.cr3
(origin_mtime = Day0, cache_mtime = Day1, 状态 dirty)
Day 1: 回写还没执行Cache 关机(电池保护数据落盘)
Day 3: 家人在 NAS 上也修改了 photo.cr3 (NAS mtime → Day3)
Day 5: Cache 开机rclone 恢复,发现本地有脏文件
Write-back Controller 准备回写 photo.cr3
回写前先 SFTP stat 查远程remote_mtime = Day3
remote_mtime(Day3) != origin_mtime(Day0) → 远程被改过!
比较 cache_mtime(Day1) vs remote_mtime(Day3)
Day3 > Day1 → 远程版本更新 → 远程胜
├── 本地脏版本 → 移到 /conflict/photo.cr3.local-20260216-Day1
├── 拉取远程新版本覆盖本地缓存
├── 更新 origin_mtime = Day3, 状态 → clean
└── 通知用户:"photo.cr3 存在冲突,远程版本更新已采用,本地修改已保存到冲突目录"
✅ 结果:不会丢数据,两个版本都保留,用户自行决定
```mermaid
flowchart TD
D1["Day 1: 用户修改 photo.cr3<br/>(origin=Day0, cache=Day1, dirty)"]
D1 --> Off["Day 1: 回写未执行Cache 关机<br/>(电池保护数据落盘)"]
Off --> D3["Day 3: 家人在 NAS 修改 photo.cr3<br/>(NAS mtime → Day3)"]
D3 --> D5["Day 5: Cache 开机<br/>发现本地有脏文件"]
D5 --> WB["Write-back Controller 准备回写"]
WB --> Stat["SFTP stat 查远程:<br/>remote_mtime = Day3"]
Stat --> Conflict["remote(Day3) != origin(Day0)<br/>→ 远程被改过!"]
Conflict --> Compare["cache(Day1) vs remote(Day3)<br/>Day3 > Day1 → 远程胜"]
Compare --> Actions["本地脏版本 → conflict/<br/>拉远程新版本覆盖缓存<br/>更新 origin=Day3, clean<br/>通知用户"]
Actions --> Result["✅ 不丢数据,两版本都保留"]
```
#### 场景 3NAS 删了文件Cache 上有脏数据
```
Day 1: 用户在 Cache 上修改了 photo.cr3 (dirty)
Day 1: Cache 关机
Day 3: 有人在 NAS 上删了 photo.cr3
Day 5: Cache 开机,准备回写脏文件
Write-back Controller 查远程photo.cr3 不存在
远程文件被删 + 本地有未回写修改 → 冲突
├── ❌ 不回写(尊重远程删除决定)
├── 本地脏版本 → 移到 /conflict/photo.cr3.local-20260216
└── 通知用户:"photo.cr3 已在远程被删除,本地修改已保存到冲突目录"
✅ 结果:不会把已删文件复活回 NAS用户可从冲突目录恢复
```mermaid
flowchart TD
D1["Day 1: 用户修改 photo.cr3 (dirty)"] --> Off["Day 1: Cache 关机"]
Off --> D3["Day 3: NAS 上 photo.cr3 被删除"]
D3 --> D5["Day 5: Cache 开机,准备回写"]
D5 --> Stat["Write-back Controller 查远程:<br/>photo.cr3 不存在"]
Stat --> Conflict["远程已删 + 本地有脏数据 → 冲突"]
Conflict --> Actions["❌ 不回写(尊重远程删除)<br/>脏版本 → conflict/<br/>通知用户"]
Actions --> Result["✅ 不会复活已删文件<br/>用户可从 conflict/ 恢复"]
```
#### 场景 4NAS 删了文件Cache 上是 clean 的
```
Day 1: Cache 缓存了 photo.cr3 (clean)
Day 3: NAS 上删了 photo.cr3
Day 3+: 后台轮询检测到远程目录变化
photo.cr3 远程不存在 + 本地是 clean → 无争议
直接删除本地缓存
✅ 结果:本地缓存与远程保持一致
```mermaid
flowchart TD
D1["Day 1: Cache 缓存 photo.cr3 (clean)"]
D1 --> D3["Day 3: NAS 上删除 photo.cr3"]
D3 --> Poll["轮询检测到远程目录变化"]
Poll --> Check["photo.cr3 远程不存在<br/>+ 本地是 clean → 无争议"]
Check --> Del["直接删除本地缓存"]
Del --> Result["✅ 本地缓存与远程保持一致"]
```
#### 场景 5Cache 在外编辑文件网络正常NAS 无变化(最常见 happy path
```
用户在酒店打开 Lightroom 修图
首次打开 photo.cr3 → 缓存未命中 → 从远程下载 → 缓存到 SSD
(origin_mtime = T0, cache_mtime = T0, 状态 clean)
用户编辑并保存
(cache_mtime = T1, 状态 dirty)
60 秒后 Write-back Controller 触发
查远程 mtimeremote_mtime == origin_mtime(T0) → 远程没变
正常回写,更新 origin_mtime = T1状态 → clean
✅ 结果:编辑秒存本地,后台静默同步,用户无感
```mermaid
flowchart TD
Open["用户在酒店打开 Lightroom 修图"]
Open --> Miss["首次打开 photo.cr3<br/>缓存未命中 → 远程下载 → 缓存到 SSD<br/>(origin=T0, cache=T0, clean)"]
Miss --> Edit["用户编辑并保存<br/>(cache=T1, dirty)"]
Edit --> Timer["60s 后 Write-back Controller 触发"]
Timer --> Check["查远程: remote == origin(T0)<br/>→ 远程没变"]
Check --> WB["正常回写<br/>更新 origin=T1, clean"]
WB --> Result["✅ 编辑秒存本地<br/>后台静默同步,用户无感"]
```
### 5.7 元数据持久化metadata DB
@ -773,34 +704,47 @@ CREATE INDEX idx_original_path ON import_history(original_path, file_size);
#### 5.7.4 四张表的关系与数据规模
```
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ dir_snapshots │ │ dir_file_list │ │ cache_files │
│ (目录级) │ │ (v1.5+, 可选) │ │ (文件级) │
│ │ │ │ │ │
│ /2026/02/ │────▶│ /2026/02/ │ │ │
│ remote_mtime │ │ IMG_0001.cr3 │────▶│ /2026/02/ │
│ last_polled │ │ IMG_0002.cr3 │ │ IMG_0001.cr3 │
│ last_accessed │ │ IMG_0003.cr3 │ │ origin_mtime │
│ │ │ ... │ │ cache_mtime │
│ │ │ (全部远程文件) │ │ state=dirty │
└──────────────────┘ └──────────────────┘ └──────────────────┘
每个关心的目录 该目录下远程全部文件 只有被缓存过的文件
1 行记录 可能 200 行 可能 3 行
```mermaid
erDiagram
dir_snapshots ||--o{ dir_file_list : "1:N (v1.5+)"
dir_file_list ||--o| cache_files : "仅缓存过的文件"
dir_snapshots ||--o{ cache_files : "1:N"
┌──────────────────┐
│ import_history │ ← 独立于缓存生命周期,永久保留
│ (导入记录) │
│ │
│ IMG_0001.cr3 │
│ checksum=abc.. │
│ imported_at │
│ writeback_at │
└──────────────────┘
每次 SD 卡导入的文件
持续累积
dir_snapshots {
TEXT dir_path PK "如 /2026/02/"
INTEGER remote_mtime
INTEGER last_polled
INTEGER last_accessed
INTEGER cached_count
}
dir_file_list {
TEXT dir_path FK "v1.5+ 可选"
TEXT file_name "该目录下全部远程文件"
INTEGER remote_mtime
INTEGER remote_size
}
cache_files {
TEXT path PK "只有缓存过的文件"
TEXT dir_path FK
INTEGER origin_mtime "NULL=SD导入"
INTEGER cache_mtime
TEXT state "clean/dirty/conflict"
TEXT source "remote/ingest"
}
import_history {
INTEGER id PK "独立于缓存生命周期"
TEXT original_path
TEXT target_path
TEXT checksum "持续累积,永久保留"
TEXT state "imported/writeback_done/failed"
}
```
数据规模参考dir_snapshots 每个关心的目录 1 行dir_file_list 可能 200 行/目录cache_files 仅缓存过的文件(可能 3 行/目录import_history 持续累积。
数据规模估算(以 NAS 上 10 万个文件、用户缓存了 1000 个为例):
| 表 | 记录范围 | 预估行数 | 存储开销 |
@ -815,24 +759,18 @@ CREATE INDEX idx_original_path ON import_history(original_path, file_size);
MVP 阶段不使用 dir_file_list通过反查 cache_files 实现删除检测:
```
轮询发现 /2026/02/ 目录 mtime 变了
sftp ls /2026/02/ → 拿到当前远程文件列表 remote_set
SELECT path FROM cache_files WHERE dir_path = '/2026/02/'
→ 拿到该目录下所有缓存文件 cached_set
对比:
在 cached_set 但不在 remote_set → 远程被删了 → 按决策矩阵处理
在 cached_set 且在 remote_set → 比较 mtime变了则标记失效
在 remote_set 但不在 cached_set → 远程新增/未缓存文件 → 不处理(等访问时再拉)
更新 dir_snapshots 的 remote_mtime 和 last_polled
```mermaid
flowchart TD
Trigger["轮询发现 /2026/02/ 目录 mtime 变了"]
Trigger --> LS["sftp ls /2026/02/<br/>→ 拿到 remote_set"]
LS --> Query["SELECT path FROM cache_files<br/>WHERE dir_path = '/2026/02/'<br/>→ 拿到 cached_set"]
Query --> Compare{"对比两个集合"}
Compare -->|"在 cached 不在 remote"| Deleted["远程被删了<br/>→ 按决策矩阵处理"]
Compare -->|"在 cached 且在 remote"| MtimeCheck["比较 mtime<br/>变了则标记失效"]
Compare -->|"在 remote 不在 cached"| Ignore["远程新增/未缓存<br/>→ 不处理(等访问时拉)"]
Deleted --> Update["更新 dir_snapshots<br/>remote_mtime + last_polled"]
MtimeCheck --> Update
Ignore --> Update
```
这样只用两张表就完成了所有检测,逻辑清晰,开销极小。
@ -859,28 +797,29 @@ WAL 模式的优势:
rclone 原生的 `--vfs-cache-mode full` 不做回写前的 mtime 比较(盲写),因此需要在 rclone 之上包一层 Write-back Controller
```
原来rclone 默认):
rclone VFS → 脏文件 → 直接 SFTP 上传 → 可能覆盖新数据 ❌
**原来rclone 默认)**rclone VFS → 脏文件 → 直接 SFTP 上传 → 可能覆盖新数据 ❌
改为(双管道架构)
**改为(双管道架构)**
管道 A — 远程拉取后的本地编辑:
rclone VFS → 脏文件 → Write-back Controller
├── SFTP stat 查远程 mtime
├── 三时间戳比较
├── 决策:回写 / 冲突保留 / 跳过
└── 回写到原始远程路径 + 更新 metadata.db
```mermaid
flowchart TD
subgraph pipeA ["管道 A — 远程拉取后的本地编辑"]
A1["rclone VFS 脏文件<br/>(source=remote)"] --> WBC_A["Write-back Controller"]
WBC_A --> A2["SFTP stat 查远程 mtime"]
A2 --> A3["三时间戳比较"]
A3 --> A4["决策: 回写 / 冲突保留 / 跳过"]
A4 --> A5["回写到原始远程路径<br/>+ 更新 metadata.db"]
end
管道 B — SD 卡导入的新文件:
SD 卡导入(通过 FUSE 挂载点写入)→ rclone 自动标记为脏文件 → Write-back Controller
├── 检测 source=ingest, origin_mtime=NULL
├── 按 INGEST_TARGET_PATH 模板计算 NAS 目标路径
├── 在 NAS 上创建目标目录(如不存在)
├── SFTP stat 查远程:按 5.4b 决策矩阵处理
└── 上传到目标路径 + 更新 metadata.db + 记录 import_history
subgraph pipeB ["管道 B — SD 卡导入的新文件"]
B1["SD 卡导入<br/>(通过 FUSE 挂载点写入)"] --> B2["rclone 自动标记为脏文件<br/>(source=ingest)"]
B2 --> WBC_B["Write-back Controller"]
WBC_B --> B3["检测 origin_mtime=NULL"]
B3 --> B4["按 INGEST_TARGET_PATH<br/>计算 NAS 目标路径"]
B4 --> B5["创建目标目录(如不存在)"]
B5 --> B6["SFTP stat → 按 5.4b 决策"]
B6 --> B7["上传到目标路径<br/>+ 更新 metadata.db<br/>+ 记录 import_history"]
end
```
Write-back Controller 作为独立进程运行,监控 rclone 缓存目录中的脏文件,替代 rclone 的原生回写逻辑。Controller 通过 metadata.db 中的 `source` 字段区分两条管道,对 `source=ingest` 的文件执行路径重映射和目录创建逻辑。
@ -916,31 +855,23 @@ Write-back Controller 作为独立进程运行,监控 rclone 缓存目录中
**解决方案**:导入过程维护严格的状态机,只有完整校验通过的文件才进入缓存。
```
导入状态机:
```mermaid
stateDiagram-v2
[*] --> detecting: SD 卡插入
detecting --> copying: 列出文件清单
copying --> checksumming: 复制到 ingest_staging/
checksumming --> registered: SHA-256 校验通过
registered --> complete: 移到 FUSE 挂载点 +<br/>写入 metadata.db + import_history
complete --> [*]: Write-back Controller 接管
detecting SD 卡插入检测,列出文件清单
copying 文件从 SD 卡复制到 ingest_staging/ 暂存目录
│ (注意:此时文件还没有进入 rclone VFS 或 cache_files
checksumming 对暂存文件计算 SHA-256可选与 SD 卡原文件二次比对
registered 校验通过,将文件从暂存目录移到 FUSE 挂载点
│ 同步在 metadata.db 创建记录state=dirty, source=ingest
│ 同步写入 import_history
complete 该文件导入完成Write-back Controller 可接管
中断处理:
- detecting/copying 阶段中断 → ingest_staging/ 中的临时文件在下次启动时清理
- checksumming 阶段中断 → 同上checksum 不完整的文件清理
- registered 阶段中断 → 文件已在 FUSE 挂载点和 metadata.db 中,重启后 Write-back Controller 正常接管
- 每个导入会话有唯一 session_id中断后可报告哪些文件已完成、哪些需要重试
note right of detecting: 中断 → 清理 staging 临时文件
note right of copying: 中断 → 清理 staging 临时文件
note right of checksumming: 中断 → 清理不完整文件
note right of registered: 中断 → 文件已在 FUSE + DB<br/>重启后 Controller 正常接管
```
**中断保护**:每个导入会话有唯一 `session_id``detecting`/`copying`/`checksumming` 阶段中断时,`ingest_staging/` 中的临时文件在下次启动时自动清理。`registered` 阶段中断时文件已安全进入缓存系统Controller 重启后正常接管。
**实现要点**
- 暂存目录 `ingest_staging/<session-id>/` 按导入会话隔离
- 进程启动时扫描 `ingest_staging/`,清理所有未完成的会话(非 registered 状态的文件)
@ -978,32 +909,23 @@ Write-back Controller 作为独立进程运行,监控 rclone 缓存目录中
核心优化思路:**SFTP 目录本身也有 mtime**。当目录下有文件新增/修改/删除时,目录的 mtime 会更新。因此可以先查目录 mtime一个 stat 请求),没变就跳过整个目录下所有文件的检查,大幅减少远程请求量。
```
┌─────────────────────────────────────────────────────────────────┐
│ 分层轮询策略 │
│ │
│ 第一层:目录 mtime 快检 │
│ 对每个已缓存的目录SFTP stat 查其 mtime │
│ 目录 mtime 没变 → 该目录下所有文件一定没变 → 跳过 ✅ │
│ 目录 mtime 变了 → 进入第二层 │
│ 开销:每个目录 1 个 stat 请求,非常轻量 │
│ │
│ 第二层:文件级 mtime 对比 │
│ 仅对 mtime 有变化的目录执行 SFTP ls -l │
│ 逐个对比文件 mtime 与 metadata.db 中的 origin_mtime │
│ 发现变化 → 标记缓存失效(不主动下载,等访问时再拉) │
│ 发现删除 → 按决策矩阵处理 │
│ │
│ 第三层:按热度分级轮询间隔 │
│ 热目录(最近 7 天有访问) → 每 30 秒轮询 │
│ 温目录7-30 天内有访问) → 每 5 分钟轮询 │
│ 冷目录30 天+ 未访问) → 每 1 小时轮询 │
│ │
│ 第四层:每日全量校对(兜底) │
│ 每天凌晨一次全量递归对比 │
│ 捕捉所有分层轮询可能遗漏的变更 │
│ 同时清理已不存在的缓存条目 │
└─────────────────────────────────────────────────────────────────┘
```mermaid
flowchart TD
Start["轮询触发"] --> L3{"第三层:热度分级<br/>决定轮询间隔"}
L3 -->|"热目录7天内访问<br/>每 30s"| L1
L3 -->|"温目录7-30天<br/>每 5m"| L1
L3 -->|"冷目录30天+<br/>每 1h"| L1
L1["第一层:目录 mtime 快检<br/>SFTP stat 查目录 mtime<br/>(每目录 1 个请求)"]
L1 -->|"mtime 没变"| Skip["跳过该目录 ✅<br/>所有文件一定没变"]
L1 -->|"mtime 变了"| L2
L2["第二层:文件级 mtime 对比<br/>SFTP ls -l 该目录"]
L2 --> Changed["发现变化 → 标记缓存失效<br/>(等访问时再拉)"]
L2 --> Del["发现删除 → 按决策矩阵处理"]
L2 --> New["远程新增 → 不处理"]
L4["第四层:每日全量校对(兜底)<br/>凌晨全量递归对比<br/>捕捉遗漏 + 清理过期条目"]
```
轮询伪代码(与 5.7.5 删除检测对齐):
@ -1052,13 +974,17 @@ for dir in watched_directories:
对于需要秒级同步的用户,可选在 NAS 上部署轻量 AgentDocker 容器),通过 inotify 监听变化并推送:
```
群晖 NAS Linux Proxy
┌────────────────────┐ ┌────────────────┐
│ inotifywait 监听 │ Tailscale │ HTTP 接收端 │
│ 文件变化 │───── 通知 ────────▶│ 触发缓存刷新 │
│ (Docker 容器) │ (轻量 HTTP POST) │ │
└────────────────────┘ └────────────────┘
```mermaid
flowchart LR
subgraph NAS ["群晖 NAS"]
Agent["inotifywait 监听文件变化<br/>(Docker 容器)"]
end
subgraph Proxy ["Linux Proxy"]
HTTP["HTTP 接收端<br/>触发缓存刷新"]
end
Agent -- "轻量 HTTP POST<br/>(via Tailscale)" --> HTTP
```
此方案作为分层轮询的增强,不是替代。即使 Agent 不可用,轮询机制仍然工作。
@ -1069,26 +995,21 @@ for dir in watched_directories:
### 7.1 读取流程
```
应用请求读取文件
① 查本地缓存 ──── 命中 ──▶ ② 后台轮询是否已标记失效?
│ │ │
未命中 未失效 已失效
│ │ │
▼ ▼ ▼
③ 向远程请求 直接返回缓存 ④ 检查文件状态
SSD 速度) │
▼ ┌────┴────┐
④ 按 chunk 分块下载 clean dirty
│ │ │
▼ ▼ ▼
⑤ 写入本地 SSD 缓存 拉远程新版本 标记冲突
⑥ 记录 origin_mtime 更新缓存 两边保留
⑦ 返回数据给应用
```mermaid
flowchart TD
App["应用请求读取文件"] --> Check{"① 查本地缓存"}
Check -->|"未命中"| Remote["③ 向远程请求"]
Check -->|"命中"| Valid{"② 轮询是否标记失效?"}
Valid -->|"未失效"| Return["直接返回缓存<br/>SSD 速度)"]
Valid -->|"已失效"| State{"④ 检查文件状态"}
State -->|"clean"| Pull["拉远程新版本<br/>更新缓存"]
State -->|"dirty"| Conflict["标记冲突<br/>两边保留"]
Remote --> Chunk["④ 按 chunk 分块下载"]
Chunk --> Write["⑤ 写入本地 SSD 缓存<br/>⑥ 记录 origin_mtime"]
Write --> ReturnData["⑦ 返回数据给应用"]
```
**设计要点**
@ -1100,52 +1021,35 @@ for dir in watched_directories:
### 7.2 写入流程
```
应用写入文件
① 写入本地 SSD 缓存(立即返回成功给应用)
② 更新 metadata.dbcache_mtime = now, state = dirty
③ 启动写回计时器(默认 60 秒)
├── 计时期间又有写入 → 重置计时器(合并频繁写入)
④ 计时器到期Write-back Controller 接管
⑤ SFTP stat 查远程 mtime
⑥ 三时间戳比较,按决策矩阵执行
├── 正常回写 → 上传 + 更新 origin_mtime + state → clean
├── 冲突 → 保留双版本 + 通知用户
└── 远程已删 → 不回写 + 移到冲突目录
├── 上传失败 → 重试(指数退避: 10s, 20s, 40s... 最多 10 次)
└── 最终失败 → 保留在本地缓存state 保持 dirty记录日志
```mermaid
flowchart TD
App["应用写入文件"] --> SSD["① 写入本地 SSD 缓存<br/>(立即返回成功给应用)"]
SSD --> Meta["② 更新 metadata.db<br/>cache_mtime=now, state=dirty"]
Meta --> Timer["③ 启动写回计时器60s"]
Timer -->|"计时期间又有写入"| Timer
Timer -->|"计时器到期"| WBC["④ Write-back Controller 接管"]
WBC --> Stat["⑤ SFTP stat 查远程 mtime"]
Stat --> Decision{"⑥ 三时间戳比较"}
Decision -->|"正常回写"| Upload["上传 + 更新 origin_mtime<br/>state → clean"]
Decision -->|"冲突"| ConflictKeep["保留双版本 + 通知用户"]
Decision -->|"远程已删"| NoWrite["不回写 + 移到 conflict/"]
Upload -->|"上传失败"| Retry["重试(指数退避)<br/>10s, 20s, 40s... 最多 10 次"]
Retry -->|"最终失败"| Keep["保留本地state=dirty<br/>记录日志"]
```
### 7.3 缓存淘汰策略
```
淘汰触发条件(任一):
- 缓存总大小超过 CACHE_MAX_SIZE
- 缓存盘可用空间低于 CACHE_MIN_FREE
淘汰规则(优先级从高到低):
① dirty 文件永不淘汰(必须等回写完成)
② conflict 文件永不淘汰(必须等用户处理)
③ 超过 CACHE_MAX_AGE 且状态为 clean 的文件优先淘汰
④ 剩余 clean 文件按 LRUlast_accessed 最早的先淘汰)
⑤ 淘汰至空间满足条件为止
⑥ 如果淘汰全部 clean 文件后空间仍不足 → 进入「缓存空间告警」状态(详见下文)
```mermaid
flowchart TD
Trigger{"淘汰触发<br/>(总大小 > MAX_SIZE<br/>或可用 < MIN_FREE)"} --> D1{"① dirty 文件?"}
D1 -->|"永不淘汰"| D2{"② conflict 文件?"}
D2 -->|"永不淘汰"| D3["③ 淘汰超过 MAX_AGE<br/>的 clean 文件"]
D3 --> D4["④ 剩余 clean 按 LRU 淘汰<br/>(last_accessed 最早优先)"]
D4 --> D5{"⑤ 空间满足?"}
D5 -->|"是"| Done["淘汰完成"]
D5 -->|"否clean 已耗尽)"| Alert["⑥ 进入「缓存空间告警」<br/>详见 7.3.1"]
```
#### 7.3.1 缓存空间保护机制
@ -1154,26 +1058,10 @@ for dir in watched_directories:
**保护措施**
```
1. 导入前空间预检SD 卡导入):
可用空间 = CACHE_MAX_SIZE - 当前dirty文件总量 - 当前conflict文件总量 - CACHE_MIN_FREE
if SD卡总数据量 > 可用空间:
→ 拒绝导入通知用户LED 红灯 + CLI 提示剩余空间和 SD 卡大小)
→ 建议用户先连接网络完成回写,或清理部分缓存
2. Time Machine 硬配额:
TIMEMACHINE_MAX_SIZE 作为硬配额强制执行(由 smb.conf 的 fruit:time machine max size 实现)
Time Machine 写入超过配额时 macOS 会自动清理旧备份
3. 配置验证(部署时检查):
TIMEMACHINE_MAX_SIZE + 预期最大单次导入量(INGEST_MAX_IMPORT_SIZE) + CACHE_MIN_FREE < CACHE_MAX_SIZE
不满足时部署脚本报警告
4. 缓存空间告警状态:
当 dirty 文件 + conflict 文件总量 > CACHE_MAX_SIZE × 80%:
→ 触发告警CLI 提示 / 日志 / 可选 Webhook
→ 提示用户尽快连接网络完成回写
```
1. **导入前空间预检**SD 卡导入):`可用空间 = CACHE_MAX_SIZE - dirty总量 - conflict总量 - CACHE_MIN_FREE`。SD 卡总数据量 > 可用空间时拒绝导入通知用户LED 红灯 + CLI 提示),建议先连网回写或清理缓存
2. **Time Machine 硬配额**`TIMEMACHINE_MAX_SIZE` 由 smb.conf 的 `fruit:time machine max size` 强制执行,超过配额时 macOS 自动清理旧备份
3. **配置验证**(部署时):`TIMEMACHINE_MAX_SIZE + INGEST_MAX_IMPORT_SIZE + CACHE_MIN_FREE < CACHE_MAX_SIZE`,不满足时部署脚本报警告
4. **缓存空间告警**dirty + conflict 总量 > `CACHE_MAX_SIZE × 80%` 时触发告警CLI / 日志 / Webhook提示用户尽快连网回写
### 7.4 离线行为
@ -1455,21 +1343,19 @@ mount -o compress=zstd /dev/ssd_partition /mnt/ssd/warpgate
**方案**
```
┌─────────────────────────────────────────────────────────────┐
│ 运营基础设施 │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Headscale │ │ DERP 节点 │ │ DERP 节点 │ │
│ │ 控制面板 │ │ 国内 BGP │ │ 香港/日本 │ │
│ │ (用户管理) │ │ (低延迟) │ │ (跨境加速) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
▲ ▲ ▲
│ │ │
盒子开箱即连 NAS 端自动连接 出差设备自动连接
(内置配置) (安装脚本) (通过盒子中继)
```mermaid
flowchart BT
subgraph infra ["运营基础设施"]
HS["Headscale<br/>控制面板<br/>(用户管理)"]
DERP1["DERP 节点<br/>国内 BGP<br/>(低延迟)"]
DERP2["DERP 节点<br/>香港/日本<br/>(跨境加速)"]
end
Box["盒子<br/>开箱即连<br/>(内置配置)"] --> HS
Box --> DERP1
NAS_C["NAS 端<br/>自动连接<br/>(安装脚本)"] --> DERP1
NAS_C --> DERP2
Travel["出差设备<br/>自动连接<br/>(通过盒子中继)"] --> DERP2
```
**用户体验**
@ -1504,24 +1390,19 @@ mount -o compress=zstd /dev/ssd_partition /mnt/ssd/warpgate
**方案**
```
盒子 SSD 缓存(热数据子集)
▼ 空闲时段,加密增量备份
┌───▼──────────────────────────────────────────────┐
│ 云存储服务 │
│ │
│ 用户视角:盒子里一键开通,按月付费 │
│ 实际后端Backblaze B2 / Cloudflare R2 / MinIO │
│ (对象存储,成本约 $5/TB/月) │
│ │
│ 数据加密:用户本地生成密钥,运营方看不到明文 │
│ 密钥备份:首次设置时强制引导用户备份恢复密钥 │
│ (下载密钥文件 / 抄写助记词 / 导出到密码管理器)│
│ 增量同步:只传变化的部分,节省带宽 │
│ 恢复流程:新盒子 → 输入恢复密钥 → 自动拉取 │
└──────────────────────────────────────────────────┘
```mermaid
flowchart TD
SSD["盒子 SSD 缓存<br/>(热数据子集)"]
SSD -->|"空闲时段<br/>加密增量备份"| Cloud
subgraph Cloud ["云存储服务"]
UX["用户视角: 一键开通,按月付费"]
Backend["后端: B2 / R2 / MinIO<br/>(~$5/TB/月)"]
Encrypt["数据加密: 用户本地生成密钥<br/>运营方看不到明文"]
KeyBackup["密钥备份: 首次设置强制引导<br/>(密钥文件 / 助记词 / 密码管理器)"]
Sync["增量同步: 只传变化部分"]
Restore["恢复: 新盒子 → 输入密钥 → 自动拉取"]
end
```
**与现有架构的关系**