no power loss
This commit is contained in:
parent
d56084e4bc
commit
2fcdbfd27d
1 changed files with 1374 additions and 3676 deletions
231
frontend/test-support/restart-reconciliation.test.ts
Normal file
231
frontend/test-support/restart-reconciliation.test.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { fixture, bytes, randomUUID } from "./sync-server-fixture";
|
||||
import { assertCanonical } from "./canonical";
|
||||
import { SyncClient } from "../sync-client/src/sync-client";
|
||||
import { MemoryDisk, MemoryPersistence } from "./storage";
|
||||
|
||||
for (const powerLoss of [false, true])
|
||||
for (const operation of ["create", "replace", "move", "metadata"] as const)
|
||||
test(
|
||||
`${operation}: a ${powerLoss ? "power" : "process"} interruption and later user edits converge without replay`,
|
||||
{ timeout: 20_000 },
|
||||
async () => {
|
||||
const f = await fixture();
|
||||
let peer: SyncClient | undefined;
|
||||
try {
|
||||
const id = randomUUID();
|
||||
const head = await f.put(
|
||||
`/documents/${id}`,
|
||||
f.content("BASE\n")
|
||||
);
|
||||
const manifest = await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: 0,
|
||||
entries: { [id]: "note.md" }
|
||||
});
|
||||
if (operation !== "create") {
|
||||
await f.client.start();
|
||||
await f.client.setSetting("isSyncEnabled", false);
|
||||
if (operation === "move")
|
||||
await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: manifest.fileManifestId,
|
||||
entries: { [id]: "moved.md" }
|
||||
});
|
||||
else
|
||||
await f.put(
|
||||
`/documents/${id}`,
|
||||
f.content("REMOTE\nBASE\n", head.vaultUpdateId)
|
||||
);
|
||||
}
|
||||
let fired = false;
|
||||
let wrote = false;
|
||||
const interrupt = () => {
|
||||
fired = true;
|
||||
f.disk.crash(powerLoss);
|
||||
throw new Error("injected interruption");
|
||||
};
|
||||
f.disk.boundary = (label) => {
|
||||
if (fired) return;
|
||||
if (label === "durable:write:note.md") wrote = true;
|
||||
if (
|
||||
operation === "move" &&
|
||||
label === "visible:rename:note.md->moved.md"
|
||||
)
|
||||
interrupt();
|
||||
if (
|
||||
(operation === "create" ||
|
||||
operation === "replace") &&
|
||||
label === "visible:write:note.md"
|
||||
)
|
||||
interrupt();
|
||||
};
|
||||
f.store.boundary = (label) => {
|
||||
if (
|
||||
!fired &&
|
||||
operation === "metadata" &&
|
||||
wrote &&
|
||||
label === "before:save"
|
||||
)
|
||||
interrupt();
|
||||
};
|
||||
await assert.rejects(
|
||||
operation === "create"
|
||||
? f.client.start()
|
||||
: f.client.setSetting("isSyncEnabled", true),
|
||||
/injected interruption/
|
||||
);
|
||||
assert(
|
||||
fired,
|
||||
"The intended filesystem/save boundary must be reached"
|
||||
);
|
||||
f.disk.boundary = () => {};
|
||||
f.store.boundary = () => {};
|
||||
// Model torn content and further independent edits. Neither is
|
||||
// recoverable from an old filesystem intention, nor should it be.
|
||||
await f.disk.userWrite(
|
||||
operation === "move" ? "moved.md" : "note.md",
|
||||
bytes("partial\nUSER EDIT\n")
|
||||
);
|
||||
await f.disk.userWrite(
|
||||
"after.md",
|
||||
bytes("AFTER INTERRUPTION\n")
|
||||
);
|
||||
await f.reopenClient();
|
||||
await f.client.start();
|
||||
const inspectable = {
|
||||
files: () => f.disk.userFiles(),
|
||||
database: () => f.store.snapshot().database
|
||||
};
|
||||
const peerDisk = new MemoryDisk();
|
||||
const peerStore = new MemoryPersistence({
|
||||
settings: f.store.snapshot().settings
|
||||
});
|
||||
peer = await SyncClient.create({
|
||||
fs: peerDisk,
|
||||
persistence: peerStore
|
||||
});
|
||||
await peer.start();
|
||||
const clients = [
|
||||
inspectable,
|
||||
{
|
||||
files: () => peerDisk.userFiles(),
|
||||
database: () => peerStore.snapshot().database
|
||||
}
|
||||
];
|
||||
await assertCanonical(
|
||||
clients,
|
||||
f.url,
|
||||
"test-token-change-me"
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles().get("after.md"),
|
||||
bytes("AFTER INTERRUPTION\n")
|
||||
);
|
||||
assert(
|
||||
(await f.disk.listFilesRecursively()).every(
|
||||
(path) => !path.startsWith(".vault-link-sync/")
|
||||
)
|
||||
);
|
||||
const files = f.disk.userFiles();
|
||||
await f.reopenClient();
|
||||
await f.client.start();
|
||||
await assertCanonical(
|
||||
clients,
|
||||
f.url,
|
||||
"test-token-change-me"
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles(),
|
||||
files,
|
||||
"A second restart must not replay old writes"
|
||||
);
|
||||
} finally {
|
||||
f.disk.boundary = () => {};
|
||||
f.store.boundary = () => {};
|
||||
await peer?.destroy();
|
||||
await f.dispose();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
for (const operation of ["create", "replace", "move"] as const)
|
||||
test(
|
||||
`${operation}: metadata can survive while completed user-file writes are lost`,
|
||||
{ timeout: 20_000 },
|
||||
async () => {
|
||||
const f = await fixture();
|
||||
let peer: SyncClient | undefined;
|
||||
try {
|
||||
const id = randomUUID();
|
||||
const head = await f.put(
|
||||
`/documents/${id}`,
|
||||
f.content("BASE\n")
|
||||
);
|
||||
const manifest = await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: 0,
|
||||
entries: { [id]: "note.md" }
|
||||
});
|
||||
if (operation !== "create") {
|
||||
await f.client.start();
|
||||
await f.client.setSetting("isSyncEnabled", false);
|
||||
if (operation === "replace")
|
||||
await f.put(
|
||||
`/documents/${id}`,
|
||||
f.content("REMOTE\nBASE\n", head.vaultUpdateId)
|
||||
);
|
||||
else
|
||||
await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: manifest.fileManifestId,
|
||||
entries: { [id]: "moved.md" }
|
||||
});
|
||||
}
|
||||
const oldDisk = f.disk.image();
|
||||
if (operation === "create") await f.client.start();
|
||||
else await f.client.setSetting("isSyncEnabled", true);
|
||||
await f.client.destroy();
|
||||
// Metadata is newer than disk: completed filesystem calls have no
|
||||
// flush contract. This is not covered by failing a call mid-flight.
|
||||
f.disk.restore(oldDisk);
|
||||
await f.disk.userWrite(
|
||||
"after.md",
|
||||
bytes("USER AFTER POWER LOSS\n")
|
||||
);
|
||||
await f.reopenClient();
|
||||
await f.client.start();
|
||||
const peerDisk = new MemoryDisk();
|
||||
const peerStore = new MemoryPersistence({
|
||||
settings: f.store.snapshot().settings
|
||||
});
|
||||
peer = await SyncClient.create({
|
||||
fs: peerDisk,
|
||||
persistence: peerStore
|
||||
});
|
||||
await peer.start();
|
||||
await assertCanonical(
|
||||
[
|
||||
{
|
||||
files: () => f.disk.userFiles(),
|
||||
database: () => f.store.snapshot().database
|
||||
},
|
||||
{
|
||||
files: () => peerDisk.userFiles(),
|
||||
database: () => peerStore.snapshot().database
|
||||
}
|
||||
],
|
||||
f.url,
|
||||
"test-token-change-me"
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.disk.userFiles().get("after.md"),
|
||||
bytes("USER AFTER POWER LOSS\n")
|
||||
);
|
||||
} finally {
|
||||
await peer?.destroy();
|
||||
await f.dispose();
|
||||
}
|
||||
}
|
||||
);
|
||||
Loading…
Reference in a new issue