mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 06:48:18 +00:00
Reuse the disposable FIFO for customisation scans
This commit is contained in:
1 parent
ceaf1f90e9
commit
a042115178
2 files changed
+111
-24
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { compatGlobal, type CompatTimeoutHandle } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { createNotifier } from "octagonal-wheels/concurrency/notifier";
|
||||
import { forEachInScope } from "octagonal-wheels/concurrency/scopedIteration";
|
||||
import { createAsyncQueue, type AsyncQueue } from "octagonal-wheels/iterable/asyncQueue";
|
||||
import { withScope, type Awaitable } from "octagonal-wheels/resource/scope";
|
||||
|
||||
interface ConfigScanQueueOptions<T> {
|
||||
@@ -9,12 +10,20 @@ interface ConfigScanQueueOptions<T> {
|
||||
onProgress?: (remaining: number) => void;
|
||||
}
|
||||
|
||||
type PendingScan<T> = { value: T };
|
||||
interface PendingQueue<T> {
|
||||
fifo: AsyncQueue<T>;
|
||||
iterator: AsyncIterator<T>;
|
||||
}
|
||||
|
||||
interface PendingScan<T> {
|
||||
value: T;
|
||||
queue: PendingQueue<T>;
|
||||
}
|
||||
|
||||
/** Owns FIFO admission, the initial scan delay, and completion for one catalogue. */
|
||||
export class ConfigScanQueue<T> {
|
||||
private pending: (PendingScan<T> | undefined)[] = [];
|
||||
private head = 0;
|
||||
private pending: PendingQueue<T>;
|
||||
private pendingCount = 0;
|
||||
private active = 0;
|
||||
private ready = false;
|
||||
private stopped = false;
|
||||
@@ -24,10 +33,13 @@ export class ConfigScanQueue<T> {
|
||||
private running?: Promise<void>;
|
||||
private readonly changed = createNotifier();
|
||||
|
||||
constructor(private readonly options: ConfigScanQueueOptions<T>) {}
|
||||
constructor(private readonly options: ConfigScanQueueOptions<T>) {
|
||||
this.pending = this.createPendingQueue();
|
||||
}
|
||||
|
||||
private get pendingCount() {
|
||||
return this.pending.length - this.head;
|
||||
private createPendingQueue(): PendingQueue<T> {
|
||||
const fifo = createAsyncQueue<T>();
|
||||
return { fifo, iterator: fifo[Symbol.asyncIterator]() };
|
||||
}
|
||||
|
||||
get remaining(): number {
|
||||
@@ -36,17 +48,20 @@ export class ConfigScanQueue<T> {
|
||||
|
||||
enqueue(value: T): void {
|
||||
if (!this.canDispatch()) throw new Error("The customisation scan queue is closed.");
|
||||
this.pending.push({ value });
|
||||
this.pending.fifo.enqueue(value);
|
||||
this.pendingCount++;
|
||||
this.reportProgress();
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
/** Discard unstarted scans; active reads and migrations retain their slots. */
|
||||
clearPending(): void {
|
||||
this.pending = [];
|
||||
this.head = 0;
|
||||
const discarded = this.pending;
|
||||
this.pending = this.createPendingQueue();
|
||||
this.pendingCount = 0;
|
||||
this.ready = false;
|
||||
this.clearTimer();
|
||||
discarded.fifo.dispose();
|
||||
this.reportProgress();
|
||||
}
|
||||
|
||||
@@ -96,26 +111,29 @@ export class ConfigScanQueue<T> {
|
||||
}
|
||||
|
||||
private claim(entry: PendingScan<T>): boolean {
|
||||
if (!this.canDispatch() || this.pending[this.head] !== entry) return false;
|
||||
this.pending[this.head++] = undefined;
|
||||
if (this.pendingCount === 0) {
|
||||
this.pending = [];
|
||||
this.head = 0;
|
||||
this.ready = false;
|
||||
} else if (this.head >= 1024 && this.head * 2 >= this.pending.length) {
|
||||
this.pending = this.pending.slice(this.head);
|
||||
this.head = 0;
|
||||
}
|
||||
if (!this.canDispatch() || this.pending !== entry.queue) return false;
|
||||
this.pendingCount--;
|
||||
if (this.pendingCount === 0) this.ready = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async *availableWork() {
|
||||
while (this.canDispatch()) {
|
||||
const entry = this.pending[this.head];
|
||||
if (this.ready && entry) {
|
||||
// The callback claims the item so clearing a pulled item also cancels it.
|
||||
yield entry;
|
||||
} else if (this.active > 0) {
|
||||
const pending = this.pending;
|
||||
if (!this.ready) {
|
||||
if (this.active === 0 && this.pendingCount === 0) return;
|
||||
await this.changed.wait();
|
||||
continue;
|
||||
}
|
||||
if (pending.fifo.size > 0) {
|
||||
const entry = await pending.iterator.next();
|
||||
if (entry.done) {
|
||||
if (pending !== this.pending) continue;
|
||||
return;
|
||||
}
|
||||
// The callback checks queue identity so clearing a pulled item cancels it.
|
||||
yield { value: entry.value, queue: pending };
|
||||
} else if (this.active > 0 || this.pendingCount > 0) {
|
||||
// Keep free slots available to later arrivals while a slow scan finishes.
|
||||
await this.changed.wait();
|
||||
} else {
|
||||
|
||||
@@ -165,6 +165,75 @@ describe("ConfigScanQueue", () => {
|
||||
expect(scans.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it("invalidates prefetched work after clearing and admits replacements alongside active scans", async () => {
|
||||
const finishFirst = gate();
|
||||
const finishReplacement = gate();
|
||||
let scans!: ConfigScanQueue<string>;
|
||||
const seen: string[] = [];
|
||||
scans = queue<string>({
|
||||
process: async (item) => {
|
||||
seen.push(item);
|
||||
if (item === "first") {
|
||||
void Promise.resolve().then(() => Promise.resolve().then(() => scans.clearPending()));
|
||||
await finishFirst.promise;
|
||||
} else if (item === "replacement") {
|
||||
await finishReplacement.promise;
|
||||
}
|
||||
},
|
||||
});
|
||||
scans.enqueue("first");
|
||||
scans.enqueue("stale");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(seen).toEqual(["first"]);
|
||||
expect(scans.remaining).toBe(1);
|
||||
|
||||
scans.enqueue("replacement");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(seen).toEqual(["first", "replacement"]);
|
||||
expect(scans.remaining).toBe(2);
|
||||
|
||||
finishFirst.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(scans.remaining).toBe(1);
|
||||
finishReplacement.resolve();
|
||||
await scans.waitForIdle();
|
||||
expect(scans.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the ten-scan limit across clear and replacement work", async () => {
|
||||
const finish = Array.from({ length: 12 }, () => gate());
|
||||
const seen: number[] = [];
|
||||
let active = 0;
|
||||
let maximum = 0;
|
||||
const scans = queue<number>({
|
||||
process: async (item) => {
|
||||
seen.push(item);
|
||||
maximum = Math.max(maximum, ++active);
|
||||
await finish[item].promise;
|
||||
active--;
|
||||
},
|
||||
});
|
||||
for (let item = 0; item < 10; item++) scans.enqueue(item);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(seen).toEqual(Array.from({ length: 10 }, (_, index) => index));
|
||||
|
||||
scans.clearPending();
|
||||
scans.enqueue(10);
|
||||
scans.enqueue(11);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(seen).toEqual(Array.from({ length: 10 }, (_, index) => index));
|
||||
expect(maximum).toBe(10);
|
||||
|
||||
finish[0].resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(seen).toEqual(Array.from({ length: 11 }, (_, index) => index));
|
||||
expect(maximum).toBe(10);
|
||||
finish.forEach((item) => item.resolve());
|
||||
await scans.waitForIdle();
|
||||
expect(seen).toEqual(Array.from({ length: 12 }, (_, index) => index));
|
||||
expect(maximum).toBe(10);
|
||||
});
|
||||
|
||||
it("stops while waiting for input, joins active work, and rejects later admission", async () => {
|
||||
const finish = gate();
|
||||
const seen: number[] = [];
|
||||
|
||||
Reference in new issue
Block a user