fix(memory): write the knowledge graph atomically (#4642)

* fix(memory): write the knowledge graph atomically

`saveGraph()` wrote the graph straight to the memory file:

    await fs.writeFile(this.memoryFilePath, lines.join("\n"));

`fs.writeFile` opens the target with `'w'`, which truncates it before any
new bytes are written. If the process is interrupted between truncation
and completion — SIGKILL, container or host stop, OOM kill, power loss —
the memory file is left empty or half-written.

That file is the only persistence layer for the knowledge graph, so the
window is small but the loss is total and unrecoverable.

The fix writes to a temporary file in the same directory and renames it
over the target. `rename(2)` is atomic on POSIX filesystems: a reader
sees either the complete old file or the complete new one, never an
intermediate state. Keeping the temp file in the same directory ensures
the rename stays on one filesystem, since cross-device renames fail with
EXDEV. On failure the temp file is removed so no strays accumulate.

Tests added in `__tests__/atomic-save.test.ts`:

- the live memory file is never opened for truncating writes
- a committed graph survives a write that fails midway
- no temporary files remain after a successful write
- the temporary file is cleaned up when the rename fails
- graph contents still round-trip correctly across reloads

The first and fourth fail against the previous implementation.

Full suite: 55 passed (50 existing, 5 new). `tsc --noEmit` clean.

Fixes #4614

* fix(memory): use randomBytes for temp filename to avoid concurrent collision

---------

Co-authored-by: teddiesloco <teddiesloco@users.noreply.github.com>
This commit is contained in:
Teddy100x 2026-08-29 00:00:33 +07:00 • committed by GitHub
commit 642a9112e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -6,6 +6,7 @@ import { SubscribeRequestSchema, UnsubscribeRequestSchema } from "@modelcontextp
import { z } from "zod";
import { promises as fs } from 'fs';
import path from 'path';
import { randomBytes } from 'crypto';
import { fileURLToPath } from 'url';
// Define memory file path using environment variable with fallback
@ -114,7 +115,29 @@ export class KnowledgeGraphManager {
relationType: r.relationType
})),
];
await fs.writeFile(this.memoryFilePath, lines.join("\n"));
// Write to a temporary file in the same directory, then rename it over
// the target. fs.writeFile would truncate the memory file before writing,
// so an interruption (SIGKILL, container stop, OOM, power loss) would
// leave the only copy of the graph truncated and unrecoverable.
// rename(2) is atomic on POSIX filesystems: readers see either the
// complete old file or the complete new one, never a partial state.
// The temp file is kept in the same directory so the rename stays on one
// filesystem — renaming across mount points fails with EXDEV.
const directory = path.dirname(this.memoryFilePath);
const tempFilePath = path.join(
directory,
`${path.basename(this.memoryFilePath)}.${randomBytes(16).toString('hex')}.tmp`
);
try {
await fs.writeFile(tempFilePath, lines.join("\n"));
await fs.rename(tempFilePath, this.memoryFilePath);
} catch (error) {
// Never leave a stray temp file behind on failure.
await fs.unlink(tempFilePath).catch(() => {});
throw error;
}
}
async createEntities(entities: Entity[]): Promise<Entity[]> {