This commit is contained in:
Andras Schmelczer 2026-05-05 21:50:24 +01:00
parent 35877b69da
commit 8aeb0d6027
20 changed files with 1198 additions and 88 deletions

View file

@ -34,6 +34,7 @@ export type { ClientCursors } from "./services/types/ClientCursors";
export type { NetworkConnectionStatus } from "./types/network-connection-status";
export type { ServerVersionMismatchError } from "./errors/server-version-mismatch-error";
export type { AuthenticationError } from "./errors/authentication-error";
export { SyncResetError } from "./errors/sync-reset-error";
export type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors";
export { DocumentSyncStatus } from "./types/document-sync-status";
export { SyncClient } from "./sync-client";

View file

@ -507,6 +507,7 @@ export class SyncClient {
await this.serverConfig.getConfig();
await this.syncer.scheduleSyncForOfflineChanges();
this.syncer.resumeDraining();
this.webSocketManager.start();
this.hasFinishedOfflineSync = true;
@ -514,6 +515,7 @@ export class SyncClient {
private async pause(): Promise<void> {
this.hasFinishedOfflineSync = false;
this.syncer.pauseDraining();
this.fetchController.startReset();
// Signal the service so any `retryForever` loop exits at its next
// iteration instead of continuing to retry a network request while

View file

@ -0,0 +1,69 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { Logger, LogLevel } from "../tracing/logger";
import { Settings } from "../persistence/settings";
import { STORED_STATE_SCHEMA_VERSION, SyncEventQueue } from "./sync-event-queue";
import { Reconciler } from "./reconciler";
import { SyncResetError } from "../errors/sync-reset-error";
import type { FileOperations } from "../file-operations/file-operations";
import type { SyncService } from "../services/sync-service";
import type { RelativePath } from "./types";
describe("Reconciler", () => {
it("does not emit an error when placement fetch is interrupted by reset", async () => {
const logger = new Logger();
const settings = new Settings(logger, {}, async () => {
/* no-op */
});
const queue = new SyncEventQueue(
settings,
logger,
{ schemaVersion: STORED_STATE_SCHEMA_VERSION },
async () => {
/* no-op */
}
);
await queue.upsertRecord({
documentId: "DOC-1",
parentVersionId: 1,
remoteHash: "hash",
remoteRelativePath: "remote.md" as RelativePath,
localPath: undefined
});
const operations = {
exists: async () => false,
create: async () => {
assert.fail("reset-interrupted placement should not write");
}
} as unknown as FileOperations;
const syncService = {
getDocumentVersionContent: async () => {
throw new SyncResetError();
}
} as unknown as SyncService;
const reconciler = new Reconciler(
logger,
operations,
syncService,
queue,
new Map()
);
await reconciler.run();
assert.deepStrictEqual(logger.getMessages(LogLevel.ERROR), []);
assert.ok(
logger
.getMessages(LogLevel.INFO)
.some((line) =>
line.message.includes(
"content fetch for DOC-1 interrupted by sync reset"
)
)
);
});
});

View file

@ -6,6 +6,7 @@ import type { SyncService } from "../services/sync-service";
import type { SyncEventQueue } from "./sync-event-queue";
import type { DocumentId, DocumentRecord, RelativePath } from "./types";
import { hash } from "../utils/hash";
import { SyncResetError } from "../errors/sync-reset-error";
const SWAP_MARKER_DIR = ".vaultlink";
const SWAP_MARKER_PREFIX = "swap-";
@ -225,6 +226,14 @@ export class Reconciler {
private async tryInitialPlacement(record: DocumentRecord): Promise<void> {
const target = record.remoteRelativePath;
if (this.queue.hasPendingCreateForPath(target)) {
this.logger.debug(
`Reconciler: cannot place ${record.documentId} at ${target} ` +
`— pending local create still claims that path; will retry next pass`
);
return;
}
// Slot occupancy: pre-check both the disk and our tracked
// records. Either form of occupancy means we wait — the
// occupant's own reconciliation pass (after their next wire-loop
@ -259,6 +268,12 @@ export class Reconciler {
vaultUpdateId: record.parentVersionId
});
} catch (e) {
if (e instanceof SyncResetError) {
this.logger.info(
`Reconciler: content fetch for ${record.documentId} interrupted by sync reset`
);
return;
}
this.logger.error(
`Reconciler: failed to fetch content for ${record.documentId}: ${String(e)}`
);

View file

@ -248,6 +248,38 @@ describe("SyncEventQueue", () => {
assert.strictEqual(second.isUserRename, true);
});
it("settled record owns a path over a stale pending create", async () => {
const queue = createQueue();
await queue.upsertRecord(fakeRecord("A", { localPath: "b.md" }));
await queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" });
await queue.enqueue({
type: SyncEventType.LocalUpdate,
path: "c.md",
oldPath: "b.md"
});
const aRecord = queue.getDocumentByDocumentId("A");
assert.strictEqual(aRecord?.localPath, "c.md");
assert.strictEqual(
queue.getRecordByLocalPath("b.md" as RelativePath),
undefined
);
assert.strictEqual(
queue.getRecordByLocalPath("c.md" as RelativePath)?.documentId,
"A"
);
const create = await queue.next();
assert.strictEqual(create?.type, SyncEventType.LocalCreate);
assert.strictEqual(create.path, "b.md");
const update = await queue.next();
assert.strictEqual(update?.type, SyncEventType.LocalUpdate);
assert.strictEqual(update.documentId, "A");
assert.strictEqual(update.path, "c.md");
});
it("byLocalPath stays consistent across upsertRecord, setLocalPath, and rename", async () => {
const queue = createQueue();
@ -502,6 +534,160 @@ describe("SyncEventQueue", () => {
assert.strictEqual(await createPromise, "DOC-1");
});
it("delete collapses a pending create that has not started processing", async () => {
const queue = createQueue();
await queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" });
const create = queue.peekFront();
assert.ok(create?.type === SyncEventType.LocalCreate);
await queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" });
assert.strictEqual(queue.pendingUpdateCount, 0);
assert.strictEqual(await queue.next(), undefined);
await assert.rejects(create.resolvers.promise, /cancelled/);
});
it("resolveCreate does not claim a localPath after an in-flight pending create was deleted", async () => {
const queue = createQueue();
await queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" });
const create = queue.peekFront();
assert.ok(create?.type === SyncEventType.LocalCreate);
create.isProcessing = true;
await queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" });
await queue.resolveCreate(
create,
fakeRecord("DOC-1", {
localPath: "a.md" as RelativePath,
remoteRelativePath: "a.md" as RelativePath
})
);
assert.strictEqual(
queue.getDocumentByDocumentId("DOC-1")?.localPath,
undefined
);
assert.strictEqual(
queue.getRecordByLocalPath("a.md" as RelativePath),
undefined
);
const deleteEvent = await queue.next();
assert.strictEqual(deleteEvent?.type, SyncEventType.LocalDelete);
assert.strictEqual(deleteEvent.documentId, "DOC-1");
});
it("resolveCreate only clears localPath for a pending delete of that path", async () => {
const queue = createQueue();
await queue.enqueue({
type: SyncEventType.LocalCreate,
path: "old.md"
});
const create = queue.peekFront();
assert.ok(create?.type === SyncEventType.LocalCreate);
create.isProcessing = true;
await queue.enqueue({
type: SyncEventType.LocalDelete,
path: "old.md"
});
await queue.resolveCreate(
create,
fakeRecord("DOC-1", {
localPath: "new.md" as RelativePath,
remoteRelativePath: "new.md" as RelativePath
})
);
assert.strictEqual(
queue.getDocumentByDocumentId("DOC-1")?.localPath,
"new.md"
);
assert.strictEqual(
queue.getRecordByLocalPath("new.md" as RelativePath)?.documentId,
"DOC-1"
);
const deleteEvent = await queue.next();
assert.strictEqual(deleteEvent?.type, SyncEventType.LocalDelete);
assert.strictEqual(deleteEvent.documentId, "DOC-1");
assert.strictEqual(deleteEvent.path, "old.md");
});
it("pending create owns a same-path delete over a stale deleting record", async () => {
const queue = createQueue();
await queue.upsertRecord(
fakeRecord("OLD", { localPath: "a.md" as RelativePath })
);
queue.markServerDeletePending("OLD");
await queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" });
const create = queue.peekFront();
assert.ok(create?.type === SyncEventType.LocalCreate);
create.isProcessing = true;
await queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" });
assert.strictEqual(
queue.getDocumentByDocumentId("OLD")?.localPath,
undefined
);
assert.strictEqual(
queue.getRecordByLocalPath("a.md" as RelativePath),
undefined
);
const createEvent = await queue.next();
assert.strictEqual(createEvent, create);
const deleteEvent = await queue.next();
assert.strictEqual(deleteEvent?.type, SyncEventType.LocalDelete);
assert.strictEqual(deleteEvent.documentId, create.resolvers.promise);
});
it("rename of a queued create drains same-path deletes first", async () => {
const queue = createQueue();
await queue.upsertRecord(
fakeRecord("OLD", { localPath: "target.md" as RelativePath })
);
await queue.enqueue({
type: SyncEventType.LocalCreate,
path: "source.md"
});
const create = queue.peekFront();
assert.ok(create?.type === SyncEventType.LocalCreate);
await queue.enqueue({
type: SyncEventType.LocalDelete,
path: "target.md"
});
await queue.enqueue({
type: SyncEventType.LocalUpdate,
oldPath: "source.md",
path: "target.md"
});
const deleteEvent = await queue.next();
assert.strictEqual(deleteEvent?.type, SyncEventType.LocalDelete);
assert.strictEqual(deleteEvent.documentId, "OLD");
assert.strictEqual(deleteEvent.path, "target.md");
const createEvent = await queue.next();
assert.strictEqual(createEvent, create);
assert.strictEqual(createEvent.path, "target.md");
const updateEvent = await queue.next();
assert.strictEqual(updateEvent?.type, SyncEventType.LocalUpdate);
assert.strictEqual(updateEvent.documentId, create.resolvers.promise);
assert.strictEqual(updateEvent.path, "target.md");
});
it("findLatestCreateForPath returns the pending create", async () => {
const queue = createQueue();

View file

@ -210,6 +210,7 @@ export class SyncEventQueue {
this.events.push({
type: SyncEventType.LocalCreate,
path,
isProcessing: false,
resolvers: Promise.withResolvers()
});
this.notifyPendingUpdateCountChanged();
@ -223,22 +224,54 @@ export class SyncEventQueue {
: path;
const record = this._byLocalPath.get(lookupPath);
// latest creation must take precedence as it's from the doc's latest generation
// If a settled record and a pending create both claim this path, the
// settled record owns the current disk slot, unless the record is
// already being deleted. A deleting record can briefly remain in the
// localPath index when a create/delete pair was queued while the
// create was pending; it must not steal the next same-path create's
// delete/update.
const pendingCreate = this.findLatestCreateForPath(lookupPath);
const pendingDocumentId: Promise<DocumentId> | undefined =
this.findLatestCreateForPath(lookupPath)?.resolvers.promise;
pendingCreate?.resolvers.promise;
const documentId: DocumentId | undefined = record?.documentId;
const recordIsDeleting =
record !== undefined &&
(this.hasPendingLocalDeleteForDocumentId(record.documentId) ||
this.hasPendingServerDelete(record.documentId));
const recordOwnsLookupPath =
record !== undefined &&
!(recordIsDeleting && pendingDocumentId !== undefined);
const documentId: DocumentId | undefined = recordOwnsLookupPath
? record.documentId
: undefined;
const effectiveDocumentId:
| Promise<DocumentId>
| DocumentId
| undefined = pendingDocumentId ?? documentId;
| undefined = documentId ?? pendingDocumentId;
if (effectiveDocumentId === undefined) {
// we can get here when deleting a local document after a remote update
return;
}
if (input.type === SyncEventType.LocalDelete) {
if (
documentId === undefined &&
pendingCreate !== undefined &&
!pendingCreate.isProcessing
) {
this.cancelPendingCreate(pendingCreate);
if (recordIsDeleting && record !== undefined) {
// A stale deleting record was still claiming this path.
// The not-yet-started create/delete pair collapsed to
// nothing, and the disk file is gone, so clear the stale
// claim too.
await this.setLocalPath(record.documentId, undefined);
}
return;
}
// Push BEFORE awaiting `setLocalPath` (and its inner `save()`).
// See the comment below on the synchronicity contract with
// `ensureDraining()`.
@ -248,10 +281,15 @@ export class SyncEventQueue {
path: lookupPath
});
this.notifyPendingUpdateCountChanged();
if (record !== undefined) {
if (recordOwnsLookupPath && record !== undefined) {
// The file is gone from disk; clear the doc's localPath so the
// Reconciler doesn't try to operate on a vacated slot.
await this.setLocalPath(record.documentId, undefined);
} else if (recordIsDeleting && record !== undefined) {
// A stale deleting record was still claiming this path while a
// newer pending create owned the actual disk file. Drop the
// stale claim now that the file is gone.
await this.setLocalPath(record.documentId, undefined);
}
return;
}
@ -259,10 +297,10 @@ export class SyncEventQueue {
const isUserRename = input.oldPath !== undefined;
let needsSave = false;
if (input.oldPath !== undefined) {
if (pendingDocumentId !== undefined) {
if (!recordOwnsLookupPath && pendingDocumentId !== undefined) {
this.updatePendingCreatePath(input.oldPath, path);
} else {
if (record === undefined) {
if (record === undefined || !recordOwnsLookupPath) {
throw new Error(
"Unreachable: record must be defined for non-pending update"
);
@ -352,10 +390,7 @@ export class SyncEventQueue {
* Return the next event without removing it. Drain uses this so the
* event stays visible in the queue while it is being processed
* critical for `findLatestCreateForPath` to update an in-flight
* `LocalCreate`'s path when a rename arrives mid-process. Also marks
* the event as in-flight so dedup checks in `enqueue` know not to
* fold a fresh content change into an event whose disk read already
* happened.
* `LocalCreate`'s local read path when a rename arrives mid-process.
*/
public peekFront(): SyncEvent | undefined {
return this.events[0];
@ -397,7 +432,13 @@ export class SyncEventQueue {
event.resolvers.promise,
record.documentId
);
await this.upsertRecord(record);
const localPath = this.hasPendingLocalDeleteForDocumentId(
record.documentId,
record.localPath
)
? undefined
: record.localPath;
await this.upsertRecord({ ...record, localPath });
event.resolvers.resolve(record.documentId);
}
@ -613,6 +654,18 @@ export class SyncEventQueue {
);
}
public hasPendingLocalDeleteForDocumentId(
documentId: DocumentId,
path?: RelativePath
): boolean {
return this.events.some(
(e) =>
e.type === SyncEventType.LocalDelete &&
e.documentId === documentId &&
(path === undefined || e.path === path)
);
}
public async clearAllState(): Promise<void> {
this.clearPending();
this.byDocId.clear();
@ -643,6 +696,12 @@ export class SyncEventQueue {
return undefined;
}
public hasPendingCreateForPath(path: RelativePath): boolean {
return this.events.some(
(e) => e.type === SyncEventType.LocalCreate && e.path === path
);
}
public updatePendingCreatePath(
oldPath: RelativePath,
newPath: RelativePath
@ -654,6 +713,9 @@ export class SyncEventQueue {
const { promise } = createEvent.resolvers;
createEvent.path = newPath;
if (!createEvent.isProcessing) {
this.moveBlockingDeletesBeforeCreate(createEvent, newPath);
}
for (const e of this.events) {
if (
@ -665,6 +727,32 @@ export class SyncEventQueue {
}
}
private moveBlockingDeletesBeforeCreate(
createEvent: Extract<SyncEvent, { type: SyncEventType.LocalCreate }>,
path: RelativePath
): void {
const { promise } = createEvent.resolvers;
let createIndex = this.events.indexOf(createEvent);
if (createIndex < 0) {
return;
}
for (let i = createIndex + 1; i < this.events.length; ) {
const event = this.events[i];
if (
event.type === SyncEventType.LocalDelete &&
event.path === path &&
event.documentId !== promise
) {
this.events.splice(i, 1);
this.events.splice(createIndex, 0, event);
createIndex++;
continue;
}
i++;
}
}
/**
* Synchronous half of `setLocalPath`: mutate `record.localPath` and
* re-key `_byLocalPath` without persisting. Used by `enqueue`'s
@ -724,6 +812,32 @@ export class SyncEventQueue {
}
}
private cancelPendingCreate(
createEvent: Extract<SyncEvent, { type: SyncEventType.LocalCreate }>
): void {
const { promise } = createEvent.resolvers;
const toRemove = this.events.filter(
(event) =>
event === createEvent ||
((event.type === SyncEventType.LocalUpdate ||
event.type === SyncEventType.LocalDelete) &&
event.documentId === promise)
);
for (const event of toRemove) {
removeFromArray(this.events, event);
}
createEvent.resolvers.promise.catch(() => {
/* suppressed — the create/delete pair collapsed locally */
});
createEvent.resolvers.reject(new Error("Create was cancelled"));
if (toRemove.length > 0) {
this.notifyPendingUpdateCountChanged();
}
}
private purgeRemoteChangesForDocumentId(documentId: DocumentId): void {
const toRemove = this.events.filter(
(e) =>

View file

@ -65,6 +65,8 @@ export class Syncer {
private runningScheduleSyncForOfflineChanges: Promise<void> | undefined;
private drainPromise: Promise<void> | undefined;
private drainRequestedWhileRunning = false;
private isDrainingPaused = false;
private isScanning = false;
private previousRemainingOperationsCount = 0;
@ -244,6 +246,15 @@ export class Syncer {
}
}
public pauseDraining(): void {
this.isDrainingPaused = true;
}
public resumeDraining(): void {
this.isDrainingPaused = false;
this.ensureDraining();
}
private sendHandshakeMessage(): void {
const message: WebSocketClientMessage = {
type: "handshake",
@ -282,13 +293,27 @@ export class Syncer {
private ensureDraining(): void {
if (this.drainPromise !== undefined) {
this.drainRequestedWhileRunning = true;
return;
}
if (this.isScanning) {
return;
}
if (this.isDrainingPaused) {
return;
}
this.drainPromise = this.drain().finally(() => {
this.drainPromise = undefined;
const shouldRestart =
this.drainRequestedWhileRunning &&
this.queue.pendingUpdateCount > 0 &&
!this.isScanning &&
!this.isDrainingPaused &&
this.settings.getSettings().isSyncEnabled;
this.drainRequestedWhileRunning = false;
if (shouldRestart) {
this.ensureDraining();
}
});
}
@ -296,9 +321,12 @@ export class Syncer {
// Peek then remove-after-processing (instead of shift-then-process):
// the event must remain reachable through `findLatestCreateForPath`
// while it is in flight, so a rename event arriving mid-process can
// call `updatePendingCreatePath` to retarget this create's path.
// call `updatePendingCreatePath` to retarget this create's local path.
for (;;) {
if (!this.settings.getSettings().isSyncEnabled) {
if (
this.isDrainingPaused ||
!this.settings.getSettings().isSyncEnabled
) {
this.logger.debug(
"Drain pausing because sync is disabled; events stay queued"
);
@ -333,6 +361,10 @@ export class Syncer {
private async processEvent(event: SyncEvent): Promise<void> {
try {
if (event.type === SyncEventType.LocalCreate) {
event.isProcessing = true;
}
if (await this.skipIfOversized(event)) {
return;
}
@ -460,21 +492,26 @@ export class Syncer {
private async processCreate(
event: Extract<SyncEvent, { type: SyncEventType.LocalCreate }>
): Promise<void> {
const contentBytes = await this.operations.read(event.path);
const requestPath = event.path;
const contentBytes = await this.operations.read(requestPath);
const contentHash = await hash(contentBytes);
// Read `event.path` live: `updatePendingCreatePath` mutates it in
// place when the user renames the pending create mid-roundtrip.
// Sending `originalPath` here would tell the server the pre-rename
// location, then the queued LocalUpdate from the rename would
// fail on `getFileSize(renamedPath)` after the reconciler moved
// the file back to match the (stale) server-side path.
// Use the path the pending create has when it reaches the wire loop.
// `updatePendingCreatePath` mutates queued creates when a not-yet-sent
// local file is renamed, so a renamed-away generation does not create
// a server document at a path that a newer local file has reused.
const response = await this.syncService.create({
relativePath: event.path,
relativePath: requestPath,
lastSeenVaultUpdateId: this.queue.lastSeenUpdateId,
contentBytes
});
// If the user renamed the file while the create request was in flight,
// event.path now points at the renamed disk slot. Apply response bytes
// and install the local record there; the queued LocalUpdate carries
// the server-side rename intent.
const localPath = event.path;
// Same-docId collapse. While our LocalCreate sat in the queue, a
// RemoteCreate may have arrived for this same path. The wire-loop's
// `processRemoteCreateForNewDocument` would have built a record with
@ -487,7 +524,7 @@ export class Syncer {
if (response.type === "MergingUpdate") {
const responseBytes = base64ToBytes(response.contentBase64);
await this.operations.write(
event.path,
localPath,
contentBytes,
responseBytes
);
@ -495,13 +532,13 @@ export class Syncer {
await this.updateCache(
response.vaultUpdateId,
responseBytes,
event.path
localPath
);
} else {
await this.updateCache(
response.vaultUpdateId,
contentBytes,
event.path
localPath
);
}
@ -516,13 +553,13 @@ export class Syncer {
parentVersionId: response.vaultUpdateId,
remoteRelativePath: response.relativePath,
remoteHash,
localPath: event.path
localPath
});
this.queue.lastSeenUpdateId = response.vaultUpdateId;
this.history.addHistoryEntry({
status: SyncStatus.SUCCESS,
details: { type: SyncType.CREATE, relativePath: event.path },
details: { type: SyncType.CREATE, relativePath: localPath },
message:
response.type === "MergingUpdate"
? "Created file and merged with existing remote version"
@ -536,6 +573,24 @@ export class Syncer {
event: Extract<SyncEvent, { type: SyncEventType.LocalDelete }>
): Promise<void> {
const documentId = await event.documentId;
const record = this.queue.getDocumentByDocumentId(documentId);
if (
record?.localPath !== undefined &&
record.localPath !== event.path
) {
this.logger.debug(
`Skipping local-delete for ${documentId} at ${event.path}: ` +
`record now owns ${record.localPath}`
);
return;
}
// The disk file is already gone when a LocalDelete reaches the wire
// loop. This is redundant for settled records deleted through
// `enqueue`, but load-bearing for creates that were deleted while the
// create request was still pending: their record only exists after the
// create ack resolves.
await this.queue.setLocalPath(documentId, undefined);
const response = await this.syncService.delete({
documentId
@ -754,23 +809,32 @@ export class Syncer {
}
if (trackedRecord !== undefined) {
// The doc is tracked. If we have a local file backing it
// and that file has gone missing — e.g. the user deleted it
// and the LocalDelete hasn't drained yet, or our HTTP
// DELETE just landed and we're still waiting on the
// WebSocket receipt — ignore the update. Otherwise we'd
// try to operate on a vanished file (or recreate one we're
// tearing down).
// The doc is tracked, but the disk slot can be stale. One
// concrete race: a remote create quick-writes a file, a
// watcher rename/delete lands before the record is fully
// settled, and the record is left claiming a path that no
// longer exists. If no queued local operation owns that
// disappearance, clear the localPath and let
// processRemoteUpdate stash/place the active server version.
if (trackedRecord.localPath !== undefined) {
const fileExists = await this.operations.exists(
trackedRecord.localPath
);
if (!fileExists) {
this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId;
if (
!fileExists &&
!this.queue.hasPendingLocalEventsForDocumentId(
remoteVersion.documentId
)
) {
this.logger.debug(
`Ignoring remote update for ${remoteVersion.documentId}: local file at ${trackedRecord.localPath} is missing`
`Remote update for ${remoteVersion.documentId}: ` +
`local file at ${trackedRecord.localPath} is missing; ` +
`clearing localPath for placement`
);
await this.queue.setLocalPath(
trackedRecord.documentId,
undefined
);
return;
}
}
return this.processRemoteUpdate(trackedRecord, remoteVersion);
@ -992,9 +1056,7 @@ export class Syncer {
// design, no buffering at receive time — the reconciler will
// fetch on demand.
const target = remoteVersion.relativePath;
const slotFree =
!(await this.operations.exists(target)) &&
this.queue.getRecordByLocalPath(target) === undefined;
const slotFree = await this.canPlaceRemoteCreateAt(target);
let localPath: RelativePath | undefined = undefined;
let remoteHash: string | undefined = undefined;
@ -1004,49 +1066,77 @@ export class Syncer {
documentId: remoteVersion.documentId,
vaultUpdateId: remoteVersion.vaultUpdateId
});
try {
const result = await this.operations.create(
target,
remoteContent
);
localPath = result.actualPath;
remoteHash = await hash(remoteContent);
await this.updateCache(
remoteVersion.vaultUpdateId,
remoteContent,
localPath
);
} catch (e) {
if (!(e instanceof FileAlreadyExistsError)) {
throw e;
}
// TOCTOU: the slot was free at the pre-check but
// something landed there between then and now. Fall
// through to the no-localPath branch and let the
// reconciler retry placement once the slot frees.
if (!(await this.canPlaceRemoteCreateAt(target))) {
this.logger.debug(
`Quick-write for ${remoteVersion.documentId} at ${target} ` +
`lost a TOCTOU race; deferring to reconciler`
`became blocked while fetching content; deferring to reconciler`
);
localPath = undefined;
remoteHash = undefined;
} else {
try {
remoteHash = await hash(remoteContent);
await this.queue.upsertRecord({
documentId: remoteVersion.documentId,
parentVersionId: remoteVersion.vaultUpdateId,
remoteRelativePath: remoteVersion.relativePath,
remoteHash,
localPath: target
});
const result = await this.operations.create(
target,
remoteContent
);
const liveRecord = this.queue.getDocumentByDocumentId(
remoteVersion.documentId
);
localPath =
liveRecord === undefined
? result.actualPath
: liveRecord.localPath;
await this.updateCache(
remoteVersion.vaultUpdateId,
remoteContent,
localPath ?? remoteVersion.relativePath
);
} catch (e) {
await this.queue.setLocalPath(
remoteVersion.documentId,
undefined
);
if (!(e instanceof FileAlreadyExistsError)) {
throw e;
}
// TOCTOU: the slot was free at the pre-check but
// something landed there between then and now. Fall
// through to the no-localPath branch and let the
// reconciler retry placement once the slot frees.
this.logger.debug(
`Quick-write for ${remoteVersion.documentId} at ${target} ` +
`lost a TOCTOU race; deferring to reconciler`
);
localPath = undefined;
}
}
}
await this.queue.upsertRecord({
documentId: remoteVersion.documentId,
parentVersionId: remoteVersion.vaultUpdateId,
remoteRelativePath: remoteVersion.relativePath,
// `remoteHash` is undefined when we deferred fetching content.
// Consumers (`processLocalUpdate`'s fast-skip,
// `findMatchingFile`'s offline-rename detection) treat
// undefined as "no comparison possible" and fall through to a
// real upload / no-match. The hash gets populated the next
// time we observe a real version (a remote update, or a
// local edit that triggers an upload).
remoteHash,
localPath
});
if (
this.queue.getDocumentByDocumentId(remoteVersion.documentId) ===
undefined
) {
await this.queue.upsertRecord({
documentId: remoteVersion.documentId,
parentVersionId: remoteVersion.vaultUpdateId,
remoteRelativePath: remoteVersion.relativePath,
// `remoteHash` is undefined when we deferred fetching content.
// Consumers (`processLocalUpdate`'s fast-skip,
// `findMatchingFile`'s offline-rename detection) treat
// undefined as "no comparison possible" and fall through to a
// real upload / no-match. The hash gets populated the next
// time we observe a real version (a remote update, or a
// local edit that triggers an upload).
remoteHash,
localPath
});
}
this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId;
@ -1065,6 +1155,16 @@ export class Syncer {
}
}
private async canPlaceRemoteCreateAt(
target: RelativePath
): Promise<boolean> {
return (
!this.queue.hasPendingCreateForPath(target) &&
!(await this.operations.exists(target)) &&
this.queue.getRecordByLocalPath(target) === undefined
);
}
private async sendUpdate({
record,
relativePath,

View file

@ -53,6 +53,7 @@ export type SyncEvent =
| {
type: SyncEventType.LocalCreate;
path: RelativePath; // current path on disk; mutated in place by `updatePendingCreatePath` when the user renames mid-flight
isProcessing: boolean; // true once the wire loop has started this create; deletes after that must wait for the server ack
resolvers: PromiseWithResolvers<DocumentId>;
}
| {