Skip to content

Fix cloud sync state consistency and provider-safe conflict handling#1504

Open
cyfung1031 wants to merge 73 commits into
scriptscat:mainfrom
cyfung1031:pr/sync-fix/100
Open

Fix cloud sync state consistency and provider-safe conflict handling#1504
cyfung1031 wants to merge 73 commits into
scriptscat:mainfrom
cyfung1031:pr/sync-fix/100

Conversation

@cyfung1031

@cyfung1031 cyfung1031 commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

背景

本 PR 对 ScriptCat 云同步做一轮生产兼容方向的修复。目标不是把 PR #1439 原样搬进来,而是在现有 main 同步语义上修复状态污染、错误吞掉、provider 能力不清晰和条件写入缺失的问题。

核心原则:

  1. 保持 per-file best-effort,不把整轮同步改成 all-or-nothing。
  2. 成功文件可以推进自己的 file_digest,失败文件保留旧 digest,下轮重试。
  3. 旧云端数据继续可读,包括旧 .user.js、旧 .meta.json、旧 file_digest string map、缺字段 scriptcat-sync.json
  4. provider 层只暴露能力和 typed error,不承担同步业务冲突策略。
  5. Google Drive / Baidu / Dropbox 等非原子 provider 不伪装成 atomic CAS。
  6. 真实 provider 验证需要账号和云端夹具,当前本地已记录未执行原因。

总体改动

本 PR 改动集中在 5 类:

  1. 同步层状态污染修复
  2. scriptcat-sync.json 合并写和旧格式兼容
  3. filesystem capabilities、条件写入、条件删除
  4. provider typed error 和 transient retry
  5. 研究文档、验证矩阵和 rollout 状态记录

涉及文件:

  • src/app/service/service_worker/synchronize.ts
  • src/app/service/service_worker/synchronize.test.ts
  • packages/filesystem/**
  • docs/README.md

关键行为变化

同步层

  • pullScript() 真实失败不再被静默吞掉。
  • deleteCloudScript() 删除失败、tombstone 写失败不再被当作成功。
  • syncOnceInternal() 保持 per-file best-effort,单个文件失败不会阻塞其他文件。
  • 失败文件不会推进 file_digest
  • scriptcat-sync.json 写回前重新读取远端最新状态并合并,降低覆盖其他设备状态的风险。
  • orphan .user.js without .meta.json 会跳过,并保留远端 status。
  • scriptInstall / scriptsDelete 队列路径的 typed failure 能被分类记录,不污染 digest。
  • 多个远端 tombstone 删除通知会聚合,不逐文件弹。

filesystem 能力

新增最小 provider capabilities:

supportsAtomicCompareAndSwap
supportsCreateOnly
supportsConditionalDelete

同步层只在 provider 显式声明能力时传条件参数:

FileCreateOptions.expectedDigest
FileCreateOptions.createOnly
FileDeleteOptions.expectedDigest

这样 WebDAV / S3 / OneDrive 可以使用原生条件写删,Google Drive / Dropbox / Baidu 继续保持非 atomic 行为,不伪造 CAS。

provider typed error

已补关键 provider 错误分类:

  • WebDAV: 429 / 5xx / create-only conflict / conditional delete conflict
  • S3: 412 / PreconditionFailed / 429 / 5xx
  • OneDrive: raw Response 404 / 409 / 412 / 429
  • Google Drive: raw Response 404 / 409 / 412 / 429,reader path lookup miss typed notFound
  • Dropbox: structured path_lookup / path not_found / conflict,raw read 429,保留 content_hash opaque digest
  • Baidu: 精确 errno conflict,reader filemetas miss typed notFound,HTTP 429/5xx typed rateLimit/retryable

retry 策略

LimiterFileSystem 只重试:

  • read-like 操作:verify/open/read/openDir/list/getDirUrl
  • 有保护条件的写入:expectedDigest / createOnly
  • 有保护条件的删除:expectedDigest

普通 write/delete/create 不重试,避免非幂等写重复创建或覆盖。

Commit 说明

Commit 改动 原因
docs(sync): document cloud sync correctness design 新增云同步正确性研究文档和文档索引 先把架构、风险、PR #1439 review 观点和生产兼容原则写清楚,避免直接改代码
test(sync): cover failed task digest preservation 增加失败任务不推进 digest 的测试 复现状态污染问题,保证失败文件保留旧 digest
fix(sync): preserve failed task digests 修复失败任务 digest 推进问题 成功文件可推进,失败文件不能被错误标记为同步成功
test(sync): cover status merge before write 增加写回前合并远端 status 测试 防止覆盖其他设备刚写入的 scriptcat-sync.json
fix(sync): merge latest status before write scriptcat-sync.json 写回前重新读取并合并 降低多设备 status 覆盖风险
test(sync): cover status sync best effort 增加 status sync best-effort 测试 单个状态更新失败不能卡死整轮同步
test(sync): cover legacy sync status file 增加旧 scriptcat-sync.json 兼容测试 旧文件可能缺字段,不能要求用户迁移
fix(sync): tolerate legacy sync status file 容忍旧 status 文件结构 保持生产旧数据可读
test(sync): cover status write failure isolation 增加 status 写失败隔离测试 status 文件写失败不应阻断成功脚本 digest 推进
193a5ac7 fix(sync): isolate status file write failures 隔离 scriptcat-sync.json 写失败 避免状态文件失败扩大成整轮失败
test(sync): cover corrupt status file isolation 增加损坏 status 文件测试 损坏 JSON 不应阻塞脚本同步,也不应覆盖坏文件
fix(sync): isolate unreadable status file 读取 status 文件失败时跳过安全写回 避免在无法读取远端状态时覆盖其他设备数据
test(fs): cover dropbox typed errors 增加 Dropbox typed error 测试 替代脆弱字符串错误判断
fix(fs): use typed dropbox errors Dropbox request 层转换 typed error 让同步层能区分 notFound/conflict/rateLimit/auth
test(fs): cover baidu errno classification 增加 Baidu errno 分类测试 防止所有非 0 errno 都被误判 conflict
fix(fs): classify baidu errno precisely Baidu 只把明确 file-exists errno 判 conflict 保留普通错误语义,避免误报冲突
test(fs): cover raw response typed errors 增加 OneDrive/Google raw response typed error 测试 覆盖 nothen=true 路径
fix(fs): type raw provider response errors OneDrive/Google raw response 转 typed error raw Response 路径不再漏分类 404/409/412/429
test(fs): cover typed transient retries 增加 typed transient retry 测试 验证 limiter 按 typed retryable/rateLimit 工作
fix(fs): retry typed transient errors Limiter 支持 typed transient retry 429/5xx 可有限重试
test(sync): cover delete notification throttling 增加删除通知节流测试 多个 tombstone 不应逐个通知用户
fix(sync): throttle delete notifications 聚合远端删除通知 降低通知噪声
test(fs): cover filesystem capabilities 增加 capabilities 测试 锁定 provider 能力默认值和包装行为
fix(fs): expose filesystem capabilities FileSystem 暴露能力描述 同步层按能力决定是否使用条件操作
test(fs): cover dropbox raw read errors 增加 Dropbox raw read 429 测试 读取内容接口也要 typed rateLimit
fix(fs): type dropbox raw read errors Dropbox reader raw response 转 typed error 避免 read 路径漏分类
test(fs): cover s3 transient errors 增加 S3 transient 测试 覆盖 S3 429/5xx 分类
fix(fs): type s3 transient errors S3 error 转 typed retryable/rateLimit 让 limiter/sync 正确处理 S3 transient
test(fs): cover webdav transient errors 增加 WebDAV transient 测试 覆盖 WebDAV 429/5xx
fix(fs): type webdav transient errors WebDAV error 转 typed retryable/rateLimit 让 transient 行为一致
test(fs): cover s3 conditional operations 增加 S3 条件写删测试 验证 If-Match / If-None-Match / conditional delete
fix(fs): support s3 conditional operations S3 支持条件写、create-only、条件删除 使用原生 S3 条件请求降低覆盖风险
test(fs): cover webdav conditional operations 增加 WebDAV 条件操作测试 验证 ETag If-Match 和 overwrite=false
fix(fs): support webdav conditional operations WebDAV 支持条件写删和 create-only 使用 WebDAV 原生能力
test(fs): cover onedrive conditional operations 增加 OneDrive 条件操作测试 验证 eTag / If-Match / If-None-Match
fix(fs): support onedrive conditional operations OneDrive 支持条件写删 使用 Graph API 条件头
test(sync): cover conditional push options 增加 sync push 条件参数测试 同步层只在安全时传 expectedDigest/createOnly
fix(sync): use provider conditional writes when safe push 使用 provider 条件写能力 已有远端 digest 用 CAS,新文件用 createOnly
test(sync): cover conditional cloud delete 增加条件删除测试 删除时可带 expectedDigest
fix(sync): use provider conditional delete when safe deleteCloudScript 按能力使用 expectedDigest 防止删除被并发修改的云端文件
test(fs): cover webdav create-only conflict 增加 WebDAV create-only false 测试 putFileContents false 应转 typed conflict
fix(fs): type webdav create-only conflict WebDAV create-only false 转 typed conflict 让 sync 识别 create-only 冲突
test(sync): cover status write after file failure 增加文件失败后 status 写回测试 失败 uuid 的远端状态应保留
fix(sync): preserve failed script status while syncing others 文件失败时保留失败脚本 status 成功脚本仍可同步,失败脚本不污染状态
test(fs): cover s3 conditional conflicts 增加 S3 条件冲突测试 覆盖 412 / PreconditionFailed
docs(sync): update implemented provider status 更新 provider 实现状态文档 记录 WebDAV/S3/OneDrive 条件能力已落地
test(sync): cover conflict error classification 增加 sync conflict 分类测试 typed conflict 应标记为 conflict
fix(sync): classify per-file sync failures 同步层增加 per-file 错误分类 区分 conflict/stale_snapshot/transient/unsupported/fatal
test(fs): cover conditional write retry 增加受保护写 retry 测试 只有有条件保护的写才可 retry
fix(fs): retry protected conditional writes Limiter 只重试 protected conditional write/delete 避免非幂等写重复执行
docs(sync): reflect protected write retry 文档记录 protected retry 范围 明确普通 write/delete 不重试
test(sync): cover sync error kind mapping 增加错误类型映射测试 确认 FileSystemError 映射到 SyncErrorKind
test(sync): cover install push transient failure 增加 install 触发 push transient 失败测试 安装成功后云同步失败不能污染 digest
fix(sync): classify queued sync failures 队列触发的 install/delete 同步失败分类 记录 typed failure,保持后续定时同步可补偿
docs(sync): record queued failure classification audit 文档记录队列失败分类审计 说明 install/delete 路径处理方式
docs(sync): refine remaining rollout plan 整理剩余 rollout 计划 明确哪些继续做,哪些不进入本轮
test(sync): cover queued delete typed failures 增加 queued delete typed failure 测试 删除路径失败不能污染 digest
test(fs): cover dropbox opaque digest 增加 Dropbox content_hash opaque digest 测试 不把 provider digest 改成本地 md5
docs(sync): document manual verification path 文档记录手工验证路径 说明真实扩展和真实 provider 验证方式
fix(fs): classify dropbox structured not found Dropbox structured not_found 转 typed notFound 覆盖无 error_summary 的 Dropbox 响应
test(fs): cover dropbox structured conflict 增加 Dropbox structured conflict 测试 覆盖 structured path conflict
docs(sync): keep rollout checklist current 更新 rollout checklist 同步当前完成状态
test(fs): cover non-atomic provider capabilities 增加 Google Drive/Dropbox/Baidu 非 atomic capability 测试 锁定这些 provider 不声明 CAS 能力
docs(sync): record non-atomic provider coverage 文档记录非 atomic provider 覆盖 说明 Google Drive/Dropbox/Baidu 的能力边界
fix(sync/fs): address remaining provider verification gaps Google Drive reader miss、Baidu filemetas miss 转 typed notFound 避免 stale/missing cloud file 被误判 fatal
docs(sync): record provider gap closure 文档记录 provider gap 已关闭 同步 上一个commit 状态
docs(sync): record manual verification result 记录本地验证结果和真实 provider 未验证原因 避免把 mock/unit/build 结果宣称为真实云验证
docs(sync): record real provider verification 记录真实 provider 验证阻塞条件和恢复条件 明确需要 OAuth/账号/夹具后才能继续真实云验证
fix(sync/fs): address real provider verification findings Baidu HTTP 429/5xx 转 typed rateLimit/retryable 真实 HTTP 层限流/服务错误不能被当作普通成功 JSON
docs(sync): finalize rollout status 最终整理 rollout 状态 说明本地可验证范围完成,真实 provider 仍待凭据环境

生产兼容性

本 PR 不要求用户手动迁移云端数据。

兼容旧数据:

  • .user.js / .meta.json 可继续读取。
  • file_digest string map 可继续读取。
  • scriptcat-sync.json 缺字段时不会崩溃。
  • orphan .user.js without .meta.json 会跳过并保留 status。
  • 新 capabilities 是 optional,未声明能力的 provider 保持旧写入语义。
  • Google Drive / Dropbox / Baidu 不声明 atomic CAS,避免把 best-effort 行为误导成强一致。

风险和暂不实现项

仍需真实 provider 验证:

  • WebDAV/S3/OneDrive 真实 ETag / If-Match mismatch。
  • Dropbox 真实 structured conflict / rate-limit。
  • Google Drive path cache stale、同名文件和 best-effort race。
  • Baidu 真实 filemetas 缺失、errno 分类和覆盖写行为。
  • OAuth refresh 路径在真实账号中的表现。

本轮暂不实现:

  • Dropbox rev CAS。
  • Google Drive / Baidu atomic CAS。
  • tombstone_digest
  • 通知聚合 UI。
  • 既有 React hooks warnings。

@cyfung1031

cyfung1031 commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator Author

@CodFrm 用 AI review 吧。我先关 #1439 了。之后同步问题有人反映再跟进吧

加了文档 docs/CLOUD-SYNC.md
有需要可以看一下

Screenshot 2026-06-12 at 13 11 19

@CodFrm

CodFrm commented Jun 12, 2026

Copy link
Copy Markdown
Member

二次 review 后,我认为目前还不能说云同步场景已经完整覆盖。下面按场景整理“期望行为 / 当前覆盖状态 / 还需要补什么”,方便逐项对照。

总体结论

同步层的本地逻辑方向是合理的:按文件 best-effort 同步、失败文件保留旧 digest、成功文件推进 digest、status 写回前合并远端较新状态,这些设计目标是对的。

但当前 PR 还存在关键缺口:provider capability / 条件写删没有真正完整落地,也没有足够测试覆盖。所以现在最多只能证明同步层决策在 mock / 内存 provider 下成立,不能证明真实 WebDAV / S3 / OneDrive 等 provider 上能防止并发覆盖。

云同步场景覆盖矩阵

场景 期望行为 当前覆盖状态 还需要补什么
本地有脚本、云端空 上传 <uuid>.user.js<uuid>.meta.json;provider 支持 create-only 时传 createOnly,避免覆盖并发新增 同步层本地 e2e 应覆盖;provider 层未完整覆盖 provider 单测验证 WebDAV/S3/OneDrive create-only 参数真正落到 API;Limiter 透传 opts
云端有完整脚本、本地空 拉取源码和 meta,install 到本地;若有 scriptcat-sync.json status,应用 enable/sort 同步层本地 e2e 应覆盖 增加损坏 meta / 读取失败时不推进 digest 的断言更稳
双端都有且 digest 未变、本地未更新 不重复 push/pull,只做必要 status 合并 同步层本地 e2e 应覆盖 无额外 provider 要求
双端都有、云端 digest 未变但本地更新时间更晚 不能直接跳过;应补偿 push,用旧 digest 做条件写 同步层本地 e2e 应覆盖 provider 条件写必须落地;否则真实云端仍是普通覆盖
双端都有、本地较新、云端也存在文件 push 本地脚本;支持 CAS 时传 expectedDigest=file_digest[filename] 同步层本地 e2e 应覆盖;provider 层未覆盖 WebDAV If-Match、S3 if-match、OneDrive If-Match 单测
双端都有、云端较新 pull 云端脚本;本地 install 完成后才能推进 digest 同步层本地 e2e / 单测应覆盖 pull 失败场景需确保错误向上抛出并保留旧 digest
云端只有 .user.js,无 .meta.json 视为 orphan/半上传;跳过,不删除、不 pull、不覆盖对应 status 同步层已有单测/本地 e2e 应覆盖 无额外 provider 要求
云端只有普通 .meta.json,本地有脚本 删除无效 meta,再重新 push 本地脚本 同步层本地 e2e 应覆盖 如果删除 meta 失败,需保留对应 digest;建议有单独失败断言
云端只有 tombstone .meta.json,本地有脚本 删除本地脚本,来源标记为 sync;同一轮多个 tombstone 只发一次通知 同步层已有单测/本地 e2e 应覆盖 无额外 provider 要求
用户本地删除,syncDelete=false 删除云端 .user.js.meta.json;支持 conditional delete 时传 expectedDigest 同步层本地 e2e 应覆盖;provider 层未覆盖 Limiter 透传 delete opts;WebDAV/S3/OneDrive conditional delete 单测
用户本地删除,syncDelete=true 删除云端 .user.js,写 tombstone <uuid>.meta.json 同步层本地 e2e 应覆盖 tombstone 写失败时 digest 不应污染,建议补断言
批量删除中单个 uuid 删除失败 继续删除后续 uuid;失败 uuid 的 user/meta digest 保留旧值 同步层单测应覆盖 provider delete typed error 单测补齐
多文件同步中单个 push 失败 其他文件继续;失败文件旧 digest 保留;成功文件 digest 推进 同步层本地 e2e 应覆盖 provider typed conflict/rateLimit/retryable 映射补齐
多文件同步中单个 pull 失败 其他文件继续;失败文件旧 digest 保留;status 保留云端原值 同步层单测应覆盖 建议本地 e2e 也加 pull 失败最终状态断言
scriptcat-sync.json 不存在 允许脚本同步;按本地/云端结果创建新的 status 文件 同步层单测应覆盖 无额外 provider 要求
scriptcat-sync.json 是旧格式,缺 statusstatus.scripts 不崩溃;按空 status 处理并可写回新格式 同步层单测应覆盖 无额外 provider 要求
scriptcat-sync.json 损坏 / JSON parse 失败 脚本文件同步继续;本轮不覆盖写回 status 文件 同步层单测应覆盖 无额外 provider 要求
status 本地较新 写回本地 enable/sort/updatetime 到 scriptcat-sync.json 同步层单测应覆盖 无额外 provider 要求
status 云端较新 应用云端 enable/sort 到本地;写回保留云端较新 status 同步层单测 / 本地 e2e 应覆盖 无额外 provider 要求
status 写回前其他设备更新 写回前重新读取远端 scriptcat-sync.json,合并较新 status,不覆盖其他设备更新 同步层单测应覆盖 需要保证 provider create/write 失败不污染 script digest
orphan uuid 的 status 本机写回 status 时保留 orphan uuid 的云端 status 同步层单测 / 本地 e2e 应覆盖 无额外 provider 要求
文件同步失败 uuid 的 status 失败 uuid 不用本地状态覆盖云端状态,保留云端原 status 同步层单测应覆盖 无额外 provider 要求
scriptcat-sync.json 写回失败 不影响成功脚本 digest 推进;不污染失败文件 digest 同步层单测应覆盖 建议明确断言 status 文件失败不会吞掉文件同步结果
provider 原生 digest file_digest 使用 provider list 返回的 opaque digest;不能用本地 md5 覆盖 provider digest 同步层单测 / 本地 e2e 应覆盖 provider list digest 来源需单测:WebDAV etag、S3 ETag、OneDrive eTag、Dropbox content_hash 等
刚 push 后 provider list 暂时不可见 只在 list 缺失刚 push 文件时,用 push 返回的本地 md5 兜底 同步层单测应覆盖 无额外 provider 要求
非 atomic provider Google Drive / Dropbox / Baidu 不声明 CAS/create-only/conditional delete;同步层不传条件参数 未完整覆盖 provider capability 单测必须明确断言 false/default
WebDAV 条件写 expectedDigest -> If-MatchcreateOnly -> 不覆盖语义;冲突转 typed conflict 未覆盖,且需确认实现 WebDAV provider 单测 + 实现
WebDAV 条件删除 delete expectedDigest -> If-Match;404 仍幂等成功 未覆盖,且需确认实现 WebDAV provider 单测 + 实现
S3 条件写 expectedDigest -> if-matchcreateOnly -> if-none-match: *;412 转 typed conflict 未覆盖,且需确认实现 S3 provider 单测 + 实现
S3 条件删除 delete expectedDigest -> if-match;NoSuchKey 幂等成功;412 转 typed conflict 未覆盖,且需确认实现 S3 provider 单测 + 实现
OneDrive 条件写 simple upload 和 upload session 创建请求都带 If-Match / If-None-Match;冲突转 typed conflict 未覆盖,且需确认实现 OneDrive provider 单测 + 实现
OneDrive 条件删除 delete expectedDigest -> If-Match;404 幂等成功;412/409 转 typed conflict 未覆盖,且需确认实现 OneDrive provider 单测 + 实现
Limiter 透传能力 LimiterFileSystem.capabilities 等于底层 provider capabilities 未覆盖,且需确认实现 Limiter 单测 + 实现
Limiter 透传写删参数 create/createDir/delete 的 opts 原样传到底层 provider;条件写操作按 retry 策略处理 未覆盖,且需确认实现 Limiter 单测 + 实现
同步队列 syncOnce、非 sync 来源 install、非 sync 来源 delete 串行;sync 来源事件不回灌 同步层单测应覆盖 无额外 provider 要求
授权失败 / token 失效 buildFileSystem 失败时记录错误;WarpTokenError 时关闭云同步并通知用户 同步层单测部分覆盖 真实 provider 授权失败仍需手工/集成验证

当前可以说“已覆盖”的范围

范围 说明
同步层决策 本地 e2e 可以覆盖 push/pull/skip/delete/status/digest 的最终状态
单文件失败不阻塞整轮 本地 e2e / 单测可以覆盖
失败 digest 保留、成功 digest 推进 本地 e2e / 单测可以覆盖
orphan/tombstone/status 合并 本地 e2e / 单测可以覆盖
队列串行 单测可以覆盖

当前不能说“已覆盖”的范围

范围 原因
真实 WebDAV CAS/create-only/conditional delete 需要 provider 实现和 provider 单测,mock 同步层不等价
真实 S3 CAS/create-only/conditional delete 同上
真实 OneDrive 条件写删 同上,且 simple upload / upload session 都要测
Limiter 后的真实 capability 可见性 Factory 返回 limiter,不测 limiter 透传就不能证明同步层能看到 provider 能力
真实账号级云端行为 本地 unit / mock e2e 不能替代真实 provider 验证

建议最低合格标准

  1. 同步层本地 e2e 覆盖上表前半部分的业务状态转移。
  2. Limiter 单测覆盖 capabilities 和 opts 透传。
  3. WebDAV / S3 / OneDrive 单测覆盖 capability 声明和条件写删参数映射。
  4. Google Drive / Dropbox / Baidu 单测明确它们不声明 atomic capability。
  5. PR 描述中区分“本地 mock/e2e 通过”和“真实 provider 验证通过”,不要混用。

@CodFrm

CodFrm commented Jun 12, 2026

Copy link
Copy Markdown
Member

🤔 看起来没大问题了,但是还是留下一个版本吧

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CloudSync Related to CloudSync

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants