diff --git a/server/write-coalescer.js b/server/write-coalescer.js index cd72b56..d84aaab 100644 --- a/server/write-coalescer.js +++ b/server/write-coalescer.js @@ -150,4 +150,13 @@ async function flushAll() { await Promise.race([Promise.allSettled(writes), timeout]); } -module.exports = { writeCoalesced, getPending, flushAll }; +// Test-only: clear all internal state. Not exported for production use. +function _reset() { + for (const entry of pending.values()) { + clearTimeout(entry.timer); + } + pending.clear(); + lastWriteTime.clear(); +} + +module.exports = { writeCoalesced, getPending, flushAll, _reset }; diff --git a/server/write-coalescer.test.mjs b/server/write-coalescer.test.mjs new file mode 100644 index 0000000..d6c4424 --- /dev/null +++ b/server/write-coalescer.test.mjs @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createRequire } from "module"; +import path from "path"; +import fs from "fs"; +import os from "os"; + +const require = createRequire(import.meta.url); +const coalescer = require("./write-coalescer.js"); +const config = require("./config.js"); + +const SHORT_WINDOW_MS = 50; +const originalWindow = config.writeCoalesceMs; + +let tmpDir; + +beforeEach(async () => { + config.writeCoalesceMs = SHORT_WINDOW_MS; + coalescer._reset(); + tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "coalesce-test-")); +}); + +afterEach(async () => { + coalescer._reset(); + vi.restoreAllMocks(); + config.writeCoalesceMs = originalWindow; + await fs.promises.rm(tmpDir, { recursive: true, force: true }); +}); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe("writeCoalesced", () => { + it("first write hits disk immediately with real mtime/size", async () => { + const filePath = path.join(tmpDir, "file.txt"); + const result = await coalescer.writeCoalesced(filePath, "hello", "utf-8"); + + expect(result.size).toBe(5); + expect(result.mtime).toBeGreaterThan(0); + + const onDisk = await fs.promises.readFile(filePath, "utf-8"); + expect(onDisk).toBe("hello"); + }); + + it("buffered write within the window returns immediately with synthetic values and is not yet on disk", async () => { + const filePath = path.join(tmpDir, "file.txt"); + + await coalescer.writeCoalesced(filePath, "first", "utf-8"); + + const start = Date.now(); + const result = await coalescer.writeCoalesced(filePath, "second", "utf-8"); + const elapsed = Date.now() - start; + + expect(elapsed).toBeLessThan(10); + expect(result.size).toBe(6); + + const onDisk = await fs.promises.readFile(filePath, "utf-8"); + expect(onDisk).toBe("first"); + }); + + it("flushes the latest buffered data after the window elapses", async () => { + const filePath = path.join(tmpDir, "file.txt"); + + await coalescer.writeCoalesced(filePath, "v1", "utf-8"); + await coalescer.writeCoalesced(filePath, "v2", "utf-8"); + await coalescer.writeCoalesced(filePath, "v3", "utf-8"); + + await sleep(SHORT_WINDOW_MS + 30); + + const onDisk = await fs.promises.readFile(filePath, "utf-8"); + expect(onDisk).toBe("v3"); + }); + + it("collapses many rapid writes into exactly two disk writes", async () => { + const filePath = path.join(tmpDir, "file.txt"); + const spy = vi.spyOn(fs.promises, "writeFile"); + + for (let i = 0; i < 20; i++) { + await coalescer.writeCoalesced(filePath, `v${i}`, "utf-8"); + } + + await sleep(SHORT_WINDOW_MS + 30); + + expect(spy).toHaveBeenCalledTimes(2); + }); + + it("stays snappy when the filesystem is slow", async () => { + const filePath = path.join(tmpDir, "file.txt"); + const realWrite = fs.promises.writeFile.bind(fs.promises); + + vi.spyOn(fs.promises, "writeFile").mockImplementation(async (...args) => { + await sleep(200); + return realWrite(...args); + }); + + await coalescer.writeCoalesced(filePath, "first", "utf-8"); + + const start = Date.now(); + await coalescer.writeCoalesced(filePath, "second", "utf-8"); + const elapsed = Date.now() - start; + + expect(elapsed).toBeLessThan(20); + }); +}); + +describe("getPending", () => { + it("returns buffered data for paths with a pending write", async () => { + const filePath = path.join(tmpDir, "file.txt"); + + await coalescer.writeCoalesced(filePath, "first", "utf-8"); + await coalescer.writeCoalesced(filePath, "buffered", "utf-8"); + + const pending = coalescer.getPending(filePath); + expect(pending).not.toBeNull(); + expect(pending.data).toBe("buffered"); + }); +}); + +describe("flushAll", () => { + it("drains all buffered writes to disk and clears pending state", async () => { + const fileA = path.join(tmpDir, "a.txt"); + const fileB = path.join(tmpDir, "b.txt"); + + await coalescer.writeCoalesced(fileA, "first-a", "utf-8"); + await coalescer.writeCoalesced(fileA, "buffered-a", "utf-8"); + await coalescer.writeCoalesced(fileB, "first-b", "utf-8"); + await coalescer.writeCoalesced(fileB, "buffered-b", "utf-8"); + + expect(coalescer.getPending(fileA)).not.toBeNull(); + expect(coalescer.getPending(fileB)).not.toBeNull(); + + await coalescer.flushAll(); + + expect(await fs.promises.readFile(fileA, "utf-8")).toBe("buffered-a"); + expect(await fs.promises.readFile(fileB, "utf-8")).toBe("buffered-b"); + expect(coalescer.getPending(fileA)).toBeNull(); + expect(coalescer.getPending(fileB)).toBeNull(); + }); +});