Replace hidden-file conflict processors with scoped queues

This commit is contained in:
vorotamoroz committed 2026-10-31 14:57:11 +00:00
1 parent de8c2148d1
commit dfb6215e77
4 files changed
+641 -46

No files matched your search

@@ -3,7 +3,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
vi.mock("@/common/PeriodicProcessor.ts", () => ({ PeriodicProcessor: class PeriodicProcessor {} }));
vi.mock("@/common/PeriodicProcessor.ts", () => ({
PeriodicProcessor: class PeriodicProcessor {
disable = vi.fn();
},
}));
vi.mock("./configureHiddenFileSyncMode.ts", () => ({ configureHiddenFileSyncMode: vi.fn() }));
import {
@@ -51,7 +55,7 @@ function createHiddenFileSync(localDatabase: object) {
afterEach(() => {
for (const hiddenFileSync of queueOwners.splice(0)) {
hiddenFileSync.conflictResolutionProcessor.terminateAll();
hiddenFileSync.conflictResolutionProcessor.dispose();
}
vi.restoreAllMocks();
vi.useRealTimers();
@@ -90,7 +94,6 @@ describe("HiddenFileSync conflict queue", () => {
let completed = false;
hiddenFileSync.queueConflictCheck(path);
try {
expect(hiddenFileSync.conflictResolutionProcessor.delay).toBe(10);
await waitForCondition(
() => hiddenFileSync.conflictResolutionProcessor.remaining === 1,
"The initial conflict check was not queued"
@@ -168,7 +171,6 @@ describe("HiddenFileSync conflict queue", () => {
let completed = false;
hiddenFileSync.queueConflictCheck(path);
try {
expect(hiddenFileSync.conflictResolutionProcessor.delay).toBe(10);
await waitForCondition(
() => hiddenFileSync.conflictResolutionProcessor.remaining === 1,
"The initial conflict check was not queued"
@@ -196,3 +198,206 @@ describe("HiddenFileSync conflict queue", () => {
}
});
});
describe("HiddenFileSync pipeline contracts", () => {
it("starts above the threshold, limits analysis to five, and retains FIFO input", async () => {
vi.useFakeTimers();
const gates = Array.from({ length: 11 }, () => deferred<MetaEntry>());
const paths = gates.map((_, index) => `i:.obsidian/${index}.txt` as FilePathWithPrefix);
const reads: string[] = [];
const getRaw = vi.fn((id: DocumentID) => {
reads.push(id);
return gates[paths.indexOf(id as unknown as FilePathWithPrefix)].promise;
});
const { hiddenFileSync } = createHiddenFileSync({ getRaw });
hiddenFileSync.path2id = async (path) => path as unknown as DocumentID;
for (const path of paths.slice(0, 10)) hiddenFileSync.queueConflictCheck(path);
await vi.advanceTimersByTimeAsync(0);
expect(reads).toEqual([]);
hiddenFileSync.queueConflictCheck(paths[10]);
await vi.advanceTimersByTimeAsync(0);
expect(reads).toEqual(paths.slice(0, 5));
hiddenFileSync.queueConflictCheck(paths[0]);
expect(hiddenFileSync.pendingConflictChecks.size).toBe(11);
gates[0].resolve({ path: paths[0] } as MetaEntry);
await vi.advanceTimersByTimeAsync(0);
expect(reads).toEqual(paths.slice(0, 6));
gates.forEach((gate, index) => gate.resolve({ path: paths[index] } as MetaEntry));
await vi.advanceTimersByTimeAsync(100);
expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(true);
expect(reads).toEqual(paths);
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
});
it("continues analysis while one manual dialogue is open and serialises the dialogues", async () => {
vi.useFakeTimers();
const paths = Array.from({ length: 12 }, (_, index) => `i:.obsidian/${index}.json` as FilePathWithPrefix);
const gates = paths.map(() => deferred<boolean>());
const localDatabase = {
getRaw: vi.fn(async (id: DocumentID, options?: { revs_info?: boolean }) =>
options?.revs_info
? {}
: {
_id: id,
path: id,
_rev: "2-current",
_conflicts: ["2-other"],
}
),
getDBEntry: vi.fn(async (path: FilePathWithPrefix) => ({ path }) as LoadedEntry),
managers: { conflictManager: { mergeObject: vi.fn(async () => false) } },
};
const { hiddenFileSync } = createHiddenFileSync(localDatabase);
hiddenFileSync.path2id = async (path) => path as unknown as DocumentID;
const opened: FilePathWithPrefix[] = [];
hiddenFileSync.showJSONMergeDialogAndMerge = vi.fn((doc) => {
opened.push(doc.path);
return gates[paths.indexOf(doc.path)].promise;
});
paths.forEach((path) => hiddenFileSync.queueConflictCheck(path));
await vi.advanceTimersByTimeAsync(100);
expect(localDatabase.managers.conflictManager.mergeObject).toHaveBeenCalledTimes(12);
expect(opened).toEqual([paths[0]]);
paths.forEach((path) => hiddenFileSync.queueConflictCheck(path));
expect(hiddenFileSync.pendingConflictChecks.size).toBe(12);
let completed = false;
void hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed().then(() => {
completed = true;
});
for (let index = 0; index < paths.length; index++) {
expect(opened).toEqual(paths.slice(0, index + 1));
expect(completed).toBe(false);
gates[index].resolve(false);
await vi.advanceTimersByTimeAsync(100);
}
expect(completed).toBe(true);
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
});
it("logs an analysis error and continues with later paths", async () => {
vi.useFakeTimers();
const failure = new Error("Injected read failure");
const getRaw = vi.fn().mockRejectedValueOnce(failure).mockResolvedValue({});
const { hiddenFileSync } = createHiddenFileSync({ getRaw });
hiddenFileSync.path2id = async (path) => path as unknown as DocumentID;
hiddenFileSync.queueConflictCheck("i:bad.txt" as FilePathWithPrefix);
hiddenFileSync.queueConflictCheck("i:good.txt" as FilePathWithPrefix);
await vi.advanceTimersByTimeAsync(100);
expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(true);
expect(getRaw).toHaveBeenCalledTimes(2);
expect(hiddenFileSync._log).toHaveBeenCalledWith(failure, expect.anything());
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
});
it("collects a conflict enumeration before resuming analysis", async () => {
vi.useFakeTimers();
const gate = deferred();
const path = "i:.obsidian/scanned.txt" as FilePathWithPrefix;
const getRaw = vi.fn(async () => ({}));
const { hiddenFileSync } = createHiddenFileSync({
getRaw,
findEntries: async function* () {
await gate.promise;
yield { _id: path, path, _conflicts: ["2-other"] };
},
});
hiddenFileSync.path2id = async (value) => value as unknown as DocumentID;
const result = hiddenFileSync.resolveConflictOnInternalFiles();
await vi.advanceTimersByTimeAsync(100);
hiddenFileSync.queueConflictCheck(path);
await vi.advanceTimersByTimeAsync(100);
expect(getRaw).not.toHaveBeenCalled();
gate.resolve();
await vi.advanceTimersByTimeAsync(100);
await result;
expect(getRaw).toHaveBeenCalledOnce();
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
});
});
describe("HiddenFileSync conflict queue ownership", () => {
it("stops delayed work on unload and rejects later conflict events", async () => {
vi.useFakeTimers();
const getRaw = vi.fn(async () => ({}));
const { hiddenFileSync } = createHiddenFileSync({ getRaw });
hiddenFileSync.path2id = async (path) => path as unknown as DocumentID;
const path = "i:.obsidian/queued.txt" as FilePathWithPrefix;
hiddenFileSync.queueConflictCheck(path);
const idle = hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed();
hiddenFileSync.onunload();
hiddenFileSync.queueConflictCheck(path);
await vi.advanceTimersByTimeAsync(100);
expect(await idle).toBe(false);
expect(getRaw).not.toHaveBeenCalled();
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
expect(vi.getTimerCount()).toBe(0);
});
it("finishes an admitted analysis without handing its result to a new dialogue after unload", async () => {
vi.useFakeTimers();
const gate = deferred<MetaEntry>();
const path = "i:.obsidian/active.json" as FilePathWithPrefix;
const getRaw = vi
.fn()
.mockImplementationOnce(() => gate.promise)
.mockResolvedValue({});
const getDBEntry = vi.fn(async () => ({ path }));
const mergeObject = vi.fn(async () => false);
const { hiddenFileSync } = createHiddenFileSync({
getRaw,
getDBEntry,
managers: { conflictManager: { mergeObject } },
});
hiddenFileSync.path2id = async () => path as unknown as DocumentID;
hiddenFileSync.queueConflictCheck(path);
await vi.advanceTimersByTimeAsync(10);
expect(getRaw).toHaveBeenCalledOnce();
hiddenFileSync.onunload();
expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(false);
gate.resolve({
_id: String(path) as DocumentID,
path,
_rev: "2-current",
_conflicts: ["2-other"],
} as MetaEntry);
await vi.advanceTimersByTimeAsync(100);
expect(mergeObject).toHaveBeenCalledOnce();
expect(getDBEntry).not.toHaveBeenCalled();
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
});
it("does not requeue a manual resolution which finishes after unload", async () => {
vi.useFakeTimers();
const gate = deferred<boolean>();
const path = "i:.obsidian/dialogue.json" as FilePathWithPrefix;
const getRaw = vi.fn(async (_id: DocumentID, options?: { revs_info?: boolean }) =>
options?.revs_info
? {}
: {
_id: path,
path,
_rev: "2-current",
_conflicts: ["2-other"],
}
);
const getDBEntry = vi.fn(async () => ({ path }) as LoadedEntry);
const { hiddenFileSync } = createHiddenFileSync({
getRaw,
getDBEntry,
managers: { conflictManager: { mergeObject: async () => false } },
});
hiddenFileSync.path2id = async () => path as unknown as DocumentID;
hiddenFileSync.showJSONMergeDialogAndMerge = vi.fn(() => gate.promise);
hiddenFileSync.queueConflictCheck(path);
await vi.advanceTimersByTimeAsync(20);
expect(hiddenFileSync.showJSONMergeDialogAndMerge).toHaveBeenCalledOnce();
hiddenFileSync.onunload();
expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(false);
gate.resolve(true);
await vi.advanceTimersByTimeAsync(100);
expect(getRaw).toHaveBeenCalledTimes(2);
expect(getDBEntry).toHaveBeenCalledTimes(2);
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
expect(hiddenFileSync.conflictResolutionProcessor.totalRemaining).toBe(0);
});
});