diff --git a/warpgate-prd-v3.md b/warpgate-prd-v3.md index 53bb307..7f99142 100644 --- a/warpgate-prd-v3.md +++ b/warpgate-prd-v3.md @@ -8,9 +8,9 @@ **核心价值**: -1. **远程访问加速**:用户在外通过 Tailscale 等组网工具访问家中 NAS 时,受限于公网带宽,SMB 协议体验极差(卡顿、超时、缩略图加载慢)。本产品在客户端侧部署一层 SSD 缓存,对上层应用(Lightroom、Finder、Explorer 等)完全透明,首次访问按需拉取并缓存,后续访问直接命中本地 SSD,写入先落本地再异步回写远程。 +1. **远程访问加速**:用户在外通过 Tailscale 等组网工具访问家中 NAS 时,受限于公网带宽,SMB 协议体验极差(卡顿、超时、缩略图加载慢)。本产品在客户端侧部署一层 SSD 只读缓存,对上层应用(Lightroom、Finder、Explorer 等)完全透明,首次访问按需拉取并缓存到本地 SSD,后续访问直接命中缓存。 2. **外拍现场备份归档**:摄影师外拍结束插入 SD 卡,一键备份到本地 SSD,后台自动异步归档回家中 NAS。把「现场备份」和「远程归档」打通成一条自动流水线,市面上没有产品做到这一点。 -3. **数据安全兜底**:支持 macOS Time Machine 备份目标 + 可选的云端异地容灾,为用户的数据提供多层保护。 +3. **数据安全兜底**:可选的云端异地容灾,为用户的数据提供多层保护。 **产品形态**: - **软件方案**(MVP):配置文件 + 一键部署脚本,部署在任意 Linux 主机上(Docker 镜像在 v2.5 提供) @@ -51,11 +51,12 @@ graph LR 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["Samba Server
(read only)"] + NFS_S["NFS Server
(read only)"] + WebDAV_S["WebDAV Server
(read only)"] + VFS["rclone VFS mount
(只读缓存)"] + Uploader["SD Uploader
(单向上传到 NAS)"] + SSD["SSD 缓存 + 元数据 DB"] Samba --> VFS NFS_S --> VFS WebDAV_S --> VFS @@ -71,7 +72,8 @@ graph LR LR -- "SMB" --> Samba Linux -- "NFS" --> NFS_S iPad -- "WebDAV" --> WebDAV_S - VFS -- "SFTP" --> TS + VFS -- "SFTP (读取)" --> TS + Uploader -- "SFTP (上传)" --> TS ``` ### 3.2 协议选择说明 @@ -89,17 +91,17 @@ graph LR **讨论**:不同客户端设备对协议的偏好不同。macOS + Lightroom 最适合 SMB,但 Linux 客户端用 NFS 性能更好(内核级支持,且 Linux 侧还能再叠一层 FS-Cache),iPad/移动端 App 则普遍支持 WebDAV。 -**设计决策**:所有对外协议服务共享同一个 rclone FUSE 挂载点。缓存层只有一份,不会因为多协议而重复缓存。 +**设计决策**:所有对外协议服务共享同一个 rclone FUSE 只读挂载点。缓存层只有一份,不会因为多协议而重复缓存。所有协议均为只读模式,客户端无法通过共享写入文件。 ```mermaid graph LR - SMB["SMB Server"] --> FUSE["/mnt/nas-photos
(rclone FUSE mount)"] - NFS["NFS Server"] --> FUSE - WebDAV --> FUSE - FUSE --> SSD["SSD 缓存
唯一缓存层"] + SMB["SMB Server
(read only)"] --> Mount["/mnt/nas-photos
(rclone 只读 FUSE 挂载)"] + NFS["NFS Server
(read only)"] --> Mount + WebDAV["WebDAV
(read only)"] --> Mount + Mount --> SSD["SSD 缓存
(rclone 内部管理)"] ``` -**注意事项**:SMB 和 NFS 的文件锁机制不同,同一文件不建议多协议同时写入。产品层面通过文档告知用户"多协议是为不同设备类型服务,非同时并发写同一文件"。 +**注意事项**:所有共享均为只读。文件写入通过 SD 卡导入功能单向上传到 NAS(独立于缓存系统)。 --- @@ -107,13 +109,14 @@ graph LR ### P0(MVP 必须) -#### 4.1 透明多协议代理 -- 对外暴露标准 SMB 共享,客户端连接方式与直连 NAS 完全一致 -- 支持 SMB2/SMB3 协议 -- 同时支持 NFS 导出(Linux 客户端)和 WebDAV 服务(移动端) +#### 4.1 透明多协议只读代理 +- 对外暴露标准 SMB 只读共享,客户端连接方式与直连 NAS 完全一致 +- 支持 SMB2/SMB3 协议(`read only = yes`) +- 同时支持 NFS 只读导出(Linux 客户端)和 WebDAV 只读服务(移动端) - 支持 macOS(Finder/Lightroom)、Windows(Explorer)、Linux、移动端客户端 -- 文件读写、目录浏览、文件属性(时间戳/权限)均正常工作 -- 所有协议共享同一个缓存层,不重复存储 +- 文件读取、目录浏览、文件属性(时间戳/权限)均正常工作 +- 所有协议共享同一个 rclone 只读缓存层,不重复存储 +- **只读设计**:Lightroom 等应用读取 RAW 文件,编辑参数存在本地 catalog 中,不需要写入 NAS 文件所在目录 #### 4.2 读缓存(Read-through Cache) - 文件首次被访问时,从远程 NAS 拉取并存入本地 SSD 缓存 @@ -122,19 +125,12 @@ graph LR - 支持预读(read-ahead):顺序读取场景下提前拉取后续数据 - 目录列表缓存:目录结构缓存一段时间,避免频繁远程查询 -#### 4.3 写回缓存(Write-back Cache) -- 文件写入先落本地 SSD -- 文件关闭后延迟一段时间再异步回写远程 NAS -- 频繁写入(如 Lightroom catalog 自动保存)自动合并,避免重复传输 -- 回写前必须检查远程状态,不盲写(详见第五章一致性模型) -- 回写失败自动重试(指数退避) - -#### 4.4 数据一致性保证 -- 基于三时间戳模型保证最终一致性(详见第五章) -- 元数据持久化到 SSD,重启后可恢复所有状态继续回写 -- 回写前检查远程 mtime,防止覆盖更新数据 -- 远程删除感知,防止已删文件被回写复活 -- 写冲突检测与冲突副本保留 +#### 4.3 缓存一致性 +- 缓存为**只读**,不存在本地修改,因此不存在写冲突问题 +- 后台轮询检测远程变更,自动标记缓存失效(详见第六章) +- 远程文件被修改 → 下次访问时 rclone 自动拉取最新版本 +- 远程文件被删除 → rclone 缓存刷新后文件自动消失 +- 无脏文件、无回写、无冲突检测 —— 架构极简 #### 4.5 远程变更检测 - 基于 SFTP 的分层轮询机制,自动发现远程数据变化(详见第六章) @@ -146,7 +142,7 @@ graph LR - 超出上限时按 LRU(最久未访问)策略自动淘汰 - 可设置缓存盘最低保留空间,防止磁盘写满 - 可设置缓存最大保留时间 -- 脏文件(未回写)永不被淘汰 +- 缓存全部为只读(clean),rclone 可自由淘汰任何缓存文件 #### 4.7 一键部署 - 提供完整的配置文件 + 部署脚本 @@ -159,18 +155,18 @@ graph LR #### 4.8 SD 卡导入 + 自动归档(Ingest) -摄影师外拍结束,把 SD 卡插入盒子,一键备份到本地 SSD,后台利用已有的 write-back 引擎异步上传回家中 NAS。 +摄影师外拍结束,把 SD 卡插入盒子,一键备份到本地 SSD 暂存,后台独立进程通过 SFTP 直接上传到家中 NAS。**与只读缓存完全独立**——导入是单向的「SD → NAS」管道,不经过缓存系统。 - 检测到 SD/CFexpress 卡插入后,支持物理按钮一键触发导入或自动导入模式 -- **导入前空间检查**:估算 SD 卡总数据量,检查 SSD 可用空间(扣除已有 dirty 文件 + CACHE_MIN_FREE),空间不足时拒绝导入并通知用户 -- 复制文件**通过 rclone FUSE 挂载点写入**(而非直接写入 VFS 缓存目录),确保 rclone 能正确跟踪这些文件(详见 5.8.1 架构讨论) -- 导入时计算文件 checksum(SHA-256),确保数据完整 -- 导入的文件在 metadata.db 中标记为 `state=dirty, origin_mtime=NULL`(表示从未在远程存在) -- Write-back Controller 自动接管,拦截 rclone 的原生回写,按自定义逻辑(INGEST_TARGET_PATH 模板)决定 NAS 上的目标路径并上传,无网络时排队等待 -- 支持重复文件检测(基于文件名+大小+checksum),同时查询当前 cache_files 和 import_history 持久表(详见 5.7.6),避免重复导入已回写到 NAS 但被 LRU 淘汰的文件 +- **导入前空间检查**:估算 SD 卡总数据量,检查 SSD 暂存区可用空间(扣除 CACHE_MIN_FREE),空间不足时拒绝导入并通知用户 +- 复制文件到 SSD 暂存目录(`ingest_staging/`),导入时计算文件 checksum(SHA-256),确保数据完整 +- 导入记录写入 import_history 持久表(详见 5.6.4) +- **SD Uploader 独立进程**通过 SFTP 将暂存文件直接上传到 NAS 目标路径,无网络时排队等待 +- 上传成功后清理暂存文件 + 可选 `vfs/forget` 通知 rclone 刷新(使新文件在只读缓存中可见) +- 支持重复文件检测(基于文件名+大小+checksum),查询 import_history 持久表(详见 5.6.4),避免重复导入 - 导入完成通过 LED 指示灯 / 蜂鸣器提示 - 支持按日期模板自动组织目标路径(如 `/{year}/{month}/{date}/`),日期来源为 EXIF 拍摄日期,非 EXIF 文件回退到文件 mtime(详见 INGEST_DATE_SOURCE 配置) -- **导入中断保护**:导入过程维护状态机(`detecting → copying → checksumming → registered → complete`),中断的文件(未完成 checksum 校验)将被清理而非作为 dirty 文件进入缓存(详见 5.9) +- **导入中断保护**:导入过程维护状态机(`detecting → copying → checksumming → uploading → complete`),中断的文件(未完成 checksum 校验)将被清理(详见 5.8) #### 4.9 双卡备份 + 校验 @@ -183,37 +179,7 @@ graph LR - 校验完成输出报告(LED 状态 + CLI 可查详情) - 校验通过的文件自动进入 4.8 导入归档流程 -#### 4.10 Time Machine 备份目标 - -利用 Samba 原生 Time Machine 支持,让 macOS 用户的 Mac 出差时也有本地备份兜底。 - -- Samba 配置开启 `fruit:time machine = yes` -- **Time Machine 使用独立目录**(`/mnt/ssd/warpgate/timemachine/`),不通过 rclone VFS 管理,避免 sparsebundle 的大量小文件干扰 rclone 缓存逻辑 -- Time Machine 备份写入 SSD 的 `timemachine/` 目录 -- **独立的 TM 回写引擎**异步归档到 NAS 的 `TIMEMACHINE_PATH` 目录(与通用 Write-back Controller 分离,详见下文) -- `TIMEMACHINE_MAX_SIZE` 作为硬配额强制执行(通过 smb.conf 的 `fruit:time machine max size` 参数),防止占满缓存盘 -- 几乎零 Samba 配置成本,但 TM 回写引擎需要少量开发 - -**Time Machine 回写策略(sparsebundle 特殊处理)**: - -Time Machine 使用 sparsebundle 格式(一个目录包含数千个 8MB band 文件)。通用回写引擎的 60s 延迟+写入合并机制不适用于此场景: -- 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 回写不走通用 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。 - -#### 4.11 配网模式 + Captive Portal 代理(Setup AP) +#### 4.10 配网模式 + Captive Portal 代理(Setup AP) 盒子是 Headless 设备(无屏幕),而绝大多数酒店/机场 WiFi 需要网页认证(Captive Portal)。没有这个功能,旅途场景直接不可用。 @@ -246,20 +212,18 @@ flowchart LR - **有线以太网**:部分酒店有网口,直插通常无需认证 - **MAC 克隆**:`warpgate clone-mac ` 克隆已认证设备的 MAC 地址(高级用户) -#### 4.12 缓存预热(Warm-up) +#### 4.11 缓存预热(Warm-up) - 命令行手动预热指定目录 - 按时间范围预热(如"最近 7 天新增的文件") - 定时预热任务(如每天凌晨自动拉取最新数据) - 预热进度显示 -#### 4.13 管理工具(CLI) -- `warpgate status` — 查看服务状态、缓存命中率、回写队列、冲突文件数、当前带宽限速(含 adaptive throttle 状态) +#### 4.12 管理工具(CLI) +- `warpgate status` — 查看服务状态、缓存使用量、SD 上传队列、当前带宽限速 - `warpgate cache-list` — 列出缓存中的文件 - `warpgate cache-clean` — 清理缓存 - `warpgate warmup` — 手动预热 - `warpgate bwlimit` — 动态调整带宽限制 -- `warpgate conflicts` — 列出所有冲突文件(跨目录聚合),显示原文件、冲突副本、时间 -- `warpgate conflicts resolve --keep=local|remote|both` — 解决冲突 - `warpgate ingest` — 手动触发 SD 卡导入 - `warpgate verify` — 双卡校验 - `warpgate log` — 查看实时日志 @@ -267,100 +231,71 @@ flowchart LR - `warpgate setup-wifi` — 手动进入配网模式 - `warpgate clone-mac ` — 克隆指定设备的 MAC 地址 -#### 4.14 带宽管理 -- 支持上传/下载分别限速 +#### 4.13 带宽管理 +- 支持上传(SD 导入上传)/下载(缓存拉取)分别限速 - 运行时动态调整限速(不重启服务) -- 回写带宽不影响读取体验 -- **自适应限速(Adaptive Throttle)**:基于吞吐量观测自动降速,避免回写占满链路影响家人上网 - - 监控回写吞吐量的滑动窗口(如最近 30s 平均值),当吞吐持续下降超过阈值(如连续 3 个窗口下降 >30%)且 RTT 同步上升时,判定链路拥塞 - - 拥塞时自动将回写限速降至当前吞吐的 50%,释放带宽给其他流量 - - 每隔一段时间(如 2 分钟)小幅探测提速(+10%),如果吞吐恢复则逐步回升 - - throttle 状态通过 `warpgate status` 实时可见(当前限速值 / 是否处于 throttle / 触发原因) - - 用户可通过 `BW_ADAPTIVE` 配置关闭自适应限速,退回纯手动限速模式 - - 自适应限速**仅控制回写(上传)**,不影响读取拉取 +- SD 上传带宽不影响读取体验 +- **自适应限速(Adaptive Throttle)**:基于吞吐量观测自动降速,避免 SD 上传占满链路 + - 监控上传吞吐量的滑动窗口(如最近 30s 平均值),当吞吐持续下降超过阈值时判定链路拥塞 + - 拥塞时自动降速,释放带宽给读取和其他流量 + - 每隔一段时间小幅探测提速 + - throttle 状态通过 `warpgate status` 实时可见 + - 用户可通过 `BW_ADAPTIVE` 配置关闭自适应限速 + - 自适应限速**仅控制 SD 上传**,不影响缓存读取拉取 -#### 4.15 连接容错 +#### 4.14 连接容错 - Tailscale 断连时自动重试 - 已缓存的文件在离线时仍可正常读取 -- 写回队列在恢复连接后自动续传 +- SD 上传队列在恢复连接后自动续传 - 连接超时参数可配置 -#### 4.16 写冲突处理 - -**冲突副本命名**:`{name} (Warpgate Conflict {YYYY-MM-DD HH-mm}).{ext}` - -冲突副本保留在**原目录**(而非移到独立 conflict/ 目录),让 Lightroom/Finder 等应用可以直接看到并打开。保留原始扩展名,确保应用兼容性。这与 Dropbox/iCloud 的冲突处理方式一致,用户已有认知习惯。 - -**各冲突场景的处理**: - -| 场景 | 主文件 | 冲突副本 | 说明 | -|------|--------|----------|------| -| 远程更新胜出(remote > local) | 拉取远程版本作为 `IMG_001.cr3` | 本地版本重命名为 `IMG_001 (Warpgate Conflict ...).cr3` | 两个版本都在原目录,用户可直接对比 | -| 本地更新胜出(local > remote) | 保持本地版本并回写 | 不产生冲突副本 | 正常回写 | -| 远程已删 + 本地有脏数据 | 本地文件**原地保留不改名** | 无 | metadata 中标记为 orphan-conflict;通知用户"NAS 上已删除,本地编辑是唯一副本,请决定是否重新上传" | - -**冲突副本的生命周期**: - -- 冲突副本在 FUSE 挂载点内,rclone 自动视为新脏文件 → Write-back Controller 将其同步到 NAS(冲突副本也有远程备份,与 Dropbox 行为一致) -- 用户在 Lightroom/Finder 中直接看到两个版本,自行决定保留哪个 -- 删除不需要的版本即可,rclone 自动同步删除到 NAS - -**管理工具**: - -- `warpgate conflicts` — 列出所有冲突文件(跨目录聚合),显示:原文件、冲突副本路径、冲突时间、哪个版本胜出 -- `warpgate conflicts resolve --keep=local|remote|both` — 批量解决冲突:保留本地/远程/两者 -- 超过 `CONFLICT_RETAIN_DAYS` 天的冲突副本,由定时清理任务自动删除(清理前记录日志) - -**通知**:冲突发生时通知用户(CLI 提示 / 日志 / 可选 Webhook),包含冲突文件路径和建议操作 - ### P2(后续迭代) -#### 4.17 WiFi AP 现场共享 +#### 4.16 WiFi AP 现场共享 -盒子内置 WiFi 模块开启持久热点,现场团队设备连上即可通过 SMB/WebDAV 访问缓存目录。与 4.11 配网模式的区别:配网 AP 是临时的(完成配网后关闭),本功能是持久的团队共享热点。 +盒子内置 WiFi 模块开启持久热点,现场团队设备连上即可通过 SMB/WebDAV 访问缓存目录。与 4.10 配网模式的区别:配网 AP 是临时的(完成配网后关闭),本功能是持久的团队共享热点。 - 支持 AP 模式,复用已有的 SMB/WebDAV 多协议服务 - AP 网络与 Tailscale/WAN 网络隔离(安全考虑) -- AP 模式下仍可同时通过有线/4G 连接 Tailscale 做后台回写 +- AP 模式下仍可同时通过有线/4G 连接 Tailscale 做后台 SD 上传 - **硬件要求**:需要两个独立网络接口——WiFi 模块用于 AP 热点,有线/USB 4G 网卡用于 WAN/Tailscale 连接。一体机硬件设计需预留双网卡 - 典型场景:婚礼现场摄影师导入 SD 卡后,助理 iPad 连上 WiFi 即可浏览选片 -#### 4.18 Web 管理界面 -- 缓存状态仪表盘(大小、命中率、回写队列、冲突文件、带宽趋势图) +#### 4.17 Web 管理界面 +- 缓存状态仪表盘(大小、命中率、SD 上传队列、带宽趋势图) - 缓存文件浏览器(查看/手动清除/手动预热) - 配置修改界面(参数调整无需编辑配置文件) -- 冲突文件可视化处理 +- SD 导入历史浏览 - 实时日志查看器 -#### 4.19 NAS 侧 Agent 推送(可选增强) +#### 4.18 NAS 侧 Agent 推送(可选增强) - 在 NAS 上运行轻量 Agent(Docker 容器),监听文件变化主动推送给 Proxy - 实现秒级远程变更感知(替代分钟级轮询) - 不依赖品牌 API,基于 inotify 通用方案 -#### 4.20 多 NAS / 多目录支持 +#### 4.19 多 NAS / 多目录支持 - 同时连接多个远程 NAS(如家里 + 工作室) - 每个 NAS 独立共享名,独立缓存策略 - 每个共享可配置不同的缓存大小和保留时间 -#### 4.21 智能缓存策略 +#### 4.20 智能缓存策略 - 根据文件类型自动调整策略: - - `.lrcat` / `.xmp`(Lightroom catalog/sidecar)→ 高优先级回写,短写回延迟 + - `.lrcat` / `.xmp`(Lightroom catalog/sidecar)→ 高缓存优先级 - `.CR3` / `.ARW` / `.NEF`(RAW 照片)→ 大块预读,长缓存保留 - `.mp4` / `.mov`(视频)→ 顺序预读优化 - `.psd` / `.ai`(设计文件)→ 完整缓存,避免分块导致的兼容问题 - 基于访问频率自动调整缓存优先级(热数据不被淘汰) -#### 4.22 Docker 镜像 +#### 4.21 Docker 镜像 - 一行命令启动:`docker run -v /mnt/ssd:/cache warpgate` - docker-compose 配置 - 支持环境变量或挂载配置文件 -#### 4.23 通知机制 -- 回写失败告警(Webhook / Telegram / 邮件) +#### 4.22 通知机制 +- SD 上传失败告警(Webhook / Telegram / 邮件) - 缓存空间不足告警 - NAS 离线告警 -- 写冲突告警 -- 回写完成通知(可选) +- SD 导入完成 / 上传完成通知(可选) --- @@ -368,192 +303,134 @@ flowchart LR ### 5.1 设计目标 -采用**最终一致性(Eventual Consistency)**模型。具体承诺: +采用**只读缓存 + 单向上传**模型,两个功能完全独立。具体承诺: -1. 所有成功写入本地缓存的数据最终会同步到远程 NAS(断电恢复靠硬件 UPS 保证) -2. 远程 NAS 上的变更会在可控时间内被 Proxy 感知并更新本地缓存 -3. 写冲突可检测、可追溯,不会静默丢数据 +1. 远程 NAS 上的变更会在可控时间内被 Proxy 感知并更新本地缓存(后台轮询) +2. 已缓存的文件在离线时仍可正常访问 +3. SD 卡导入的文件最终会上传到远程 NAS(SD Uploader 异步上传 + 断电恢复靠 SSD 持久化暂存文件) +4. **无写冲突**:缓存只读,不存在本地修改与远程变更冲突的可能 ### 5.2 设计讨论与决策过程 -#### 问题 A:写回中断(断电/crash)是否会丢数据? +#### 问题 A:为什么选择只读缓存而非读写缓存? -**讨论**:rclone 的 write-back 机制下,文件先写入本地 SSD 缓存,延迟回写到远程。如果在回写之前 Proxy 断电,未回写的脏数据面临丢失风险。 +**讨论**:最初设计了 OverlayFS + sync daemon 的读写缓存方案(本地写入 → 异步回写 NAS),但面临大量边界场景: +- 写冲突检测(本地改了文件 + NAS 也被别人改了) +- mtime 精度不一致导致误判 +- 断电后脏文件恢复 +- TOCTOU 竞态条件 +- 远程删除后脏文件复活 -**考虑过的方案**: -- 方案 1:软件层 Write-Ahead Log(WAL),写入前先写日志再写数据,恢复时重放 -- 方案 2:选用支持 Power Loss Protection 的企业级 SSD -- 方案 3:缓存盘用 btrfs/ZFS,利用 CoW + journal 保证文件系统一致性 -- 方案 4:硬件内置电池(类似 UPS),保证断电后有时间 flush +**最终决策**:只读缓存 + 单向上传。理由: +- 摄影师核心工作流是「浏览/粗选 RAW」(只读)+ 「SD 卡导入」(单向),不需要通过缓存写回 NAS +- Lightroom 编辑参数存在本地 catalog(.lrcat),不修改 RAW 文件本身 +- 只读缓存消灭了所有写冲突、脏文件、回写等复杂性 +- SD 导入是纯新文件的单向上传,不存在冲突 +- 写回能力可以作为 v2.0 按需加入 -**最终决策**:硬件层面通过内置电池/UPS 保证断电安全,软件层面不做额外的 WAL。原因是硬件方案最简单可靠,且后续做硬件产品时电池是自然的组成部分。软件层面只需保证**重启后能正确识别脏文件并继续回写**即可。 +> **MVP 部署建议**:**建议**缓存盘使用 btrfs 或 ZFS 文件系统(CoW + checksum 保护断电一致性)。部署脚本应检测并警告非 CoW 文件系统。 -rclone 的 VFS cache 目录本身维护了元数据,重启后会自动发现未同步的脏文件并继续回写,满足此需求。 +#### 问题 B:SD 导入上传中断怎么办? -#### 问题 B:写冲突如何处理?谁来比较时间? +**讨论**:SD 卡导入后文件暂存在 SSD 上等待上传到 NAS。上传过程中可能断电、断网、进程崩溃。 -**讨论**:核心场景是 Cache 在外改了文件但还没回写,NAS 侧也被别人改了同一个文件。如果 Cache 关机几天后重启,盲目回写会覆盖 NAS 上的新版本。 +**决策**: +- 暂存文件持久化在 SSD 上,重启后 SD Uploader 扫描暂存目录自动继续上传 +- 上传到 NAS 时使用临时文件名(`.warpgate-tmp-`),完成后 rename 为最终文件名,避免 NAS 上出现不完整文件 +- 上传成功后清理暂存文件,可选 `vfs/forget` 刷新缓存使新文件可见 -反过来,如果 NAS 上删了文件,Cache 重启后发现本地有脏文件,盲目回写会把已删文件复活。 +### 5.3 一致性模型(只读缓存 + 单向上传) -**考虑过的方案**: -- 方案 1:Last-write-wins 盲写(简单但会丢数据) -- 方案 2:基于 mtime 比较的 last-write-wins(有保护的覆盖) -- 方案 3:全版本保留(两边都存,用户手动选) -- 方案 4:基于 vector clock 的分布式一致性(过于复杂) - -**最终决策**:采用方案 2——基于 mtime 的 last-write-wins,配合冲突副本保留。由 Proxy 侧的 **Write-back Controller** 负责在回写前查询远程 mtime 并做比较决策。 - -### 5.3 三时间戳模型 - -每个缓存文件维护三个关键时间戳: +架构分为两个完全独立的子系统: ``` -origin_mtime ── 文件从远程拉下来时(或上次成功回写后)远程的 mtime - 含义:上次同步时远程的状态,是判断"远程是否变了"的基准线 - -cache_mtime ── 本地缓存文件当前的 mtime - 含义:本地版本的时间,如果 != origin_mtime 则说明本地改过 - -remote_mtime ── 回写/读取时实时查询远程的当前 mtime - 含义:远程此刻的真实状态 +只读缓存路径:应用 → Samba/NFS(只读) → rclone FUSE 挂载 → SSD 缓存 ← SFTP 从 NAS 按需拉取 +SD 上传路径: SD 卡 → ingest_staging/(SSD 暂存) → SD Uploader → SFTP 直传 NAS ``` -状态推导: +**只读缓存**: +- rclone 以 `--read-only --vfs-cache-mode full` 挂载,Samba/NFS 以只读模式共享 +- 所有缓存文件都是 clean 的(与远程一致),rclone 自由管理 LRU 淘汰 +- 后台轮询检测远程变更 → `vfs/forget` 通知 rclone 刷新 → 下次访问拿到最新版本 +- 无脏文件、无回写、无冲突 —— **零数据一致性风险** -``` -本地是否修改过: - origin_mtime = NULL → 从未在远程存在(SD 卡导入的新文件),始终视为 dirty - cache_mtime == origin_mtime → 干净(clean) - cache_mtime != origin_mtime → 脏(dirty),有未回写的本地修改 +**SD 单向上传**: +- SD Uploader 是独立进程,与缓存系统无交互 +- 从 `ingest_staging/` 扫描待上传文件 → SFTP 上传到 NAS(临时文件名 + rename) +- 上传失败自动重试(指数退避),断电重启后扫描暂存目录继续上传 +- 上传成功后清理暂存文件,可选 `vfs/forget` 刷新缓存使新文件可见 +- **无冲突**:SD 导入的都是新文件,NAS 上不存在同名文件时直接创建;已存在时跳过(import_history 去重已在导入阶段处理) -远程是否变化过: - origin_mtime = NULL → 不适用(文件从未同步过远程,回写逻辑见 5.4b) - remote_mtime == origin_mtime → 远程没变(自上次同步后) - remote_mtime != origin_mtime → 远程被别人改了 - 远程文件不存在 → 远程被删了 -``` +**缓存淘汰**:rclone 由 `--vfs-cache-max-size` 和 `--vfs-cache-max-age` 控制 LRU 淘汰,所有缓存文件均可安全淘汰。 -**注意**:虽然 cache_files 表中有显式的 `state` 字段存储状态(clean/dirty/conflict),运行时依赖 `state` 字段即可。上述推导规则用于状态字段的正确性校验和初始化逻辑。`origin_mtime = NULL` 的记录 `state` 必须为 `dirty`(或 `conflict`),不可能为 `clean`。 +### 5.4 读取时的缓存验证 -### 5.4 回写决策矩阵 +缓存为只读,所有文件都是 clean 的,验证逻辑非常简单: -当 Write-back Controller 准备回写一个脏文件时,先通过 SFTP stat 查询远程 mtime,然后按以下矩阵决策: +| 远程状态 | 缓存行为 | +|---|---| +| 远程没变 | ✅ 直接用缓存 | +| 远程被修改 | 🔄 轮询发现变化 → `vfs/forget` → rclone 下次访问时拉新版本 | +| 远程被删除 | 🗑️ 轮询发现变化 → `vfs/forget` → rclone 刷新后文件消失 | -```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["尊重远程删除
本地文件原地保留
标记 orphan-conflict
通知用户"] -``` - -### 5.4b SD 卡导入文件的回写决策(补充) - -从 SD 卡导入的文件,本质上等同于「本地新创建的脏文件」。在 metadata.db 的 cache_files 表中,`origin_mtime = NULL` 表示此文件从未存在于远程 NAS。 - -```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)** | ✅ 用本地版本,等待回写 | ⚠️ 标记冲突,两边版本都保留,通知用户 | ⚠️ 用本地版本,但标记为冲突,通知用户 | - -**注意**:读取时不是每次都查远程 mtime(那样太慢)。远程 mtime 信息由后台轮询线程定期更新(见第六章)。热路径上读取命中缓存直接返回,只有当轮询发现 mtime 变化时才触发重新验证。 - -### 5.6 关键场景走查 +### 5.5 关键场景走查 #### 场景 1:Cache 关机几天,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)"] + D1["Day 1: Cache 缓存 photo.cr3 (clean)"] --> Off["Day 1: Cache 关机"] + Off --> D3["Day 3: NAS 上 photo.cr3 被修改"] D3 --> D5["Day 5: Cache 开机
rclone 启动,轮询线程开始"] - D5 --> Detect["轮询发现 remote_mtime(Day3)
!= origin_mtime(Day1)"] - Detect --> Clean["文件是 clean(本地没改过)"] - Clean --> Invalidate["标记缓存失效
下次访问时拉新版本"] - Invalidate --> Result["✅ 用户读到 NAS 最新版本"] + D5 --> Detect["轮询发现目录 mtime 变化"] + Detect --> Forget["vfs/forget → rclone 缓存失效"] + Forget --> Access["用户访问时 rclone 自动拉新版本"] + Access --> Result["✅ 用户读到 NAS 最新版本"] ``` -#### 场景 2:Cache 关机几天,本地有脏数据,NAS 也被更新 - -```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["本地版本重命名为
(Warpgate Conflict ...)
拉远程新版本
更新 origin=Day3, clean
通知用户"] - Actions --> Result["✅ 不丢数据,两版本都保留"] -``` - -#### 场景 3:NAS 删了文件,Cache 上有脏数据 - -```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["❌ 不回写(尊重远程删除)
本地文件原地保留
标记 orphan-conflict
通知用户"] - Actions --> Result["✅ 不会复活已删文件
用户可决定是否重新上传"] -``` - -#### 场景 4:NAS 删了文件,Cache 上是 clean 的 +#### 场景 2:NAS 删了文件 ```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["✅ 本地缓存与远程保持一致"] + Poll --> Forget["vfs/forget → rclone 刷新"] + Forget --> Gone["rclone 缓存自动消失"] + Gone --> Result["✅ 本地缓存与远程保持一致"] ``` -#### 场景 5:Cache 在外编辑文件,网络正常,NAS 无变化(最常见 happy path) +#### 场景 3:SD 卡导入 + 上传到 NAS(最常见 happy path) ```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["✅ 编辑秒存本地
后台静默同步,用户无感"] + Insert["摄影师插入 SD 卡"] --> Detect["检测到卡,按按钮触发导入"] + Detect --> Copy["复制到 ingest_staging/
计算 SHA-256"] + Copy --> Dedup["查 import_history → 无重复"] + Dedup --> Record["记录 import_history"] + Record --> Upload["SD Uploader 通过 SFTP
上传到 NAS(临时文件 + rename)"] + Upload --> Clean["清理暂存文件
vfs/forget 刷新缓存"] + Clean --> Result["✅ 文件安全到达 NAS
缓存中可见新文件"] ``` -### 5.7 元数据持久化(metadata DB) +#### 场景 4:SD 导入后离线,后续上传 -三时间戳和文件状态必须持久化到 SSD,确保重启不丢失。使用 SQLite 存储。 +```mermaid +flowchart TD + Insert["外拍现场,无网络"] --> Import["SD 卡导入到暂存目录
文件安全存在 SSD 上"] + Import --> Queue["SD Uploader 发现无网络
上传排队等待"] + Queue --> Hotel["回到酒店,网络恢复"] + Hotel --> Resume["SD Uploader 自动续传
逐文件上传到 NAS"] + Resume --> Result["✅ 无需人工干预"] +``` -#### 5.7.1 设计讨论:metadata DB 应该包含什么? +### 5.6 元数据持久化(metadata DB) + +metadata.db 用于 **SD 卡导入去重**(import_history)和**远程变更轮询**(dir_snapshots)。使用 SQLite 存储。只读缓存不需要额外状态数据库——rclone 内部管理所有缓存状态。 + +#### 5.6.1 设计讨论:metadata DB 应该包含什么? **问题**:metadata DB 需要存 NAS 侧的所有文件元数据吗? @@ -563,104 +440,69 @@ flowchart TD - 存储浪费——大量从未访问的文件元数据没有价值 - 同步负担重——需要持续维护全量数据的一致性 -**结论**:metadata DB **只管"跟缓存有交集的文件"**,不存全量 NAS 元数据。NAS 上有 10 万张照片,用户只打开过 500 张,那核心表就只有 500 行。 +**结论**:metadata DB **只管辅助功能**(轮询和导入去重),不存文件级缓存状态。缓存状态完全由 rclone 内部管理。 -但有一个衍生问题:**怎么检测远程文件被删了?** +**怎么检测远程文件被删了?** -轮询发现某目录 mtime 变了,做 `sftp ls` 拿到当前远程文件列表,但要知道"哪个文件消失了",需要跟之前的列表比较。这引出了"是否需要额外存目录文件列表"的设计选择。 - -**方案推导**: +rclone 自动处理,不需要数据库: 假设目录 `/2026/02/` 下有 200 张照片,用户只缓存了 3 张。 场景 A:NAS 上 IMG_0050.cr3 被删了(从未缓存过) -→ 跟缓存无关,不需要感知,不处理 ✅ +→ 跟缓存无关,不处理 ✅ 场景 B:NAS 上 IMG_0001.cr3 被删了(缓存过) -→ `sftp ls` 结果里找不到 IMG_0001 → 但 cache_files 表里有它 → 检测到删除 ✅ +→ 轮询发现目录变化 → `vfs/forget` → rclone 刷新 → 缓存文件消失 ✅ -场景 C:NAS 上新增了 IMG_0201.cr3,同时修改了 IMG_0050.cr3 -→ IMG_0201 从没缓存过,不关心 ✅ -→ IMG_0050 从没缓存过,不关心 ✅ -→ 如果 IMG_0050 缓存过,cache_files 里有 origin_mtime 可以直接比 ✅ +场景 C:NAS 上 IMG_0050.cr3 被修改了(缓存过) +→ 轮询发现目录变化 → `vfs/forget` → rclone 刷新 → 下次读取拿到新版本 ✅ -**关键洞察**:删除检测只需要用 `sftp ls` 结果去反查 cache_files 表——"cache_files 里有记录,但 sftp ls 结果里没有这个文件"即为远程删除。不需要额外维护一张完整的目录文件列表。 +**最终决策**: -**最终决策(分阶段)**: +| 表 | 用途 | 理由 | +|---|---|---| +| dir_snapshots | 轮询目录 mtime 快检 | 记住上次轮询时目录的 mtime,判断是否变化 | +| import_history | SD 导入去重 | 持久记录历史导入,防止重复导入 | -| 阶段 | 策略 | 表结构 | 理由 | -|------|------|--------|------| -| MVP (v1.0) | 精简模式 | cache_files + dir_snapshots(两张表) | 删除检测通过反查 cache_files 实现,逻辑简单 | -| v1.5+ | 完整模式 | 加 dir_file_list(三张表) | 记录关心目录下全部远程文件,支持精确变更类型识别和智能预热(如"自动缓存新增文件") | - -#### 5.7.2 缓存目录结构 +#### 5.6.2 缓存目录结构 ``` /mnt/ssd/warpgate/ -├── vfs/ # rclone VFS 缓存目录(rclone 内部管理,外部进程不直接写入) -│ └── photos/ -│ ├── 2026/ -│ │ └── 02/ -│ │ ├── IMG_0001.cr3 # 远程拉取缓存 -│ │ └── IMG_0002.cr3 # 或通过 FUSE 挂载点写入的 SD 卡导入文件 -│ └── ... -├── metadata.db # SQLite 元数据库(WAL 模式,详见 5.7.7) -├── conflict/ # 冲突副本自动清理暂存(CONFLICT_RETAIN_DAYS 到期后移入) -│ └── (仅用于到期自动清理前的归档,正常冲突副本在原目录) -├── ingest_staging/ # SD 卡导入暂存目录(导入状态机使用,详见 5.9) -│ └── / # 每次导入会话独立目录 -└── timemachine/ # Time Machine 备份目录(独立于 rclone VFS,详见 4.10) - └── MacBook-Pro.sparsebundle +├── rclone-cache/ # rclone VFS 内部缓存目录(rclone 自动管理) +│ └── vfs/ +│ └── photos/ +│ └── 2026/02/ +│ └── IMG_0001.cr3 # 远程拉取缓存 +├── metadata.db # SQLite 元数据库(WAL 模式,详见 5.6.7) +└── ingest_staging/ # SD 卡导入暂存目录(导入状态机使用,详见 5.8) + └── / # 每次导入会话独立目录 ``` -**重要**:`vfs/` 目录由 rclone VFS 内部管理,维护自己的元数据来跟踪缓存了哪些文件。**任何外部进程(包括 SD 卡导入)不得直接写入此目录**,否则会导致 rclone 内部元数据与 metadata.db 不同步。SD 卡导入必须通过 rclone FUSE 挂载点(`/mnt/nas-photos`)写入,详见 5.8.1。 +**挂载关系**: +```bash +# rclone 只读 FUSE 挂载(= Samba/NFS 共享的根目录) +rclone mount remote:photos /mnt/nas-photos \ + --read-only \ + --vfs-cache-mode full \ + --vfs-cache-max-size ${CACHE_MAX_SIZE} \ + --vfs-cache-max-age ${CACHE_MAX_AGE} \ + --cache-dir /mnt/ssd/warpgate/rclone-cache \ + --rc -#### 5.7.3 表结构定义 - -**表 1:cache_files — 缓存文件状态(核心表)** - -只有进过缓存的文件才会有记录。 - -```sql -CREATE TABLE cache_files ( - path TEXT PRIMARY KEY, -- 相对路径,如 /2026/02/IMG_0001.cr3 - dir_path TEXT NOT NULL, -- 所属目录,如 /2026/02/(加速目录级查询) - origin_mtime INTEGER, -- 拉下来时(或上次成功回写后)远程的 mtime - -- NULL 表示 SD 卡导入的新文件,从未在远程存在 - cache_mtime INTEGER NOT NULL, -- 本地缓存文件当前 mtime - file_size INTEGER NOT NULL, -- 文件大小(字节,统计 + 淘汰决策用) - state TEXT NOT NULL, -- clean / dirty / conflict - source TEXT DEFAULT 'remote', -- 文件来源:remote(远程拉取)/ ingest(SD 卡导入) - last_accessed INTEGER NOT NULL, -- 最后访问时间(LRU 淘汰用) - writeback_retry INTEGER DEFAULT 0, -- 回写失败累计重试次数 - checksum TEXT, -- SHA-256 校验和(SD 卡导入时计算) - created_at INTEGER NOT NULL -- 首次缓存时间 -); - -CREATE INDEX idx_state ON cache_files(state); -CREATE INDEX idx_dir_path ON cache_files(dir_path); -CREATE INDEX idx_last_accessed ON cache_files(last_accessed); -CREATE INDEX idx_dirty ON cache_files(state) WHERE state = 'dirty'; +# Samba 配置(只读) +# [nas-photos] +# path = /mnt/nas-photos +# read only = yes ``` -生命周期: +**重要**: +- `rclone-cache/` 由 rclone 内部管理,外部进程**不得直接操作** +- `ingest_staging/` 由 SD 导入进程管理,SD Uploader 从此目录读取待上传文件 +- Samba/NFS 直接服务于 rclone FUSE 挂载点 `/mnt/nas-photos` -``` -文件首次被访问并下载 → INSERT (state=clean, source=remote, origin_mtime=远程mtime, cache_mtime=远程mtime) -SD 卡导入新文件 → INSERT (state=dirty, source=ingest, origin_mtime=NULL, cache_mtime=当前时间, checksum=SHA256) -本地修改(应用保存) → UPDATE (state=dirty, cache_mtime=当前时间) -回写成功 → UPDATE (state=clean, origin_mtime=更新后的mtime, writeback_retry=0) -回写失败 → UPDATE (writeback_retry += 1) -缓存被 LRU 淘汰 → DELETE(仅 state=clean 可被淘汰) -检测到远程删除(clean)→ DELETE -检测到冲突(远程胜) → 原文件 UPDATE (拉远程版本, state=clean) - 冲突副本 INSERT (重命名后的本地版本, state=dirty, source=conflict) - 冲突副本通过 rclone 自动回写到 NAS -检测到冲突(远程已删)→ UPDATE (state=conflict, 标记 orphan-conflict, 原地保留不改名) -冲突处理完成 → DELETE(用户删除不需要的版本)或 UPDATE (state=clean) -``` +#### 5.6.3 表结构定义 -**表 2:dir_snapshots — 目录级轮询快照** +**表 1:dir_snapshots — 目录级轮询快照** 用于分层轮询的"目录 mtime 快检"。只记录被缓存文件所在的目录,不是 NAS 全量目录。 @@ -670,26 +512,23 @@ CREATE TABLE dir_snapshots ( remote_mtime INTEGER, -- 上次轮询时远程目录的 mtime -- NULL 表示 SD 卡导入创建的目录,远程尚不存在 last_polled INTEGER NOT NULL, -- 上次轮询时间 - last_accessed INTEGER NOT NULL, -- 目录最后被访问时间(决定热/温/冷分级) - cached_count INTEGER DEFAULT 0 -- 该目录下 cache_files 条目数(辅助清理判断) + last_accessed INTEGER NOT NULL -- 目录最后被访问时间(决定热/温/冷分级) ); ``` 生命周期: ``` -某目录下的文件首次被缓存 → INSERT(或 UPDATE cached_count) -SD 卡导入创建新目录 → INSERT(remote_mtime = NULL,表示远程目录尚不存在) - 回写成功后更新 remote_mtime 为实际值 +某目录下的文件首次被缓存 → INSERT +SD 卡导入上传到新目录 → INSERT(SD Uploader 上传完成后,remote_mtime 为上传后 stat 的值) 轮询时目录 mtime 没变 → UPDATE last_polled -轮询时目录 mtime 变了 → UPDATE remote_mtime,触发文件级检查 +轮询时目录 mtime 变了 → UPDATE remote_mtime,触发 vfs/forget 刷新缓存 目录下已无缓存文件 → DELETE(可选) -remote_mtime = NULL 的目录 → 跳过轮询(远程还不存在,等回写创建后再轮询) ``` -**表 3:dir_file_list — 目录文件列表快照(v1.5+)** +**表 1b:dir_file_list — 目录文件列表快照(后续增强)** -MVP 阶段不需要。v1.5 加入后,记录被关心目录下的全部远程文件,支持精确变更类型识别和智能预热。 +MVP 阶段不需要。后续加入后,记录被关心目录下的全部远程文件,支持精确变更类型识别和智能预热。 ```sql CREATE TABLE dir_file_list ( @@ -704,23 +543,23 @@ CREATE TABLE dir_file_list ( CREATE INDEX idx_dir ON dir_file_list(dir_path); ``` -#### 5.7.6 导入历史表(重复检测用) +#### 5.6.4 导入历史表(重复检测用) -**问题**:重复文件检测如果只查 cache_files 表,那么已回写到 NAS 并被 LRU 淘汰的文件(cache_files 记录已被 DELETE)再次导入同一张卡时无法检测到重复。 +**问题**:重复文件检测不能只看当前暂存目录内容——已上传到 NAS 并从暂存区清理的文件,再次导入同一张 SD 卡时无法检测到重复。 -**解决方案**:增加 import_history 持久表,记录所有历史导入记录,不随 LRU 淘汰删除。 +**解决方案**:import_history 持久表,记录所有历史导入记录,永不删除。 ```sql CREATE TABLE import_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, original_path TEXT NOT NULL, -- SD 卡上的原始路径 - target_path TEXT NOT NULL, -- 导入到缓存/NAS 的目标路径 + target_path TEXT NOT NULL, -- 上传到 NAS 的目标路径 file_size INTEGER NOT NULL, -- 文件大小 checksum TEXT NOT NULL, -- SHA-256 校验和 source_device TEXT, -- SD 卡设备标识(如序列号) imported_at INTEGER NOT NULL, -- 导入时间 - writeback_at INTEGER, -- 回写到 NAS 的时间(NULL=未回写) - state TEXT NOT NULL -- imported / writeback_done / failed + uploaded_at INTEGER, -- 上传到 NAS 的时间(NULL=未上传) + state TEXT NOT NULL -- imported / uploaded / failed ); CREATE INDEX idx_checksum ON import_history(checksum); @@ -732,40 +571,18 @@ CREATE INDEX idx_original_path ON import_history(original_path, file_size); 导入文件前 → 计算 checksum → 查 import_history WHERE checksum = ? AND file_size = ? → 命中 → 跳过该文件,标记为"已导入过" - → 未命中 → 继续查 cache_files WHERE checksum = ? AND file_size = ? - → 均未命中 → 执行导入 + → 未命中 → 执行导入 ``` -#### 5.7.4 四张表的关系与数据规模 +#### 5.6.5 MVP 表结构关系与数据规模 ```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" - 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 { @@ -773,45 +590,31 @@ erDiagram TEXT original_path TEXT target_path TEXT checksum "持续累积,永久保留" - TEXT state "imported/writeback_done/failed" + TEXT state "imported/uploaded/failed" } ``` -数据规模参考:dir_snapshots 每个关心的目录 1 行,dir_file_list 可能 200 行/目录,cache_files 仅缓存过的文件(可能 3 行/目录),import_history 持续累积。 +两张核心表。只读缓存不需要额外的文件状态数据库——rclone 内部管理所有缓存状态。后续可选增加 dir_file_list(精确变更检测)。 -数据规模估算(以 NAS 上 10 万个文件、用户缓存了 1000 个为例): +数据规模估算: | 表 | 记录范围 | 预估行数 | 存储开销 | |---|---|---|---| -| cache_files | 本地缓存过的文件 | ~1,000 | ~100 KB | | dir_snapshots | 有缓存文件的目录 | ~50 | ~5 KB | -| dir_file_list (v1.5+) | 关心目录下的全部远程文件 | ~10,000 | ~1 MB | | import_history | 所有历史导入记录(持续累积) | ~5,000 | ~500 KB | -| **总计** | | | **< 2 MB** | +| **总计** | | | **< 1 MB** | -#### 5.7.5 删除检测流程(MVP 精简模式) +#### 5.6.6 远程变更检测 -MVP 阶段不使用 dir_file_list,通过反查 cache_files 实现删除检测: +后台轮询线程通过 dir_snapshots 表实现分层轮询(热/温/冷目录区分访问频率)。发现目录 mtime 变化后: +1. `vfs/forget` 通知 rclone 清除该目录的缓存元数据 +2. rclone 下次访问该目录时自动从远程重新读取(删除的文件消失,修改的文件 mtime 更新,新增的文件出现) -```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 -``` +**不需要逐文件对比数据库记录**——rclone 的 `vfs/forget` + 自动重新拉取机制覆盖所有远程变更场景。 -这样只用两张表就完成了所有检测,逻辑清晰,开销极小。 +#### 5.6.7 SQLite 并发访问策略 -#### 5.7.7 SQLite 并发访问策略 - -metadata.db 会被多个进程/线程并发访问:Write-back Controller、轮询线程、SD 卡导入进程、CLI 管理工具。 +metadata.db 会被多个进程/线程并发访问:轮询线程(读写 dir_snapshots)、SD 卡导入进程(读写 import_history)、SD Uploader(更新 import_history 状态)、CLI 管理工具(只读查询)。 **要求**:metadata.db 必须存放在**本地文件系统**(SSD 的 ext4/btrfs/ZFS)上,**严禁放在 rclone FUSE 挂载目录中**。SQLite WAL 依赖 POSIX 文件锁和共享内存(`-shm` 文件),FUSE/网络文件系统无法正确支持这些语义,会导致数据库损坏。 @@ -829,88 +632,47 @@ WAL 模式的优势: 所有访问 metadata.db 的进程必须使用同一个 WAL 模式配置。部署脚本在初始化数据库时自动设置。 -### 5.8 Write-back Controller 架构 +### 5.7 SD 卡导入与上传 -rclone 原生的 `--vfs-cache-mode full` 不做回写前的 mtime 比较(盲写),因此需要在 rclone 之上包一层 Write-back Controller: +SD 卡导入和上传是**独立于缓存系统的单向管道**:SD 卡 → SSD 暂存 → SFTP 上传 NAS。 -**原来(rclone 默认)**:rclone VFS → 脏文件 → 直接 SFTP 上传 → 可能覆盖新数据 ❌ +**实现要点**: +1. SD 卡导入进程将文件复制到 `ingest_staging//` 暂存目录并计算 checksum +2. 查 import_history 去重,校验通过后记录导入历史 +3. SD Uploader 独立进程扫描 `ingest_staging/` 中已校验完成的文件 +4. 通过 SFTP 上传到 NAS 目标路径(临时文件名 `.warpgate-tmp-` → rename 为最终文件名) +5. 上传成功后更新 import_history 状态 + 清理暂存文件 + 可选 `vfs/forget` 刷新缓存 -**改为(双管道架构)**: +**性能优势**:导入直接写入 SSD 本地文件系统,无 FUSE 开销。导入速度取决于 SD 卡读取速度和 SSD 写入速度,通常可达 100MB/s+。上传带宽独立于缓存读取带宽。 -```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 +### 5.8 SD 卡导入状态机(中断保护) - 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 -``` +**问题**:SD 卡导入过程中可能发生中断(卡被拔出、电池耗尽、进程崩溃)。部分复制的文件不应进入上传队列。 -Write-back Controller 作为独立进程运行,监控 rclone 缓存目录中的脏文件,替代 rclone 的原生回写逻辑。Controller 通过 metadata.db 中的 `source` 字段区分两条管道,对 `source=ingest` 的文件执行路径重映射和目录创建逻辑。 - -#### 5.8.1 SD 卡导入与 rclone VFS 的集成方案(架构讨论) - -**问题**:rclone VFS 维护自己的内部元数据来跟踪缓存了哪些文件。如果外部进程(SD 卡导入)直接往 VFS 缓存目录写文件,rclone 完全不知道这些文件的存在,会导致: -1. rclone 的内部元数据与 metadata.db 不同步 -2. 应用通过 FUSE 挂载点访问这些文件时,rclone 可能尝试从远程拉取(因为它不知道本地已有) -3. Write-back Controller 需要处理的脏文件从 rclone 角度看根本不是脏文件 - -**考虑过的方案**: - -| 方案 | 原理 | 优点 | 缺点 | -|------|------|------|------| -| A. 通过 FUSE 挂载点写入 | SD 卡导入写入 `/mnt/nas-photos/ingest/...`,rclone 自然跟踪 | rclone 自动管理缓存元数据;脏文件对 rclone 可见 | 需要拦截 rclone 的原生回写以实现自定义路径映射;FUSE 写入性能可能低于直写 SSD | -| B. 自定义缓存层 | 不依赖 rclone 作为唯一缓存管理器,构建自定义缓存层 | 完全控制缓存行为 | 开发量大,需要自己实现远程拉取、缓存淘汰等 rclone 已有功能 | - -**最终决策**:采用**方案 A**——通过 rclone FUSE 挂载点写入。 - -实现要点: -1. SD 卡导入进程先将文件复制到 `ingest_staging/` 暂存目录并计算 checksum -2. 校验通过后,通过 FUSE 挂载点(`/mnt/nas-photos`)写入到 INGEST_TARGET_PATH 指定的路径 -3. rclone 自动将其纳入 VFS 缓存管理并标记为脏文件 -4. 导入进程同步在 metadata.db 中创建 `source=ingest, origin_mtime=NULL` 记录 -5. Write-back Controller 拦截 rclone 的原生回写(通过配置 `--vfs-write-back` 为极大值或禁用),执行自定义回写逻辑 - -**⚠️ 实现前需要做架构 spike 验证**:确认 rclone 在 `--vfs-write-back` 被禁用时的行为,以及通过 FUSE 挂载点写入大文件的性能是否满足导入场景需求(目标:SD 卡导入速度 ≥ 80MB/s)。 - -### 5.9 SD 卡导入状态机(中断保护) - -**问题**:SD 卡导入过程中可能发生中断(卡被拔出、电池耗尽、进程崩溃)。部分复制的文件如果被标记为 dirty 并进入回写队列,会把不完整的文件上传到 NAS。 - -**解决方案**:导入过程维护严格的状态机,只有完整校验通过的文件才进入缓存。 +**解决方案**:导入过程维护严格的状态机,只有完整校验通过的文件才进入上传队列。 ```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 接管 + checksumming --> registered: SHA-256 校验通过 +
写入 import_history + registered --> uploading: SD Uploader 开始上传 + uploading --> complete: SFTP 上传成功 +
清理暂存文件 + complete --> [*] note right of detecting: 中断 → 清理 staging 临时文件 note right of copying: 中断 → 清理 staging 临时文件 note right of checksumming: 中断 → 清理不完整文件 - note right of registered: 中断 → 文件已在 FUSE + DB
重启后 Controller 正常接管 + note right of registered: 中断 → 文件在暂存区 + DB
重启后 SD Uploader 继续上传 + note right of uploading: 中断 → NAS 上临时文件
重启后重新上传覆盖 ``` -**中断保护**:每个导入会话有唯一 `session_id`。`detecting`/`copying`/`checksumming` 阶段中断时,`ingest_staging/` 中的临时文件在下次启动时自动清理。`registered` 阶段中断时文件已安全进入缓存系统,Controller 重启后正常接管。 +**中断保护**:每个导入会话有唯一 `session_id`。`detecting`/`copying`/`checksumming` 阶段中断时,`ingest_staging/` 中的临时文件在下次启动时自动清理。`registered`/`uploading` 阶段中断时文件安全在暂存目录中,SD Uploader 重启后扫描暂存目录继续上传。NAS 上的临时文件名(`.warpgate-tmp-`)确保不完整上传不会被当作正常文件。 **实现要点**: - 暂存目录 `ingest_staging//` 按导入会话隔离 -- 进程启动时扫描 `ingest_staging/`,清理所有未完成的会话(非 registered 状态的文件) +- SD Uploader 启动时扫描 `ingest_staging/`,清理未完成校验的会话,续传已校验的文件 - 导入进度持久化到 `ingest_sessions` 表或简单的 JSON 文件,支持断点续传 --- @@ -958,13 +720,13 @@ flowchart TD L2["第二层:文件级 mtime 对比
SFTP ls -l 该目录"] L2 --> Changed["发现变化 → 标记缓存失效
(等访问时再拉)"] - L2 --> Del["发现删除 → 按决策矩阵处理"] + L2 --> Del["发现删除 → 清理本地缓存"] L2 --> New["远程新增 → 不处理"] L4["第四层:每日全量校对(兜底)
凌晨全量递归对比
捕捉遗漏 + 清理过期条目"] ``` -轮询伪代码(与 5.7.5 删除检测对齐): +轮询伪代码(MVP): ``` # watched_directories = SELECT dir_path FROM dir_snapshots @@ -978,28 +740,15 @@ for dir in watched_directories: dir_mtime = sftp_stat(dir.dir_path).mtime if dir_mtime == dir.remote_mtime: UPDATE dir_snapshots SET last_polled = now WHERE dir_path = dir.dir_path - continue # 目录没变,跳过全部文件 ✅ + continue # 目录没变,跳过 ✅ - # 第二层:目录变了,查里面的文件 - remote_files = sftp_ls(dir.dir_path) # { name → mtime } - cached_files = SELECT * FROM cache_files # 该目录下的缓存文件 - WHERE dir_path = dir.dir_path - - # 检查已缓存文件的变化 - for file in cached_files: - remote = remote_files.get(file.path) - if remote is None: - # 远程文件被删了 → 按决策矩阵处理 - handle_remote_deletion(file) - elif remote.mtime != file.origin_mtime: - # 远程文件被修改了 - if file.state == "clean": - invalidate_cache(file.path) # 标记失效,下次访问拉新版 - else: - mark_conflict(file.path) # 本地有脏数据,标记冲突 - - # 远程新增的文件(在 remote_files 但不在 cached_files)不处理 - # 等用户实际访问时再按需拉取 + # 第二层:目录变了 → 通知 rclone 刷新缓存 + rclone_rc("vfs/forget", dir=dir.dir_path) # 清除 rclone 目录缓存 + # rclone 下次访问时自动从远程重新读取: + # - 远程删除的文件 → rclone 缓存消失 → 文件不再可见 + # - 远程修改的文件 → rclone 下次访问时拉新版本 + # - 远程新增的文件 → rclone 下次访问目录时可见 + log_info(f"Remote directory changed: {dir.dir_path}, rclone cache invalidated") # 更新目录快照 UPDATE dir_snapshots SET remote_mtime = dir_mtime, last_polled = now @@ -1033,71 +782,57 @@ flowchart LR ```mermaid flowchart TD - App["应用请求读取文件"] --> Check{"① 查本地缓存"} - Check -->|"未命中"| Remote["③ 向远程请求"] - Check -->|"命中"| Valid{"② 轮询是否标记失效?"} + App["应用请求读取文件"] --> Samba["① Samba/NFS(只读)"] + Samba --> FUSE["② rclone FUSE 挂载"] + FUSE -->|"缓存命中"| Return["直接返回缓存
(SSD 速度)"] + FUSE -->|"缓存未命中"| Remote["③ rclone 从远程 NAS 拉取"] + FUSE -->|"缓存已过期"| Refresh["④ rclone 检查远程 mtime
变了则重新拉取"] - Valid -->|"未失效"| Return["直接返回缓存
(SSD 速度)"] - Valid -->|"已失效"| State{"④ 检查文件状态"} - - State -->|"clean"| Pull["拉远程新版本
更新缓存"] - State -->|"dirty"| Conflict["标记冲突
两边保留"] - - Remote --> Chunk["④ 按 chunk 分块下载"] - Chunk --> Write["⑤ 写入本地 SSD 缓存
⑥ 记录 origin_mtime"] - Write --> ReturnData["⑦ 返回数据给应用"] + Remote --> Chunk["⑤ 按 chunk 分块下载"] + Chunk --> Cache["⑥ 缓存到 SSD"] + Cache --> ReturnData["⑦ 返回数据给应用"] + Refresh --> ReturnData ``` **设计要点**: - 读取热路径上**不查远程**(不产生网络请求),直接返回缓存,保证响应速度 -- 远程变更检测由后台轮询线程异步完成,发现变化时标记缓存失效 -- 失效的 clean 文件下次访问时自动拉新版本 -- 失效的 dirty 文件标记为冲突,需要用户介入 +- 目录缓存通过 rclone `--dir-cache-time` 控制过期刷新 +- 后台轮询线程通过 `vfs/forget` 主动刷新已知变化的目录 -### 7.2 写入流程 +### 7.2 SD 导入上传流程 ```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{"⑥ 三时间戳比较"} + SD["① SD 卡插入 → 导入到暂存目录
(SHA-256 校验 + import_history 去重)"] + SD --> Staging["② 文件安全存在 ingest_staging/
(SSD 本地目录)"] + Staging --> Scan["③ SD Uploader 扫描暂存目录"] + Scan --> Upload["④ SFTP 上传到 NAS
(临时文件名 → rename)"] - Decision -->|"正常回写"| Upload["上传 + 更新 origin_mtime
state → clean"] - Decision -->|"冲突"| ConflictKeep["保留双版本 + 通知用户"] - Decision -->|"远程已删"| NoWrite["不回写
原地保留,标记 orphan-conflict"] - - Upload -->|"上传失败"| Retry["重试(指数退避)
10s, 20s, 40s... 最多 10 次"] - Retry -->|"最终失败"| Keep["保留本地,state=dirty
记录日志"] + Upload -->|"上传成功"| Clean["⑤ 更新 import_history
清理暂存文件
vfs/forget 刷新缓存"] + Upload -->|"上传失败"| Retry["⑤ 自动重试
(指数退避)"] + Upload -->|"网络不可用"| Queue["⑤ 排队等待
网络恢复后自动续传"] + Retry --> Upload + Queue --> Upload ``` ### 7.3 缓存淘汰策略 -```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"] -``` +缓存全部由 rclone 管理,所有文件都是 clean 的(与远程一致),可安全淘汰: -#### 7.3.1 缓存空间保护机制 +- `--vfs-cache-max-size`:缓存总大小上限,超出时按 LRU 淘汰 +- `--vfs-cache-max-age`:缓存最大保留时间 +- rclone 自行管理淘汰,无需外部干预 +- 无脏文件 → 不存在「不能淘汰」的场景 → 缓存空间管理极简 -**问题**:dirty 文件永不被淘汰,但 SD 卡导入和 Time Machine 写入都会创建大量 dirty 文件。极端场景下(如离线导入 350GB 照片),dirty 文件总量可能超过 SSD 可用空间。 +#### 7.3.1 SD 导入暂存空间保护 + +**问题**:SD 卡导入的暂存文件占用 SSD 空间,等待上传到 NAS。离线时暂存文件会累积。 **保护措施**: -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),提示用户尽快连网回写 +1. **导入前空间预检**:估算 SD 卡总数据量,检查 SSD 可用空间(扣除缓存 + `CACHE_MIN_FREE`),空间不足时拒绝导入并通知用户(LED 红灯 + CLI 提示),建议先连网上传或清理 +2. **配置验证**(部署时):`INGEST_MAX_IMPORT_SIZE + CACHE_MIN_FREE < SSD 总容量 - CACHE_MAX_SIZE`,不满足时部署脚本报警告 ### 7.4 离线行为 @@ -1105,9 +840,9 @@ flowchart TD |------|------| | 远程不可达,读取已缓存文件 | 正常返回,无影响 | | 远程不可达,读取未缓存文件 | 超时报错(可配置超时时间) | -| 远程不可达,写入文件 | 正常写入本地缓存,回写排队等待恢复 | +| 远程不可达,SD 卡导入 | 正常导入到暂存目录,上传排队等待网络恢复 | | 远程不可达,后台轮询 | 静默跳过,不报错,下次重试 | -| 恢复连接后 | 自动续传回写队列 + 立即触发一轮轮询 | +| 恢复连接后 | SD Uploader 自动续传 + 立即触发一轮轮询 | --- @@ -1142,17 +877,14 @@ flowchart TD | `READ_AHEAD` | 预读缓冲区 | `512M` | 视频场景可加到 `1G` | | `BUFFER_SIZE` | 内存缓冲区 | `256M` | - | -### 写回配置 +### 带宽配置 | 参数 | 说明 | 默认值 | 场景建议 | |------|------|--------|----------| -| `WRITE_BACK` | 写回延迟 | `60s` | Lightroom: `60-120s`,文档: `10-30s` | -| `TRANSFERS` | 并发回写线程 | `4` | 带宽小就设 `2` | -| `BW_LIMIT_UP` | 上传限速上限 | `0`(不限) | 酒店 WiFi 建议 `10-20M` | -| `BW_LIMIT_DOWN` | 下载限速 | `0`(不限) | 一般不限 | -| `BW_ADAPTIVE` | 自适应限速开关 | `yes` | `yes`=根据吞吐量自动降速,`no`=纯手动 | -| `BW_ADAPTIVE_WINDOW` | 吞吐量观测滑动窗口 | `30s` | - | -| `BW_ADAPTIVE_PROBE_INTERVAL` | 拥塞后探测提速间隔 | `2m` | - | +| `UPLOAD_TRANSFERS` | SD 上传并发线程 | `4` | 带宽小就设 `2` | +| `BW_LIMIT_UP` | SD 上传限速上限 | `0`(不限) | 酒店 WiFi 建议 `10-20M` | +| `BW_LIMIT_DOWN` | 缓存拉取下载限速 | `0`(不限) | 一般不限 | +| `BW_ADAPTIVE` | 自适应上传限速开关 | `yes` | `yes`=根据吞吐量自动降速,`no`=纯手动 | ### 目录缓存与轮询 @@ -1164,19 +896,6 @@ flowchart TD | `POLL_COLD_INTERVAL` | 冷目录轮询间隔(30天+未访问) | `1h` | - | | `FULL_SYNC_SCHEDULE` | 每日全量校对时间 | `03:00` | 凌晨低峰期 | -### 冲突处理 - -| 参数 | 说明 | 默认值 | 建议值 | -|------|------|--------|--------| -| `CONFLICT_STRATEGY` | 冲突策略 | `mtime_wins` | `mtime_wins` | -| `CONFLICT_NOTIFY` | 冲突通知方式 | `log` | `log` / `webhook` | -| `CONFLICT_RETAIN_DAYS` | 冲突副本保留天数 | `30` | - | -| `CONFLICT_CLEANUP_SCHEDULE` | 冲突副本自动清理时间 | `04:00` | 与 FULL_SYNC_SCHEDULE 错开 | - -**冲突副本命名**:`{name} (Warpgate Conflict {YYYY-MM-DD HH-mm}).{ext}`,保留在原目录中。 - -**清理进程**:每天在 `CONFLICT_CLEANUP_SCHEDULE` 扫描所有匹配 `(Warpgate Conflict ...)` 命名模式的文件,超过 `CONFLICT_RETAIN_DAYS` 天的自动删除(本地 + 已同步到 NAS 的副本一并删除)。清理前记录日志。 - ### 多协议配置 | 参数 | 说明 | 默认值 | 建议值 | @@ -1197,7 +916,6 @@ flowchart TD | `INGEST_DATE_SOURCE` | 路径模板中日期变量的来源 | `exif` | `exif`=EXIF拍摄日期(回退到mtime),`mtime`=文件修改时间,`import`=导入时间 | | `INGEST_DUPLICATE_CHECK` | 重复文件检测(基于文件名+大小+checksum) | `yes` | `yes` | | `INGEST_DELETE_AFTER` | 导入+校验完成后是否删除卡上数据 | `no` | `no`(安全起见) | -| `INGEST_PRIORITY` | 导入文件的回写优先级 | `high` | 高于普通编辑文件 | | `INGEST_IO_CLASS` | 导入时的 I/O 调度优先级 | `best-effort:4` | 使用 ionice 设置,避免导入阻塞缓存读取 | ### 配网模式配置 @@ -1220,14 +938,6 @@ flowchart TD | `AP_ISOLATION` | AP 网络与 WAN 隔离 | `yes` | `yes` | | `AP_MAX_CLIENTS` | 最大连接数 | `8` | - | -### Time Machine 配置 - -| 参数 | 说明 | 默认值 | 建议值 | -|------|------|--------|--------| -| `TIMEMACHINE_ENABLED` | 启用 Time Machine 支持 | `no` | Mac 用户开启 | -| `TIMEMACHINE_MAX_SIZE` | Time Machine 空间上限 | `200G` | 按 SSD 容量调整 | -| `TIMEMACHINE_PATH` | NAS 上的归档目标路径 | `/timemachine/` | - | - --- ## 九、场景预设(模板) @@ -1237,14 +947,13 @@ flowchart TD ### 摄影师模式 ``` -重点优化:大文件读取性能、Lightroom catalog 回写保护 +重点优化:大文件读取性能、RAW 浏览流畅 - CACHE_MAX_SIZE=500G - READ_CHUNK_SIZE=256M - READ_AHEAD=512M -- WRITE_BACK=120s ← Lightroom 频繁自动保存,合并写入 - DIR_CACHE_TIME=2h ← 目录结构不常变 - POLL_HOT_INTERVAL=30s -- TRANSFERS=4 +- UPLOAD_TRANSFERS=4 ← SD 上传并发 - ENABLE_SMB=yes - ENABLE_NFS=no - ENABLE_WEBDAV=no @@ -1257,10 +966,9 @@ flowchart TD - CACHE_MAX_SIZE=1T - READ_CHUNK_SIZE=512M - READ_AHEAD=1G ← 大预读保证播放流畅 -- WRITE_BACK=60s - DIR_CACHE_TIME=1h - POLL_HOT_INTERVAL=1m -- TRANSFERS=2 ← 减少回写并发,保带宽给播放 +- UPLOAD_TRANSFERS=2 ← 减少 SD 上传并发,保带宽给播放 - ENABLE_SMB=yes - ENABLE_NFS=no - ENABLE_WEBDAV=no @@ -1269,14 +977,13 @@ flowchart TD ### 文档办公模式 ``` -重点优化:小文件快速响应、写入快速同步 +重点优化:小文件快速响应、频繁感知远程变更 - CACHE_MAX_SIZE=50G - READ_CHUNK_SIZE=64M - READ_AHEAD=128M -- WRITE_BACK=10s ← 文档改完快同步 - DIR_CACHE_TIME=30m ← 协作场景需要较快看到新文件 - POLL_HOT_INTERVAL=15s ← 更频繁感知远程变更 -- TRANSFERS=4 +- UPLOAD_TRANSFERS=4 - ENABLE_SMB=yes - ENABLE_NFS=no - ENABLE_WEBDAV=yes ← 移动端也能访问 @@ -1308,7 +1015,7 @@ flowchart TD | USB-A/C 口 | 至少 2 个,用于外接读卡器(XQD 等)或移动硬盘 | | WiFi 模块 | 支持 **AP+STA 并发模式**(配网必须),建议 WiFi 6 | | 物理按钮 | 触发 SD 卡导入 / 确认操作 | -| LED 状态指示 | 导入进度 / 完成 / 错误 / 回写状态 | +| LED 状态指示 | 导入进度 / 完成 / 错误 / 上传状态 | | 内置电池 | 支持断电保护 + 便携使用 | **缓存盘文件系统建议**:btrfs 或 ZFS。利用 CoW(Copy-on-Write)和 journal 机制,即使意外断电也能保证文件系统级别的一致性。 @@ -1324,7 +1031,7 @@ mount -o compress=zstd /dev/ssd_partition /mnt/ssd/warpgate | 组件 | 版本 | |------|------| | OS | Ubuntu 22.04+ / Debian 12+ / 任意 Linux | -| rclone | 1.65+(关键参数:`--vfs-cache-mode full --vfs-write-back 999h --vfs-cache-max-size {CACHE_MAX_SIZE}`) | +| rclone | 1.65+(关键参数:`--read-only --vfs-cache-mode full --vfs-cache-max-size {CACHE_MAX_SIZE} --cache-dir {CACHE_DIR}/rclone-cache --rc`)。`--read-only` 确保只读挂载。`--rc` 启用 RC API,供轮询线程调用 `vfs/forget` 刷新目录缓存 | | Samba | 4.x | | NFS server | nfs-kernel-server(如启用 NFS) | | FUSE | 3.x | @@ -1346,20 +1053,17 @@ mount -o compress=zstd /dev/ssd_partition /mnt/ssd/warpgate | 风险 | 等级 | 说明 | 缓解措施 | |------|------|------|----------| -| 断电丢数据 | 低 | write-back 窗口期内断电 | 硬件 UPS/电池保证落盘;btrfs/ZFS 保证文件系统一致性 | -| 写冲突 | 低 | 多端同时改同一文件 | 回写前 mtime 比较 + 冲突副本保留 + 通知用户 | -| 远程删除后复活 | 低 | Cache 脏文件回写已删文件 | 回写前检查远程存在性,远程已删则不回写 | | 首次访问慢 | 固有 | 未缓存文件必须走远程 | 预热功能;分块下载优化 | | 缓存一致性延迟 | 低 | 远程变更在轮询间隔内不可见 | 分层轮询(热目录 30s);后续可选 Agent 推送 | -| Tailscale 断连 | 中 | 远程不可达时新文件无法获取 | 已缓存文件仍可用;回写自动排队;恢复后自动续传 | -| 多协议锁冲突 | 低 | SMB/NFS 锁机制不同 | 文档约束"同一文件不建议多协议同时写" | +| Tailscale 断连 | 中 | 远程不可达时新文件无法获取 | 已缓存文件仍可用;SD 上传自动排队;恢复后自动续传 | | 轮询开销 | 低 | 大量文件目录轮询消耗带宽 | 目录 mtime 快检跳过未变目录;热度分级降低冷目录频率 | | SD 卡导入数据损坏 | 低 | 卡本身坏块导致导入不完整 | 导入时计算 SHA-256 校验和;双卡校验比对 | -| SD 卡导入中断 | 低 | 卡被拔出 / 电池耗尽 / 进程崩溃 | 导入状态机保护(5.9);未完成文件清理而非标为 dirty | -| 缓存空间耗尽 | 中 | dirty 文件(导入+TM)撑满 SSD | 导入前空间预检;TM 硬配额;缓存空间告警(7.3.1) | +| SD 卡导入中断 | 低 | 卡被拔出 / 电池耗尽 / 进程崩溃 | 导入状态机保护(5.8);未完成文件清理 | +| SD 上传中断 | 低 | 上传过程中断网/断电 | 临时文件名保护 NAS 数据完整;重启后自动续传 | +| 暂存空间耗尽 | 中 | 离线时 SD 导入暂存累积 | 导入前空间预检(7.3.1)| | 中转服务带宽成本 | 中 | DERP 中继带宽随用户增长上升 | 大部分连接走 P2P 直连;按流量分级限速/计费;初期节点少按需扩容 | | 云备份存储成本 | 低 | 用户数据增长导致存储费用上升 | 接低价对象存储(B2/R2);按量计费传导成本;增量备份减少传输量 | -| 酒店 Captive Portal | 中 | Headless 设备无法完成网页认证,旅途场景不可用 | 配网 AP + Portal 代理(4.11);fallback:USB tethering / 手机热点 / MAC 克隆 | +| 酒店 Captive Portal | 中 | Headless 设备无法完成网页认证,旅途场景不可用 | 配网 AP + Portal 代理(4.10);fallback:USB tethering / 手机热点 / MAC 克隆 | --- @@ -1367,8 +1071,8 @@ mount -o compress=zstd /dev/ssd_partition /mnt/ssd/warpgate | 阶段 | 内容 | 重点 | |------|------|------| -| **v1.0 — MVP** | 配置文件 + 部署脚本 + CLI 管理 + 基础一致性 + Time Machine 支持 | SMB + 读写缓存 + 三时间戳 + 精简 metadata(两表)+ 分层轮询 + TM 备份目标(几乎零 Samba 配置成本) | -| **v1.5 — 硬件原型** | SD 卡导入 + 自动归档 + 双卡校验 + **配网模式 + Captive Portal 代理** + LED/按钮交互 + 缓存预热 + 带宽管理 + 连接容错 + 写冲突通知 | 外拍现场核心场景 + P1 功能补全,验证硬件形态 | +| **v1.0 — MVP** | 配置文件 + 部署脚本 + CLI 管理 | SMB 只读共享 + rclone 只读缓存 + 分层轮询 + SD 导入 + SD Uploader 单向上传 NAS | +| **v1.5 — 硬件原型 + P1 功能** | SD 卡导入 + 双卡校验 + 配网模式 + Captive Portal 代理 + LED/按钮交互 + 缓存预热 + 带宽管理 + 连接容错 | 硬件原型开发,P1 功能完善 | | **v2.0 — 组网服务** | 内置 Headscale + 高速 DERP 节点 + WiFi AP 共享 | 开箱即连 + 现场团队协作 | | **v2.5 — 容灾 + 附加** | 云端异地备份 + Docker 镜像 + 多协议(NFS/WebDAV)+ NAS 侧 Agent 推送 | 数据安全闭环 + 降低部署门槛 | | **v3.0 — 硬件产品** | 定制硬件(SSD + 电池 + SD 槽 + WiFi),工业设计,开箱即用 | 产品化,面向非技术用户 | @@ -1447,7 +1151,7 @@ flowchart TD **与现有架构的关系**: -- 复用 write-back 引擎的思路:本地脏文件 → 异步上传 +- 复用 SD Uploader 的思路:本地文件 → 异步上传到云端 - 不同点:备份目标是云端对象存储而非 NAS,且可以备份 NAS 全量数据(不限于缓存过的文件) - 可以做成两级: - **热备份**:盒子 SSD 上缓存过的文件自动备份(几乎零额外成本) @@ -1477,7 +1181,7 @@ flowchart TD | 程序员场景(Git 缓存、Docker 镜像等) | 痛点不够强,已有成熟方案(Git 天然分布式、Codespaces 等) | | 公网文件分享链接 | 法律风险 + 需求不明确 | | 多设备 SaaS 管理面板 | 需求不明确,过早 | -| Docker 开放运行环境 | 产品定位发散(注:这里指的是允许用户在盒子上运行任意 Docker 容器,而非 4.22 的"将本产品打包为 Docker 镜像部署") | +| Docker 开放运行环境 | 产品定位发散(注:这里指的是允许用户在盒子上运行任意 Docker 容器,而非 4.21 的"将本产品打包为 Docker 镜像部署") | --- @@ -1489,12 +1193,9 @@ flowchart TD | **NAS** | 用户家中的网络存储设备(Network Attached Storage) | | **VFS** | rclone 的虚拟文件系统层(Virtual File System),将远程存储挂载为本地目录 | | **FUSE** | 用户空间文件系统(Filesystem in Userspace),Linux 内核机制,允许 rclone 在不修改内核的情况下提供文件系统挂载 | -| **FUSE 挂载点** | rclone 通过 FUSE 暴露的本地目录(如 `/mnt/nas-photos`),应用通过它访问远程文件 | -| **dirty / 脏文件** | 本地已修改但尚未回写到远程 NAS 的文件 | -| **clean / 干净文件** | 与远程 NAS 保持一致的缓存文件 | -| **origin_mtime** | 文件上次与远程同步时远程的修改时间,用于检测远程变更 | -| **Write-back Controller** | 自定义的回写控制器,替代 rclone 原生的盲写逻辑,加入冲突检测 | -| **sparsebundle** | macOS 的稀疏包磁盘映像格式,由大量 8MB band 文件组成,Time Machine 使用此格式 | +| **FUSE 挂载点** | rclone 通过 FUSE 暴露的只读本地目录(如 `/mnt/nas-photos`),Samba/NFS 直接服务于此目录 | +| **SD Uploader** | 独立上传进程,将 SD 卡导入的暂存文件通过 SFTP 单向上传到 NAS | +| **ingest_staging** | SD 卡导入暂存目录,文件在此完成校验后等待上传到 NAS | | **LRU** | 最近最少使用(Least Recently Used),缓存淘汰算法 | | **SFTP** | SSH 文件传输协议,本产品与 NAS 通信的主要协议 | | **Tailscale** | 基于 WireGuard 的组网工具,用于建立盒子与 NAS 之间的安全隧道 |