前两篇解决了同步的中断恢复。第一篇定义了本地操作日志、remoteCursor 和 baseVersion;第二篇说明元数据提交、分块上传和日志拉取如何持久化进度,以及游标失效后如何用快照校准。还剩下一种情况是重试和校准都处理不了的:两台设备基于不同版本修改了同一个文件,而协议里没有把两份修改合成一份的规则。
沿用第一篇的例子。用户在笔记本上离线修改了 计划.txt,编辑基于服务端版本 12;另一台电脑已经把同一文件推进到版本 14,并且改了名字。笔记本联网后提交 update,服务端在 rejected 中返回 version_conflict、currentVersion 和 currentEntry。这次拒绝和请求失败不同:请求正常到达了服务端,本地修改也是一次有效编辑。问题在于两份修改基于不同版本,又没有自动合并规则。
本文固定一种处理策略:服务端优先。服务端版本占用原路径和原文件名;本地未上传的内容必须保留,改为一个可识别的重命名副本;保留哪一份、是否手动合并,由用户决定。下面依次说明冲突的判定条件、三方的职责、收到冲突响应后的持久化流程、用户动作的后果,以及冲突记录的恢复。
用基础版本判断冲突
判定条件由两部分组成:本地操作的 baseVersion 落后于服务端当前版本,并且该操作会改变同一文件的内容、名称或位置。baseVersion 表示用户编辑时所见的服务端状态,落后意味着在自己不知道的情况下,另一端已经提交过有效修改。
按第一篇定义的五类操作分别讨论:
update:两端都修改了内容。如果本地contentHash与服务端当前版本的内容摘要一致,服务端可以按幂等结果接受,两份修改结果相同,不需要用户处理。内容不一致时进入冲突流程,第一篇响应示例中的mac-7f2:184就属于这类:本地提交的是update操作,等待提交期间服务端既改过内容又改过名称,返回的冲突类型是content_and_name。rename:本地重命名基于旧版本。服务端当前名称已经与目标名称一致时,同样的改名已经生效,按重复操作接受。服务端把文件改成了另一个名字,或者名称和内容都变过,进入冲突流程。服务端只改了内容、没有动名称时,本地操作不与服务端竞争同一字段,客户端用服务端当前版本更新baseVersion后重新提交,不需要用户处理。move:判断方式与rename相同,只是比较的字段换成父目录。服务端已经把文件移到别处时进入冲突流程;服务端只更新内容时,客户端基于当前版本重提。目标目录被服务端删除时,服务端按第一篇的约定返回parent_deleted,操作标记为failed,这属于目录约束不满足,不进入用户冲突处理。delete:本地删除基于旧版本,服务端在之后有过有效修改(内容、名称或位置)。直接执行删除会丢掉另一端的修改,服务端拒绝并返回当前版本。这类冲突本地没有需要保护的文件内容,处理方式在后面单独说明。create:本地新建文件时,服务端同目录已存在同名但fileId不同的对象。两个对象的稳定 ID 不同,无法按名称合并,按name类型的冲突处理。
同名创建有一种情况可以自动处理:本地 create 的名称、对象类型和 contentHash 都与服务端已有对象一致时,服务端仍然按 name 冲突拒绝,但在冲突信息里带上 identical: true。客户端收到后按保留服务端版本自动关闭:本地新建对象的内容与服务端相同,删除它不丢任何内容;这条 create 从未被接受,直接关闭,本地索引条目一并移除,服务端对象随后经正常日志拉取落地。删除本地对象时同样要处理事件回流:先登记一条一次性预期 delete 事件,携带路径和这个客户端生成的 fileId,事件到达后由监听器核验消费,登记方式与副本清理一致。整个过程不产生任何 fileId 关联或映射,两个对象的稳定 ID 保持各自独立。自动关闭还要求本地不存在其他引用该 fileId 的未提交操作:先 create 目录再在其中 create 子项时,子项操作依赖目录的 fileId,直接关闭并移除索引条目会让依赖操作失去目标,这种情况即使满足 identical 条件也仍进入用户冲突流程,由用户决定。除此之外,凡是 baseVersion 落后且结果无法确认一致的修改,都交给用户。
function checkOperation(entry, operation) {
if (operation.type === "create") {
// 重复提交在进入本函数之前已按 operationId 返回首次结果。
var sameName = findChildByName(operation.parentId, operation.name);
if (!sameName) return accept(operation);
return rejectConflict(operation, "name", sameName, {
identical: sameName.entryType === operation.entryType &&
sameName.contentHash === operation.contentHash
});
}
if (!entry) return reject(operation, "not_found");
if (operation.baseVersion >= entry.version) return accept(operation);
if (operation.type === "update" &&
operation.contentHash === entry.contentHash) {
return acceptAsDuplicate(operation, entry.version);
}
if (operation.type === "rename" && operation.name === entry.name) {
return acceptAsDuplicate(operation, entry.version);
}
if (operation.type === "move" && operation.parentId === entry.parentId) {
return acceptAsDuplicate(operation, entry.version);
}
return rejectConflict(operation, conflictTypeOf(entry, operation), entry);
}服务端的拒绝条目沿用第一篇的格式,带 operationId、reason: "version_conflict"、retryable: false、currentVersion、currentEntry 和 conflict.type。客户端只把这种带冲突信息的拒绝送入本文的流程;其他不可重试拒绝仍标记 failed。delete 收到 not_found 是例外:服务端对象已经不存在,用户的删除意图事实上已经达成,客户端按幂等结果处理,把操作标记为 confirmed 并直接写入已消费记录,因为服务端不会为这个 operationId 生成日志;本地索引中的删除墓碑一并清除。它不是失败,也不进入冲突流程。
rename 和 move 的 version_conflict 还要先过一次客户端检查。按第一篇的操作结构,rename 和 move 除目标 name、parentId 外还记录 originalName、originalParentId,取自操作生成时本地索引中的名称和父目录。客户端把 currentEntry 与这两个字段比较:名称和父目录都没有被服务端改动时,说明落后的只是内容版本,客户端用 currentVersion 更新操作的 baseVersion 后重新排队,不创建冲突记录;只有服务端也变更了该对象的名称或父目录时,才进入下面的用户冲突处理。
时间戳不能决定保留哪份
一个容易想到的替代方案是比较修改时间,让后写的覆盖先写的。这个办法在两个地方不成立。
第一,设备时钟不可信。用户可以手动修改系统时间,不同设备之间也没有时钟同步保证,两个时间戳的先后关系可能本身就是错的。第二,修改时间表达的是字节发生变化的时刻,不能表达这次编辑基于哪个版本。用户基于版本 12 离线改了一下午的文档,另一台设备基于版本 14 只改了一个字,按最后写入覆盖会静默丢掉前者,而丢掉之前没有任何一方看到过这份内容。
baseVersion 回答的是另一个问题:编辑基于哪个服务端状态。只要它落后,就存在一份自己没有见过的有效修改。此时正确的做法是承认两份修改都存在,按固定规则落地,再由用户判断,而不是用不可信的时间戳替用户做决定。
服务端、客户端和用户各自的职责
服务端保存权威的文件版本。它校验每个操作的 baseVersion 和目录约束,拒绝时返回冲突原因、当前版本和当前条目元数据。服务端从来没有收到过本地未上传的字节,因此它无法替两端合并内容,也不保存本地副本。
客户端在收到冲突响应后负责四件事:停止这条操作的自动重试,下载服务端当前版本,保护本地未上传的内容,生成用户能看懂的冲突记录和提示。客户端不能为了完成同步而静默覆盖任何一边。
用户在无法自动合并的内容之间做决定:保留服务端版本、保留本地副本、打开两个版本手动合并,或者删除确认不需要的副本。协议不理解文档内容的语义,不猜测用户想保留哪一段文字。
服务端优先需要说清含义:原路径和原文件名对应服务端版本,本地内容以重命名副本保留,两者都留在磁盘上。选择这个方向是因为服务端版本已经是其他设备能看到的状态,让它留在共享路径上,其他设备继续按游标消费日志即可;本地修改只存在于这台机器,改名保留不会影响别人。代价是用户需要处理一个副本,客户端必须保证副本找得到、名字认得出、后续动作可追踪。
收到冲突响应后的持久化流程
客户端收到 version_conflict 后按固定顺序处理,每一步都先落盘再执行文件系统操作。
第一步,在处理 rejected 的同一事务里完成三件事:冻结操作、创建冲突记录、为服务端版本登记下载任务(冲突对象是目录时没有内容字节,服务端条目的 contentHash 为 null,跳过登记)。冻结指把这条操作标记为 conflict,它不再进入 listReadyOperations 返回的可提交批次,也不再用原 operationId 重发。remoteCursor 只在这个事务提交后推进。进程在事务提交前退出时,操作仍是 pending,下次用原 operationId 重发,服务端返回首次的拒绝结果,客户端重放同一个事务;进程在事务提交后退出时,冻结标记、冲突记录和下载任务都已落盘。两个方向都不存在操作已冻结、本地内容在哪个路径却没有记录的窗口。
// 在第一篇 syncOnce 的 rejected 分支基础上展开:delete 的 not_found
// 按幂等确认,identical 同名 create 自动关闭,version_conflict
// 在同一事务内完成冻结与登记。
result.rejected.forEach(function (item) {
var operation = tx.readOperation(item.operationId);
// delete 收到 not_found:服务端对象已不存在,删除意图已达成,按幂等结果确认。
if (operation.type === "delete" && item.reason === "not_found") {
// not_found 响应不携带 currentVersion,markOperation 第三参按约定传 null。
tx.markOperation(item.operationId, "confirmed", null);
// 服务端不会为这个 operationId 生成日志,按本次响应的游标直接记为已消费。
tx.markOperationLogConsumed(item.operationId, result.nextCursor);
tx.clearDeleteTombstone(operation.fileId);
return;
}
if (item.reason !== "version_conflict") {
var operationState = item.retryable ? "pending" : "failed";
tx.markOperation(item.operationId, operationState, item.currentVersion);
tx.saveOperationError(item.operationId, item.reason, item.retryable, {
currentEntry: item.currentEntry || null,
conflict: item.conflict || null
});
return;
}
// identical 同名 create 的自动关闭:服务端同名对象的类型和内容摘要与
// 本地新建对象一致,删除本地对象不丢内容,不创建副本、不登记下载任务。
// 前提是本地没有其他引用该 fileId 的未提交操作(例如先建目录再建子项),
// 存在依赖操作时不自动关闭,落入下方用户冲突流程,由用户决定。
if (operation.type === "create" && item.conflict.identical &&
!tx.hasUncommittedOperationFor(operation.fileId)) {
var localEntry = tx.readEntry(operation.fileId);
// 删除本地对象会引发事件回流:登记一次性预期 delete 事件,携带
// 路径与客户端生成的 fileId,事件到达后由监听器核验消费,登记
// 方式与副本清理一致;实际删除在事务提交后执行。
tx.expectFsEvent({
type: "delete",
path: localEntry.path,
fileId: operation.fileId,
conflictId: "conflict-" + item.operationId
});
// 冲突记录直接以 keep_server 关闭,不进入待用户处理的 pending 状态。
tx.createConflictRecord({
conflictId: "conflict-" + item.operationId,
fileId: item.currentEntry.fileId,
baseVersion: operation.baseVersion,
serverVersion: item.currentVersion,
localOperationId: item.operationId,
conflictType: item.conflict.type,
localCopyPath: null,
landingState: "applied",
state: "resolved",
userAction: "keep_server",
occurredAt: Date.now()
});
// 这条 create 从未被服务端接受,服务端不会为它生成日志;创建意图
// 已由服务端同名对象达成,按幂等结果确认,并直接写入已消费记录。
tx.markOperation(item.operationId, "confirmed", item.currentVersion);
tx.markOperationLogConsumed(item.operationId, result.nextCursor);
// 移除本地索引条目,服务端对象随后经正常日志拉取落地。
tx.removeEntry(operation.fileId);
return;
}
// rename/move 前置拦截:服务端没有改动名称和父目录时,落后的只是内容版本,
// 用 currentVersion 更新 baseVersion 后重新排队,不创建冲突记录。
if ((operation.type === "rename" || operation.type === "move") &&
item.currentEntry.name === operation.originalName &&
item.currentEntry.parentId === operation.originalParentId) {
tx.markOperation(item.operationId, "pending", item.currentVersion);
tx.updateOperationBaseVersion(item.operationId, item.currentVersion);
return;
}
var conflictId = "conflict-" + item.operationId;
tx.markOperation(item.operationId, "conflict", item.currentVersion);
tx.saveOperationError(item.operationId, item.reason, false, {
currentEntry: item.currentEntry,
conflict: item.conflict
});
tx.createConflictRecord({
conflictId: conflictId,
fileId: item.currentEntry.fileId,
baseVersion: operation.baseVersion,
serverVersion: item.currentVersion,
localOperationId: item.operationId,
conflictType: item.conflict.type,
localCopyPath: null,
landingState: "pending_download",
state: "landing",
occurredAt: Date.now()
});
// 目录没有内容字节,currentEntry.contentHash 为 null 时不登记下载任务;
// 对应目录变体的落地流程跳过下载与核验,见下文 landConflict 的说明。
if (item.currentEntry.contentHash) {
tx.createDownloadTask({
fileId: item.currentEntry.fileId,
version: item.currentVersion,
contentHash: item.currentEntry.contentHash,
targetPath: downloadTargetOf(item.currentEntry),
conflictId: conflictId,
state: "pending"
});
}
});
// 事务其余部分不变,最后统一 tx.saveRemoteCursor(result.nextCursor)。操作错误记录同时保存 currentEntry 和 conflict,作为这条被拒绝操作的诊断上下文;完整的冲突信息以冲突记录为准,恢复和用户处理流程都读取冲突记录。冲突记录在任何文件系统操作之前写入。之后无论哪一步失败,客户端都能从记录中知道本地内容当前在哪个路径、服务端版本下载到哪一步。localCopyPath 在登记落地事务中写入:landConflict 把 landingState 推进到 renaming 的同一事务里写入副本路径,此前它为 null,表示本地内容仍在原路径。
冲突下载与日志拉取登记的常规下载可能指向同一 fileId 和 version:另一台设备的更新既会出现在服务端日志里,也会被这次冲突的 currentVersion 引用。下载任务按 fileId 加 version 去重,同一版本只存在一个任务。冲突登记时若任务已存在,复用它并附加 conflictId;带 conflictId 的任务不走常规的直接落地,统一由冲突落地流程写回,完成后任务关闭。常规拉取随后再遇到同一版本时按任务状态跳过,不下载两次,也不落地两次。
第二步,下载完成并核验摘要后,生成本地副本名称。名称保留原文件的可读部分,追加设备标识、冲突时间和原扩展名,例如 计划(Ximing 的 MacBook 冲突副本 2015-06-06 21-30).txt。目标位置已有同名文件时追加序号,不覆盖已有副本,因为已有副本可能是上一次冲突留下的内容。
第三步,执行文件系统落地:先把本地文件重命名为副本路径,再把核验过的临时文件原子替换到原路径。两步都由同步引擎发起,必须沿用第一篇的一次性确认记录控制事件回流。重命名前登记预期的 rename 事件,包含旧路径、新路径和 fileId;写回前登记预期的 create 事件,包含原路径、版本和 contentHash。缺少这些记录时,监听器会把副本当作新建文件生成上传操作,再把写回原路径的服务端版本当成用户的另一次新建。
// 对应 update/rename/move 冲突路径:服务端已存在同 fileId 的条目,
// 本地路径直接按 conflict.fileId 查索引。create 冲突的落地变体见下文。
function landConflict(store, fs, conflictId, callback) {
var prepared = store.transaction(function (tx) {
var conflict = tx.readConflict(conflictId);
var entry = tx.readEntry(conflict.fileId);
// 门禁按对象类型分支:目录没有内容字节,冲突登记时不创建下载
// 任务,folder 只核对 landingState;文件还要求下载任务核验完成。
if (conflict.landingState !== "pending_download") {
return null;
}
var task = null;
if (entry.entryType !== "folder") {
task = tx.readDownloadTask(conflictId);
if (task.state !== "verified") {
return null;
}
}
var operation = tx.readOperation(conflict.localOperationId);
var copyPath = tx.allocateConflictCopyPath(entry.path,
tx.deviceName(), conflict.occurredAt);
// copyContentHash 是本地内容的摘要,登记副本时使用:update 冲突取被拒绝
// 操作的 contentHash,rename/move 不改动内容,取本地索引条目的摘要。
// 下方的 task.contentHash 是服务端版本的摘要,只用于核验写回原路径的
// 预期 create 事件。两者来源不同,不能混用。
var copyContentHash = operation.type === "update"
? operation.contentHash
: entry.contentHash;
tx.expectFsEvent({
type: "rename",
from: entry.path,
to: copyPath,
fileId: conflict.fileId,
conflictId: conflictId
});
tx.expectFsEvent({
type: "create",
path: entry.path,
fileId: conflict.fileId,
version: conflict.serverVersion,
// 目录变体的 task 为 null,这条事件不带 contentHash。
contentHash: task && task.contentHash,
conflictId: conflictId
});
tx.updateConflictLanding(conflictId, "renaming", {
localCopyPath: copyPath
});
return {
fileId: conflict.fileId,
originalPath: entry.path,
copyPath: copyPath,
copyContentHash: copyContentHash,
// 副本登记需要对象类型,keepLocalVersion 按 copy.entryType
// 拦截目录副本。主流程取自本地索引条目;create 目录变体取自
// 被移除条目的 entryType。
entryType: entry.entryType,
// 文件冲突的 task 已通过门禁;目录变体没有 task,这两个字段
// 为 null,目录落地在下方执行段的 entryType 分流中不读取它们。
tempPath: task && task.tempPath,
version: conflict.serverVersion,
contentHash: task && task.contentHash
};
});
if (!prepared) return callback(null, false);
fs.rename(prepared.originalPath, prepared.copyPath, function (renameErr) {
if (renameErr) {
return store.failConflictLanding(conflictId, renameErr, callback);
}
store.markConflictLanding(conflictId, "renamed", function (markErr) {
if (markErr) return callback(markErr);
// 执行段按对象类型分流:文件把下载核验过的临时文件原子替换到
// 原路径;目录没有临时文件,直接在原路径创建目录。
if (prepared.entryType === "folder") {
fs.mkdir(prepared.originalPath, function (mkdirErr) {
if (mkdirErr) {
return store.failConflictLanding(conflictId, mkdirErr, callback);
}
finishConflictLanding(store, prepared, conflictId, callback);
});
return;
}
atomicReplace(prepared.tempPath, prepared.originalPath,
function (replaceErr) {
if (replaceErr) {
return store.failConflictLanding(conflictId, replaceErr, callback);
}
finishConflictLanding(store, prepared, conflictId, callback);
});
});
});
}
// 文件原子替换与目录创建之后汇合的最终事务。
function finishConflictLanding(store, prepared, conflictId, callback) {
store.transaction(function (tx) {
tx.updateConflictLanding(conflictId, "applied", null);
tx.updateConflictState(conflictId, "pending");
tx.updateEntryVersion(prepared.fileId, prepared.version);
// 副本登记为不参与同步的索引对象,摘要取本地内容的
// copyContentHash,后续事件归并入冲突记录。entryType 随
// 副本一并登记,作为 copy.entryType 的写入来源。
tx.addConflictCopy(conflictId, prepared.copyPath,
prepared.copyContentHash, prepared.entryType);
}, callback);
}落地过程用 landingState 记录进度,进程在任何一步退出都能恢复。pending_download 时退出,下载任务按第二篇的方式续传;renaming 或 renamed 时退出,恢复任务检查原路径和副本路径的实际状态,补齐缺少的重命名或替换;applied 时退出,只剩界面状态需要恢复。
副本落地的同时在本地索引中登记为不参与同步的对象,并关联 conflictId。监听器收到副本路径的后续事件时不生成上传操作,只归并入对应的冲突记录,用于更新界面展示的副本修改时间和大小。用户在副本上继续编辑因此不会产生新的待提交操作,这一点决定了后面连续冲突的触发条件。
create 冲突进入用户流程时走 landConflict 的一个变体,差异集中在两处。第一,conflict.fileId 是服务端同名对象的 ID,本地索引里没有对应条目,本地路径要通过被拒绝操作的 localOperationId 解析出客户端生成的 fileId 再查索引;写回原路径的预期 create 事件仍登记服务端对象的 fileId、serverVersion 和下载任务的 contentHash。第二,落地完成的最终事务要多移除一条索引:这条 create 从未被服务端接受,服务端日志不会包含这个 ID,本地内容改由冲突副本登记接管。
// create 冲突落地变体相对 landConflict 的关键差异:
var operation = tx.readOperation(conflict.localOperationId);
var clientFileId = operation.fileId; // 客户端生成、服务端从未接受
var localEntry = tx.readEntry(clientFileId);
// 重命名的 from 取 localEntry.path,预期 rename 事件登记 clientFileId。
// 最终事务在 updateConflictLanding 等步骤之外补一条:
tx.removeEntry(clientFileId);这与 identical 自动关闭时移除索引条目的路径对称,区别只是内容保留在副本中等待用户处理。
这个变体还有一个索引时序前提:服务端对象的索引条目不由冲突流程创建,要等后续日志拉取按常规路径写入。最终事务里的 updateEntryVersion 和“保留本地版本”的就绪判断都以这条目存在为前提。日志尚未拉到时,索引里查不到服务端对象,readEntry 返回空,版本更新没有目标。此时“保留本地版本”按未就绪处理,返回未就绪错误,等日志拉取把条目落地后再操作,不为冲突流程单独补建条目。
create 冲突的同名对象也可能是目录。目录没有内容字节,对应前面两处调整:第一步事务里 currentEntry.contentHash 为 null,不登记下载任务;进入落地时门禁只核对 landingState,跳过 task.state 检查。落地本身相应跳过下载核验和原子替换两步:本地目录重命名为副本后,登记一条预期 create 事件(条目类型为目录、路径为原路径、不带 contentHash),随后直接创建目录,再进入最终事务移除客户端生成的索引条目。落地状态机和恢复路径与文件情形一致。
目录的 rename 和 move 冲突走同一条目录落地路径:同样跳过下载核验与原子替换,本地目录改名为副本,再按索引中的当前路径重建目录。与 create 变体的差别在索引处理:被冲突的目录在本地索引中已有条目,不执行 removeEntry;最终事务只更新版本,名称和父目录由常规日志拉取按服务端当前状态写回索引。
落地失败要保护两件事:本地内容和用户后续的真实操作。failConflictLanding 把错误原因写入冲突记录、保持 landingState 不变,并在同一事务里按 conflictId 撤销预期事件记录,撤销范围只包括对应文件系统操作尚未执行的那些。替换失败时,重命名已经执行,它的预期 rename 记录保留,等事件到达后由监听器消费,或按有效期过期;撤销它会让延迟到达的重命名事件被误当作用户操作。尚未执行的写回原路径的预期 create 则必须撤销,不撤销的话,用户之后在同一路径的真实新建会被这条过期记录核验通过,被当成同步引擎自己的写入而吞掉。
重命名失败,常见原因是文件被其他程序占用或权限不足。副本靠重命名生成,只是路径变更,不额外写入字节,磁盘空间不足不会在副本这一步发生;它会在更早的下载阶段让服务端版本的临时文件写入失败,此时落地尚未开始,本地原文件保持不动。这两类失败下客户端都不删除本地原文件、不覆盖已有副本,同步列表显示具体原因,提示用户关闭占用程序或释放空间。用户处理后,下载任务按第二篇的方式从已接收范围续传,落地流程再从持久化状态重试。
delete 操作冲突时没有本地内容需要保护,但索引里有一条用户删除留下的墓碑。落地先在第一个事务里恢复索引条目:清除墓碑、把版本更新为服务端当前版本。服务端版本下载完成并核验摘要后,在原子替换回原路径之前登记预期 create 事件,事件携带原路径、服务端版本和核验过的 contentHash,与主流程 landConflict 先核验、再登记、后替换的顺序一致。缺少这条登记时,监听器会把写回的文件当成用户新建,生成一条多余的上传操作。冲突记录中注明用户曾发起删除,落地完成后直接标记 resolved,不进入等待用户处理的 pending 状态:本地没有内容需要保留,四个处理动作对它不适用。用户看到文件回来了,如果仍想删除,可以再删一次,新的 delete 以当前版本为 baseVersion,能正常提交。冲突对象是目录时同样没有内容字节:跳过下载与原子替换,按索引中的路径直接重建目录,恢复索引条目、登记预期 create 事件的顺序与文件情形一致。
用户提示和四个处理动作
提示有两个入口。第一个是同步列表中的“需要处理”状态,它来自第一篇定义的状态聚合,冲突操作和服务端拒绝信息让对应文件进入这个状态。第二个是首次冲突后的非阻塞通知,告知用户哪个文件需要处理。通知不在每次同步循环时重复弹出;这条操作已经冻结,本来也不会再出现在请求里。
处理界面需要展示用户做判断所需的信息:原文件路径,服务端版本的修改时间和来源设备,本地副本的修改时间和大小,以及两个版本的预览入口。只显示“同步冲突”四个字,用户无法决定任何事。
用户有四个动作,每个动作的文件系统后果和日志后果都必须明确:
| 动作 | 文件系统后果 | 日志与记录后果 |
|---|---|---|
| 保留服务端版本 | 删除本地冲突副本,或按产品策略移入回收站。 | 冲突记录标记 resolved: keep_server,不产生新的同步操作,原路径保持服务端版本。 |
| 保留本地版本 | 副本保留到新操作确认且对应服务端日志消费后再清理;原路径内容变为本地内容,名称和位置保持服务端状态。 | 以当前服务端版本为 baseVersion 生成新的 update 操作,引用副本内容;rename/move 冲突的名称和位置意图不随此动作恢复,需要时由用户重新执行;旧操作保持 conflict 状态关闭。 |
| 打开比较,手动合并 | 打开两个版本;用户在原路径上编辑并保存。 | 用户的保存经监听层生成普通 update 操作,基于当前服务端版本;用户确认后冲突记录标记 resolved: merged。 |
| 删除副本 | 删除本地冲突副本。 | 冲突记录标记 resolved: discard_local,本地修改就此放弃。 |
四个动作中,“保留本地版本”只适用于有内容的文件冲突。目录的 name 冲突没有内容字节需要重新上传,用户可选“保留服务端版本”,由客户端删除目录副本并关闭冲突记录;若想保留目录副本中的内容,需自行在客户端之外归置,之后选择“删除副本”关闭记录,此时副本若已不存在,删除按幂等成功处理,与重启恢复按磁盘实际状态修正副本标记的逻辑一致。“打开比较,手动合并”对目录同样没有意义。delete 冲突本地没有内容需要保护,落地后冲突记录直接标记 resolved,这四个动作对它不适用;用户仍想删除的,重新删除一次即可。
“保留本地版本”需要特别说明。它是一次全新的上传,不复用被拒绝的旧操作:新操作使用新的 operationId,baseVersion 等于当前服务端版本,contentHash 指向副本内容。旧操作留在日志中保持 conflict 终态,用于诊断。这样服务端看到的是一条基于最新版本的正常 update,校验规则不需要为冲突处理开口子。它恢复的也只有内容。对 rename/move 冲突,新操作是 update 类型,不携带名称和父目录,原路径的名称和位置保持服务端当前状态。用户仍要改名或移动时,在落地完成后重新执行一次 rename/move,新操作以当前服务端状态为 baseVersion 正常提交,与 delete 冲突“想删除就再删一次”是同一种处理方式。动作开始前要校验冲突记录仍在 pending 状态、服务端版本已经落地,避免在落地未完成或已解决的记录上重复执行。
function keepLocalVersion(store, conflictId, callback) {
store.transaction(function (tx) {
var state = tx.readSyncState();
var conflict = tx.readConflict(conflictId);
var entry = tx.readEntry(conflict.fileId);
var copy = tx.readConflictCopy(conflictId);
// 只有落地完成、等待用户处理的冲突才能执行这个动作。
// 第三项是防御判断:索引版本落后于冲突记录的服务端版本说明落地未完成。
// create 冲突的服务端条目要等后续日志拉取落地,落地前 entry 为空,
// 空值与版本落后一样按未就绪返回,风格与 task && task.contentHash 一致。
// 连续冲突时索引版本可能已被常规日志拉取推进,属于正常情况,
// appendOperation 会以它为新的 baseVersion。
if (conflict.state !== "pending" ||
conflict.landingState !== "applied" ||
!entry || entry.version < conflict.serverVersion) {
return { error: "conflict_not_ready" };
}
// delete 冲突没有本地内容需要保护,不登记副本,落地后直接标记
// resolved,正常流程到不了这里。这里防御副本记录为空的情况:
// 没有副本就没有可上传的内容,动作不适用。该判断必须放在下方
// copy.entryType 读取之前。
if (!copy) {
tx.saveConflictLastError(conflictId, "action_not_applicable");
return { error: "action_not_applicable" };
}
// 本动作只适用于有内容的文件冲突。目录副本没有内容字节,
// contentHash 为空属于正常情况,必须先按对象类型拦截,
// 否则下方的副本缺失判断会把目录副本误判为缺失。
if (copy.entryType === "folder") {
tx.saveConflictLastError(conflictId, "action_not_applicable");
return { error: "action_not_applicable" };
}
// 新操作以副本路径为上传来源,副本在确认前不可删除。
if (!copy.exists || !copy.contentHash) {
// 错误写入冲突记录的 lastError,不覆盖旧操作的 version_conflict 记录。
tx.saveConflictLastError(conflictId, "conflict_copy_missing");
return { error: "conflict_copy_missing" };
}
var sequence = tx.nextSequence();
tx.appendOperation({
operationId: state.deviceId + ":" + sequence,
deviceId: state.deviceId,
sequence: sequence,
type: "update",
fileId: conflict.fileId,
baseVersion: entry.version,
contentHash: copy.contentHash,
sourcePath: copy.path,
state: "pending",
resolvesConflictId: conflictId
});
tx.resolveConflict(conflictId, "keep_local",
state.deviceId + ":" + sequence);
return { error: null };
}, callback);
}
// 新操作确认且对应服务端日志已消费后,才能清理副本。
function cleanupConflictCopy(store, fs, conflictId, callback) {
var prepared = store.transaction(function (tx) {
var conflict = tx.readConflict(conflictId);
if (conflict.userAction !== "keep_local" ||
!conflict.resolveOperationId) {
return null;
}
var operation = tx.readOperation(conflict.resolveOperationId);
if (!operation || operation.state !== "confirmed" ||
!tx.isOperationLogConsumed(operation.operationId)) {
return null;
}
var copy = tx.readConflictCopy(conflictId);
tx.expectFsEvent({
type: "delete",
path: copy.path,
conflictId: conflictId
});
// 先持久化 deleting 中间状态,删除成功后才标记 deleted。
tx.markConflictCopyDeleting(conflictId);
return { path: copy.path };
});
if (!prepared) return callback(null, false);
fs.unlink(prepared.path, function (unlinkErr) {
if (unlinkErr) {
return store.failConflictCopyDelete(conflictId, unlinkErr, callback);
}
store.markConflictCopyDeleted(conflictId, callback);
});
}新操作的 baseVersion 取自提交时本地索引中的当前版本,而不是冲突发生时的版本。连续冲突时另一台设备可能已把服务端版本再次推进,常规日志拉取把它落入本地索引后,新操作仍以当前索引版本为新的基础版本,不要求它等于冲突记录中的 serverVersion。如果提交前服务端又前进了,这条操作会再次收到 version_conflict,按连续冲突的规则处理。副本必须保留到新操作确认、对应服务端日志消费之后:上传模块从副本路径创建不可变快照,提前删除会让上传失去来源。清理时先在事务中登记预期 delete 事件,再执行删除,监听器核验后消费该记录,不会把这次删除当成用户操作。登记预期事件时副本状态先置为 deleting,fs.unlink 成功后才标记 deleted。删除失败或进程在两者之间退出时,副本标记与磁盘实际状态可能不一致:恢复流程按副本路径是否存在修正标记,文件仍在则回到待清理状态重试删除,文件已不存在则补记 deleted。
手动合并路径中,客户端不替用户合并任何内容。它只保证原路径是服务端版本、副本是本地版本、两个文件都能打开。用户的编辑经正常监听进入本地日志,和其他编辑走同一条提交路径。
可以自动处理的边界
二进制文件和未知格式默认不合并。没有格式知识时,任何按字节拼接的尝试都可能产生两边都打不开的文件,这类文件一律走冲突副本流程。
文本文件只有在条件严格满足时才考虑三方合并:产品明确提供合并能力;客户端能取得 baseVersion 对应的共同祖先内容,可以来自本地内容快照或服务端历史版本;两端相对祖先的修改都能计算;合并失败的完整回退路径保留,失败时仍然生成冲突副本。任何一个条件不满足,就回到副本流程。即使合并成功,结果也应对用户可见,并保留两个原始版本直到用户确认。
目录级冲突以保护内容为先。本地修改了某个文件,服务端删除了它所在的目录:文件无法归位时,不能随目录一起丢弃,应放入一个明确的恢复位置或列入冲突列表,让用户能找到。移动和重命名并发时,名称和父目录是对象的两个独立字段,同一 fileId 的变更可以按对象身份核对:服务端状态落地后,本地未提交的 move 或 rename 在新索引上重新检查,目标仍有效且不涉及同名占用时可以重新提交,不需要用户介入。
服务端优先策略的成本落在用户身上:每处理一次冲突,用户都要面对一个副本。客户端这边对应的要求是,副本命名能认出原文件和来源设备,冲突列表能追到每个版本的来源,任何动作都不会静默丢失内容。
冲突记录、重启恢复与连续冲突
冲突记录是冲突处理的持久化依据,字段如下:
| 字段 | 用途 |
|---|---|
conflictId |
冲突的稳定标识,由触发它的操作 ID 派生。 |
fileId |
冲突对象的稳定文件 ID。 |
baseVersion |
本地操作的基础版本。 |
serverVersion |
冲突发生时的服务端当前版本。 |
localOperationId |
被拒绝的本地操作 ID。 |
localCopyPath |
本地冲突副本路径,登记落地(进入 renaming)时写入,此前为 null。 |
conflictType |
冲突类型,例如 content、name、content_and_name。 |
landingState |
落地进度,用于中断恢复。 |
occurredAt |
冲突发生时间。 |
state |
landing、pending、resolved。 |
userAction |
用户最终动作,keep_server、keep_local、merged、discard_local。 |
resolveOperationId |
“保留本地版本”生成的新操作 ID,清理副本前用它核对新操作的确认状态。 |
lastError |
落地或用户动作最近一次失败的原因,不覆盖旧操作的 version_conflict 记录。 |
客户端重启后按 conflictId 恢复待处理列表。通知已读只影响界面,不影响记录;用户关掉通知后重新打开客户端,冲突仍然出现在“需要处理”列表里。landingState 未完成时,恢复流程先补齐落地,再等待用户处理。
同一文件可能连续冲突,但触发条件只有一种:服务端版本再次前进。副本登记为不参与同步的对象后,用户在副本上继续编辑不会产生新操作,也就不会自己制造第二次冲突。连续冲突来自另一端:用户还没处理完,另一台设备又把服务端版本推进了,常规日志拉取会把原路径更新到最新版本;或者用户点击“保留本地版本”时服务端已经更新,新操作再次收到 version_conflict。规则有三条:已存在 pending 状态的冲突记录时,新冲突合并进同一条通知,不重复弹窗;本地每次为冲突生成的副本都保留来源信息,包括触发它的操作 ID 和时间,副本名追加序号,不覆盖上一个副本;“保留本地版本”始终基于提交时的最新服务端版本,而不是某次旧冲突时的版本。
实现完成后至少用四个场景验收。第一,两台设备离线修改同一文件后先后上线,后上线的一端应生成副本并提示,两端内容都不丢失。第二,网络在冲突落地过程中中断,恢复后落地流程从 landingState 继续,原路径最终是完整的服务端版本。第三,磁盘写满导致服务端版本的临时文件下载失败,本地原文件保持不动,界面显示空间不足,释放空间后从已接收范围续传并完成落地。第四,用户在处理前手动删除了冲突副本,再点击“保留本地版本”,客户端应发现副本缺失并报错提示,不能上传空内容或静默改用服务端版本。
系列总结
三篇文章合起来覆盖了同步状态的完整处理过程。本地操作日志记录用户在每台设备上的意图,operationId 让提交可以安全重试;remoteCursor 限定客户端已消费的服务端日志范围,中断后从旧游标重新读取;上传会话、下载任务和校准流程把中断恢复落实到每种进度;冲突流程处理最后一种情况,让无法自动合并的本地内容以副本形式保留,由用户决定结果。
实现时需要重点验证的边界也在这条链路上:提交幂等,重复请求不产生第二个版本;游标只在远端变更落盘并登记任务后推进;同步引擎写文件引发的事件要用预期记录核验,不能回流成新的上传操作;冲突副本在用户处理前必须可恢复。
后续的多端设备状态页、选择性同步和共享文件权限变化,都应继续复用操作日志、游标和冲突记录,在既有状态机上扩展;不应为每个功能单独发明一套进度存储。

