From df05330ea0a484414c18ba9dccb4cb51de4b495d Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Sat, 20 Jun 2026 20:37:26 +0800 Subject: [PATCH 1/2] feat(memory): serialize graph writes with file locks --- package-lock.json | 87 +++++++++++++++++++- src/memory/index.ts | 178 ++++++++++++++++++++++++++++++---------- src/memory/package.json | 8 +- 3 files changed, 225 insertions(+), 48 deletions(-) diff --git a/package-lock.json b/package-lock.json index 26261a7ade..4af5843f1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -658,6 +658,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/proper-lockfile": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz", + "integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -672,6 +682,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/retry": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", + "integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -693,6 +710,16 @@ "@types/node": "*" } }, + "node_modules/@types/write-file-atomic": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/write-file-atomic/-/write-file-atomic-4.0.3.tgz", + "integrity": "sha512-qdo+vZRchyJIHNeuI1nrpsLw+hnkgqP/8mlaN6Wle/NKhydHmUN9l4p3ZE8yP90AJNJW4uB8HQhedb4f1vNayQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -1731,6 +1758,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1823,6 +1856,15 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -2659,6 +2701,23 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2778,6 +2837,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -3708,6 +3776,19 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/write-file-atomic": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz", + "integrity": "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -3855,13 +3936,17 @@ "version": "0.6.3", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0" + "@modelcontextprotocol/sdk": "^1.29.0", + "proper-lockfile": "^4.1.2", + "write-file-atomic": "^6.0.0" }, "bin": { "mcp-server-memory": "dist/index.js" }, "devDependencies": { "@types/node": "^22", + "@types/proper-lockfile": "^4.1.4", + "@types/write-file-atomic": "^4.0.3", "@vitest/coverage-v8": "^4.1.8", "shx": "^0.3.4", "typescript": "^5.6.2", diff --git a/src/memory/index.ts b/src/memory/index.ts index 9865c5318e..e6495da634 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -6,6 +6,8 @@ import { SubscribeRequestSchema, UnsubscribeRequestSchema } from "@modelcontextp import { z } from "zod"; import { promises as fs } from 'fs'; import path from 'path'; +import lockfile from "proper-lockfile"; +import writeFileAtomic from "write-file-atomic"; import { fileURLToPath } from 'url'; // Define memory file path using environment variable with fallback @@ -66,9 +68,95 @@ export interface KnowledgeGraph { } // The KnowledgeGraphManager class contains all operations to interact with the knowledge graph +// Lock configuration tuned for both local disk and NFS/network file systems. +// +// For NFS: stale must be at least acdirmax (typically 60s) to avoid false stale +// detection due to attribute caching. Setting stale=60000ms ensures that even if a +// process sees a 50s-old cached mtime, it won't incorrectly assume the lock is stale. +// Note: If your NFS has non-default acdirmax, set stale >= acdirmax via lockOptions. +// +// For local disk: the longer stale timeout just means a slightly longer wait if a +// process actually crashes, which is acceptable for this server. +// +// Retry strategy: minTimeout=60ms allows fast acquisition on local disk while the +// exponential backoff handles NFS latency. +// Max wait: 60 + 120 + 240 + 480 + 960 + 1920 + 3840 + 7680 + 15360 + 30720 ā‰ˆ 61s +// This exceeds the stale timeout (60s) to ensure we wait long enough for NFS cache delays. +type LockRetryOptions = { + retries: number; + minTimeout: number; + maxTimeout?: number; + factor: number; +}; + +type MemoryLockOptions = { + stale: number; + update: number; + retries: LockRetryOptions; + realpath: boolean; +}; + +type MemoryLockOptionsInput = Partial> & { + retries?: Partial; +}; + +const DEFAULT_LOCK_OPTIONS: MemoryLockOptions = { + stale: 60000, + update: 10000, + retries: { + retries: 10, + minTimeout: 60, + factor: 2, + }, + realpath: false, +}; + export class KnowledgeGraphManager { - constructor(private memoryFilePath: string) {} + private readonly lockOptions: MemoryLockOptions; + + constructor(private memoryFilePath: string, lockOptions: MemoryLockOptionsInput = {}) { + this.lockOptions = { + ...DEFAULT_LOCK_OPTIONS, + ...lockOptions, + retries: { + ...DEFAULT_LOCK_OPTIONS.retries, + ...lockOptions.retries, + }, + }; + } + + private async withLock(fn: () => Promise): Promise { + let release: (() => Promise) | undefined; + + try { + release = await lockfile.lock(this.memoryFilePath, this.lockOptions); + } catch (error) { + throw new Error(`Lock operation failed: ${error instanceof Error ? error.message : String(error)}`); + } + + try { + return await fn(); + } finally { + try { + await release(); + } catch (error) { + console.error(`Failed to release memory file lock: ${error instanceof Error ? error.message : String(error)}`); + } + } + } + private async updateGraph(mutate: (graph: KnowledgeGraph) => Promise | T): Promise { + return this.withLock(async () => { + const graph = await this.loadGraph(); + const result = await mutate(graph); + await this.saveGraph(graph); + return result; + }); + } + + // Reads intentionally do not acquire the write lock. Writers use write-file-atomic, + // so readers observe either the previous complete file or the new complete file, + // while read-heavy workloads avoid serializing behind writes. private async loadGraph(): Promise { try { const data = await fs.readFile(this.memoryFilePath, "utf-8"); @@ -114,70 +202,70 @@ export class KnowledgeGraphManager { relationType: r.relationType })), ]; - await fs.writeFile(this.memoryFilePath, lines.join("\n")); + await writeFileAtomic(this.memoryFilePath, lines.join("\n"), { fsync: false }); } async createEntities(entities: Entity[]): Promise { - const graph = await this.loadGraph(); - const newEntities = entities.filter(e => !graph.entities.some(existingEntity => existingEntity.name === e.name)); - graph.entities.push(...newEntities); - await this.saveGraph(graph); - return newEntities; + return this.updateGraph(async (graph) => { + const newEntities = entities.filter(e => !graph.entities.some(existingEntity => existingEntity.name === e.name)); + graph.entities.push(...newEntities); + return newEntities; + }); } async createRelations(relations: Relation[]): Promise { - const graph = await this.loadGraph(); - const newRelations = relations.filter(r => !graph.relations.some(existingRelation => - existingRelation.from === r.from && - existingRelation.to === r.to && - existingRelation.relationType === r.relationType - )); - graph.relations.push(...newRelations); - await this.saveGraph(graph); - return newRelations; + return this.updateGraph(async (graph) => { + const newRelations = relations.filter(r => !graph.relations.some(existingRelation => + existingRelation.from === r.from && + existingRelation.to === r.to && + existingRelation.relationType === r.relationType + )); + graph.relations.push(...newRelations); + return newRelations; + }); } async addObservations(observations: { entityName: string; contents: string[] }[]): Promise<{ entityName: string; addedObservations: string[] }[]> { - const graph = await this.loadGraph(); - const results = observations.map(o => { - const entity = graph.entities.find(e => e.name === o.entityName); - if (!entity) { - throw new Error(`Entity with name ${o.entityName} not found`); - } - const newObservations = o.contents.filter(content => !entity.observations.includes(content)); - entity.observations.push(...newObservations); - return { entityName: o.entityName, addedObservations: newObservations }; + return this.updateGraph(async (graph) => { + const results = observations.map(o => { + const entity = graph.entities.find(e => e.name === o.entityName); + if (!entity) { + throw new Error(`Entity with name ${o.entityName} not found`); + } + const newObservations = o.contents.filter(content => !entity.observations.includes(content)); + entity.observations.push(...newObservations); + return { entityName: o.entityName, addedObservations: newObservations }; + }); + return results; }); - await this.saveGraph(graph); - return results; } async deleteEntities(entityNames: string[]): Promise { - const graph = await this.loadGraph(); - graph.entities = graph.entities.filter(e => !entityNames.includes(e.name)); - graph.relations = graph.relations.filter(r => !entityNames.includes(r.from) && !entityNames.includes(r.to)); - await this.saveGraph(graph); + await this.updateGraph(async (graph) => { + graph.entities = graph.entities.filter(e => !entityNames.includes(e.name)); + graph.relations = graph.relations.filter(r => !entityNames.includes(r.from) && !entityNames.includes(r.to)); + }); } async deleteObservations(deletions: { entityName: string; observations: string[] }[]): Promise { - const graph = await this.loadGraph(); - deletions.forEach(d => { - const entity = graph.entities.find(e => e.name === d.entityName); - if (entity) { - entity.observations = entity.observations.filter(o => !d.observations.includes(o)); - } + await this.updateGraph(async (graph) => { + deletions.forEach(d => { + const entity = graph.entities.find(e => e.name === d.entityName); + if (entity) { + entity.observations = entity.observations.filter(o => !d.observations.includes(o)); + } + }); }); - await this.saveGraph(graph); } async deleteRelations(relations: Relation[]): Promise { - const graph = await this.loadGraph(); - graph.relations = graph.relations.filter(r => !relations.some(delRelation => - r.from === delRelation.from && - r.to === delRelation.to && - r.relationType === delRelation.relationType - )); - await this.saveGraph(graph); + await this.updateGraph(async (graph) => { + graph.relations = graph.relations.filter(r => !relations.some(delRelation => + r.from === delRelation.from && + r.to === delRelation.to && + r.relationType === delRelation.relationType + )); + }); } async readGraph(): Promise { diff --git a/src/memory/package.json b/src/memory/package.json index e32d25a3e2..0d205a41cd 100644 --- a/src/memory/package.json +++ b/src/memory/package.json @@ -25,13 +25,17 @@ "test": "vitest run --coverage" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0" + "@modelcontextprotocol/sdk": "^1.29.0", + "proper-lockfile": "^4.1.2", + "write-file-atomic": "^6.0.0" }, "devDependencies": { "@types/node": "^22", + "@types/proper-lockfile": "^4.1.4", + "@types/write-file-atomic": "^4.0.3", "@vitest/coverage-v8": "^4.1.8", "shx": "^0.3.4", "typescript": "^5.6.2", "vitest": "^4.1.8" } -} \ No newline at end of file +} From 630233637942515dc9ef150eb1445bacce9542cb Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Sat, 20 Jun 2026 20:37:32 +0800 Subject: [PATCH 2/2] test(memory): cover concurrent file locking --- package-lock.json | 504 ++++++++++++++++++ src/memory/__tests__/knowledge-graph.test.ts | 55 ++ .../__tests__/multi-process-lock.test.ts | 222 ++++++++ src/memory/package.json | 1 + 4 files changed, 782 insertions(+) create mode 100644 src/memory/__tests__/multi-process-lock.test.ts diff --git a/package-lock.json b/package-lock.json index 4af5843f1a..59e07dbb69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -112,6 +112,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -1397,6 +1839,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3403,6 +3887,25 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -3949,6 +4452,7 @@ "@types/write-file-atomic": "^4.0.3", "@vitest/coverage-v8": "^4.1.8", "shx": "^0.3.4", + "tsx": "^4.21.0", "typescript": "^5.6.2", "vitest": "^4.1.8" } diff --git a/src/memory/__tests__/knowledge-graph.test.ts b/src/memory/__tests__/knowledge-graph.test.ts index 236242413a..1d596a7829 100644 --- a/src/memory/__tests__/knowledge-graph.test.ts +++ b/src/memory/__tests__/knowledge-graph.test.ts @@ -515,4 +515,59 @@ describe('KnowledgeGraphManager', () => { expect(result.relations[0]).not.toHaveProperty('type'); }); }); + + describe('saveGraph locking', () => { + it('should guarantee consistency: succeeded operations must be in file', async () => { + const stressTestManager = new KnowledgeGraphManager(testFilePath, { + retries: { + retries: 100, + minTimeout: 10, + maxTimeout: 50, + }, + }); + const totalOperations = 200; + const minSuccessfulOperations = 100; + const promises: Promise[] = []; + + for (let i = 0; i < totalOperations; i++) { + const randomName = `Entity_${Math.random().toString(36).substring(2)}`; + promises.push( + stressTestManager.createEntities([ + { name: randomName, entityType: 'test', observations: [] }, + ]) + ); + } + + const results = await Promise.allSettled(promises); + + const succeeded = results.filter(r => r.status === 'fulfilled') as PromiseFulfilledResult[]; + const failed = results.filter(r => r.status === 'rejected') as PromiseRejectedResult[]; + + expect(succeeded.length).toBeGreaterThanOrEqual(minSuccessfulOperations); + + // Collect succeeded entity names + const succeededNames = new Set( + succeeded.flatMap(r => r.value.map(e => e.name)) + ); + + // Read file + const graph = await stressTestManager.readGraph(); + const fileNames = new Set(graph.entities.map(e => e.name)); + + // Verify: succeeded entities must be in file + succeededNames.forEach(name => { + expect(fileNames.has(name)).toBe(true); + }); + + // File entity count should equal succeeded count + expect(graph.entities.length).toBe(succeededNames.size); + + // Failed operations should contain correct error message + failed.forEach(f => { + expect(f.reason.message).toContain('Lock operation failed:'); + }); + + console.log(`Stress test: ${succeeded.length} succeeded, ${failed.length} failed`); + }, 30000); + }); }); diff --git a/src/memory/__tests__/multi-process-lock.test.ts b/src/memory/__tests__/multi-process-lock.test.ts new file mode 100644 index 0000000000..4f4bd9249f --- /dev/null +++ b/src/memory/__tests__/multi-process-lock.test.ts @@ -0,0 +1,222 @@ +/// +import { existsSync, promises as fs } from 'fs'; +import { spawn } from 'child_process'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { KnowledgeGraphManager } from '../index.js'; + +const isStressMode = process.env.RUN_MEMORY_LOCK_STRESS === '1'; +const defaultProcessCount = 3; +const defaultWritesPerProcess = 50; +const stressProcessCount = 5; +const stressWritesPerProcess = 2000; +const smokeMinimumSuccess = 120; +const stressMinimumSuccess = 7500; + +function getTsxExecutable(currentFilePath: string): { command: string; args: string[] } { + const localTsx = path.resolve(path.dirname(currentFilePath), '../../../node_modules/.bin/tsx'); + if (existsSync(localTsx)) { + return { command: localTsx, args: [currentFilePath] }; + } + + return { command: 'npx', args: ['tsx', currentFilePath] }; +} + +// Check if running in worker mode +const isWorker = process.argv.includes('--worker'); + +if (isWorker) { + runWorker().catch((error) => { + console.error(`Worker crashed:`, error); + process.exit(1); + }); +} else { + // Main Test Suite + describe('Multi-process file locking', () => { + let testFilePath: string; + const currentFilePath = fileURLToPath(import.meta.url); + + beforeEach(async () => { + testFilePath = path.join( + path.dirname(currentFilePath), + `test-multi-process-${Date.now()}.jsonl` + ); + // Create empty file for locking (proper-lockfile requires file to exist) + await fs.writeFile(testFilePath, ''); + }); + + afterEach(async () => { + try { + await fs.unlink(testFilePath); + } catch { + // Ignore errors if file doesn't exist + } + // Clean up lock file if exists + try { + await fs.unlink(`${testFilePath}.lock`); + } catch { + // Ignore + } + }); + + it('should guarantee consistency: succeeded operations must be in file', async () => { + const NUM_PROCESSES = isStressMode ? stressProcessCount : defaultProcessCount; + const WRITES_PER_PROCESS = isStressMode ? stressWritesPerProcess : defaultWritesPerProcess; + const minSuccessfulOperations = isStressMode ? stressMinimumSuccess : smokeMinimumSuccess; + const workerExecutable = getTsxExecutable(currentFilePath); + + // Spawn all worker processes in parallel + const workerPromises: Promise<{ workerId: string; succeededNames: string[]; failed: number }>[] = []; + + for (let i = 0; i < NUM_PROCESSES; i++) { + workerPromises.push( + new Promise((resolve, reject) => { + const child = spawn(workerExecutable.command, [...workerExecutable.args, '--worker', testFilePath, String(i), String(WRITES_PER_PROCESS)], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env }, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + if (code !== 0) { + reject(new Error(`Worker ${i} exited with code ${code}: ${stderr}`)); + return; + } + try { + const lines = stdout.trim().split('\n'); + let result: any = null; + for (let j = lines.length - 1; j >= 0; j--) { + try { + const parsed = JSON.parse(lines[j]); + if (parsed && typeof parsed === 'object' && parsed.workerId !== undefined) { + result = parsed; + break; + } + } catch { + continue; + } + } + + if (!result) { + try { + result = JSON.parse(stdout.trim()); + } catch { + // ignore + } + } + + if (!result) { + reject(new Error(`Worker ${i} output parse error: ${stdout}`)); + return; + } + + resolve(result); + } catch (e: any) { + reject(new Error(`Worker ${i} output parse error: ${stdout}. Error: ${e.message}`)); + } + }); + + child.on('error', (err) => { + reject(new Error(`Worker ${i} spawn error: ${err.message}`)); + }); + }) + ); + } + + const results = await Promise.all(workerPromises); + const succeededNames = new Set(results.flatMap(r => r.succeededNames)); + const totalFailed = results.reduce((sum, r) => sum + r.failed, 0); + + console.log(`\n=== Multi-process Lock Test Results ===`); + console.log(`Mode: ${isStressMode ? 'stress' : 'smoke'}`); + console.log(`Processes: ${NUM_PROCESSES}`); + console.log(`Writes per process: ${WRITES_PER_PROCESS}`); + console.log(`Minimum success required: ${minSuccessfulOperations}`); + console.log(`Total succeeded: ${succeededNames.size}`); + console.log(`Total failed: ${totalFailed}`); + + expect(succeededNames.size).toBeGreaterThanOrEqual(minSuccessfulOperations); + + const manager = new KnowledgeGraphManager(testFilePath); + const graph = await manager.readGraph(); + const fileNames = new Set(graph.entities.map(e => e.name)); + + console.log(`Entities in file: ${graph.entities.length}`); + + succeededNames.forEach(name => { + expect(fileNames.has(name)).toBe(true); + }); + + expect(graph.entities.length).toBe(succeededNames.size); + + console.log(`\nPer-worker breakdown:`); + for (const r of results) { + console.log(` Worker ${r.workerId}: ${r.succeededNames.length} succeeded, ${r.failed} failed`); + } + + console.log(`\nāœ“ File integrity verified: all ${succeededNames.size} succeeded writes are in the file`); + }, isStressMode ? 300000 : 30000); + }); +} + +/** + * Worker Logic + */ +async function runWorker() { + const workerFlagIndex = process.argv.indexOf('--worker'); + const memoryFilePath = process.argv[workerFlagIndex + 1]; + const workerId = process.argv[workerFlagIndex + 2]; + const numWritesStr = process.argv[workerFlagIndex + 3]; + + if (!memoryFilePath || !workerId || !numWritesStr) { + console.error('Usage: npx tsx multi-process-lock.test.ts --worker '); + process.exit(1); + } + + const numWrites = parseInt(numWritesStr, 10); + + // Low retry count to speed up test - failures are expected under heavy contention + const manager = new KnowledgeGraphManager(memoryFilePath, { + retries: { + retries: 10, + minTimeout: 10, + factor: 1.5, + maxTimeout: 50, + }, + }); + + const succeededNames: string[] = []; + let failed = 0; + + for (let i = 0; i < numWrites; i++) { + const entityName = `Worker${workerId}_Entity${i}`; + try { + const created = await manager.createEntities([ + { + name: entityName, + entityType: 'test', + observations: [`Created by worker ${workerId}`], + }, + ]); + // Only count as succeeded if entity was actually created + if (created.length > 0) { + succeededNames.push(entityName); + } + } catch { + failed++; + } + } + + // Output result as JSON for parent process to parse + console.log(JSON.stringify({ workerId, succeededNames, failed })); +} diff --git a/src/memory/package.json b/src/memory/package.json index 0d205a41cd..c327abe964 100644 --- a/src/memory/package.json +++ b/src/memory/package.json @@ -35,6 +35,7 @@ "@types/write-file-atomic": "^4.0.3", "@vitest/coverage-v8": "^4.1.8", "shx": "^0.3.4", + "tsx": "^4.21.0", "typescript": "^5.6.2", "vitest": "^4.1.8" }