上一篇把同步拆成两份可持久化的记录。本地操作日志记录用户在本机做过什么,remoteCursor 记录客户端已经连续处理到哪一段服务端日志。网络断开以后,客户端不需要根据文件树猜测已完成的工作,只需从本地记录继续。
不过,文件同步里的“继续”至少有三种含义。元数据操作提交后,客户端要知道服务端是否已经接受;大文件传到一半时,客户端要知道哪些分块已经收到;服务端增量日志拉取到一半时,客户端要知道哪些变更已经写入本地索引。三种进度的存储位置、确认条件和恢复方式不同,不能合并成一个“同步完成”标记。
本文沿用上一篇的约定:客户端生成稳定的 fileId;每条本地操作带唯一的 operationId、baseVersion 和状态;服务端为接受的目录变更写入全局有序日志;remoteCursor 只在连续日志已经落入本地索引后推进。文件内容传输仍由内容服务处理,目录变更仍由同步协议处理。
同步循环按批次处理增量
同步线程可以由文件系统事件、网络恢复、用户手动同步和定时补偿唤醒。唤醒不等于立即发起多个请求。同一同步目录应当只有一个运行中的同步循环,以免两个请求从同一个 remoteCursor 拉取日志,再以不同顺序写入本地数据库。
每次循环从待提交日志中选取一个依赖闭包完整的前缀。目录创建、同一 fileId 的连续修改等依赖关系仍按上一篇的拓扑顺序处理。批次还需要设置上限,例如操作数量、元数据请求体大小、随操作引用的内容大小和请求超时。一个批次过大时,网络在最后阶段中断会延长恢复时间;一个批次过小时,连接和事务开销又会变多。上限是客户端调度参数,不改变 operationId、基础版本和服务端日志的含义。
文件内容不能直接塞进元数据批次。一次 update 操作只引用确定的 contentHash 或已完成的上传会话,例如:
var operation = {
operationId: "mac-7f2:184",
type: "update",
fileId: "file-91",
baseVersion: 12,
contentHash: "2cf24dba5fb0...",
uploadId: "upload-4a8d",
state: "pending"
};内容服务负责接收字节,元数据服务负责校验 fileId、baseVersion 和目录约束。客户端只有在内容服务确认对应版本完整可用后,才把引用它的 update 放进可提交批次。这样服务端日志中的内容引用总是指向一个可读取的完整版本,不会指向半个文件。
操作确认和内容上传也不能互相代替。内容已经上传完成,只表示内容服务保存了字节;update 得到接受结果后,才表示该内容成为文件的一个服务端版本。相反,元数据请求超时后,即使客户端不知道响应是否到达,也仍可以带原来的 operationId 重发。服务端保存首次处理结果,重复请求返回同一结果,不会创建第二个版本。
分块上传保存独立会话
大文件上传开始前,客户端先取得或创建一个上传会话。本地数据库至少保存以下内容:
| 字段 | 用途 |
|---|---|
uploadId |
服务端上传会话标识。 |
fileId |
这次上传对应的稳定文件标识。 |
sourceVersion |
本地索引中的内容版本或本地快照标识。 |
contentHash |
完整文件的 SHA-256 摘要,以小写十六进制编码。 |
fileSize |
本地快照的总字节数,创建和恢复会话时都要校验。 |
chunkSize |
分块大小。 |
lastChunkLength |
最后一块的字节数,用于拒绝截断或扩展后的文件。 |
confirmedChunks |
服务端已确认的分块编号或范围,以及每块的 chunkHash 和长度。写入确认时同时废止该块的本地租约。 |
chunkLease |
每块的领取者、不可复用的 leaseToken、leaseEpoch 和租约到期时间。租约为 30 秒,worker 必须在上传中续租。 |
uploadAttempt |
每次领取对应的 attemptId、请求 generation、租约健康状态、在途续租数和 allowNextDispatch。续租回调必须更新这条记录。 |
expiresAt |
上传会话的过期时间。 |
localPath |
上传所依据的本地快照路径。 |
分块摘要和整文件摘要都使用 SHA-256,并以小写十六进制编码。每个固定字节范围的摘要字段叫 chunkHash;完整文件的摘要字段继续叫 contentHash。客户端提交上传会话标识、块编号、workerId、leaseToken、实际长度和 chunkHash。服务端先校验上传会话、块编号、领取者、租约和块长度,再记录已收到的块,并在响应中回传相同字段。完成会话时,服务端按顺序组合所有块,校验组合后字节数和完整 contentHash,再产生内容版本引用。contentHash 只覆盖文件字节,不覆盖上传会话字段或目录元数据。
一个目录可以有多个上传 worker,但块领取必须持久化。worker 在本地事务中原子领取一个未确认且未被有效租约占用的块。每次领取都生成新的、不可复用的随机 leaseToken,并写入领取者、30 秒到期时间、递增的 leaseEpoch,以及本次上传尝试的 attemptId;恢复时回收到期租约。上传中的 worker 每隔较短时间向内容服务续租。续租请求携带 uploadId、chunk、workerId、leaseToken 和 leaseEpoch,并关联 attemptId 与递增的 renewalId。内容服务返回 accepted、有效的 leaseEpoch 和到期时间。客户端只在服务端接受且响应字段匹配时,才在本地事务中写入 uploadAttempt 和租约的新到期时间。拒绝、超时或 epoch 不匹配时,回调把对应 attemptId 持久化为不健康,并将 allowNextDispatch 置为 false。续租操作要求领取者和 leaseToken 都匹配当前记录,且原租约尚未过期。
putChunk 请求携带 uploadId、块编号、workerId、leaseToken、长度、字节和 chunkHash。服务端把 uploadId、块编号、workerId、leaseToken 和未过期租约作为一个联合条件校验,只有它们同时匹配当前领取记录时才接收字节。accepted 是服务端已经接收该块的确定事实。客户端先校验响应中的会话标识、块编号、领取者、token、长度和摘要,再调用 markChunkConfirmed。该操作不以本地当前租约仍属于该 worker 为前提,只以已校验的服务端确认和本地会话参数为前提;它在同一事务中写入确认的长度与摘要、清除当前领取记录并递增 leaseEpoch。因此,已经重新领取该块的 worker 和旧租约的后续续租都失效,不能覆盖确认记录。若同一块已有不同的确认摘要或长度,客户端保留会话并创建状态核对任务,不把任一记录当作确认。
租约过期、续租被内容服务拒绝、请求超时或任一字段不匹配时,续租回调把对应 attemptId 持久化为不健康,并将 allowNextDispatch 置为 false。这个写入不依赖当前块的租约是否尚在,因此迟到的拒绝或 epoch 不匹配结果也会留下不可调度状态。收到 putChunk 响应后,worker 停止创建新的续租请求,并等待该尝试已有的续租回调全部落盘。只有分块已确认、在途续租数为零、该尝试仍健康、allowNextDispatch 为真、generation 匹配且同步未暂停时,调度器才领取下一块。否则下一轮从持久化状态重新领取,必要时先查询上传会话。
function uploadNextChunk(store, contentApi, uploadId, workerId, requestGeneration,
callback) {
var claimResult = store.transaction(function (tx) {
var state = tx.readSyncState();
if (state.paused || state.currentGeneration !== requestGeneration) {
return { status: "paused_or_stale" };
}
var now = Date.now();
tx.reclaimExpiredChunkLeases(uploadId, now);
var claim = tx.claimMissingChunk(uploadId, workerId, {
attemptId: randomToken(),
leaseToken: randomToken(),
expiresAt: now + 30000,
requestGeneration: requestGeneration
});
return claim ? { status: "claimed", claim: claim } : { status: "complete" };
});
if (claimResult.status !== "claimed") {
return callback(null, claimResult.status);
}
var claim = claimResult.claim;
var renewal = renewChunkLease(store, contentApi, claim);
var session = claim.session;
var expectedLength = claim.index === claim.lastIndex ?
session.lastChunkLength : session.chunkSize;
var part = readFileRange(session.localPath, claim.index * session.chunkSize,
expectedLength);
var chunkHash = sha256(part);
function releaseClaim(reason, done) {
renewal.stop();
renewal.drain(function () {
store.releaseChunkLease(uploadId, claim.index, workerId, claim.leaseToken,
reason, done);
});
}
if (part.length !== expectedLength) {
return releaseClaim(new Error("local snapshot length changed"), callback);
}
var canSend = store.transaction(function (tx) {
var state = tx.readSyncState();
return !state.paused && state.currentGeneration === requestGeneration;
});
if (!canSend) {
return releaseClaim(new Error("sync paused or generation changed"),
function (releaseErr) {
callback(releaseErr, "paused_or_stale");
});
}
contentApi.putChunk({
uploadId: session.uploadId,
index: claim.index,
workerId: workerId,
length: part.length,
data: part,
chunkHash: chunkHash,
leaseToken: claim.leaseToken
}, function (err, result) {
renewal.stop();
if (err) {
return renewal.drain(function () {
store.releaseChunkLease(uploadId, claim.index, workerId, claim.leaseToken,
err, callback);
});
}
store.transaction(function (tx) {
var responseValid = result && result.accepted &&
result.uploadId === session.uploadId &&
result.index === claim.index &&
result.workerId === workerId &&
result.chunkHash === chunkHash &&
result.length === part.length &&
result.leaseToken === claim.leaseToken;
if (!responseValid) return { responseValid: false };
// 不检查当前租约。accepted 是服务端已接收该块的权威证据。
var confirmation = tx.markChunkConfirmed({
uploadId: uploadId,
index: claim.index,
attemptId: claim.attemptId,
length: part.length,
chunkHash: chunkHash,
acceptedAt: Date.now()
});
confirmation.responseValid = true;
return confirmation;
}, function (saveErr, outcome) {
if (saveErr) return callback(saveErr);
if (!outcome.responseValid) {
return renewal.drain(function () {
store.releaseChunkLease(uploadId, claim.index, workerId,
claim.leaseToken, new Error("chunk response mismatch"), callback);
});
}
if (outcome.status === "conflicting_confirmation") {
return callback(null, "reconcile_required");
}
if (outcome.status !== "confirmed") return callback(new Error(
"could not persist accepted chunk"));
// 停止定时器后等待所有续租回调持久化,迟到的 lost 也会关闭调度闸门。
renewal.drain(function (drainErr) {
if (drainErr) return callback(drainErr);
store.transaction(function (tx) {
var state = tx.readSyncState();
var attempt = tx.readUploadAttempt(claim.attemptId);
return attempt.chunkConfirmed && attempt.pendingRenewals === 0 &&
attempt.leaseHealthy && attempt.allowNextDispatch &&
!state.paused &&
state.currentGeneration === requestGeneration;
}, function (checkErr, canSchedule) {
if (checkErr) return callback(checkErr);
callback(null, canSchedule ? "uploaded" : "confirmed_no_schedule");
});
});
});
});
}
function renewChunkLease(store, contentApi, claim) {
var renewal = {
stopped: false,
pending: 0,
waiters: [],
nextRenewalId: 1
};
function finishOne(err) {
renewal.pending -= 1;
if (renewal.pending !== 0) return;
renewal.waiters.splice(0).forEach(function (done) { done(err); });
}
function renew() {
if (renewal.stopped) return;
var renewalId = renewal.nextRenewalId;
renewal.nextRenewalId += 1;
renewal.pending += 1;
store.transaction(function (tx) {
// 这里只登记在途请求,不能在本地延长租约。
tx.beginChunkRenewal(claim.attemptId, renewalId, claim.leaseEpoch);
}, function (beginErr) {
if (beginErr) return finishOne(beginErr);
contentApi.renewChunkLease({
uploadId: claim.uploadId,
chunk: claim.index,
workerId: claim.workerId,
leaseToken: claim.leaseToken,
leaseEpoch: claim.leaseEpoch,
attemptId: claim.attemptId,
renewalId: renewalId
}, function (requestErr, result) {
store.transaction(function (tx) {
var accepted = !requestErr && result && result.accepted &&
result.uploadId === claim.uploadId &&
result.chunk === claim.index &&
result.workerId === claim.workerId &&
result.leaseToken === claim.leaseToken &&
result.leaseEpoch === claim.leaseEpoch &&
result.attemptId === claim.attemptId &&
result.renewalId === renewalId &&
typeof result.expiresAt === "number";
// 以 attemptId 和 renewalId 归属结果,不要求租约现在仍有效。
// 只有内容服务接受的结果才能更新本地租约到期时间。
tx.finishChunkRenewal(claim.attemptId, renewalId, {
accepted: accepted,
leaseEpoch: accepted ? result.leaseEpoch : null,
expiresAt: accepted ? result.expiresAt : null,
failure: accepted ? null : "renewal_rejected_or_unknown"
});
}, function (saveErr) {
// 拒绝、超时和 epoch 不匹配均已写为不健康并关闭后续调度。
finishOne(saveErr);
});
});
});
}
renewal.timer = setInterval(renew, 10000);
renewal.stop = function () {
renewal.stopped = true;
clearInterval(renewal.timer);
};
renewal.drain = function (done) {
if (renewal.pending === 0) return done();
renewal.waiters.push(done);
};
return renewal;
}服务端可能已经接收分块,但确认响应在网络中丢失。重启后,客户端不能只根据本地 confirmedChunks 假定服务端状态。它先读取本地快照,校验 fileSize、lastChunkLength 和完整 contentHash,再查询 uploadId。如果会话仍有效,服务端返回已经收到的分块范围、长度和 chunkHash,客户端只合并校验一致的确认记录,再继续缺失部分。如果会话不存在、过期,或服务端记录与本地会话参数不一致,客户端创建新会话,并继续使用同一个已校验快照重新传输。新会话并不改变尚未提交操作的 operationId。
完整文件传完后,客户端向内容服务请求完成会话。服务端校验全部块、总字节数和完整 contentHash 后返回内容版本引用。客户端在同一事务中保存内容引用,并把关联的 update 操作标为可提交。若完成会话请求结果未知,恢复时先查询会话状态;不能直接假定内容完整,也不能另建一个内容版本来覆盖原引用。
上传时用户可能再次保存同一个文件。此时正在传输的字节必须来自一个确定快照。客户端可以在开始上传前创建临时快照,旧会话继续上传该快照;新的保存生成新的 contentHash 和新的本地操作。也可以取消尚未完成的旧会话,重新为新快照创建会话,但必须同时取消或替换引用旧内容的未提交操作。两种策略都不能把旧文件前半段和新文件后半段放进同一会话。
如果本地文件被改写而客户端没有可用快照,上传线程应停止该会话,记录原因,再让归并层为当前内容生成新的操作。把变化中的原路径直接反复读取,会让完整摘要和实际分块内容不一致,也无法解释这次上传代表哪个版本。
拉取日志时先落本地记录
下载方向从 remoteCursor 开始。客户端请求服务端日志后,先按 cursor 排序并验证连续性。日志中每个条目都更新本地索引:创建和移动更新 parentId 与名称,删除写入删除状态,内容更新登记下载任务或内容缺失标记。实际下载内容可以在事务提交后进行,但任务登记必须与索引更新、游标推进放在同一个本地事务里。
例如服务端返回 cursor 为 82001 到 82020 的变更,客户端在处理到 82012 时进程退出。若 82001 到 82012 的索引更新和下载任务已经提交,remoteCursor 应为 82012;下次请求只会从 82013 开始。若退出发生在事务提交前,remoteCursor 仍是 82000,下次会重新得到整段日志。重复读取必须安全。
function applyChanges(store, state, changes, nextCursor, callback) {
var ordered = changes.slice().sort(function (a, b) {
return a.cursor - b.cursor;
});
if (!isContiguous(state.remoteCursor, ordered, nextCursor)) {
return callback(new Error("server changes are not contiguous"));
}
var missingSelfChange = findMissingSelfChange(store, state, ordered);
if (missingSelfChange) {
return blockForSelfOperationRecovery(store, state, missingSelfChange,
callback);
}
store.transaction(function (tx) {
ordered.forEach(function (change) {
if (change.sourceDeviceId === state.deviceId) {
// 预检已确认本地操作或已消费记录存在,不能把本机日志落地为文件操作。
if (tx.hasOperation(change.operationId)) {
tx.confirmOperation(change.operationId, change.version);
tx.updateEntryVersion(change.fileId, change.version);
tx.markOperationLogConsumed(change.operationId, change.cursor);
} else {
tx.updateEntryVersion(change.fileId, change.version);
}
return;
}
if (tx.hasAppliedVersion(change.fileId, change.version)) return;
tx.applyRemoteMetadata(change);
if (change.contentHash) {
tx.createDownloadTask({
fileId: change.fileId,
version: change.version,
contentHash: change.contentHash,
targetPath: tx.pathFor(change.fileId),
state: "pending"
});
}
});
tx.saveRemoteCursor(nextCursor);
}, callback);
}
function findMissingSelfChange(store, state, changes) {
for (var i = 0; i < changes.length; i++) {
var change = changes[i];
if (change.sourceDeviceId === state.deviceId &&
!store.hasOperationOrConsumedRecord(change.operationId)) {
return change;
}
}
return null;
}
function blockForSelfOperationRecovery(store, state, change, callback) {
store.transaction(function (tx) {
tx.recordSelfOperationAnomaly(change.operationId, change.cursor,
"self operation is missing from local log and consumed records");
tx.createRecoveryTask({
type: "reconcile_missing_self_operation",
operationId: change.operationId,
fileId: change.fileId,
cursor: change.cursor,
expectedVersion: change.version
});
tx.blockSync("missing_self_operation", change.cursor);
// 不写 remoteCursor,也不写本批 changes 的应用结果。
}, function (err) {
if (err) return callback(err);
callback(new Error("sync blocked: self operation recovery required"));
});
}自设备日志的处理仍遵循上一篇的限制。客户端用本地操作或已消费 operationId 记录确认它,不把它再次写入本地文件系统。若服务端返回本机来源的日志,而本地找不到相应日志和已消费记录,客户端必须停止普通同步,保留旧游标并创建恢复任务。此时不能把该条日志当作其他设备变更落地,因为本地未确认操作与索引状态可能已经不一致。
其他设备的重复日志用 fileId 和版本去重。版本低于本地索引版本的条目不能覆盖较新的索引状态。客户端也不能因为两个请求的响应先后不同而改变应用顺序。一个同步目录只有一个日志拉取循环;如果实现允许并发请求,最终写事务仍必须以连续 cursor 区间串行提交。服务端响应中出现缺号、重复却无法识别的版本,或 nextCursor 与实际交付范围不一致时,客户端保留原游标并记录协议错误。
内容下载使用临时文件。下载任务保存目标路径、临时路径、已接收范围、完整内容 SHA-256 摘要、目标版本和任务创建时的本地索引版本。任务完成后,客户端先核验临时文件的完整摘要,再在同一事务中核对本地索引仍是任务创建时预期的版本,且该对象没有未解决的本地意图,例如 pending、confirmed、conflict 操作或待提交的重命名、删除、内容快照。任一核验失败时,客户端保留临时文件,创建冲突或恢复任务,不覆盖目标文件。只有全部核验通过,才登记预期文件系统事件并原子替换目标文件。进程退出时,临时文件和任务状态都保留;恢复时可以继续下载,或在服务端不支持分块下载时删除临时文件后重新下载。
中断后的重试和恢复
网络错误、超时和进程退出不能都按同一种方式重试。带 operationId 的元数据提交可以重发原请求。分块上传的未知结果需要先查询上传会话。下载任务可以根据临时文件范围继续,也可以重新下载。游标拉取只要没有推进本地 remoteCursor,就从原游标重新拉取。
客户端应记录每个可恢复单元的上次失败时间、失败原因和重试次数。连续网络错误按递增间隔重试,例如先等待几秒,再逐步延长等待时间;网络恢复事件和用户点击同步可以立即唤醒等待中的循环。鉴权失败、空间不足、权限拒绝和校验失败不会因为等待而自行解决,客户端应停止对应任务,显示具体原因,等待新的凭据、磁盘空间或用户操作。
客户端关闭前至少要提交以下本地状态:待提交操作和确认状态,remoteCursor,上传会话与已确认分块,下载任务的临时路径与校验信息,以及未处理的恢复任务。关闭过程不应把运行中的操作标为已完成。进程下次启动后,先读取这些状态,再依次恢复元数据确认、上传会话和下载任务。
用户暂停同步时,客户端在同一事务中持久化 paused 状态,并递增 currentGeneration。每个请求创建时读取并保存当时的 requestGeneration。暂停后调度器不再领取块、创建下载任务或发起元数据请求。分块上传在领取块的同一个持久化事务中读取 paused 和 currentGeneration,只有 !paused && currentGeneration === requestGeneration 时才原子写入租约和上传尝试记录并领取块;条件不满足时直接不领取。读取文件和计算摘要后,发送请求前仍检查相同条件;此时条件失效则停止续租,等待已有续租回调落盘,再释放已领取的租约,不发送请求。
已在网络中的请求可以完成,回调可以落盘已经确定的服务端确认结果、块确认或失败原因。putChunk 返回 accepted 时,服务端已在接收字节时验证租约。客户端校验响应后,无论本地 token 是否已过期或该块是否已被其他 worker 领取,都把一致的长度和 chunkHash 作为权威确认写入,并在同一事务中使该块的本地租约失效。确认写入不授权下一块调度。worker 停止新的续租并等待全部在途续租结果写入上传尝试记录;任何一次续租拒绝、超时或 epoch 不匹配都把该记录写成不健康并关闭 allowNextDispatch,即使回调在块确认之后才到达。调度器只读取事务中的持久化状态:当前块已确认、在途续租数为零、租约状态健康、allowNextDispatch 为真、generation 仍匹配且未暂停,才领取下一块。其余情况由下一轮重新领取或查询会话。恢复同步不会回退 currentGeneration,因此暂停前发出的旧请求即使在恢复后才返回,也只能落盘确定结果,不能继续调度。
function dispatchRequest(store, requestGeneration, send, scheduleNext) {
var request = store.transaction(function (tx) {
var state = tx.readSyncState();
if (state.paused || state.currentGeneration !== requestGeneration) {
return null;
}
var request = tx.createRequest({
requestGeneration: requestGeneration
});
tx.saveRequest(request);
return request;
});
if (!request) return;
send(request, function (err, result) {
store.transaction(function (tx) {
tx.saveDeterminateRequestResult(request.id, err, result);
return tx.readSyncState();
}, function (saveErr, state) {
if (saveErr || state.paused ||
state.currentGeneration !== request.requestGeneration) return;
scheduleNext(request.requestGeneration);
});
});
}用户取消某个文件的上传时,需要区分操作是否已被服务端确认。未确认的操作可以在本地标记取消,并尝试通知内容服务清理上传会话;已确认的操作不能仅通过删除本地会话撤回,需要生成一条新的业务操作。这里的状态差异应显示在同步列表中。
日志游标失效后请求快照
服务端日志有保留期限。客户端离线太久时,服务端可能已经无法提供旧 remoteCursor 之后的完整连续日志。这种情况服务端应返回明确的 cursor_expired,不能跳过中间日志后返回一个较大的 nextCursor。
客户端收到游标失效后进入校准状态,暂停普通日志拉取,保留全部 pending、confirmed、conflict 和 failed 操作。校准请求目录快照以及快照对应的服务端游标。快照可以分页传输,但每一页必须属于同一个快照版本;否则客户端会把不同时刻的目录状态混在一起。
confirmed 不等于已消费。校准时必须逐条检查尚未写入已消费记录的本机操作。快照游标范围和对象版本能证明该操作已包含在快照时,事务中写入对应的已消费 operationId 记录,并更新本地对象版本;例如快照游标不早于该操作日志游标,且快照中的同一 fileId 已达到服务端确认版本。删除操作还需要快照中的墓碑版本达到确认版本。若快照缺少对象、版本不足,或无法得到可比较的操作日志游标,客户端创建恢复或冲突任务,保持同步阻塞,不能只把 remoteCursor 推进到快照游标来绕过该操作。这与上一篇的清理规则一致:确认记录只能在对应日志已消费后删除,或留下覆盖恢复窗口的已消费记录。
校准过程先以快照重建或修正本地索引,再为缺少内容的远端文件登记下载任务,最后写入覆盖该快照的游标。待提交操作不能直接覆盖进快照,也不能因为快照中没有相同结果而删除。它们仍带着原来的 baseVersion,需要在校准后的索引上重新检查对象是否存在、父目录是否可用、基础版本是否落后。无法满足条件的操作交给服务端返回拒绝或冲突信息。
function finishReconcile(store, snapshot, callback) {
store.transaction(function (tx) {
var pendingSelfChanges = tx.listConfirmedUnconsumedOperations();
var protection = tx.buildLocalIntentProtection({
states: ["pending", "confirmed", "conflict", "failed"],
includeCreateIds: true,
includeTombstones: true,
includeRenameSnapshots: true,
includeContentSnapshots: true
});
tx.replaceRemoteIndex(snapshot.entries, protection);
pendingSelfChanges.forEach(function (operation) {
if (tx.snapshotProvesOperationConsumed(snapshot, operation)) {
tx.markOperationLogConsumed(operation.operationId, snapshot.cursor);
tx.updateEntryVersion(operation.fileId, operation.confirmedVersion);
} else {
tx.createRecoveryOrConflictTask(operation, snapshot.cursor);
tx.blockSync("unproven_confirmed_operation", snapshot.cursor);
}
});
snapshot.entries.forEach(function (entry) {
if (entry.contentHash && !tx.hasContent(entry.contentHash)) {
tx.createDownloadTask({
fileId: entry.fileId,
version: entry.version,
contentHash: entry.contentHash,
targetPath: tx.pathFor(entry.fileId),
state: "pending"
});
}
});
tx.recheckPendingOperations(snapshot.cursor);
if (!tx.hasSyncBlockOtherThan("cursor_expired")) {
tx.saveRemoteCursor(snapshot.cursor);
tx.clearSyncBlock("cursor_expired");
}
}, callback);
}replaceRemoteIndex 不能简单清空索引后写入快照。它先收集保护集:所有待提交、已确认未消费、冲突和失败操作关联的 fileId,本地新建但快照中不存在的对象,删除墓碑,重命名后的路径快照,以及尚待提交或冲突处理的内容快照。它仅用快照替换不在保护集内的远端基线;保护对象保留本地意图,并保存快照中可见的远端版本作为后续重放和冲突判断的依据。这样 pending create 不会因快照缺少同一 fileId 被删除,墓碑和本地文件内容也不会在校准时丢失。快照负责校准已消费的远端状态,操作日志负责保留尚未完成的本地修改。
目录很大时,快照校准的成本高于增量日志拉取。服务端可以为快照分页,并在开始时固定快照游标;客户端按页落盘,全部页面校验完成后才提交新的 remoteCursor。如果有任一已确认未消费的本机操作无法由快照游标和对象版本证明已经覆盖,校准事务保持阻塞并不写新游标。日志保留时间、客户端定期同步和分页快照都能减少校准触发次数,但不会改变校准时保护本地意图和已确认操作的要求。
进度信息要对应实际工作
同步面板常见的问题是把一次 HTTP 成功显示为百分之百。对用户而言,操作已经确认、文件正在上传、远端文件正在下载是不同状态。客户端可以分别统计发现的变更数、已确认的本地操作数、上传和下载的字节数、已经应用的远端日志数。
一个文件的状态还应说明阻塞原因。比如“等待网络”表示任务可重试但当前无连接,“正在恢复上传”表示已有上传会话和已确认分块,“等待冲突处理”表示服务端拒绝了基础版本,“校准目录中”表示旧游标已经失效。状态文字应读取持久化记录,内存中的请求回调只反映当前请求。
诊断记录至少包含批次标识、请求的游标和响应游标、operationId、fileId、上传会话标识、分块范围、重试次数、服务端错误码和校验失败原因。遇到某台设备长期无法同步时,这些记录可以区分是重复提交、上传会话过期、日志不连续,还是本地磁盘写入失败。
限速、上传下载并发数、电量和网络类型策略属于调度层。它们可以改变每次传多少块、同时跑多少任务,却不能跳过本地日志确认、分块确认或游标落盘。
本文的范围
增量同步依赖三类持久化进度。operationId 让元数据操作可以重复提交;上传会话和分块确认让文件内容可以续传;remoteCursor 让服务端日志可以重复读取并连续落盘。进程退出时,客户端根据这些记录恢复,不需要从完整目录扫描重新开始。
日志超过保留期限时,客户端以快照校准远端状态,同时保留待提交操作及其基础版本。校准能够恢复可见的目录状态,却不能判断两端基于不同版本修改同一文件时用户想保留什么。下一篇讨论服务端返回版本冲突后,客户端如何保存本地内容、写回服务端版本,并让用户处理两个版本。

