diff --git a/warpgate-prd-v3.md b/warpgate-prd-v3.md index 65381be..80943c3 100644 --- a/warpgate-prd-v3.md +++ b/warpgate-prd-v3.md @@ -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
macOS"] + Linux["Linux
客户端"] + iPad["iPad
移动端"] + end + + subgraph proxy ["Linux Proxy(局域网·高速)"] + Samba["Samba Server"] + NFS_S["NFS Server"] + WebDAV_S["WebDAV Server"] + VFS["rclone VFS mount
+ Write-back Controller"] + SSD["SSD 缓存 + 元数据 DB
(btrfs/ZFS)"] + Samba --> VFS + NFS_S --> VFS + WebDAV_S --> VFS + VFS --> SSD + end + + subgraph remote ["远程(公网 / Tailscale·低速)"] + TS["Tailscale /
WireGuard"] + NAS["任意品牌 NAS
(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
(rclone FUSE mount)"] + NFS["NFS Server"] --> FUSE + WebDAV --> FUSE + FUSE --> SSD["SSD 缓存
唯一缓存层"] ``` **注意事项**: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
写入 sparsebundle"] --> Monitor["① 会话结束检测
连续 5min 无新写入"] + Monitor --> Sync["② band 文件级增量同步
rsync/rclone 只传 mtime 变化的 band"] + Sync --> NAS["③ SFTP 直传到
TIMEMACHINE_PATH
(NAS 独立目录)"] + + style NAS fill:#e8f5e9 ``` -TM 回写策略: - ① TM 备份会话结束检测:监控 sparsebundle 目录,连续 5 分钟无新写入 → 视为会话结束 - ② band 文件级增量同步:rsync/rclone sync 只传输 mtime 变化的 band 文件到 NAS - ③ 目标路径:TIMEMACHINE_PATH(NAS 上独立目录,如 /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
准备回写脏文件"] --> Check["SFTP stat 查远程 mtime"] + Check --> NoChange{"remote == origin?
远程没变"} + Check --> Changed{"remote != origin?
远程也改了"} + Check --> Deleted{"远程文件不存在?
远程被删了"} + + NoChange -->|"✅ 安全回写"| WB["回写到远程
更新 origin_mtime"] + + Changed --> CmpMtime{"比较 mtime"} + CmpMtime -->|"cache > remote
本地更新"| WB2["本地胜,回写"] + CmpMtime -->|"remote > cache
远程更新"| Conflict1["远程胜
拉远程新版本
本地存冲突副本"] + CmpMtime -->|"mtime 相等
内容可能不同"| LocalWins["本地胜
(写入者优先)"] + + Deleted -->|"⚠️ 不回写"| Respect["尊重远程删除
脏文件移到 conflict/
通知用户"] ``` ### 5.4b SD 卡导入文件的回写决策(补充) 从 SD 卡导入的文件,本质上等同于「本地新创建的脏文件」。在 metadata.db 的 cache_files 表中,`origin_mtime = NULL` 表示此文件从未存在于远程 NAS。 -``` -origin_mtime = NULL(SD 卡导入的新文件) - │ - ├── SFTP stat 查远程:文件不存在 - │ → 直接上传(新建),更新 origin_mtime,state → clean - │ - ├── SFTP stat 查远程:文件已存在(说明之前某次导入已成功上传,或远程碰巧有同名文件) - │ → 比较 cache_mtime vs remote_mtime - │ → 按 5.4 正常冲突逻辑处理 - │ - └── 上传失败 - → 重试(指数退避),保持 dirty 状态 +```mermaid +flowchart TD + Start["origin_mtime = NULL
SD 卡导入的新文件"] --> Stat["SFTP stat 查远程"] + Stat -->|"文件不存在"| Upload["直接上传(新建)
更新 origin_mtime
state → clean"] + Stat -->|"文件已存在
(之前导入已上传 / 远程碰巧同名)"| Compare["比较 cache_mtime vs remote_mtime
按 5.4 正常冲突逻辑处理"] + Stat -->|"上传失败"| Retry["重试(指数退避)
保持 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 = NULL(SD 卡导入的新文件) #### 场景 1:Cache 关机几天,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
(origin_mtime=Day1, clean)"] --> Off["Day 1: Cache 关机"] + Off --> D3["Day 3: NAS 上 photo.cr3 被修改
(NAS mtime → Day3)"] + D3 --> D5["Day 5: Cache 开机
rclone 启动,轮询线程开始"] + D5 --> Detect["轮询发现 remote_mtime(Day3)
!= origin_mtime(Day1)"] + Detect --> Clean["文件是 clean(本地没改过)"] + Clean --> Invalidate["标记缓存失效
下次访问时拉新版本"] + Invalidate --> Result["✅ 用户读到 NAS 最新版本"] ``` #### 场景 2:Cache 关机几天,本地有脏数据,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
(origin=Day0, cache=Day1, dirty)"] + D1 --> Off["Day 1: 回写未执行,Cache 关机
(电池保护数据落盘)"] + Off --> D3["Day 3: 家人在 NAS 修改 photo.cr3
(NAS mtime → Day3)"] + D3 --> D5["Day 5: Cache 开机
发现本地有脏文件"] + D5 --> WB["Write-back Controller 准备回写"] + WB --> Stat["SFTP stat 查远程:
remote_mtime = Day3"] + Stat --> Conflict["remote(Day3) != origin(Day0)
→ 远程被改过!"] + Conflict --> Compare["cache(Day1) vs remote(Day3)
Day3 > Day1 → 远程胜"] + Compare --> Actions["本地脏版本 → conflict/
拉远程新版本覆盖缓存
更新 origin=Day3, clean
通知用户"] + Actions --> Result["✅ 不丢数据,两版本都保留"] ``` #### 场景 3:NAS 删了文件,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 查远程:
photo.cr3 不存在"] + Stat --> Conflict["远程已删 + 本地有脏数据 → 冲突"] + Conflict --> Actions["❌ 不回写(尊重远程删除)
脏版本 → conflict/
通知用户"] + Actions --> Result["✅ 不会复活已删文件
用户可从 conflict/ 恢复"] ``` #### 场景 4:NAS 删了文件,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 远程不存在
+ 本地是 clean → 无争议"] + Check --> Del["直接删除本地缓存"] + Del --> Result["✅ 本地缓存与远程保持一致"] ``` #### 场景 5:Cache 在外编辑文件,网络正常,NAS 无变化(最常见 happy path) -``` -用户在酒店打开 Lightroom 修图 - │ - ▼ - 首次打开 photo.cr3 → 缓存未命中 → 从远程下载 → 缓存到 SSD - (origin_mtime = T0, cache_mtime = T0, 状态 clean) - │ - ▼ - 用户编辑并保存 - (cache_mtime = T1, 状态 dirty) - │ - ▼ - 60 秒后 Write-back Controller 触发 - │ - ▼ - 查远程 mtime:remote_mtime == origin_mtime(T0) → 远程没变 - │ - ▼ - 正常回写,更新 origin_mtime = T1,状态 → clean - │ - ▼ - ✅ 结果:编辑秒存本地,后台静默同步,用户无感 +```mermaid +flowchart TD + Open["用户在酒店打开 Lightroom 修图"] + Open --> Miss["首次打开 photo.cr3
缓存未命中 → 远程下载 → 缓存到 SSD
(origin=T0, cache=T0, clean)"] + Miss --> Edit["用户编辑并保存
(cache=T1, dirty)"] + Edit --> Timer["60s 后 Write-back Controller 触发"] + Timer --> Check["查远程: remote == origin(T0)
→ 远程没变"] + Check --> WB["正常回写
更新 origin=T1, clean"] + WB --> Result["✅ 编辑秒存本地
后台静默同步,用户无感"] ``` ### 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/
→ 拿到 remote_set"] + LS --> Query["SELECT path FROM cache_files
WHERE dir_path = '/2026/02/'
→ 拿到 cached_set"] + Query --> Compare{"对比两个集合"} + Compare -->|"在 cached 不在 remote"| Deleted["远程被删了
→ 按决策矩阵处理"] + Compare -->|"在 cached 且在 remote"| MtimeCheck["比较 mtime
变了则标记失效"] + Compare -->|"在 remote 不在 cached"| Ignore["远程新增/未缓存
→ 不处理(等访问时拉)"] + Deleted --> Update["更新 dir_snapshots
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 脏文件
(source=remote)"] --> WBC_A["Write-back Controller"] + WBC_A --> A2["SFTP stat 查远程 mtime"] + A2 --> A3["三时间戳比较"] + A3 --> A4["决策: 回写 / 冲突保留 / 跳过"] + A4 --> A5["回写到原始远程路径
+ 更新 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 卡导入
(通过 FUSE 挂载点写入)"] --> B2["rclone 自动标记为脏文件
(source=ingest)"] + B2 --> WBC_B["Write-back Controller"] + WBC_B --> B3["检测 origin_mtime=NULL"] + B3 --> B4["按 INGEST_TARGET_PATH
计算 NAS 目标路径"] + B4 --> B5["创建目标目录(如不存在)"] + B5 --> B6["SFTP stat → 按 5.4b 决策"] + B6 --> B7["上传到目标路径
+ 更新 metadata.db
+ 记录 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 挂载点 +
写入 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
重启后 Controller 正常接管 ``` +**中断保护**:每个导入会话有唯一 `session_id`。`detecting`/`copying`/`checksumming` 阶段中断时,`ingest_staging/` 中的临时文件在下次启动时自动清理。`registered` 阶段中断时文件已安全进入缓存系统,Controller 重启后正常接管。 + **实现要点**: - 暂存目录 `ingest_staging//` 按导入会话隔离 - 进程启动时扫描 `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{"第三层:热度分级
决定轮询间隔"} + L3 -->|"热目录(7天内访问)
每 30s"| L1 + L3 -->|"温目录(7-30天)
每 5m"| L1 + L3 -->|"冷目录(30天+)
每 1h"| L1 + + L1["第一层:目录 mtime 快检
SFTP stat 查目录 mtime
(每目录 1 个请求)"] + L1 -->|"mtime 没变"| Skip["跳过该目录 ✅
所有文件一定没变"] + L1 -->|"mtime 变了"| L2 + + L2["第二层:文件级 mtime 对比
SFTP ls -l 该目录"] + L2 --> Changed["发现变化 → 标记缓存失效
(等访问时再拉)"] + L2 --> Del["发现删除 → 按决策矩阵处理"] + L2 --> New["远程新增 → 不处理"] + + L4["第四层:每日全量校对(兜底)
凌晨全量递归对比
捕捉遗漏 + 清理过期条目"] ``` 轮询伪代码(与 5.7.5 删除检测对齐): @@ -1052,13 +974,17 @@ for dir in watched_directories: 对于需要秒级同步的用户,可选在 NAS 上部署轻量 Agent(Docker 容器),通过 inotify 监听变化并推送: -``` -群晖 NAS Linux Proxy -┌────────────────────┐ ┌────────────────┐ -│ inotifywait 监听 │ Tailscale │ HTTP 接收端 │ -│ 文件变化 │───── 通知 ────────▶│ 触发缓存刷新 │ -│ (Docker 容器) │ (轻量 HTTP POST) │ │ -└────────────────────┘ └────────────────┘ +```mermaid +flowchart LR + subgraph NAS ["群晖 NAS"] + Agent["inotifywait 监听文件变化
(Docker 容器)"] + end + + subgraph Proxy ["Linux Proxy"] + HTTP["HTTP 接收端
触发缓存刷新"] + end + + Agent -- "轻量 HTTP POST
(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["直接返回缓存
(SSD 速度)"] + Valid -->|"已失效"| State{"④ 检查文件状态"} + + State -->|"clean"| Pull["拉远程新版本
更新缓存"] + State -->|"dirty"| Conflict["标记冲突
两边保留"] + + Remote --> Chunk["④ 按 chunk 分块下载"] + Chunk --> Write["⑤ 写入本地 SSD 缓存
⑥ 记录 origin_mtime"] + Write --> ReturnData["⑦ 返回数据给应用"] ``` **设计要点**: @@ -1100,52 +1021,35 @@ for dir in watched_directories: ### 7.2 写入流程 -``` -应用写入文件 - │ - ▼ - ① 写入本地 SSD 缓存(立即返回成功给应用) - │ - ▼ - ② 更新 metadata.db:cache_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 缓存
(立即返回成功给应用)"] + SSD --> Meta["② 更新 metadata.db
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
state → clean"] + Decision -->|"冲突"| ConflictKeep["保留双版本 + 通知用户"] + Decision -->|"远程已删"| NoWrite["不回写 + 移到 conflict/"] + + Upload -->|"上传失败"| Retry["重试(指数退避)
10s, 20s, 40s... 最多 10 次"] + Retry -->|"最终失败"| Keep["保留本地,state=dirty
记录日志"] ``` ### 7.3 缓存淘汰策略 -``` -淘汰触发条件(任一): - - 缓存总大小超过 CACHE_MAX_SIZE - - 缓存盘可用空间低于 CACHE_MIN_FREE - -淘汰规则(优先级从高到低): - ① dirty 文件永不淘汰(必须等回写完成) - ② conflict 文件永不淘汰(必须等用户处理) - ③ 超过 CACHE_MAX_AGE 且状态为 clean 的文件优先淘汰 - ④ 剩余 clean 文件按 LRU(last_accessed 最早的先淘汰) - ⑤ 淘汰至空间满足条件为止 - ⑥ 如果淘汰全部 clean 文件后空间仍不足 → 进入「缓存空间告警」状态(详见下文) +```mermaid +flowchart TD + Trigger{"淘汰触发
(总大小 > MAX_SIZE
或可用 < MIN_FREE)"} --> D1{"① dirty 文件?"} + D1 -->|"永不淘汰"| D2{"② conflict 文件?"} + D2 -->|"永不淘汰"| D3["③ 淘汰超过 MAX_AGE
的 clean 文件"] + D3 --> D4["④ 剩余 clean 按 LRU 淘汰
(last_accessed 最早优先)"] + D4 --> D5{"⑤ 空间满足?"} + D5 -->|"是"| Done["淘汰完成"] + D5 -->|"否(clean 已耗尽)"| Alert["⑥ 进入「缓存空间告警」
详见 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
控制面板
(用户管理)"] + DERP1["DERP 节点
国内 BGP
(低延迟)"] + DERP2["DERP 节点
香港/日本
(跨境加速)"] + end + + Box["盒子
开箱即连
(内置配置)"] --> HS + Box --> DERP1 + NAS_C["NAS 端
自动连接
(安装脚本)"] --> DERP1 + NAS_C --> DERP2 + Travel["出差设备
自动连接
(通过盒子中继)"] --> 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 缓存
(热数据子集)"] + SSD -->|"空闲时段
加密增量备份"| Cloud + + subgraph Cloud ["云存储服务"] + UX["用户视角: 一键开通,按月付费"] + Backend["后端: B2 / R2 / MinIO
(~$5/TB/月)"] + Encrypt["数据加密: 用户本地生成密钥
运营方看不到明文"] + KeyBackup["密钥备份: 首次设置强制引导
(密钥文件 / 助记词 / 密码管理器)"] + Sync["增量同步: 只传变化部分"] + Restore["恢复: 新盒子 → 输入密钥 → 自动拉取"] + end ``` **与现有架构的关系**: