codex
This commit is contained in:
parent
35877b69da
commit
8aeb0d6027
20 changed files with 1198 additions and 88 deletions
|
|
@ -1,10 +1,17 @@
|
||||||
import type {
|
import type {
|
||||||
|
HistoryEntry,
|
||||||
StoredDatabase,
|
StoredDatabase,
|
||||||
SyncSettings,
|
SyncSettings,
|
||||||
RelativePath,
|
RelativePath,
|
||||||
TextWithCursors
|
TextWithCursors
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
import { SyncClient, debugging, LogLevel, utils } from "sync-client";
|
import {
|
||||||
|
SyncClient,
|
||||||
|
SyncResetError,
|
||||||
|
debugging,
|
||||||
|
LogLevel,
|
||||||
|
utils
|
||||||
|
} from "sync-client";
|
||||||
import { assert } from "./utils/assert";
|
import { assert } from "./utils/assert";
|
||||||
import { sleep } from "./utils/sleep";
|
import { sleep } from "./utils/sleep";
|
||||||
import { withTimeout } from "./utils/with-timeout";
|
import { withTimeout } from "./utils/with-timeout";
|
||||||
|
|
@ -28,6 +35,18 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
private readonly syncErrors: Error[] = [];
|
private readonly syncErrors: Error[] = [];
|
||||||
private readonly pendingSyncOperations = new Set<Promise<void>>();
|
private readonly pendingSyncOperations = new Set<Promise<void>>();
|
||||||
private readonly wsFactory = new ManagedWebSocketFactory();
|
private readonly wsFactory = new ManagedWebSocketFactory();
|
||||||
|
private nextWriteRename:
|
||||||
|
| {
|
||||||
|
oldPath: RelativePath;
|
||||||
|
newPath: RelativePath;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
private nextCreateResponseDrop:
|
||||||
|
| {
|
||||||
|
dropped: Promise<void>;
|
||||||
|
resolveDropped: () => void;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
clientId: number,
|
clientId: number,
|
||||||
|
|
@ -49,7 +68,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
load: async () => this.data,
|
load: async () => this.data,
|
||||||
save: async (data) => void (this.data = data)
|
save: async (data) => void (this.data = data)
|
||||||
},
|
},
|
||||||
fetch: fetchImplementation,
|
fetch: this.wrapFetch(fetchImplementation),
|
||||||
webSocket: this.wsFactory.constructorFn
|
webSocket: this.wsFactory.constructorFn
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -94,6 +113,65 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
this.wsFactory.resume();
|
this.wsFactory.resume();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public dropNextCreateResponse(): void {
|
||||||
|
assert(
|
||||||
|
this.nextCreateResponseDrop === undefined,
|
||||||
|
`Client ${this.clientId} already has a create response drop armed`
|
||||||
|
);
|
||||||
|
let resolveDropped!: () => void;
|
||||||
|
const dropped = new Promise<void>((resolve) => {
|
||||||
|
resolveDropped = resolve;
|
||||||
|
});
|
||||||
|
this.nextCreateResponseDrop = {
|
||||||
|
dropped,
|
||||||
|
resolveDropped
|
||||||
|
};
|
||||||
|
this.log("Armed next create response drop");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async waitForDroppedCreateResponse(): Promise<void> {
|
||||||
|
assert(
|
||||||
|
this.nextCreateResponseDrop !== undefined,
|
||||||
|
`Client ${this.clientId} has no create response drop armed`
|
||||||
|
);
|
||||||
|
await withTimeout(
|
||||||
|
this.nextCreateResponseDrop.dropped,
|
||||||
|
WAIT_TIMEOUT_MS,
|
||||||
|
`Client ${this.clientId} timed out waiting for create response drop`
|
||||||
|
);
|
||||||
|
this.log("Create response was dropped after server commit");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async waitForHistoryEntry(
|
||||||
|
matches: (entry: HistoryEntry) => boolean,
|
||||||
|
onMatch?: (entry: HistoryEntry) => void
|
||||||
|
): Promise<void> {
|
||||||
|
const existing = this.client.getHistoryEntries().find(matches);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
onMatch?.(existing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withTimeout(
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
const unsubscribe = this.client.onSyncHistoryUpdated.add(() => {
|
||||||
|
const entry = this.client
|
||||||
|
.getHistoryEntries()
|
||||||
|
.find(matches);
|
||||||
|
if (entry === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
onMatch?.(entry);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
WAIT_TIMEOUT_MS,
|
||||||
|
`Client ${this.clientId} timed out waiting for history entry`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public async waitForSync(): Promise<void> {
|
public async waitForSync(): Promise<void> {
|
||||||
this.log("Waiting for sync to complete...");
|
this.log("Waiting for sync to complete...");
|
||||||
// Drain agent-level sync operations first. These are the fire-and-forget
|
// Drain agent-level sync operations first. These are the fire-and-forget
|
||||||
|
|
@ -160,6 +238,15 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
return new TextDecoder().decode(bytes);
|
return new TextDecoder().decode(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public renameNextWrite(oldPath: RelativePath, newPath: RelativePath): void {
|
||||||
|
assert(
|
||||||
|
this.nextWriteRename === undefined,
|
||||||
|
`Client ${this.clientId} already has a next-write rename armed`
|
||||||
|
);
|
||||||
|
this.nextWriteRename = { oldPath, newPath };
|
||||||
|
this.log(`Armed next write rename: ${oldPath} -> ${newPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
public async cleanup(): Promise<void> {
|
public async cleanup(): Promise<void> {
|
||||||
this.log("Cleaning up...");
|
this.log("Cleaning up...");
|
||||||
// Guard against uninitialized client (init() failed partway).
|
// Guard against uninitialized client (init() failed partway).
|
||||||
|
|
@ -201,15 +288,37 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
const isNew = !this.files.has(path);
|
const isNew = !this.files.has(path);
|
||||||
await super.write(path, content);
|
await super.write(path, content);
|
||||||
|
|
||||||
|
if (this.isSyncEnabled && isNew) {
|
||||||
|
this.enqueueSync(async () => {
|
||||||
|
this.client.syncLocallyCreatedFile(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextWriteRename = this.nextWriteRename;
|
||||||
|
if (
|
||||||
|
nextWriteRename !== undefined &&
|
||||||
|
nextWriteRename.oldPath === path
|
||||||
|
) {
|
||||||
|
this.nextWriteRename = undefined;
|
||||||
|
await super.rename(
|
||||||
|
nextWriteRename.oldPath,
|
||||||
|
nextWriteRename.newPath
|
||||||
|
);
|
||||||
|
if (this.isSyncEnabled) {
|
||||||
|
this.enqueueSync(async () => {
|
||||||
|
this.client.syncLocallyUpdatedFile({
|
||||||
|
oldPath: nextWriteRename.oldPath,
|
||||||
|
relativePath: nextWriteRename.newPath
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.isSyncEnabled) {
|
if (!this.isSyncEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNew) {
|
if (!isNew) {
|
||||||
this.enqueueSync(async () => {
|
|
||||||
this.client.syncLocallyCreatedFile(path);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.enqueueSync(async () => {
|
this.enqueueSync(async () => {
|
||||||
this.client.syncLocallyUpdatedFile({ relativePath: path });
|
this.client.syncLocallyUpdatedFile({ relativePath: path });
|
||||||
});
|
});
|
||||||
|
|
@ -314,4 +423,42 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
private log(message: string): void {
|
private log(message: string): void {
|
||||||
this.logger(`[Client ${this.clientId}] ${message}`);
|
this.logger(`[Client ${this.clientId}] ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private wrapFetch(
|
||||||
|
fetchImplementation: typeof globalThis.fetch
|
||||||
|
): typeof globalThis.fetch {
|
||||||
|
return async (input, init) => {
|
||||||
|
const response = await fetchImplementation(input, init);
|
||||||
|
const drop = this.nextCreateResponseDrop;
|
||||||
|
if (
|
||||||
|
drop !== undefined &&
|
||||||
|
DeterministicAgent.isCreateDocumentRequest(input, init)
|
||||||
|
) {
|
||||||
|
this.nextCreateResponseDrop = undefined;
|
||||||
|
drop.resolveDropped();
|
||||||
|
throw new SyncResetError();
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isCreateDocumentRequest(
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init: RequestInit | undefined
|
||||||
|
): boolean {
|
||||||
|
const method =
|
||||||
|
init?.method ??
|
||||||
|
(typeof Request !== "undefined" && input instanceof Request
|
||||||
|
? input.method
|
||||||
|
: "GET");
|
||||||
|
if (method.toUpperCase() !== "POST") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url =
|
||||||
|
input instanceof URL
|
||||||
|
? input
|
||||||
|
: new URL(typeof input === "string" ? input : input.url);
|
||||||
|
return /\/documents\/?$/.test(url.pathname);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,30 @@ export type TestStep =
|
||||||
| { type: "create"; client: number; path: string; content: string }
|
| { type: "create"; client: number; path: string; content: string }
|
||||||
| { type: "update"; client: number; path: string; content: string }
|
| { type: "update"; client: number; path: string; content: string }
|
||||||
| { type: "rename"; client: number; oldPath: string; newPath: string }
|
| { type: "rename"; client: number; oldPath: string; newPath: string }
|
||||||
|
| {
|
||||||
|
type: "rename-next-write";
|
||||||
|
client: number;
|
||||||
|
oldPath: string;
|
||||||
|
newPath: string;
|
||||||
|
}
|
||||||
| { type: "delete"; client: number; path: string }
|
| { type: "delete"; client: number; path: string }
|
||||||
| { type: "sync"; client?: number }
|
| { type: "sync"; client?: number }
|
||||||
| { type: "disable-sync"; client: number }
|
| { type: "disable-sync"; client: number }
|
||||||
| { type: "enable-sync"; client: number }
|
| { type: "enable-sync"; client: number }
|
||||||
| { type: "pause-server" }
|
| { type: "pause-server" }
|
||||||
| { type: "resume-server" }
|
| { type: "resume-server" }
|
||||||
|
| {
|
||||||
|
type: "resume-server-until-history-then-pause";
|
||||||
|
client: number;
|
||||||
|
syncType: "CREATE" | "UPDATE" | "DELETE";
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
| { type: "barrier" }
|
| { type: "barrier" }
|
||||||
| { type: "assert-consistent"; verify?: (state: AssertableState) => void }
|
| { type: "assert-consistent"; verify?: (state: AssertableState) => void }
|
||||||
| { type: "pause-websocket"; client: number }
|
| { type: "pause-websocket"; client: number }
|
||||||
| { type: "resume-websocket"; client: number }
|
| { type: "resume-websocket"; client: number }
|
||||||
|
| { type: "drop-next-create-response"; client: number }
|
||||||
|
| { type: "wait-for-dropped-create-response"; client: number }
|
||||||
| { type: "sleep"; ms: number }
|
| { type: "sleep"; ms: number }
|
||||||
| { type: "reset"; client: number };
|
| { type: "reset"; client: number };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,13 @@ import { renameChainDuringPendingCreateTest } from "./tests/rename-chain-during-
|
||||||
import { remoteRenameCollidesWithPendingLocalCreateTest } from "./tests/remote-rename-collides-with-pending-local-create.test";
|
import { remoteRenameCollidesWithPendingLocalCreateTest } from "./tests/remote-rename-collides-with-pending-local-create.test";
|
||||||
import { remoteUpdateSurvivesUserRenameTest } from "./tests/remote-update-survives-user-rename.test";
|
import { remoteUpdateSurvivesUserRenameTest } from "./tests/remote-update-survives-user-rename.test";
|
||||||
import { sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest } from "./tests/same-doc-id-collapse-on-local-create-after-remote-create.test";
|
import { sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest } from "./tests/same-doc-id-collapse-on-local-create-after-remote-create.test";
|
||||||
|
import { sameDocIdCollapseAfterRemoteQuickWriteAndPendingRenameTest } from "./tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test";
|
||||||
|
import { renameOverwritesPendingCreateThenDeleteTest } from "./tests/rename-overwrites-pending-create-then-delete.test";
|
||||||
|
import { deleteRecreatedPendingCreateWithStaleDeletingRecordTest } from "./tests/delete-recreated-pending-create-with-stale-deleting-record.test";
|
||||||
|
import { queuedCreateDeleteDoesNotHijackReusedPathTest } from "./tests/queued-create-delete-does-not-hijack-reused-path.test";
|
||||||
|
import { renamedPendingCreateReusedPathThenDeleteTest } from "./tests/renamed-pending-create-reused-path-then-delete.test";
|
||||||
|
import { renamePendingCreateOntoPendingDeletePathTest } from "./tests/rename-pending-create-onto-pending-delete-path.test";
|
||||||
|
import { remoteQuickWriteRenameBeforeRecordTest } from "./tests/remote-quick-write-rename-before-record.test";
|
||||||
|
|
||||||
export const TESTS: Partial<Record<string, TestDefinition>> = {
|
export const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||||
"rename-create-conflict": renameCreateConflictTest,
|
"rename-create-conflict": renameCreateConflictTest,
|
||||||
|
|
@ -221,5 +228,19 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||||
remoteRenameCollidesWithPendingLocalCreateTest,
|
remoteRenameCollidesWithPendingLocalCreateTest,
|
||||||
"remote-update-survives-user-rename": remoteUpdateSurvivesUserRenameTest,
|
"remote-update-survives-user-rename": remoteUpdateSurvivesUserRenameTest,
|
||||||
"same-doc-id-collapse-on-local-create-after-remote-create":
|
"same-doc-id-collapse-on-local-create-after-remote-create":
|
||||||
sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest
|
sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest,
|
||||||
|
"renamed-pending-create-reused-path-then-delete":
|
||||||
|
renamedPendingCreateReusedPathThenDeleteTest,
|
||||||
|
"rename-pending-create-onto-pending-delete-path":
|
||||||
|
renamePendingCreateOntoPendingDeletePathTest,
|
||||||
|
"rename-overwrites-pending-create-then-delete":
|
||||||
|
renameOverwritesPendingCreateThenDeleteTest,
|
||||||
|
"same-doc-id-collapse-after-remote-quick-write-and-pending-rename":
|
||||||
|
sameDocIdCollapseAfterRemoteQuickWriteAndPendingRenameTest,
|
||||||
|
"delete-recreated-pending-create-with-stale-deleting-record":
|
||||||
|
deleteRecreatedPendingCreateWithStaleDeletingRecordTest,
|
||||||
|
"queued-create-delete-does-not-hijack-reused-path":
|
||||||
|
queuedCreateDeleteDoesNotHijackReusedPathTest,
|
||||||
|
"remote-quick-write-rename-before-record":
|
||||||
|
remoteQuickWriteRenameBeforeRecordTest
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,13 @@ export class TestRunner {
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "rename-next-write":
|
||||||
|
this.getAgent(step.client).renameNextWrite(
|
||||||
|
step.oldPath,
|
||||||
|
step.newPath
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
case "delete":
|
case "delete":
|
||||||
await this.getAgent(step.client).delete(step.path);
|
await this.getAgent(step.client).delete(step.path);
|
||||||
break;
|
break;
|
||||||
|
|
@ -177,6 +184,19 @@ export class TestRunner {
|
||||||
await this.serverControl.waitForReady();
|
await this.serverControl.waitForReady();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "resume-server-until-history-then-pause": {
|
||||||
|
const agent = this.getAgent(step.client);
|
||||||
|
const historySeen = agent.waitForHistoryEntry(
|
||||||
|
(entry) =>
|
||||||
|
entry.details.type === step.syncType &&
|
||||||
|
entry.details.relativePath === step.path,
|
||||||
|
() => this.serverControl.pause()
|
||||||
|
);
|
||||||
|
this.serverControl.resume();
|
||||||
|
await historySeen;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "barrier":
|
case "barrier":
|
||||||
await this.waitForConvergence();
|
await this.waitForConvergence();
|
||||||
break;
|
break;
|
||||||
|
|
@ -193,6 +213,14 @@ export class TestRunner {
|
||||||
this.getAgent(step.client).resumeWebSocket();
|
this.getAgent(step.client).resumeWebSocket();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "drop-next-create-response":
|
||||||
|
this.getAgent(step.client).dropNextCreateResponse();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "wait-for-dropped-create-response":
|
||||||
|
await this.getAgent(step.client).waitForDroppedCreateResponse();
|
||||||
|
break;
|
||||||
|
|
||||||
case "sleep":
|
case "sleep":
|
||||||
await sleep(step.ms);
|
await sleep(step.ms);
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const deleteRecreatedPendingCreateWithStaleDeletingRecordTest: TestDefinition =
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"A local delete for a recreated pending create must target the " +
|
||||||
|
"new pending create, not an older same-path record whose server " +
|
||||||
|
"delete has been acked but whose WebSocket delete receipt is " +
|
||||||
|
"still paused.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{ type: "pause-websocket", client: 0 },
|
||||||
|
{ type: "pause-server" },
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 0,
|
||||||
|
path: "binary-14.bin",
|
||||||
|
content: "BINARY:first"
|
||||||
|
},
|
||||||
|
{ type: "sleep", ms: 100 },
|
||||||
|
{ type: "delete", client: 0, path: "binary-14.bin" },
|
||||||
|
{ type: "resume-server" },
|
||||||
|
{ type: "sync", client: 0 },
|
||||||
|
|
||||||
|
{ type: "pause-server" },
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 0,
|
||||||
|
path: "binary-14.bin",
|
||||||
|
content: "BINARY:second"
|
||||||
|
},
|
||||||
|
{ type: "sleep", ms: 100 },
|
||||||
|
{ type: "delete", client: 0, path: "binary-14.bin" },
|
||||||
|
{ type: "resume-server" },
|
||||||
|
{ type: "sync", client: 0 },
|
||||||
|
|
||||||
|
{ type: "resume-websocket", client: 0 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state.assertFileCount(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const queuedCreateDeleteDoesNotHijackReusedPathTest: TestDefinition = {
|
||||||
|
description:
|
||||||
|
"A create/delete pair that is still queued behind another request " +
|
||||||
|
"must collapse locally. It must not later read a different file " +
|
||||||
|
"that reused the same path before the queued create drained.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{ type: "pause-server" },
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 1,
|
||||||
|
path: "blocker.bin",
|
||||||
|
content: "BINARY:blocker"
|
||||||
|
},
|
||||||
|
{ type: "sleep", ms: 100 },
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 1,
|
||||||
|
path: "target.bin",
|
||||||
|
content: "BINARY:old"
|
||||||
|
},
|
||||||
|
{ type: "delete", client: 1, path: "target.bin" },
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 1,
|
||||||
|
path: "source.bin",
|
||||||
|
content: "BINARY:new"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "rename",
|
||||||
|
client: 1,
|
||||||
|
oldPath: "source.bin",
|
||||||
|
newPath: "target.bin"
|
||||||
|
},
|
||||||
|
{ type: "resume-server" },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state
|
||||||
|
.assertFileCount(2)
|
||||||
|
.assertContent("blocker.bin", "BINARY:blocker")
|
||||||
|
.assertContent("target.bin", "BINARY:new")
|
||||||
|
.assertFileNotExists("source.bin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const remoteQuickWriteRenameBeforeRecordTest: TestDefinition = {
|
||||||
|
description:
|
||||||
|
"Client 0 receives a remote create and the user renames the new " +
|
||||||
|
"file immediately after the syncer writes it. The watcher event " +
|
||||||
|
"must bind to the new document instead of being dropped before " +
|
||||||
|
"the remote-create handler persists the record.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "rename-next-write",
|
||||||
|
client: 0,
|
||||||
|
oldPath: "doc.md",
|
||||||
|
newPath: "renamed.md"
|
||||||
|
},
|
||||||
|
|
||||||
|
{ type: "create", client: 1, path: "doc.md", content: "v1\n" },
|
||||||
|
{ type: "sync", client: 1 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1);
|
||||||
|
s.assertFileExists("renamed.md");
|
||||||
|
s.assertFileNotExists("doc.md");
|
||||||
|
s.assertContent("renamed.md", "v1\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const renameOverwritesPendingCreateThenDeleteTest: TestDefinition = {
|
||||||
|
description:
|
||||||
|
"A pending local create at a path must not mask a synced document renamed onto that path; later rename/delete events still belong to the synced document.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 0,
|
||||||
|
path: "tracked.bin",
|
||||||
|
content: "BINARY:tracked"
|
||||||
|
},
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{ type: "pause-server" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 0,
|
||||||
|
path: "pending.bin",
|
||||||
|
content: "BINARY:pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "rename",
|
||||||
|
client: 0,
|
||||||
|
oldPath: "tracked.bin",
|
||||||
|
newPath: "pending.bin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "rename",
|
||||||
|
client: 0,
|
||||||
|
oldPath: "pending.bin",
|
||||||
|
newPath: "final.bin"
|
||||||
|
},
|
||||||
|
{ type: "delete", client: 0, path: "final.bin" },
|
||||||
|
|
||||||
|
{ type: "resume-server" },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state.assertFileCount(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const renamePendingCreateOntoPendingDeletePathTest: TestDefinition = {
|
||||||
|
description:
|
||||||
|
"A pending create is renamed onto a path whose old server document " +
|
||||||
|
"has a queued delete. The delete must reach the server before the " +
|
||||||
|
"new create so the new generation is not merged into the soon-to-be " +
|
||||||
|
"deleted document.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 1,
|
||||||
|
path: "file-17.md",
|
||||||
|
content: "old\n"
|
||||||
|
},
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{ type: "pause-server" },
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 1,
|
||||||
|
path: "blocker.md",
|
||||||
|
content: "blocker\n"
|
||||||
|
},
|
||||||
|
{ type: "sleep", ms: 100 },
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 1,
|
||||||
|
path: "file-23.md",
|
||||||
|
content: "new\n"
|
||||||
|
},
|
||||||
|
{ type: "delete", client: 1, path: "file-17.md" },
|
||||||
|
{
|
||||||
|
type: "rename",
|
||||||
|
client: 1,
|
||||||
|
oldPath: "file-23.md",
|
||||||
|
newPath: "file-17.md"
|
||||||
|
},
|
||||||
|
{ type: "resume-server" },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state
|
||||||
|
.assertFileCount(2)
|
||||||
|
.assertContent("blocker.md", "blocker\n")
|
||||||
|
.assertContent("file-17.md", "new\n")
|
||||||
|
.assertFileNotExists("file-23.md");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const renamedPendingCreateReusedPathThenDeleteTest: TestDefinition = {
|
||||||
|
description:
|
||||||
|
"A queued create is renamed away from file-59.md, a newer local " +
|
||||||
|
"file reuses file-59.md before the queued create drains, and the " +
|
||||||
|
"renamed-away generation is deleted. The delete must not erase or " +
|
||||||
|
"orphan the newer file-59.md generation.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{ type: "pause-server" },
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 1,
|
||||||
|
path: "blocker.md",
|
||||||
|
content: "blocker\n"
|
||||||
|
},
|
||||||
|
{ type: "sleep", ms: 100 },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 1,
|
||||||
|
path: "file-59.md",
|
||||||
|
content: "old\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "rename",
|
||||||
|
client: 1,
|
||||||
|
oldPath: "file-59.md",
|
||||||
|
newPath: "file-33.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 1,
|
||||||
|
path: "file-59.md",
|
||||||
|
content: "new\n"
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "resume-server-until-history-then-pause",
|
||||||
|
client: 1,
|
||||||
|
syncType: "CREATE",
|
||||||
|
path: "file-33.md"
|
||||||
|
},
|
||||||
|
{ type: "delete", client: 1, path: "file-33.md" },
|
||||||
|
{ type: "resume-server" },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state
|
||||||
|
.assertFileCount(2)
|
||||||
|
.assertContent("blocker.md", "blocker\n")
|
||||||
|
.assertContent("file-59.md", "new\n")
|
||||||
|
.assertFileNotExists("file-33.md");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const sameDocIdCollapseAfterRemoteQuickWriteAndPendingRenameTest: TestDefinition =
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"A remote create starts quick-writing at doc.md while a local " +
|
||||||
|
"create for the same path is queued and renamed to renamed.md. " +
|
||||||
|
"Because the local create was renamed before it reached the " +
|
||||||
|
"server, the two generations should remain separate tracked " +
|
||||||
|
"documents.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
|
||||||
|
// Create a deleted latest version before client 1 joins.
|
||||||
|
// Catch-up will advance MinCovered with a non-contiguous id,
|
||||||
|
// keeping client 1's create lastSeen low enough to exercise
|
||||||
|
// the server's same-doc merge path from the e2e failure.
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 0,
|
||||||
|
path: "history.md",
|
||||||
|
content: "history-v1"
|
||||||
|
},
|
||||||
|
{ type: "sync", client: 0 },
|
||||||
|
{
|
||||||
|
type: "update",
|
||||||
|
client: 0,
|
||||||
|
path: "history.md",
|
||||||
|
content: "history-v2"
|
||||||
|
},
|
||||||
|
{ type: "sync", client: 0 },
|
||||||
|
{ type: "delete", client: 0, path: "history.md" },
|
||||||
|
{ type: "sync", client: 0 },
|
||||||
|
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "sync", client: 1 },
|
||||||
|
|
||||||
|
{ type: "pause-websocket", client: 1 },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 0,
|
||||||
|
path: "doc.md",
|
||||||
|
content: "remote\n"
|
||||||
|
},
|
||||||
|
{ type: "sync", client: 0 },
|
||||||
|
|
||||||
|
// Let client 1's buffered RemoteCreate enter the quick-write
|
||||||
|
// path, but hold the content fetch until the local create has
|
||||||
|
// appeared and moved away from doc.md.
|
||||||
|
{ type: "pause-server" },
|
||||||
|
{ type: "resume-websocket", client: 1 },
|
||||||
|
{ type: "sleep", ms: 100 },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 1,
|
||||||
|
path: "doc.md",
|
||||||
|
content: "local\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "rename",
|
||||||
|
client: 1,
|
||||||
|
oldPath: "doc.md",
|
||||||
|
newPath: "renamed.md"
|
||||||
|
},
|
||||||
|
|
||||||
|
{ type: "resume-server" },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state.assertFileCount(2);
|
||||||
|
state.assertContent("doc.md", "remote\n");
|
||||||
|
state.assertContent("renamed.md", "local\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -34,6 +34,7 @@ export type { ClientCursors } from "./services/types/ClientCursors";
|
||||||
export type { NetworkConnectionStatus } from "./types/network-connection-status";
|
export type { NetworkConnectionStatus } from "./types/network-connection-status";
|
||||||
export type { ServerVersionMismatchError } from "./errors/server-version-mismatch-error";
|
export type { ServerVersionMismatchError } from "./errors/server-version-mismatch-error";
|
||||||
export type { AuthenticationError } from "./errors/authentication-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 type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors";
|
||||||
export { DocumentSyncStatus } from "./types/document-sync-status";
|
export { DocumentSyncStatus } from "./types/document-sync-status";
|
||||||
export { SyncClient } from "./sync-client";
|
export { SyncClient } from "./sync-client";
|
||||||
|
|
|
||||||
|
|
@ -507,6 +507,7 @@ export class SyncClient {
|
||||||
await this.serverConfig.getConfig();
|
await this.serverConfig.getConfig();
|
||||||
|
|
||||||
await this.syncer.scheduleSyncForOfflineChanges();
|
await this.syncer.scheduleSyncForOfflineChanges();
|
||||||
|
this.syncer.resumeDraining();
|
||||||
this.webSocketManager.start();
|
this.webSocketManager.start();
|
||||||
|
|
||||||
this.hasFinishedOfflineSync = true;
|
this.hasFinishedOfflineSync = true;
|
||||||
|
|
@ -514,6 +515,7 @@ export class SyncClient {
|
||||||
|
|
||||||
private async pause(): Promise<void> {
|
private async pause(): Promise<void> {
|
||||||
this.hasFinishedOfflineSync = false;
|
this.hasFinishedOfflineSync = false;
|
||||||
|
this.syncer.pauseDraining();
|
||||||
this.fetchController.startReset();
|
this.fetchController.startReset();
|
||||||
// Signal the service so any `retryForever` loop exits at its next
|
// Signal the service so any `retryForever` loop exits at its next
|
||||||
// iteration instead of continuing to retry a network request while
|
// iteration instead of continuing to retry a network request while
|
||||||
|
|
|
||||||
69
frontend/sync-client/src/sync-operations/reconciler.test.ts
Normal file
69
frontend/sync-client/src/sync-operations/reconciler.test.ts
Normal 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"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,6 +6,7 @@ import type { SyncService } from "../services/sync-service";
|
||||||
import type { SyncEventQueue } from "./sync-event-queue";
|
import type { SyncEventQueue } from "./sync-event-queue";
|
||||||
import type { DocumentId, DocumentRecord, RelativePath } from "./types";
|
import type { DocumentId, DocumentRecord, RelativePath } from "./types";
|
||||||
import { hash } from "../utils/hash";
|
import { hash } from "../utils/hash";
|
||||||
|
import { SyncResetError } from "../errors/sync-reset-error";
|
||||||
|
|
||||||
const SWAP_MARKER_DIR = ".vaultlink";
|
const SWAP_MARKER_DIR = ".vaultlink";
|
||||||
const SWAP_MARKER_PREFIX = "swap-";
|
const SWAP_MARKER_PREFIX = "swap-";
|
||||||
|
|
@ -225,6 +226,14 @@ export class Reconciler {
|
||||||
private async tryInitialPlacement(record: DocumentRecord): Promise<void> {
|
private async tryInitialPlacement(record: DocumentRecord): Promise<void> {
|
||||||
const target = record.remoteRelativePath;
|
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
|
// Slot occupancy: pre-check both the disk and our tracked
|
||||||
// records. Either form of occupancy means we wait — the
|
// records. Either form of occupancy means we wait — the
|
||||||
// occupant's own reconciliation pass (after their next wire-loop
|
// occupant's own reconciliation pass (after their next wire-loop
|
||||||
|
|
@ -259,6 +268,12 @@ export class Reconciler {
|
||||||
vaultUpdateId: record.parentVersionId
|
vaultUpdateId: record.parentVersionId
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e instanceof SyncResetError) {
|
||||||
|
this.logger.info(
|
||||||
|
`Reconciler: content fetch for ${record.documentId} interrupted by sync reset`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Reconciler: failed to fetch content for ${record.documentId}: ${String(e)}`
|
`Reconciler: failed to fetch content for ${record.documentId}: ${String(e)}`
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -248,6 +248,38 @@ describe("SyncEventQueue", () => {
|
||||||
assert.strictEqual(second.isUserRename, true);
|
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 () => {
|
it("byLocalPath stays consistent across upsertRecord, setLocalPath, and rename", async () => {
|
||||||
const queue = createQueue();
|
const queue = createQueue();
|
||||||
|
|
||||||
|
|
@ -502,6 +534,160 @@ describe("SyncEventQueue", () => {
|
||||||
assert.strictEqual(await createPromise, "DOC-1");
|
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 () => {
|
it("findLatestCreateForPath returns the pending create", async () => {
|
||||||
const queue = createQueue();
|
const queue = createQueue();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,7 @@ export class SyncEventQueue {
|
||||||
this.events.push({
|
this.events.push({
|
||||||
type: SyncEventType.LocalCreate,
|
type: SyncEventType.LocalCreate,
|
||||||
path,
|
path,
|
||||||
|
isProcessing: false,
|
||||||
resolvers: Promise.withResolvers()
|
resolvers: Promise.withResolvers()
|
||||||
});
|
});
|
||||||
this.notifyPendingUpdateCountChanged();
|
this.notifyPendingUpdateCountChanged();
|
||||||
|
|
@ -223,22 +224,54 @@ export class SyncEventQueue {
|
||||||
: path;
|
: path;
|
||||||
const record = this._byLocalPath.get(lookupPath);
|
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 =
|
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:
|
const effectiveDocumentId:
|
||||||
| Promise<DocumentId>
|
| Promise<DocumentId>
|
||||||
| DocumentId
|
| DocumentId
|
||||||
| undefined = pendingDocumentId ?? documentId;
|
| undefined = documentId ?? pendingDocumentId;
|
||||||
if (effectiveDocumentId === undefined) {
|
if (effectiveDocumentId === undefined) {
|
||||||
// we can get here when deleting a local document after a remote update
|
// we can get here when deleting a local document after a remote update
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.type === SyncEventType.LocalDelete) {
|
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()`).
|
// Push BEFORE awaiting `setLocalPath` (and its inner `save()`).
|
||||||
// See the comment below on the synchronicity contract with
|
// See the comment below on the synchronicity contract with
|
||||||
// `ensureDraining()`.
|
// `ensureDraining()`.
|
||||||
|
|
@ -248,10 +281,15 @@ export class SyncEventQueue {
|
||||||
path: lookupPath
|
path: lookupPath
|
||||||
});
|
});
|
||||||
this.notifyPendingUpdateCountChanged();
|
this.notifyPendingUpdateCountChanged();
|
||||||
if (record !== undefined) {
|
if (recordOwnsLookupPath && record !== undefined) {
|
||||||
// The file is gone from disk; clear the doc's localPath so the
|
// The file is gone from disk; clear the doc's localPath so the
|
||||||
// Reconciler doesn't try to operate on a vacated slot.
|
// Reconciler doesn't try to operate on a vacated slot.
|
||||||
await this.setLocalPath(record.documentId, undefined);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -259,10 +297,10 @@ export class SyncEventQueue {
|
||||||
const isUserRename = input.oldPath !== undefined;
|
const isUserRename = input.oldPath !== undefined;
|
||||||
let needsSave = false;
|
let needsSave = false;
|
||||||
if (input.oldPath !== undefined) {
|
if (input.oldPath !== undefined) {
|
||||||
if (pendingDocumentId !== undefined) {
|
if (!recordOwnsLookupPath && pendingDocumentId !== undefined) {
|
||||||
this.updatePendingCreatePath(input.oldPath, path);
|
this.updatePendingCreatePath(input.oldPath, path);
|
||||||
} else {
|
} else {
|
||||||
if (record === undefined) {
|
if (record === undefined || !recordOwnsLookupPath) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Unreachable: record must be defined for non-pending update"
|
"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
|
* Return the next event without removing it. Drain uses this so the
|
||||||
* event stays visible in the queue while it is being processed —
|
* event stays visible in the queue while it is being processed —
|
||||||
* critical for `findLatestCreateForPath` to update an in-flight
|
* critical for `findLatestCreateForPath` to update an in-flight
|
||||||
* `LocalCreate`'s path when a rename arrives mid-process. Also marks
|
* `LocalCreate`'s local read path when a rename arrives mid-process.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
public peekFront(): SyncEvent | undefined {
|
public peekFront(): SyncEvent | undefined {
|
||||||
return this.events[0];
|
return this.events[0];
|
||||||
|
|
@ -397,7 +432,13 @@ export class SyncEventQueue {
|
||||||
event.resolvers.promise,
|
event.resolvers.promise,
|
||||||
record.documentId
|
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);
|
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> {
|
public async clearAllState(): Promise<void> {
|
||||||
this.clearPending();
|
this.clearPending();
|
||||||
this.byDocId.clear();
|
this.byDocId.clear();
|
||||||
|
|
@ -643,6 +696,12 @@ export class SyncEventQueue {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public hasPendingCreateForPath(path: RelativePath): boolean {
|
||||||
|
return this.events.some(
|
||||||
|
(e) => e.type === SyncEventType.LocalCreate && e.path === path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public updatePendingCreatePath(
|
public updatePendingCreatePath(
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
newPath: RelativePath
|
newPath: RelativePath
|
||||||
|
|
@ -654,6 +713,9 @@ export class SyncEventQueue {
|
||||||
|
|
||||||
const { promise } = createEvent.resolvers;
|
const { promise } = createEvent.resolvers;
|
||||||
createEvent.path = newPath;
|
createEvent.path = newPath;
|
||||||
|
if (!createEvent.isProcessing) {
|
||||||
|
this.moveBlockingDeletesBeforeCreate(createEvent, newPath);
|
||||||
|
}
|
||||||
|
|
||||||
for (const e of this.events) {
|
for (const e of this.events) {
|
||||||
if (
|
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
|
* Synchronous half of `setLocalPath`: mutate `record.localPath` and
|
||||||
* re-key `_byLocalPath` without persisting. Used by `enqueue`'s
|
* 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 {
|
private purgeRemoteChangesForDocumentId(documentId: DocumentId): void {
|
||||||
const toRemove = this.events.filter(
|
const toRemove = this.events.filter(
|
||||||
(e) =>
|
(e) =>
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ export class Syncer {
|
||||||
|
|
||||||
private runningScheduleSyncForOfflineChanges: Promise<void> | undefined;
|
private runningScheduleSyncForOfflineChanges: Promise<void> | undefined;
|
||||||
private drainPromise: Promise<void> | undefined;
|
private drainPromise: Promise<void> | undefined;
|
||||||
|
private drainRequestedWhileRunning = false;
|
||||||
|
private isDrainingPaused = false;
|
||||||
private isScanning = false;
|
private isScanning = false;
|
||||||
private previousRemainingOperationsCount = 0;
|
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 {
|
private sendHandshakeMessage(): void {
|
||||||
const message: WebSocketClientMessage = {
|
const message: WebSocketClientMessage = {
|
||||||
type: "handshake",
|
type: "handshake",
|
||||||
|
|
@ -282,13 +293,27 @@ export class Syncer {
|
||||||
|
|
||||||
private ensureDraining(): void {
|
private ensureDraining(): void {
|
||||||
if (this.drainPromise !== undefined) {
|
if (this.drainPromise !== undefined) {
|
||||||
|
this.drainRequestedWhileRunning = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.isScanning) {
|
if (this.isScanning) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.isDrainingPaused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.drainPromise = this.drain().finally(() => {
|
this.drainPromise = this.drain().finally(() => {
|
||||||
this.drainPromise = undefined;
|
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):
|
// Peek then remove-after-processing (instead of shift-then-process):
|
||||||
// the event must remain reachable through `findLatestCreateForPath`
|
// the event must remain reachable through `findLatestCreateForPath`
|
||||||
// while it is in flight, so a rename event arriving mid-process can
|
// 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 (;;) {
|
for (;;) {
|
||||||
if (!this.settings.getSettings().isSyncEnabled) {
|
if (
|
||||||
|
this.isDrainingPaused ||
|
||||||
|
!this.settings.getSettings().isSyncEnabled
|
||||||
|
) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
"Drain pausing because sync is disabled; events stay queued"
|
"Drain pausing because sync is disabled; events stay queued"
|
||||||
);
|
);
|
||||||
|
|
@ -333,6 +361,10 @@ export class Syncer {
|
||||||
|
|
||||||
private async processEvent(event: SyncEvent): Promise<void> {
|
private async processEvent(event: SyncEvent): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
if (event.type === SyncEventType.LocalCreate) {
|
||||||
|
event.isProcessing = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (await this.skipIfOversized(event)) {
|
if (await this.skipIfOversized(event)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -460,21 +492,26 @@ export class Syncer {
|
||||||
private async processCreate(
|
private async processCreate(
|
||||||
event: Extract<SyncEvent, { type: SyncEventType.LocalCreate }>
|
event: Extract<SyncEvent, { type: SyncEventType.LocalCreate }>
|
||||||
): Promise<void> {
|
): 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);
|
const contentHash = await hash(contentBytes);
|
||||||
|
|
||||||
// Read `event.path` live: `updatePendingCreatePath` mutates it in
|
// Use the path the pending create has when it reaches the wire loop.
|
||||||
// place when the user renames the pending create mid-roundtrip.
|
// `updatePendingCreatePath` mutates queued creates when a not-yet-sent
|
||||||
// Sending `originalPath` here would tell the server the pre-rename
|
// local file is renamed, so a renamed-away generation does not create
|
||||||
// location, then the queued LocalUpdate from the rename would
|
// a server document at a path that a newer local file has reused.
|
||||||
// fail on `getFileSize(renamedPath)` after the reconciler moved
|
|
||||||
// the file back to match the (stale) server-side path.
|
|
||||||
const response = await this.syncService.create({
|
const response = await this.syncService.create({
|
||||||
relativePath: event.path,
|
relativePath: requestPath,
|
||||||
lastSeenVaultUpdateId: this.queue.lastSeenUpdateId,
|
lastSeenVaultUpdateId: this.queue.lastSeenUpdateId,
|
||||||
contentBytes
|
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
|
// Same-docId collapse. While our LocalCreate sat in the queue, a
|
||||||
// RemoteCreate may have arrived for this same path. The wire-loop's
|
// RemoteCreate may have arrived for this same path. The wire-loop's
|
||||||
// `processRemoteCreateForNewDocument` would have built a record with
|
// `processRemoteCreateForNewDocument` would have built a record with
|
||||||
|
|
@ -487,7 +524,7 @@ export class Syncer {
|
||||||
if (response.type === "MergingUpdate") {
|
if (response.type === "MergingUpdate") {
|
||||||
const responseBytes = base64ToBytes(response.contentBase64);
|
const responseBytes = base64ToBytes(response.contentBase64);
|
||||||
await this.operations.write(
|
await this.operations.write(
|
||||||
event.path,
|
localPath,
|
||||||
contentBytes,
|
contentBytes,
|
||||||
responseBytes
|
responseBytes
|
||||||
);
|
);
|
||||||
|
|
@ -495,13 +532,13 @@ export class Syncer {
|
||||||
await this.updateCache(
|
await this.updateCache(
|
||||||
response.vaultUpdateId,
|
response.vaultUpdateId,
|
||||||
responseBytes,
|
responseBytes,
|
||||||
event.path
|
localPath
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await this.updateCache(
|
await this.updateCache(
|
||||||
response.vaultUpdateId,
|
response.vaultUpdateId,
|
||||||
contentBytes,
|
contentBytes,
|
||||||
event.path
|
localPath
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -516,13 +553,13 @@ export class Syncer {
|
||||||
parentVersionId: response.vaultUpdateId,
|
parentVersionId: response.vaultUpdateId,
|
||||||
remoteRelativePath: response.relativePath,
|
remoteRelativePath: response.relativePath,
|
||||||
remoteHash,
|
remoteHash,
|
||||||
localPath: event.path
|
localPath
|
||||||
});
|
});
|
||||||
|
|
||||||
this.queue.lastSeenUpdateId = response.vaultUpdateId;
|
this.queue.lastSeenUpdateId = response.vaultUpdateId;
|
||||||
this.history.addHistoryEntry({
|
this.history.addHistoryEntry({
|
||||||
status: SyncStatus.SUCCESS,
|
status: SyncStatus.SUCCESS,
|
||||||
details: { type: SyncType.CREATE, relativePath: event.path },
|
details: { type: SyncType.CREATE, relativePath: localPath },
|
||||||
message:
|
message:
|
||||||
response.type === "MergingUpdate"
|
response.type === "MergingUpdate"
|
||||||
? "Created file and merged with existing remote version"
|
? "Created file and merged with existing remote version"
|
||||||
|
|
@ -536,6 +573,24 @@ export class Syncer {
|
||||||
event: Extract<SyncEvent, { type: SyncEventType.LocalDelete }>
|
event: Extract<SyncEvent, { type: SyncEventType.LocalDelete }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const documentId = await event.documentId;
|
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({
|
const response = await this.syncService.delete({
|
||||||
documentId
|
documentId
|
||||||
|
|
@ -754,23 +809,32 @@ export class Syncer {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trackedRecord !== undefined) {
|
if (trackedRecord !== undefined) {
|
||||||
// The doc is tracked. If we have a local file backing it
|
// The doc is tracked, but the disk slot can be stale. One
|
||||||
// and that file has gone missing — e.g. the user deleted it
|
// concrete race: a remote create quick-writes a file, a
|
||||||
// and the LocalDelete hasn't drained yet, or our HTTP
|
// watcher rename/delete lands before the record is fully
|
||||||
// DELETE just landed and we're still waiting on the
|
// settled, and the record is left claiming a path that no
|
||||||
// WebSocket receipt — ignore the update. Otherwise we'd
|
// longer exists. If no queued local operation owns that
|
||||||
// try to operate on a vanished file (or recreate one we're
|
// disappearance, clear the localPath and let
|
||||||
// tearing down).
|
// processRemoteUpdate stash/place the active server version.
|
||||||
if (trackedRecord.localPath !== undefined) {
|
if (trackedRecord.localPath !== undefined) {
|
||||||
const fileExists = await this.operations.exists(
|
const fileExists = await this.operations.exists(
|
||||||
trackedRecord.localPath
|
trackedRecord.localPath
|
||||||
);
|
);
|
||||||
if (!fileExists) {
|
if (
|
||||||
this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId;
|
!fileExists &&
|
||||||
|
!this.queue.hasPendingLocalEventsForDocumentId(
|
||||||
|
remoteVersion.documentId
|
||||||
|
)
|
||||||
|
) {
|
||||||
this.logger.debug(
|
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);
|
return this.processRemoteUpdate(trackedRecord, remoteVersion);
|
||||||
|
|
@ -992,9 +1056,7 @@ export class Syncer {
|
||||||
// design, no buffering at receive time — the reconciler will
|
// design, no buffering at receive time — the reconciler will
|
||||||
// fetch on demand.
|
// fetch on demand.
|
||||||
const target = remoteVersion.relativePath;
|
const target = remoteVersion.relativePath;
|
||||||
const slotFree =
|
const slotFree = await this.canPlaceRemoteCreateAt(target);
|
||||||
!(await this.operations.exists(target)) &&
|
|
||||||
this.queue.getRecordByLocalPath(target) === undefined;
|
|
||||||
|
|
||||||
let localPath: RelativePath | undefined = undefined;
|
let localPath: RelativePath | undefined = undefined;
|
||||||
let remoteHash: string | undefined = undefined;
|
let remoteHash: string | undefined = undefined;
|
||||||
|
|
@ -1004,49 +1066,77 @@ export class Syncer {
|
||||||
documentId: remoteVersion.documentId,
|
documentId: remoteVersion.documentId,
|
||||||
vaultUpdateId: remoteVersion.vaultUpdateId
|
vaultUpdateId: remoteVersion.vaultUpdateId
|
||||||
});
|
});
|
||||||
try {
|
if (!(await this.canPlaceRemoteCreateAt(target))) {
|
||||||
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.
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Quick-write for ${remoteVersion.documentId} at ${target} ` +
|
`Quick-write for ${remoteVersion.documentId} at ${target} ` +
|
||||||
`lost a TOCTOU race; deferring to reconciler`
|
`became blocked while fetching content; deferring to reconciler`
|
||||||
);
|
);
|
||||||
localPath = undefined;
|
} else {
|
||||||
remoteHash = undefined;
|
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({
|
if (
|
||||||
documentId: remoteVersion.documentId,
|
this.queue.getDocumentByDocumentId(remoteVersion.documentId) ===
|
||||||
parentVersionId: remoteVersion.vaultUpdateId,
|
undefined
|
||||||
remoteRelativePath: remoteVersion.relativePath,
|
) {
|
||||||
// `remoteHash` is undefined when we deferred fetching content.
|
await this.queue.upsertRecord({
|
||||||
// Consumers (`processLocalUpdate`'s fast-skip,
|
documentId: remoteVersion.documentId,
|
||||||
// `findMatchingFile`'s offline-rename detection) treat
|
parentVersionId: remoteVersion.vaultUpdateId,
|
||||||
// undefined as "no comparison possible" and fall through to a
|
remoteRelativePath: remoteVersion.relativePath,
|
||||||
// real upload / no-match. The hash gets populated the next
|
// `remoteHash` is undefined when we deferred fetching content.
|
||||||
// time we observe a real version (a remote update, or a
|
// Consumers (`processLocalUpdate`'s fast-skip,
|
||||||
// local edit that triggers an upload).
|
// `findMatchingFile`'s offline-rename detection) treat
|
||||||
remoteHash,
|
// undefined as "no comparison possible" and fall through to a
|
||||||
localPath
|
// 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;
|
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({
|
private async sendUpdate({
|
||||||
record,
|
record,
|
||||||
relativePath,
|
relativePath,
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ export type SyncEvent =
|
||||||
| {
|
| {
|
||||||
type: SyncEventType.LocalCreate;
|
type: SyncEventType.LocalCreate;
|
||||||
path: RelativePath; // current path on disk; mutated in place by `updatePendingCreatePath` when the user renames mid-flight
|
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>;
|
resolvers: PromiseWithResolvers<DocumentId>;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export class MockAgent extends MockClient {
|
||||||
|
|
||||||
// The renamed file finding algorithm isn't too smart so we can't both update and rename the same file
|
// The renamed file finding algorithm isn't too smart so we can't both update and rename the same file
|
||||||
private readonly doNotTouchWhileOffline: string[] = [];
|
private readonly doNotTouchWhileOffline: string[] = [];
|
||||||
|
private readonly doNotRenameWhileOffline: string[] = [];
|
||||||
private lastSyncEnabledState = true;
|
private lastSyncEnabledState = true;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
|
@ -62,6 +63,10 @@ export class MockAgent extends MockClient {
|
||||||
this.doNotTouchWhileOffline,
|
this.doNotTouchWhileOffline,
|
||||||
historyEntry[1]
|
historyEntry[1]
|
||||||
);
|
);
|
||||||
|
utils.removeFromArray(
|
||||||
|
this.doNotRenameWhileOffline,
|
||||||
|
historyEntry[1]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
switch (logLine.level) {
|
switch (logLine.level) {
|
||||||
case LogLevel.ERROR:
|
case LogLevel.ERROR:
|
||||||
|
|
@ -365,6 +370,8 @@ export class MockAgent extends MockClient {
|
||||||
`Decided to create file ${file} with content ${content}`
|
`Decided to create file ${file} with content ${content}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.doNotRenameWhileOffline.push(file);
|
||||||
|
|
||||||
return this.write(file, new TextEncoder().encode(` ${content} `));
|
return this.write(file, new TextEncoder().encode(` ${content} `));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -385,6 +392,8 @@ export class MockAgent extends MockClient {
|
||||||
`Decided to create binary file ${file}: ${uuid}`
|
`Decided to create binary file ${file}: ${uuid}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.doNotRenameWhileOffline.push(file);
|
||||||
|
|
||||||
return this.write(file, bytes);
|
return this.write(file, bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -412,10 +421,11 @@ export class MockAgent extends MockClient {
|
||||||
// Otherwise, the resolution logic couldn't handle it.
|
// Otherwise, the resolution logic couldn't handle it.
|
||||||
if (
|
if (
|
||||||
!this.lastSyncEnabledState &&
|
!this.lastSyncEnabledState &&
|
||||||
this.doNotTouchWhileOffline.includes(file)
|
(this.doNotTouchWhileOffline.includes(file) ||
|
||||||
|
this.doNotRenameWhileOffline.includes(file))
|
||||||
) {
|
) {
|
||||||
this.client.logger.info(
|
this.client.logger.info(
|
||||||
`Skipping file ${file} because it has been updated while offline`
|
`Skipping file ${file} because it cannot be renamed while offline`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -516,6 +526,7 @@ export class MockAgent extends MockClient {
|
||||||
`Deleting file: ${file} with:\n content '${new TextDecoder().decode(this.files.get(file))}'`
|
`Deleting file: ${file} with:\n content '${new TextDecoder().decode(this.files.get(file))}'`
|
||||||
);
|
);
|
||||||
await this.delete(file);
|
await this.delete(file);
|
||||||
|
utils.removeFromArray(this.doNotRenameWhileOffline, file);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getContent(): string {
|
private getContent(): string {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue