perf(tui): batch persistence work off the render path

This commit is contained in:
Wolfgang Schoenberger
2026-07-30 15:35:38 -07:00
parent 4df68d6043
commit 070167dd4a
3 changed files with 72 additions and 14 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Changed
- Batched same-turn file-session appends behind the session writer's microtask boundary. Render and tool paths can now record several transcript entries without paying one synchronous file write per entry, while explicit `flush()` and `close()` still make queued lines durable in order.
## [17.2.1] - 2026-07-30
### Added
@@ -90,6 +90,8 @@ class FileSessionStorageWriter implements SessionStorageWriter {
#closed = false;
#error: Error | undefined;
#onError: ((err: Error) => void) | undefined;
#pending = "";
#flushScheduled = false;
constructor(fpath: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) {
this.#onError = options?.onError;
@@ -112,25 +114,41 @@ class FileSessionStorageWriter implements SessionStorageWriter {
return error;
}
async append(line: string): Promise<void> {
if (this.#closed) throw new Error("Writer closed");
if (this.#error) throw this.#error;
try {
const buf = Buffer.from(line, "utf-8");
let offset = 0;
while (offset < buf.length) {
const written = fs.writeSync(this.#fd, buf, offset, buf.length - offset);
if (written === 0) {
throw new Error("Short write");
}
offset += written;
#writeNow(line: string): void {
const buf = Buffer.from(line, "utf-8");
let offset = 0;
while (offset < buf.length) {
const written = fs.writeSync(this.#fd, buf, offset, buf.length - offset);
if (written === 0) {
throw new Error("Short write");
}
} catch (err) {
throw this.#recordError(err);
offset += written;
}
}
#flushPendingNow(): void {
this.#flushScheduled = false;
if (this.#pending.length === 0) return;
const pending = this.#pending;
this.#pending = "";
try {
this.#writeNow(pending);
} catch (err) {
this.#recordError(err);
}
}
async append(line: string): Promise<void> {
if (this.#closed) throw new Error("Writer closed");
if (this.#error) throw this.#error;
this.#pending += line;
if (this.#flushScheduled) return;
this.#flushScheduled = true;
queueMicrotask(() => this.#flushPendingNow());
}
async flush(): Promise<void> {
this.#flushPendingNow();
if (this.#error) throw this.#error;
}
@@ -140,6 +158,7 @@ class FileSessionStorageWriter implements SessionStorageWriter {
async close(): Promise<void> {
if (this.#closed) return;
this.#flushPendingNow();
this.#closed = true;
// Unregister from finalization - we're closing properly
writerRegistry.unregister(this);
@@ -87,6 +87,41 @@ class ControlledTitleUpdateBackend implements SessionStorageBackend {
this.#firstUpdate.reject(error);
}
}
describe("FileSessionStorage writer", () => {
let tempDir: string;
let storage: FileSessionStorage;
beforeEach(async () => {
tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-session-writer-"));
storage = new FileSessionStorage();
});
afterEach(async () => {
await fsp.rm(tempDir, { recursive: true, force: true });
});
it("preserves append order through flush and close", async () => {
const sessionPath = path.join(tempDir, "batched.jsonl");
const writer = storage.openWriter(sessionPath, { flags: "w" });
await writer.append("one\n");
await writer.append("two\n");
await writer.flush();
expect(fs.readFileSync(sessionPath, "utf8")).toBe("one\ntwo\n");
await writer.close();
});
it("flushes queued appends before closing", async () => {
const sessionPath = path.join(tempDir, "closed.jsonl");
const writer = storage.openWriter(sessionPath, { flags: "w" });
await writer.append("one\n");
await writer.append("two\n");
await writer.close();
expect(fs.readFileSync(sessionPath, "utf8")).toBe("one\ntwo\n");
});
});
describe("FileSessionStorage.deleteSessionWithArtifacts", () => {
let tempDir: string;
let storage: FileSessionStorage;