mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-06 16:28:18 +00:00
Merge pull request #1321 from vrtmrz/fix/cli-reconstruction-reflection
Prevent old reflection replay during CLI reconstruction
This commit is contained in:
7 files changed
+173
-39
No files matched your search
@@ -40,6 +40,7 @@ export interface LiveSyncCoreFeatureViews {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StartupDatabaseOptions {
|
export interface StartupDatabaseOptions {
|
||||||
|
readonly discardReplicationResultSnapshotOnReset?: boolean;
|
||||||
readonly ignoreSuspending?: boolean;
|
readonly ignoreSuspending?: boolean;
|
||||||
readonly continueOnFileFailure?: boolean;
|
readonly continueOnFileFailure?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type FilePathWithPrefix,
|
type FilePathWithPrefix,
|
||||||
type ObsidianLiveSyncSettings,
|
type ObsidianLiveSyncSettings,
|
||||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import { ReplicateResultProcessor } from "@/serviceFeatures/replication/ReplicateResultProcessor";
|
||||||
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
|
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
|
||||||
import { ServiceFileAccessCLI } from "./serviceModules/ServiceFileAccessImpl";
|
import { ServiceFileAccessCLI } from "./serviceModules/ServiceFileAccessImpl";
|
||||||
import { runCommand } from "./commands/runCommand";
|
import { runCommand } from "./commands/runCommand";
|
||||||
@@ -103,6 +104,39 @@ describe("CLI database preparation", () => {
|
|||||||
await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`);
|
await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it.each(["fetch", "rebuild"] as const)("does not replay the old reflection snapshot before %s", async (command) => {
|
||||||
|
const restore = vi.spyOn(ReplicateResultProcessor.prototype, "restoreFromSnapshot");
|
||||||
|
const runner = vi.fn<CliCommandRunner>(async () => false);
|
||||||
|
|
||||||
|
await start(command, runner, 1, true);
|
||||||
|
|
||||||
|
expect(runner).toHaveBeenCalledOnce();
|
||||||
|
expect(restore).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["fetch", "rebuild"] as const)("invalidates old reflection work after the %s database reset", async (command) => {
|
||||||
|
const snapshotKey = "replicationResultProcessorSnapshot";
|
||||||
|
const oldSnapshot = { queued: [{ _id: "old", _rev: "1-old" }], processing: [] };
|
||||||
|
let retainedBeforeReset: unknown;
|
||||||
|
let retainedAfterReset: unknown;
|
||||||
|
let reset: boolean | undefined;
|
||||||
|
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
|
||||||
|
await core.services.keyValueDB.kvDB.set(snapshotKey, oldSnapshot);
|
||||||
|
retainedBeforeReset = await core.services.keyValueDB.kvDB.get(snapshotKey);
|
||||||
|
reset = await core.services.database.resetDatabase();
|
||||||
|
retainedAfterReset = await core.services.keyValueDB.kvDB.get(snapshotKey);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
await start(command, runner, 1, true);
|
||||||
|
|
||||||
|
expect(runner).toHaveBeenCalledOnce();
|
||||||
|
expect(reset).toBe(true);
|
||||||
|
expect(retainedBeforeReset).toEqual(oldSnapshot);
|
||||||
|
expect(retainedAfterReset).toEqual({ queued: [], processing: [] });
|
||||||
|
expect(await readFile(join(vaultPath, "notes/local.md"), "utf8")).toBe("local content");
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{ handleFilenameCaseSensitive: true, pathNormalizationVersion: 0 as const },
|
{ handleFilenameCaseSensitive: true, pathNormalizationVersion: 0 as const },
|
||||||
{ handleFilenameCaseSensitive: true, pathNormalizationVersion: 1 as const },
|
{ handleFilenameCaseSensitive: true, pathNormalizationVersion: 1 as const },
|
||||||
|
|||||||
+10
-2
@@ -77,13 +77,21 @@ const VAULT_SYNC_MODES: Readonly<Partial<Record<CLICommand, CLIVaultSyncMode>>>
|
|||||||
watchFiles: false,
|
watchFiles: false,
|
||||||
reflectReplicationResults: false,
|
reflectReplicationResults: false,
|
||||||
scanOnStartup: false,
|
scanOnStartup: false,
|
||||||
startupDatabaseOptions: { ignoreSuspending: false, continueOnFileFailure: false },
|
startupDatabaseOptions: {
|
||||||
|
ignoreSuspending: false,
|
||||||
|
continueOnFileFailure: false,
|
||||||
|
discardReplicationResultSnapshotOnReset: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fetch: {
|
fetch: {
|
||||||
watchFiles: false,
|
watchFiles: false,
|
||||||
reflectReplicationResults: true,
|
reflectReplicationResults: true,
|
||||||
scanOnStartup: false,
|
scanOnStartup: false,
|
||||||
startupDatabaseOptions: { ignoreSuspending: false, continueOnFileFailure: false },
|
startupDatabaseOptions: {
|
||||||
|
ignoreSuspending: false,
|
||||||
|
continueOnFileFailure: false,
|
||||||
|
discardReplicationResultSnapshotOnReset: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,19 @@ export class ReplicateResultProcessor {
|
|||||||
if (cause !== undefined) this.logError(cause);
|
if (cause !== undefined) this.logError(cause);
|
||||||
Logger($msg("Ui.Common.SomeFilesCouldNotBeSynchronised"), LOG_LEVEL_NOTICE);
|
Logger($msg("Ui.Common.SomeFilesCouldNotBeSynchronised"), LOG_LEVEL_NOTICE);
|
||||||
}
|
}
|
||||||
constructor(private readonly context: ReplicateResultProcessorContext) {}
|
constructor(
|
||||||
|
private readonly context: ReplicateResultProcessorContext,
|
||||||
|
private waitingForDatabaseReset = false
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Discard old reflection work only after the requested startup database replacement succeeds. */
|
||||||
|
public async completeStartupDatabaseReset(): Promise<void> {
|
||||||
|
if (!this.waitingForDatabaseReset) return;
|
||||||
|
await this._takeSnapshot();
|
||||||
|
this._restoreFromSnapshot = Promise.resolve();
|
||||||
|
this.waitingForDatabaseReset = false;
|
||||||
|
this.continueHeldDocuments();
|
||||||
|
}
|
||||||
|
|
||||||
private get localDatabase() {
|
private get localDatabase() {
|
||||||
return this.context.getLocalDatabase();
|
return this.context.getLocalDatabase();
|
||||||
@@ -150,6 +162,7 @@ export class ReplicateResultProcessor {
|
|||||||
|
|
||||||
public get isSuspended() {
|
public get isSuspended() {
|
||||||
return (
|
return (
|
||||||
|
this.waitingForDatabaseReset ||
|
||||||
this._suspended ||
|
this._suspended ||
|
||||||
!this.acceptsResultApplication ||
|
!this.acceptsResultApplication ||
|
||||||
this.context.currentSettings().suspendParseReplicationResult ||
|
this.context.currentSettings().suspendParseReplicationResult ||
|
||||||
@@ -188,6 +201,7 @@ export class ReplicateResultProcessor {
|
|||||||
* Restore from snapshot.
|
* Restore from snapshot.
|
||||||
*/
|
*/
|
||||||
public async restoreFromSnapshot() {
|
public async restoreFromSnapshot() {
|
||||||
|
if (this.waitingForDatabaseReset) return;
|
||||||
const snapshot = await this.context
|
const snapshot = await this.context
|
||||||
.getKeyValueDB()
|
.getKeyValueDB()
|
||||||
.get<ReplicateResultProcessorState>(KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT);
|
.get<ReplicateResultProcessorState>(KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT);
|
||||||
@@ -211,6 +225,7 @@ export class ReplicateResultProcessor {
|
|||||||
* @returns Promise that resolves when restoration is complete.
|
* @returns Promise that resolves when restoration is complete.
|
||||||
*/
|
*/
|
||||||
public restoreFromSnapshotOnce() {
|
public restoreFromSnapshotOnce() {
|
||||||
|
if (this.waitingForDatabaseReset) return Promise.resolve();
|
||||||
if (!this._restoreFromSnapshot) {
|
if (!this._restoreFromSnapshot) {
|
||||||
this._restoreFromSnapshot = this.restoreFromSnapshot();
|
this._restoreFromSnapshot = this.restoreFromSnapshot();
|
||||||
}
|
}
|
||||||
@@ -360,6 +375,7 @@ export class ReplicateResultProcessor {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
protected enqueueChange(doc: PouchDB.Core.ExistingDocument<EntryDoc>) {
|
protected enqueueChange(doc: PouchDB.Core.ExistingDocument<EntryDoc>) {
|
||||||
|
if (this.waitingForDatabaseReset) return;
|
||||||
const old = this._queuedChanges.find((e) => e._id == doc._id);
|
const old = this._queuedChanges.find((e) => e._id == doc._id);
|
||||||
const path = "path" in doc ? this.getPath(doc) : "<unknown>";
|
const path = "path" in doc ? this.getPath(doc) : "<unknown>";
|
||||||
const docNote = `${path} (${shortenId(doc._id)}, ${shortenRev(doc._rev)})`;
|
const docNote = `${path} (${shortenId(doc._id)}, ${shortenRev(doc._rev)})`;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ function note(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SetupOptions = {
|
type SetupOptions = {
|
||||||
|
waitForDatabaseReset?: boolean;
|
||||||
onVaultReflectionFailure?: () => void;
|
onVaultReflectionFailure?: () => void;
|
||||||
getRaw?: (id: string) => Promise<unknown>;
|
getRaw?: (id: string) => Promise<unknown>;
|
||||||
getDBEntryFromMeta?: (entry: object) => Promise<unknown>;
|
getDBEntryFromMeta?: (entry: object) => Promise<unknown>;
|
||||||
@@ -98,21 +99,24 @@ function setup(options: SetupOptions = {}) {
|
|||||||
getDBEntryFromMeta,
|
getDBEntryFromMeta,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const processor = new ReplicateResultProcessor({
|
const processor = new ReplicateResultProcessor(
|
||||||
onVaultReflectionFailure: options.onVaultReflectionFailure,
|
{
|
||||||
currentSettings: () => ({
|
onVaultReflectionFailure: options.onVaultReflectionFailure,
|
||||||
maxMTimeForReflectEvents: options.maxMTimeForReflectEvents ?? 0,
|
currentSettings: () => ({
|
||||||
suspendParseReplicationResult: false,
|
maxMTimeForReflectEvents: options.maxMTimeForReflectEvents ?? 0,
|
||||||
pathNormalizationVersion: options.pathNormalizationVersion ?? 0,
|
suspendParseReplicationResult: false,
|
||||||
}),
|
pathNormalizationVersion: options.pathNormalizationVersion ?? 0,
|
||||||
getKeyValueDB: () => core.kvDB,
|
}),
|
||||||
getLocalDatabase: () => core.localDatabase,
|
getKeyValueDB: () => core.kvDB,
|
||||||
requestActiveReplicatorRetirement: () => {
|
getLocalDatabase: () => core.localDatabase,
|
||||||
void onCloseActiveReplication();
|
requestActiveReplicatorRetirement: () => {
|
||||||
},
|
void onCloseActiveReplication();
|
||||||
runLocalApplicationActivity: runBoundedLocalApplicationActivity,
|
},
|
||||||
services: core.services,
|
runLocalApplicationActivity: runBoundedLocalApplicationActivity,
|
||||||
} as never);
|
services: core.services,
|
||||||
|
} as never,
|
||||||
|
options.waitForDatabaseReset
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
getDBEntryFromMeta,
|
getDBEntryFromMeta,
|
||||||
isTargetFile: core.services.vault.isTargetFile,
|
isTargetFile: core.services.vault.isTargetFile,
|
||||||
@@ -129,6 +133,65 @@ function setup(options: SetupOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ReplicateResultProcessor", () => {
|
describe("ReplicateResultProcessor", () => {
|
||||||
|
it("does not restore old reflection work while waiting for database replacement", async () => {
|
||||||
|
const getSnapshot = vi.fn(async () => ({ queued: [note("old")], processing: [] }));
|
||||||
|
const setSnapshot = vi.fn(async () => undefined);
|
||||||
|
const { processor, processSynchroniseResult, replicationResultCount } = setup({
|
||||||
|
waitForDatabaseReset: true,
|
||||||
|
getSnapshot,
|
||||||
|
setSnapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
await processor.restoreFromSnapshotOnce();
|
||||||
|
processor.resume();
|
||||||
|
processor.continueHeldDocuments();
|
||||||
|
|
||||||
|
expect(getSnapshot).not.toHaveBeenCalled();
|
||||||
|
expect(setSnapshot).not.toHaveBeenCalled();
|
||||||
|
expect(replicationResultCount.value).toBe(0);
|
||||||
|
expect(processSynchroniseResult).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards the old snapshot after reset and accepts only newly received documents", async () => {
|
||||||
|
const getSnapshot = vi.fn(async () => ({ queued: [note("old")], processing: [note("in-flight")] }));
|
||||||
|
const setSnapshot = vi.fn(async () => undefined);
|
||||||
|
const { processor, processSynchroniseResult, replicationResultCount } = setup({
|
||||||
|
waitForDatabaseReset: true,
|
||||||
|
getSnapshot,
|
||||||
|
setSnapshot,
|
||||||
|
});
|
||||||
|
await processor.restoreFromSnapshotOnce();
|
||||||
|
processor.enqueueAll([note("before-reset")]);
|
||||||
|
expect(setSnapshot).not.toHaveBeenCalled();
|
||||||
|
expect(replicationResultCount.value).toBe(0);
|
||||||
|
|
||||||
|
await processor.completeStartupDatabaseReset();
|
||||||
|
|
||||||
|
expect(setSnapshot).toHaveBeenCalledWith("replicationResultProcessorSnapshot", { queued: [], processing: [] });
|
||||||
|
await processor.restoreFromSnapshotOnce();
|
||||||
|
expect(getSnapshot).not.toHaveBeenCalled();
|
||||||
|
processor.enqueueAll([note("new")]);
|
||||||
|
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
|
||||||
|
expect(processSynchroniseResult).toHaveBeenCalledWith(expect.objectContaining({ path: "new.md" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps reflection blocked if persisting snapshot invalidation fails", async () => {
|
||||||
|
const failure = new Error("Snapshot write failed");
|
||||||
|
const setSnapshot = vi.fn(async () => undefined).mockRejectedValueOnce(failure);
|
||||||
|
const { processor, processSynchroniseResult } = setup({ waitForDatabaseReset: true, setSnapshot });
|
||||||
|
|
||||||
|
await expect(processor.completeStartupDatabaseReset()).rejects.toThrow(failure);
|
||||||
|
processor.resume();
|
||||||
|
processor.enqueueAll([note("blocked")]);
|
||||||
|
expect(processor.isSuspended).toBe(true);
|
||||||
|
expect(processSynchroniseResult).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await processor.completeStartupDatabaseReset();
|
||||||
|
processor.enqueueAll([note("after-retry")]);
|
||||||
|
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
|
||||||
|
expect(processSynchroniseResult).toHaveBeenCalledWith(expect.objectContaining({ path: "after-retry.md" }));
|
||||||
|
});
|
||||||
|
|
||||||
it("reports an IndexedDB read error as failed reflection", async () => {
|
it("reports an IndexedDB read error as failed reflection", async () => {
|
||||||
const onVaultReflectionFailure = vi.fn();
|
const onVaultReflectionFailure = vi.fn();
|
||||||
const { processor, processSynchroniseResult } = setup({
|
const { processor, processSynchroniseResult } = setup({
|
||||||
|
|||||||
@@ -37,28 +37,38 @@ export function useReplicationFeature<TContext extends ServiceContext, TCommands
|
|||||||
const localApplicationActivityOwner = ownsLocalApplicationActivity(services.replicator)
|
const localApplicationActivityOwner = ownsLocalApplicationActivity(services.replicator)
|
||||||
? services.replicator
|
? services.replicator
|
||||||
: undefined;
|
: undefined;
|
||||||
const resultProcessor = new ReplicateResultProcessor({
|
const discardSnapshotOnReset = core.startupDatabaseOptions?.discardReplicationResultSnapshotOnReset ?? false;
|
||||||
onVaultReflectionFailure: () => services.context.events.emitEvent(EVENT_VAULT_REFLECTION_FAILED),
|
const resultProcessor = new ReplicateResultProcessor(
|
||||||
currentSettings: () => services.setting.currentSettings(),
|
{
|
||||||
getKeyValueDB: () => services.keyValueDB.kvDB,
|
onVaultReflectionFailure: () => services.context.events.emitEvent(EVENT_VAULT_REFLECTION_FAILED),
|
||||||
getLocalDatabase: () => core.localDatabase,
|
currentSettings: () => services.setting.currentSettings(),
|
||||||
requestActiveReplicatorRetirement: () => {
|
getKeyValueDB: () => services.keyValueDB.kvDB,
|
||||||
// Do not await a retirement transition from result application: it
|
getLocalDatabase: () => core.localDatabase,
|
||||||
// may be draining the replication work which delivered this item.
|
requestActiveReplicatorRetirement: () => {
|
||||||
fireAndForget(() => services.replicator.onCloseActiveReplication());
|
// Do not await a retirement transition from result application: it
|
||||||
|
// may be draining the replication work which delivered this item.
|
||||||
|
fireAndForget(() => services.replicator.onCloseActiveReplication());
|
||||||
|
},
|
||||||
|
runLocalApplicationActivity: async (task, options) =>
|
||||||
|
localApplicationActivityOwner
|
||||||
|
? await localApplicationActivityOwner.runBoundedLocalApplicationActivity(task, options)
|
||||||
|
: await task(),
|
||||||
|
services: {
|
||||||
|
appLifecycle: services.appLifecycle,
|
||||||
|
database: services.database,
|
||||||
|
path: services.path,
|
||||||
|
replication: services.replication,
|
||||||
|
vault: services.vault,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
runLocalApplicationActivity: async (task, options) =>
|
discardSnapshotOnReset
|
||||||
localApplicationActivityOwner
|
);
|
||||||
? await localApplicationActivityOwner.runBoundedLocalApplicationActivity(task, options)
|
if (discardSnapshotOnReset) {
|
||||||
: await task(),
|
services.database.onDatabaseReset.addHandler(async () => {
|
||||||
services: {
|
await resultProcessor.completeStartupDatabaseReset();
|
||||||
appLifecycle: services.appLifecycle,
|
return true;
|
||||||
database: services.database,
|
});
|
||||||
path: services.path,
|
}
|
||||||
replication: services.replication,
|
|
||||||
vault: services.vault,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const unresolvedErrorManager = new UnresolvedErrorManager(services.appLifecycle, services.context.events);
|
const unresolvedErrorManager = new UnresolvedErrorManager(services.appLifecycle, services.context.events);
|
||||||
const initialiseAutomaticReplicationTriggers = createAutomaticReplicationTriggers({
|
const initialiseAutomaticReplicationTriggers = createAutomaticReplicationTriggers({
|
||||||
currentSettings: () => services.setting.currentSettings(),
|
currentSettings: () => services.setting.currentSettings(),
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
|||||||
|
|
||||||
#### Fixed
|
#### Fixed
|
||||||
|
|
||||||
|
- CLI `fetch` and `rebuild` no longer replay pending file updates from the previous database during startup. The saved queue is discarded only after the local database reset succeeds.
|
||||||
|
|
||||||
- CLI `fetch` and `daemon` can write received files to the Vault when `handleFilenameCaseSensitive` is enabled. The CLI now detects whether the Vault filesystem distinguishes letter case. (#1319)
|
- CLI `fetch` and `daemon` can write received files to the Vault when `handleFilenameCaseSensitive` is enabled. The CLI now detects whether the Vault filesystem distinguishes letter case. (#1319)
|
||||||
|
|
||||||
- Commonlib warnings now appear on stderr without `--debug`, so deliberately skipped Journals and other important notices remain visible during ordinary CLI use.
|
- Commonlib warnings now appear on stderr without `--debug`, so deliberately skipped Journals and other important notices remain visible during ordinary CLI use.
|
||||||
|
|||||||
Reference in new issue
Block a user