Remove expected FS events
This commit is contained in:
parent
cd08cd80c7
commit
935ed9c8e7
13 changed files with 129 additions and 243 deletions
|
|
@ -7,7 +7,6 @@ import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly";
|
||||||
import type { FileSystemOperations } from "./filesystem-operations";
|
import type { FileSystemOperations } from "./filesystem-operations";
|
||||||
import type { TextWithCursors } from "reconcile-text";
|
import type { TextWithCursors } from "reconcile-text";
|
||||||
import type { ServerConfig, ServerConfigData } from "../services/server-config";
|
import type { ServerConfig, ServerConfigData } from "../services/server-config";
|
||||||
import { ExpectedFsEvents } from "../sync-operations/expected-fs-events";
|
|
||||||
import { FileAlreadyExistsError } from "../errors/file-already-exists-error";
|
import { FileAlreadyExistsError } from "../errors/file-already-exists-error";
|
||||||
|
|
||||||
class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
||||||
|
|
@ -72,8 +71,7 @@ function makeOps(): {
|
||||||
const ops = new FileOperations(
|
const ops = new FileOperations(
|
||||||
new Logger(),
|
new Logger(),
|
||||||
fs,
|
fs,
|
||||||
new MockServerConfig() as ServerConfig, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
new ExpectedFsEvents()
|
|
||||||
);
|
);
|
||||||
return { fs, ops };
|
return { fs, ops };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import { isBinary } from "../utils/is-binary";
|
||||||
import type { ServerConfig } from "../services/server-config";
|
import type { ServerConfig } from "../services/server-config";
|
||||||
import { FileNotFoundError } from "../errors/file-not-found-error";
|
import { FileNotFoundError } from "../errors/file-not-found-error";
|
||||||
import { FileAlreadyExistsError } from "../errors/file-already-exists-error";
|
import { FileAlreadyExistsError } from "../errors/file-already-exists-error";
|
||||||
import type { ExpectedFsEvents } from "../sync-operations/expected-fs-events";
|
|
||||||
|
|
||||||
export class FileOperations {
|
export class FileOperations {
|
||||||
private readonly fs: SafeFileSystemOperations;
|
private readonly fs: SafeFileSystemOperations;
|
||||||
|
|
@ -18,7 +17,6 @@ export class FileOperations {
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
fs: FileSystemOperations,
|
fs: FileSystemOperations,
|
||||||
private readonly serverConfig: ServerConfig,
|
private readonly serverConfig: ServerConfig,
|
||||||
private readonly expectedFsEvents: ExpectedFsEvents,
|
|
||||||
private readonly nativeLineEndings = "\n"
|
private readonly nativeLineEndings = "\n"
|
||||||
) {
|
) {
|
||||||
this.fs = new SafeFileSystemOperations(fs, logger);
|
this.fs = new SafeFileSystemOperations(fs, logger);
|
||||||
|
|
@ -67,13 +65,7 @@ export class FileOperations {
|
||||||
}
|
}
|
||||||
await this.createParentDirectories(path);
|
await this.createParentDirectories(path);
|
||||||
|
|
||||||
this.expectedFsEvents.expectCreate(path);
|
|
||||||
try {
|
|
||||||
await this.fs.write(path, this.toNativeLineEndings(newContent));
|
await this.fs.write(path, this.toNativeLineEndings(newContent));
|
||||||
} catch (e) {
|
|
||||||
this.expectedFsEvents.unexpectCreate(path);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,12 +87,6 @@ export class FileOperations {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single-source the expectation registration: register exactly once
|
|
||||||
// per call, and unexpect from the catch if the underlying fs op
|
|
||||||
// throws (FileNotFoundError or otherwise). The previous shape
|
|
||||||
// registered inside each branch and let the catch swallow
|
|
||||||
// FileNotFoundError, leaking the expectation into the map.
|
|
||||||
this.expectedFsEvents.expectUpdate(path);
|
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
!isFileTypeMergable(
|
!isFileTypeMergable(
|
||||||
|
|
@ -165,7 +151,6 @@ export class FileOperations {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.expectedFsEvents.unexpectUpdate(path);
|
|
||||||
if (e instanceof FileNotFoundError) {
|
if (e instanceof FileNotFoundError) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`File ${path} disappeared during write; not recreating`
|
`File ${path} disappeared during write; not recreating`
|
||||||
|
|
@ -178,13 +163,7 @@ export class FileOperations {
|
||||||
|
|
||||||
public async delete(path: RelativePath): Promise<void> {
|
public async delete(path: RelativePath): Promise<void> {
|
||||||
if (await this.exists(path)) {
|
if (await this.exists(path)) {
|
||||||
this.expectedFsEvents.expectDelete(path);
|
|
||||||
try {
|
|
||||||
await this.fs.delete(path);
|
await this.fs.delete(path);
|
||||||
} catch (e) {
|
|
||||||
this.expectedFsEvents.unexpectDelete(path);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(path);
|
await this.deletingEmptyParentDirectoriesOfDeletedFile(path);
|
||||||
} else {
|
} else {
|
||||||
this.logger.debug(`No need to delete '${path}', it doesn't exist`);
|
this.logger.debug(`No need to delete '${path}', it doesn't exist`);
|
||||||
|
|
@ -223,13 +202,7 @@ export class FileOperations {
|
||||||
}
|
}
|
||||||
await this.createParentDirectories(newPath);
|
await this.createParentDirectories(newPath);
|
||||||
|
|
||||||
this.expectedFsEvents.expectRename(oldPath, newPath);
|
|
||||||
try {
|
|
||||||
await this.fs.rename(oldPath, newPath);
|
await this.fs.rename(oldPath, newPath);
|
||||||
} catch (e) {
|
|
||||||
this.expectedFsEvents.unexpectRename(oldPath, newPath);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
|
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
|
||||||
return newPath;
|
return newPath;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import { setUpTelemetry } from "./utils/set-up-telemetry";
|
||||||
import { ServerConfig } from "./services/server-config";
|
import { ServerConfig } from "./services/server-config";
|
||||||
import type { EventListeners } from "./utils/data-structures/event-listeners";
|
import type { EventListeners } from "./utils/data-structures/event-listeners";
|
||||||
import { Lock } from "./utils/data-structures/locks";
|
import { Lock } from "./utils/data-structures/locks";
|
||||||
import { ExpectedFsEvents } from "./sync-operations/expected-fs-events";
|
|
||||||
|
|
||||||
export class SyncClient {
|
export class SyncClient {
|
||||||
private hasFinishedOfflineSync = false;
|
private hasFinishedOfflineSync = false;
|
||||||
|
|
@ -55,8 +54,7 @@ export class SyncClient {
|
||||||
private readonly fileChangeNotifier: FileChangeNotifier,
|
private readonly fileChangeNotifier: FileChangeNotifier,
|
||||||
private readonly contentCache: FixedSizeDocumentCache,
|
private readonly contentCache: FixedSizeDocumentCache,
|
||||||
private readonly serverConfig: ServerConfig,
|
private readonly serverConfig: ServerConfig,
|
||||||
private readonly syncService: SyncService,
|
private readonly syncService: SyncService
|
||||||
private readonly expectedFsEvents: ExpectedFsEvents
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public get syncedDocumentCount(): number {
|
public get syncedDocumentCount(): number {
|
||||||
|
|
@ -209,13 +207,10 @@ export class SyncClient {
|
||||||
|
|
||||||
const serverConfig = new ServerConfig(syncService, settings);
|
const serverConfig = new ServerConfig(syncService, settings);
|
||||||
|
|
||||||
const expectedFsEvents = new ExpectedFsEvents();
|
|
||||||
|
|
||||||
const fileOperations = new FileOperations(
|
const fileOperations = new FileOperations(
|
||||||
logger,
|
logger,
|
||||||
fs,
|
fs,
|
||||||
serverConfig,
|
serverConfig,
|
||||||
expectedFsEvents,
|
|
||||||
nativeLineEndings
|
nativeLineEndings
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -262,8 +257,7 @@ export class SyncClient {
|
||||||
fileChangeNotifier,
|
fileChangeNotifier,
|
||||||
contentCache,
|
contentCache,
|
||||||
serverConfig,
|
serverConfig,
|
||||||
syncService,
|
syncService
|
||||||
expectedFsEvents
|
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info("SyncClient created successfully");
|
logger.info("SyncClient created successfully");
|
||||||
|
|
@ -378,10 +372,6 @@ export class SyncClient {
|
||||||
this.checkIfDestroyed("syncLocallyCreatedFile");
|
this.checkIfDestroyed("syncLocallyCreatedFile");
|
||||||
|
|
||||||
this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors
|
this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors
|
||||||
if (this.expectedFsEvents.matchCreate(relativePath)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.syncer.syncLocallyCreatedFile(relativePath);
|
this.syncer.syncLocallyCreatedFile(relativePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -395,10 +385,6 @@ export class SyncClient {
|
||||||
this.checkIfDestroyed("syncLocallyUpdatedFile");
|
this.checkIfDestroyed("syncLocallyUpdatedFile");
|
||||||
|
|
||||||
this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors
|
this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors
|
||||||
if (this.expectedFsEvents.matchUpdate(relativePath, oldPath)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.syncer.syncLocallyUpdatedFile({
|
this.syncer.syncLocallyUpdatedFile({
|
||||||
oldPath,
|
oldPath,
|
||||||
relativePath
|
relativePath
|
||||||
|
|
@ -409,10 +395,6 @@ export class SyncClient {
|
||||||
this.checkIfDestroyed("syncLocallyDeletedFile");
|
this.checkIfDestroyed("syncLocallyDeletedFile");
|
||||||
|
|
||||||
this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors
|
this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors
|
||||||
if (this.expectedFsEvents.matchDelete(relativePath)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.syncer.syncLocallyDeletedFile(relativePath);
|
this.syncer.syncLocallyDeletedFile(relativePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -540,10 +522,6 @@ export class SyncClient {
|
||||||
// paused (offline edits, deletes, renames) wouldn't be detected, and
|
// paused (offline edits, deletes, renames) wouldn't be detected, and
|
||||||
// an incoming remote update would silently overwrite them.
|
// an incoming remote update would silently overwrite them.
|
||||||
this.syncer.clearOfflineScanGate();
|
this.syncer.clearOfflineScanGate();
|
||||||
// Drop any expected fs events that were registered but never matched
|
|
||||||
// (e.g. an op aborted by SyncResetError). Otherwise a real user edit
|
|
||||||
// at the same path after re-enable would be swallowed.
|
|
||||||
this.expectedFsEvents.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetInMemoryState(): void {
|
private resetInMemoryState(): void {
|
||||||
|
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
import type { RelativePath } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Counter-based registry of filesystem events the syncer is about to
|
|
||||||
* cause. The syncer's own writes/renames/deletes go through
|
|
||||||
* `FileOperations`, which calls into the host filesystem; the host then
|
|
||||||
* fires watcher events that come back through `SyncClient.syncLocallyXxx`.
|
|
||||||
* Without filtering, those echo events would be re-uploaded to the server
|
|
||||||
* and broadcast back, producing an unbounded loop.
|
|
||||||
*
|
|
||||||
* The fix: every fs call in `FileOperations` registers the event it is
|
|
||||||
* about to provoke; the matching `syncLocallyXxx` handler consumes it.
|
|
||||||
* User-initiated edits never register, so they pass through unchanged.
|
|
||||||
*
|
|
||||||
* Counts are per (kind, path) so back-to-back syncer ops on the same path
|
|
||||||
* (e.g. apply remote update then re-apply during convergence) match
|
|
||||||
* one-for-one. If the watcher never fires for a registered op (e.g. the
|
|
||||||
* fs throws before notifying), the entry is left behind; `clear()` is
|
|
||||||
* called on pause/destroy to drop those before they collide with a real
|
|
||||||
* user event later.
|
|
||||||
*/
|
|
||||||
export class ExpectedFsEvents {
|
|
||||||
private readonly creates = new Map<RelativePath, number>();
|
|
||||||
private readonly updates = new Map<RelativePath, number>();
|
|
||||||
private readonly deletes = new Map<RelativePath, number>();
|
|
||||||
// Renames are keyed by `JSON.stringify({oldPath, newPath})` so the
|
|
||||||
// delimiter cannot occur inside either path.
|
|
||||||
private readonly renames = new Map<RelativePath, number>();
|
|
||||||
|
|
||||||
private static renameKey(
|
|
||||||
oldPath: RelativePath,
|
|
||||||
newPath: RelativePath
|
|
||||||
): string {
|
|
||||||
return JSON.stringify({ oldPath, newPath });
|
|
||||||
}
|
|
||||||
|
|
||||||
public expectCreate(path: RelativePath): void {
|
|
||||||
this.bump(this.creates, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public expectUpdate(path: RelativePath): void {
|
|
||||||
this.bump(this.updates, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public expectDelete(path: RelativePath): void {
|
|
||||||
this.bump(this.deletes, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public expectRename(oldPath: RelativePath, newPath: RelativePath): void {
|
|
||||||
this.bump(this.renames, ExpectedFsEvents.renameKey(oldPath, newPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel a previously-registered expectation when the fs op that registered
|
|
||||||
* it failed before any watcher event could fire. Without this, a leaked
|
|
||||||
* expectation silently swallows the next genuine user event at the same
|
|
||||||
* path (or, for renames, the same `oldPath → newPath` pair).
|
|
||||||
*
|
|
||||||
* Floored at zero: if the watcher *did* fire (op partially completed) and
|
|
||||||
* already consumed the entry, the unexpect is a no-op. The fallback is
|
|
||||||
* acceptable — at worst we re-upload a real edit we'd otherwise filter.
|
|
||||||
*/
|
|
||||||
public unexpectCreate(path: RelativePath): void {
|
|
||||||
this.decrement(this.creates, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public unexpectUpdate(path: RelativePath): void {
|
|
||||||
this.decrement(this.updates, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public unexpectDelete(path: RelativePath): void {
|
|
||||||
this.decrement(this.deletes, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public unexpectRename(oldPath: RelativePath, newPath: RelativePath): void {
|
|
||||||
this.decrement(
|
|
||||||
this.renames,
|
|
||||||
ExpectedFsEvents.renameKey(oldPath, newPath)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public matchCreate(path: RelativePath): boolean {
|
|
||||||
return this.consume(this.creates, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public matchUpdate(
|
|
||||||
path: RelativePath,
|
|
||||||
oldPath: RelativePath | undefined
|
|
||||||
): boolean {
|
|
||||||
if (oldPath !== undefined) {
|
|
||||||
return this.consume(
|
|
||||||
this.renames,
|
|
||||||
ExpectedFsEvents.renameKey(oldPath, path)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.consume(this.updates, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public matchDelete(path: RelativePath): boolean {
|
|
||||||
return this.consume(this.deletes, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public clear(): void {
|
|
||||||
this.creates.clear();
|
|
||||||
this.updates.clear();
|
|
||||||
this.deletes.clear();
|
|
||||||
this.renames.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private bump(map: Map<RelativePath, number>, key: RelativePath): void {
|
|
||||||
map.set(key, (map.get(key) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private consume(
|
|
||||||
map: Map<RelativePath, number>,
|
|
||||||
key: RelativePath
|
|
||||||
): boolean {
|
|
||||||
const count = map.get(key) ?? 0;
|
|
||||||
if (count === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (count === 1) {
|
|
||||||
map.delete(key);
|
|
||||||
} else {
|
|
||||||
map.set(key, count - 1);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private decrement(map: Map<RelativePath, number>, key: RelativePath): void {
|
|
||||||
const count = map.get(key) ?? 0;
|
|
||||||
if (count <= 1) {
|
|
||||||
map.delete(key);
|
|
||||||
} else {
|
|
||||||
map.set(key, count - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -306,9 +306,65 @@ export class Reconciler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-check ownership after the content fetch. A user rename or
|
||||||
|
// other interleaved op may have placed bytes / claimed the slot
|
||||||
|
// during the await. Without this, `upsertRecord` below would
|
||||||
|
// displace the new owner (clearing its `localPath`) and the
|
||||||
|
// following `operations.create` would then throw
|
||||||
|
// `FileAlreadyExistsError`, leaving the displaced record
|
||||||
|
// placement-pending with its bytes orphaned on disk.
|
||||||
|
try {
|
||||||
|
if (await this.operations.exists(target)) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Reconciler: cannot place ${record.documentId} at ${target} ` +
|
||||||
|
`— slot newly occupied on disk after fetch; will retry next pass`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(
|
||||||
|
`Reconciler: existence check failed for ${target}: ${String(e)}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.queue.byLocalPath.get(target) !== undefined) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Reconciler: cannot place ${record.documentId} at ${target} ` +
|
||||||
|
`— slot newly tracked by another record after fetch; will retry next pass`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install the slot *before* the disk write so the watcher's
|
||||||
|
// create echo, when it arrives, sees `byLocalPath[target]` set
|
||||||
|
// and the queue's enqueue-time echo guard drops it. Also pre-
|
||||||
|
// populates `remoteHash` so any downstream operation that
|
||||||
|
// compares against it (e.g. `processLocalUpdate`'s hashChanged
|
||||||
|
// skip) sees the right value. Mirrors the ordering in
|
||||||
|
// `processRemoteCreateForNewDocument`'s quick-write branch.
|
||||||
|
const contentHash = await hash(content);
|
||||||
|
try {
|
||||||
|
await this.queue.upsertRecord({
|
||||||
|
documentId: record.documentId,
|
||||||
|
parentVersionId: record.parentVersionId,
|
||||||
|
remoteRelativePath: record.remoteRelativePath,
|
||||||
|
remoteHash: contentHash,
|
||||||
|
localPath: target
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(
|
||||||
|
`Reconciler: upsertRecord before create failed for ${record.documentId}: ${String(e)}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.operations.create(target, content);
|
await this.operations.create(target, content);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Roll back the slot claim so a later pass can retry or
|
||||||
|
// re-resolve. Without this, the record looks placed but the
|
||||||
|
// bytes never made it to disk.
|
||||||
|
await this.queue.setLocalPath(record.documentId, undefined);
|
||||||
if (e instanceof FileNotFoundError) {
|
if (e instanceof FileNotFoundError) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Reconciler: create at ${target} hit FileNotFound (likely parent ` +
|
`Reconciler: create at ${target} hit FileNotFound (likely parent ` +
|
||||||
|
|
@ -329,14 +385,6 @@ export class Reconciler {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
await this.queue.setLocalPath(record.documentId, target);
|
|
||||||
} catch (e) {
|
|
||||||
this.logger.error(
|
|
||||||
`Reconciler: setLocalPath after create failed for ${record.documentId}: ${String(e)}`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.pendingPlacementContent.delete(record.documentId);
|
this.pendingPlacementContent.delete(record.documentId);
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Reconciler: placed ${record.documentId} at ${target}`
|
`Reconciler: placed ${record.documentId} at ${target}`
|
||||||
|
|
@ -663,8 +711,10 @@ export class Reconciler {
|
||||||
// We pass the freshly-read pre-write content as
|
// We pass the freshly-read pre-write content as
|
||||||
// `expectedContent` so the 3-way merge inside `operations.write`
|
// `expectedContent` so the 3-way merge inside `operations.write`
|
||||||
// becomes a clean overwrite (no concurrent edits to merge with).
|
// becomes a clean overwrite (no concurrent edits to merge with).
|
||||||
// `operations.write` registers `expectUpdate` itself, so the
|
// Each leg's echo modify event is harmless: the wire-loop's
|
||||||
// watcher swallows each leg's modify event.
|
// `processLocalUpdate` re-hashes the file and compares to
|
||||||
|
// `record.remoteHash`, which was set to match the just-written
|
||||||
|
// bytes — so the echo is dropped as a no-op.
|
||||||
const writtenLegs: SwapLeg[] = [];
|
const writtenLegs: SwapLeg[] = [];
|
||||||
for (const leg of legs) {
|
for (const leg of legs) {
|
||||||
const newBytes = contentByDocId.get(leg.documentId);
|
const newBytes = contentByDocId.get(leg.documentId);
|
||||||
|
|
|
||||||
|
|
@ -247,36 +247,33 @@ describe("SyncEventQueue", () => {
|
||||||
assert.strictEqual(second.isUserRename, true);
|
assert.strictEqual(second.isUserRename, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("settled record owns a path over a stale pending create", async () => {
|
it("drops LocalCreate echoes for paths already tracked", async () => {
|
||||||
|
// The syncer's own remote-create writes (quick-write +
|
||||||
|
// reconciler placements) upsert the record at `localPath`
|
||||||
|
// before calling `operations.create`. The watcher echo then
|
||||||
|
// re-enters as a LocalCreate at the same path — it must be
|
||||||
|
// dropped here, otherwise the wire-loop would POST a duplicate
|
||||||
|
// and the server would deconflict it into a phantom file.
|
||||||
const queue = createQueue();
|
const queue = createQueue();
|
||||||
await queue.upsertRecord(fakeRecord("A", { localPath: "b.md" }));
|
await queue.upsertRecord(fakeRecord("A", { localPath: "b.md" }));
|
||||||
|
|
||||||
await queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" });
|
await queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" });
|
||||||
await queue.enqueue({
|
|
||||||
type: SyncEventType.LocalUpdate,
|
assert.strictEqual(await queue.next(), undefined);
|
||||||
path: "c.md",
|
|
||||||
oldPath: "b.md"
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const aRecord = queue.getDocumentByDocumentId("A");
|
it("admits LocalCreate when the prior owner is pending server delete", async () => {
|
||||||
assert.strictEqual(aRecord?.localPath, "c.md");
|
// A user create at a path whose previous doc is in the
|
||||||
assert.strictEqual(
|
// HTTP-acked-but-WS-pending window is genuine — propagate it.
|
||||||
queue.getRecordByLocalPath("b.md" as RelativePath),
|
const queue = createQueue();
|
||||||
undefined
|
await queue.upsertRecord(fakeRecord("A", { localPath: "b.md" }));
|
||||||
);
|
queue.markServerDeletePending("A");
|
||||||
assert.strictEqual(
|
|
||||||
queue.getRecordByLocalPath("c.md" as RelativePath)?.documentId,
|
await queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" });
|
||||||
"A"
|
|
||||||
);
|
|
||||||
|
|
||||||
const create = await queue.next();
|
const create = await queue.next();
|
||||||
assert.strictEqual(create?.type, SyncEventType.LocalCreate);
|
assert.strictEqual(create?.type, SyncEventType.LocalCreate);
|
||||||
assert.strictEqual(create.path, "b.md");
|
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 () => {
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,16 @@ export class SyncEventQueue {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.type === SyncEventType.LocalCreate) {
|
if (input.type === SyncEventType.LocalCreate) {
|
||||||
|
const owner = this._byLocalPath.get(path);
|
||||||
|
if (
|
||||||
|
owner !== undefined &&
|
||||||
|
!this.hasPendingServerDelete(owner.documentId)
|
||||||
|
) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Ignoring LocalCreate echo at ${path}: slot is already tracked by ${owner.documentId}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.events.push({
|
this.events.push({
|
||||||
type: SyncEventType.LocalCreate,
|
type: SyncEventType.LocalCreate,
|
||||||
path,
|
path,
|
||||||
|
|
@ -796,7 +806,7 @@ export class SyncEventQueue {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = createIndex + 1; i < this.events.length; ) {
|
for (let i = createIndex + 1; i < this.events.length;) {
|
||||||
const event = this.events[i];
|
const event = this.events[i];
|
||||||
if (
|
if (
|
||||||
event.type === SyncEventType.LocalDelete &&
|
event.type === SyncEventType.LocalDelete &&
|
||||||
|
|
@ -849,7 +859,7 @@ export class SyncEventQueue {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = createIndex + 1; i < this.events.length; ) {
|
for (let i = createIndex + 1; i < this.events.length;) {
|
||||||
const event = this.events[i];
|
const event = this.events[i];
|
||||||
if (
|
if (
|
||||||
event.type === SyncEventType.LocalUpdate &&
|
event.type === SyncEventType.LocalUpdate &&
|
||||||
|
|
|
||||||
|
|
@ -592,10 +592,28 @@ export class Syncer {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const documentId = await event.documentId;
|
const documentId = await event.documentId;
|
||||||
const record = this.queue.getDocumentByDocumentId(documentId);
|
const record = this.queue.getDocumentByDocumentId(documentId);
|
||||||
if (
|
if (record === undefined) {
|
||||||
record?.localPath !== undefined &&
|
// The doc is no longer tracked. Typical cause: a remote delete
|
||||||
record.localPath !== event.path
|
// arrived first and `processRemoteDelete` already ran
|
||||||
) {
|
// `removeDocumentById`, but `operations.delete` fired a
|
||||||
|
// watcher echo that landed in the queue as a stale LocalDelete.
|
||||||
|
// Without this skip we'd re-send DELETE to the server for a
|
||||||
|
// doc that's already gone.
|
||||||
|
this.logger.debug(
|
||||||
|
`Skipping local-delete for ${documentId} — doc no longer tracked`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.queue.hasPendingServerDelete(documentId)) {
|
||||||
|
// We already initiated the server delete; nothing more to do.
|
||||||
|
// Reaches here when a LocalDelete echo lands behind the
|
||||||
|
// already-queued LocalDelete that drove the server delete.
|
||||||
|
this.logger.debug(
|
||||||
|
`Skipping local-delete for ${documentId} — server delete already pending`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (record.localPath !== undefined && record.localPath !== event.path) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Skipping local-delete for ${documentId} at ${event.path}: ` +
|
`Skipping local-delete for ${documentId} at ${event.path}: ` +
|
||||||
`record now owns ${record.localPath}`
|
`record now owns ${record.localPath}`
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
rm -rf /tmp/vaultlink-e2e-databases
|
rm -rf /host/tmp/vaultlink-e2e-databases
|
||||||
rm -rf logs
|
rm -rf logs
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ sleep 1
|
||||||
|
|
||||||
# Clean databases (uses tmpfs via /dev/shm for zero disk I/O)
|
# Clean databases (uses tmpfs via /dev/shm for zero disk I/O)
|
||||||
echo "Cleaning databases..."
|
echo "Cleaning databases..."
|
||||||
rm -rf /tmp/databases
|
rm -rf /host/tmp/vaultlink-e2e-databases
|
||||||
|
|
||||||
# Start the server in the background
|
# Start the server in the background
|
||||||
echo "Starting server..."
|
echo "Starting server..."
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
database:
|
database:
|
||||||
databases_directory_path: /tmp/databases
|
databases_directory_path: /host/tmp/vaultlink-e2e-databases
|
||||||
max_connections_per_vault: 8
|
max_connections_per_vault: 8
|
||||||
cursor_timeout: 1m
|
cursor_timeout: 1m
|
||||||
server:
|
server:
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ pub const DEFAULT_LOG_ROTATION_INTERVAL: Duration = Duration::from_hours(24);
|
||||||
pub const IDLE_POOL_TIMEOUT: Duration = Duration::from_mins(5);
|
pub const IDLE_POOL_TIMEOUT: Duration = Duration::from_mins(5);
|
||||||
|
|
||||||
/// Fail fast on pool acquire so a transiently locked database surfaces as
|
/// Fail fast on pool acquire so a transiently locked database surfaces as
|
||||||
/// a 429 in seconds, not after a 30s busy_timeout. Callers retry.
|
/// a 429 in seconds, not after a 30s `busy_timeout`. Callers retry.
|
||||||
pub const POOL_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(5);
|
pub const POOL_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
pub const GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
|
pub const GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
|
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,8 @@ pub async fn update_document(
|
||||||
let content_clone = content.clone();
|
let content_clone = content.clone();
|
||||||
|
|
||||||
let merged = tokio::task::spawn_blocking(move || {
|
let merged = tokio::task::spawn_blocking(move || {
|
||||||
let merged = reconcile(
|
|
||||||
|
reconcile(
|
||||||
&parent_owned,
|
&parent_owned,
|
||||||
&latest_owned.into(),
|
&latest_owned.into(),
|
||||||
&new_owned.into(),
|
&new_owned.into(),
|
||||||
|
|
@ -252,8 +253,7 @@ pub async fn update_document(
|
||||||
)
|
)
|
||||||
.apply()
|
.apply()
|
||||||
.text()
|
.text()
|
||||||
.into_bytes();
|
.into_bytes()
|
||||||
merged
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| server_error(anyhow::anyhow!("Reconcile task failed: {e}")))?;
|
.map_err(|e| server_error(anyhow::anyhow!("Reconcile task failed: {e}")))?;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue