上篇已经说明账户授权、消息和附件的存储结构,以及文件传输的过程。
这一篇接着讨论数据保存之后的问题:多台设备怎样读取和修改同一份消息,历史记录怎样归档,以及删除后如何恢复或清理。

仍以 OneDrop 1.0.3 为准。


# 多设备同步、冲突与归档

# 消息同步与缓存

# 触发同步的时机

OneDrop 的同步主要由客户端主动拉取。
打开界面、回到前台、手动刷新和发送相关流程都会触发对应的读取,因为没有一个自建服务持续向所有客户端推送消息,这也是无服务器方案的一种妥协。
关闭浏览器以后,也就不能把它当作始终在线的即时消息系统。

界面重新回到前台时,还会检查距离上次同步已经过去多久,避免每次切换窗口都重复读取。
设置中,针对同步的频率,设计了 3 个模式。普通模式在距离上次同步达到三十秒时检查更新,Reduced 模式把这个判断间隔延长到五分钟,手动模式则不会因为这项前台检查而自动同步。

# 按月份读取与复用缓存

读取某个月份的消息时,程序先列出 messages/YYYY-MM 下的文件,每页请求 200 项,根据返回结果中的下一页地址 @odata.nextLink 继续读取,再按块编号读取。
这里的 “200 项” 指一次最多请求多少个文件条目,并不是这个月只能保存 200 条消息或 200 个块。
月份目录不存在可以作为空状态;请求失败则需要报告错误,两者不能混在一起。
分页返回的地址还会检查是否仍在预期的 Graph API 范围内。

为了避免每次同步都重新下载未变化的消息块,OneDrop 会缓存已经读取的内容。
缓存除了最终的消息数组,还保存各块的 DriveItem ID、ETag、文档和消息行号。

ETag 是服务端返回的文件版本标记,可以用来判断云端文件是否发生变化。
如果远端 ETag 与缓存一致,并且缓存中有需要的行号信息,就复用这块内容;只有出现变化的块才重新下载解析,且最多同时下载三个块。
因此消息越多时,也不需要每次把所有 JSON 正文都重新取回来。

缓存复用的判断可以直接对照这一段。版本未变、消息行号也齐全时使用本地记录,否则调用 downloadDocument() 下载:

const downloaded =
  cached?.eTag === item.eTag && cached.messageLines
    ? { document: cached.document, messageLines: cached.messageLines }
    : await downloadDocument(item.id, month, accessToken);

解析后按 ID 合并并按 createdAt 和 ID 排序,再根据 tombstones 的删除标记过滤已删除的消息。
同一个 ID、相同内容的记录可以去重;同一个 ID 却有不同内容,则会保留冲突信息,包括所在文件与行号,留给后面的处理。

# 跟进上传状态

上篇中,介绍了 file-uploading 占位记录:附件尚未传完时,其他设备会先看到这条记录。
为了及时显示上传结果,界面会更频繁地重新读取这些消息,检查占位是否已被正式文件消息替换。

轮询逻辑在界面可见且已登录时,以 2、5、8、10 秒的等待间隔读取,后续沿用十秒间隔。
累计等待达到 120 秒后停止本轮检查,并把仍未完成的占位标记为无响应;这个累计值只计算等待间隔,不包含网络请求本身的耗时。

本地已有附件,且处于 committing (正在提交正式消息)状态的记录,也按同样的等待节奏补交正式消息,达到本轮上限后停止自动重试。
重新联网的 online 事件则会再次补交这类记录,并清理失败上传留下的占位。
这些工作需要客户端继续运行或重新恢复连接,由本地保存的状态接续处理。

# 加载历史消息

向上浏览历史消息时,程序先展开当前已加载月份中的更多批次,并再向前读取一个 UTC 月份。
读取请求还会关联请求版本和本地写入版本,响应回来时重新判断是否可以应用,避免旧响应把刚完成的本地操作盖掉。
另外,为了做到更好的 UX,下拉加载历史的过程中,还会保存可见消息的位置作为滚动锚点。这是为了尽量避免当前显示的内容,突然被更新的历史消息挤走。

# ETag 与并发写入

读取阶段可以把多个块合并起来,但写入时还要防止相互覆盖。
假设有这样一个场景:设备 A 和 B 都读到了版本 v1 ,A 加上一条消息后写入成功;如果 B 随后把自己基于 v1 拼出的完整 JSON 无条件写回,A 新增的消息就会丢失。

# 检查版本并重试写入

前面读取缓存时,使用 ETag 判断内容是否变化,写入时也可以使用这个版本标记。
更新现有块时,OneDrop 把读取到的值放进 If-Match ,要求服务端只在版本仍匹配时接受这次写入。
A 已经更新过文件以后,B 的旧条件就不能直接通过。

两台设备使用 ETag 检测旧版本写入并重新合并消息的示例

如果文件版本已变化,服务端会返回 412 Precondition Failed ;创建同名对象等冲突可能返回 409 Conflict 。
OneDrop 遇到这两类响应时,会清掉旧缓存,再读取云端内容后重试:

if (response.status === 409 || response.status === 412) {
  await deleteMonthCache(month);
  snapshot = undefined;
  continue;
}

continue 之前丢掉的是旧快照,下轮会重新读取远端记录,重新按消息 ID 合并,再决定写入哪个块。
如果已经发现同一个消息 ID,就直接返回已有结果,避免同一次发送的重试重复追加。
最多尝试五轮,仍然冲突就明确报错,让上层保留失败状态。

新块没有旧 ETag,所以使用创建时的冲突失败策略。
两个设备同时决定创建 0002.json ,只有一个创建成功,另一个重新读取后再判断下一步。
目录被删除导致 404 时,也会清理相关缓存并尝试重新确认目录。

# 网络异常与本地写入顺序

网络异常不能照搬 409/412 的处理。请求可能已经到达服务端,只是响应没能回到客户端。
因此,写入函数捕获这种异常后会使缓存失效并返回错误,不在这个分支自动重放整次写入。
等后续同步或明确重试时,再通过消息 ID 判断现状。

同一份 OneDrop 内部还有一个 Promise 队列,让对月消息的修改依次执行,上一项结束后再开始下一项,减少本机自己的冲突。
但它只协调当前运行时中的任务,多设备间仍然要依靠服务端条件写入。
将队列和 ETag 放在不同层处理,也就不需要为了一个大文件的上传,让所有文本消息一直等待网络传输结束。

# 消息分块与异常处理

# 分块大小与追加位置

如果整个月的消息都写在一个无限增长的 JSON 中,每次发送都需要处理越来越大的文档。
因此追加新消息时,优先向最后一个活动块写入。准备写入后,若超过 256 KiB 软上限,就为这条消息创建下一块。
单块还有 320 KB 硬上限,单块文件的编号最多到 9999,超过限制会报错。不过毕竟只是记录消息记录的 JSON,不至于能超过 9999 的编号限制。

软上限决定追加消息时是否换块,硬上限则限制最终写入的整个块的大小,包括新建块以及替换上传占位后的块。
旧块仍可读取;最终文件消息替换占位时,则要定位原来所在的块,而不是把它追加到当前最后一块。

代码先尝试把消息合并到最后一块,超过软上限时改为新建块,再检查最终内容是否超过硬上限。

追加消息时选择消息块并检查大小的源码

# 损坏记录与内容冲突

除了文件大小,读取和修改消息时还可能遇到 JSON 损坏,或者同一个消息 ID 对应不同内容的情况。
读取时可以收集这些问题,让界面保留其余可读内容;严格读取则会中止,不把坏 JSON 当作空数组。

普通追加仍可以在保留损坏文件的情况下继续;计算下一块编号时,会把损坏块的编号也算进去,避免占用原路径。

远端重新读取后,如果目标消息 ID 存在已知内容冲突,就拒绝这次写入。
这项检查位于远端重读路径;写入也会先复用本地缓存,并不会在每次写入前都完整检查所有远端冲突。
历史消息归档的时候,对此的处理条件更严格,只要源月份还有损坏文件或消息冲突,就先阻塞整理。

对于同 ID 的不同版本,程序不能用 “最后看见的版本” 静默覆盖,应当交给用户判断处理方法。
用户选定要保留的来源后, resolveMessageConflict() 从其他来源块中移除对应记录,更新仍然带 ETag 条件。
如果处理期间文件再次变化,会要求重新检查,不能把之前的选择无条件套到新内容上。

# 历史消息归档

# 生成归档与校验内容

当一个 UTC 月份结束超过二十四小时以后,程序可以把它归档到 archive/YYYY-MM.json 。
这个时间条件让月末写入有一个缓冲,而归档本身把许多小块整理成一份历史文档,减少后续读取的文件数量。
符合归档条件的历史读取会先尝试归档路径;归档读取超过八秒、读取失败或没有归档时,回退到源消息块。
归档读取仍会应用当前的删除标记,不会因为进入历史文件就忽略后来发生的删除。

创建归档时使用 conflictBehavior=fail ,表示遇到同名文件就返回冲突,不直接覆盖已有归档。
写入以后还要读回,比较序列化内容是否与准备发布的文档一致;如果归档已经存在,也需要核对它与源月份的可见内容。
源文件损坏、消息存在冲突或现有归档不一致时,任务进入阻塞状态。

# 失败重试与超时处理

任务状态会保存在本地: cooling-down 表示等待下一次重试, auth-paused 表示等待恢复授权, blocked 表示有数据问题需要先处理, succeeded 表示归档已成功。
普通失败按五分钟、三十分钟、六小时,之后二十四小时的间隔等待重试;授权失败暂停,阻塞问题需要先处理。
这避免每次打开界面都立即重复一项肯定失败的归档工作。

归档任务还有三十秒的超时报告:到这个时间会记录失败通知,但不会取消底层归档操作。
程序仍等待真实结果,操作稍后成功时可以更新任务状态。
所以看到超时提示时,后台工作也可能还在继续。

# 清理源目录与恢复限制

归档完成后,原来的月份目录仍然占用空间,需要另行清理。
为了在清理前确认归档内容没有变化,成功任务会保存一份根据归档内容计算的摘要,并标记 cleanupPending (源目录待清理)。
后续再读回原始归档,确认摘要仍一致,才尝试清理源月份目录。
所以 “已经归档” 不代表源块已经在同一时刻全部删除,清理失败可以留待下一次执行。

下面截取清理函数开头的检查:归档不存在、未保存摘要,或者当前摘要与任务记录不一致时,直接返回,不继续删除源目录。

清理源月份目录前核对归档摘要的源码

这部分也有需要继续完善的地方。
归档摘要用来确认归档内容是否发生变化,但当前清理流程没有在删除源目录前,再核对源数据是否仍是归档时的版本。
另外,归档使用的是根据删除标记( tombstone )过滤后的可见消息,而不是把所有删除前原始记录一并保留。
因此,如果另一台设备在归档后又改了源数据,清理流程并不能仅凭归档摘要发现这次变化;已经删除的记录也可能随着源目录清理而失去恢复来源。
后面讨论恢复时,需要再检查原始记录是否仍然存在。

# 删除、恢复与物理清理

# 删除标记与保留策略

如果删除只是从本机消息数组里移除一项,下次同步还会把云端记录读回来。
即便马上从某个 JSON 块删除,也要考虑另一台设备可能还持有旧数据。
OneDrop 因此把 “这条消息已删除” 单独保存成 tombstone ,由所有读取流程应用同一份删除状态。

删除标记也常称为墓碑记录。它包含消息 ID messageId 、消息所属月份 originalMonth 、删除时间 deletedAt 和可选的恢复策略 recovery 。
按原始月份保存到 tombstones/YYYY-MM.json 后,普通消息读取与归档读取都会过滤命中的 ID。
因此一条消息从界面消失,并不要求它的附件和原始 JSON 同时删除。

普通月份读取中的过滤代码如下。先读出已删除消息的 ID 集合,再从合并后的消息中排除这些 ID;这里只改变本次读取结果,没有删除源文件:

const deletedMessageIds = await readTombstoneIds(month, accessToken);
const messages = unfilteredMessages.filter(
  (message) => !deletedMessageIds.has(message.id),
);

OneDrop 删除标记、恢复与分步清理的生命周期示意

墓碑本身也可能被多设备同时修改。
写入和移除都使用读取、合并、条件更新的方式处理,冲突时重新读取,最多五次尝试。
这避免设备 A 删除一条消息时,把设备 B 刚写进去的另一个删除标记覆盖掉。

恢复策略会记录在删除操作里。
消息结构中的恢复策略支持关闭恢复,以及保留 3、7、10、30 天或永久保留;有限保留可以写入明确的 recoverableUntil 。
旧记录没有这项信息时,清理逻辑用删除时间加十天作为兜底。
因此恢复期限由删除时的策略决定;“关闭恢复” 会使记录具备清理资格,但远端对象仍要经过后面的清理流程。

# 回收站与消息恢复

墓碑只记录删除状态,不保存完整文本或文件内容。
显示回收站时,程序要把 tombstone 与原始消息重新对应起来:读取原始归档,再读取源块中的未过滤消息,合并后找对应 ID。
所以普通列表已经隐藏的记录,只要原始内容还在,回收站仍然可以显示。

OneDrop 回收站中的已删除消息、剩余保留天数和恢复按钮

回收站读取时也会检查同 ID 的内容。
相同记录可以合并,有互相矛盾的版本则不把它当作一条确定可恢复的消息。
原始记录已经不存在时,也不会仅凭墓碑造出一个空壳,继续显示成 “可以恢复”。

恢复前会再次确认墓碑和原始消息都还存在,然后移除对应 tombstone ,并清掉该月缓存。
下一次正常读取不再过滤它,消息就重新进入列表。
这个过程没有重新上传附件,也没有重新生成一条新消息。

对应的恢复函数先找到删除标记和原始消息,再移除标记、清空月份缓存。任一来源不存在时,函数会提前报错,不继续恢复。

核对来源并恢复已删除消息的源码

因此恢复成立的前提是原始记录仍然可取得。
保留时间是清理资格的依据,不是独立备份承诺;如果用户已经手动移除原始文件,或者前面提到的归档清理已使删除记录失去来源,就不能只靠一个 ID 恢复正文。
对文件而言,消息记录存在也不等于附件仍然存在,附件可能被外部操作移走或删除。

# 过期数据清理

# 筛选待清理记录

恢复期限结束后,还需要真正删除云端附件和消息记录,释放它们占用的空间。
这一步由独立的物理清理流程处理。
普通扫描间隔是二十四小时,每轮最多检查二十个符合条件的待清理项目;手动清理会跳过普通扫描间隔和到期等待,也不受这一普通轮次上限约束。
保留策略为永久时,自动扫描不会把它当成到期项目;关闭恢复时,则从删除时起就具备自动清理资格。

下面通过 eligibleAt 计算可以开始清理的时间;自动扫描会跳过尚未到期的记录,手动清理则跳过这项时间判断。

根据恢复策略筛选待清理记录的源码

进入清理前,还要确认能够安全定位数据。
清理流程会读取原始消息,检查消息时间对应的月份,遇到无法唯一确认的内容就停止处理这项。
如果记录已不存在但仍有损坏文件,需要进一步检查,不能仅因正常解析结果为空就宣布清理完成。

# 核对引用并依次删除

文件消息还要核对附件关系。
在当前读取到的该月原始消息集合里,如果另一条未删除消息引用同一个 driveItemId ,就不删除附件。
随后根据 files/年/月/消息ID 找目录,确认它确实是文件夹,而且里面存在目标 DriveItem ,才删除这个确定路径下的附件目录。
这项引用检查限于当前读取的月份数据,没有遍历整个 OneDrive 建立全局引用关系。

附件处理以后,再从消息块或归档中移除元数据。
JSON 改写使用 ETag 条件;遇到变化或冲突就推迟,保留墓碑等待后续处理。
确认元数据处理完成后,最后移除 tombstone ,并记录这项已经完成。

这个顺序意味着物理清理也可能有中间状态,例如附件已经删除,但元数据条件更新还没成功。
本地维护队列可以让当前设备上的恢复与清理串行执行,但不是所有设备共同持有的一把锁。
一次清理操作可能需要分几步完成,恢复前仍然要重新检查来源。

还有两类数据需要分开看。

  • 从未成功提交到云端的本地失败发送,可以在确认后删除 IndexedDB 记录,不需要制造一个云端 tombstone 。
  • 已经由用户下载、保存到系统目录中的文件,则属于用户自己的副本,不在 OneDrop 的消息清理范围内。
    删除下载登记信息也不等于删除那个本地文件。