Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/ci-test-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const CI_TEST_MANIFEST = [
{ group: "storage-and-schema", runner: "node", file: "test/cross-process-lock.test.mjs", args: ["--test"] },
{ group: "core-regression", runner: "node", file: "test/lock-stress-test.mjs", args: ["--test"] },
{ group: "core-regression", runner: "node", file: "test/lock-release-on-error.test.mjs", args: ["--test"] },
{ group: "core-regression", runner: "node", file: "test/p3-async-file-lock.test.mjs" },
{ group: "core-regression", runner: "node", file: "test/preference-slots.test.mjs", args: ["--test"] },
{ group: "core-regression", runner: "node", file: "test/is-latest-auto-supersede.test.mjs" },
{ group: "core-regression", runner: "node", file: "test/temporal-awareness.test.mjs", args: ["--test"] },
Expand Down
23 changes: 16 additions & 7 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
statSync,
unlinkSync,
} from "node:fs";
import { access, mkdir, stat, unlink, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { buildSmartMetadata, isMemoryActiveAt, parseSmartMetadata, stringifySmartMetadata } from "./smart-metadata.js";

Expand Down Expand Up @@ -241,12 +242,19 @@ export class MemoryStore {

constructor(private readonly config: StoreConfig) { }

// 【P3 修復】async 等價於 existsSync
private static async pathExists(p: string): Promise<boolean> {
try { await access(p, constants.F_OK); return true; }
catch { return false; }
}

private async runWithFileLock<T>(fn: () => Promise<T>): Promise<T> {
const lockfile = await loadLockfile();
const lockPath = join(this.config.dbPath, ".memory-write.lock");
if (!existsSync(lockPath)) {
try { mkdirSync(dirname(lockPath), { recursive: true }); } catch {}
try { const { writeFileSync } = await import("node:fs"); writeFileSync(lockPath, "", { flag: "wx" }); } catch {}
// 【P3 修復】async 化 init 區塊:不再 sync blocking
if (!(await MemoryStore.pathExists(lockPath))) {
try { await mkdir(dirname(lockPath), { recursive: true }); } catch {}
try { await writeFile(lockPath, "", { flag: "wx" }); } catch {}
}
// 【修復 #415】調整 retries:max wait 從 ~3100ms → ~151秒
// 指數退避:1s, 2s, 4s, 8s, 16s, 30s×5,總計約 151 秒
Expand All @@ -258,13 +266,14 @@ export class MemoryStore {

// Proactive cleanup of stale lock artifacts(from PR #626)
// 根本避免 >5 分鐘的 lock artifact 導致 ECOMPROMISED
if (existsSync(lockPath)) {
// 【P3 修復】async 化 stale check:不再 sync blocking
if (await MemoryStore.pathExists(lockPath)) {
try {
const stat = statSync(lockPath);
const ageMs = Date.now() - stat.mtimeMs;
const s = await stat(lockPath);
const ageMs = Date.now() - s.mtimeMs;
const staleThresholdMs = 5 * 60 * 1000;
if (ageMs > staleThresholdMs) {
try { unlinkSync(lockPath); } catch {}
try { await unlink(lockPath); } catch {}
console.warn(`[memory-lancedb-pro] cleared stale lock: ${lockPath} ageMs=${ageMs}`);
}
} catch {}
Expand Down
98 changes: 98 additions & 0 deletions test/p3-async-file-lock.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* P3: Async File Lock Tests — Issue #763
* 驗證 runWithFileLock() 的 5 個 sync I/O 已改為 async:
* existsSync → pathExists() [access]
* mkdirSync → await mkdir()
* writeFileSync → await writeFile()
* statSync → await stat()
* unlinkSync → await unlink()
*
* 核心驗證:pathExists() 是 static async method,不會 block event loop。
* 至於 runWithFileLock() 內部的 store 初始化(含 LanceDB open)本身有
* 額外延遲,與 P3 async file lock 修復是獨立的議題。
*/

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, existsSync, writeFileSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import jitiFactory from "jiti";

const jiti = jitiFactory(import.meta.url, { interopDefault: true });
const { MemoryStore } = jiti("../src/store.ts");

function makeStore() {
const dir = mkdtempSync(join(tmpdir(), "memory-p3-async-"));
const store = new MemoryStore({ dbPath: dir, vectorDim: 3 });
return { store, dir };
}

describe("runWithFileLock async I/O (P3 Issue #763)", () => {

describe("pathExists() helper", () => {
it("should return true for existing file", async () => {
const tmp = tmpdir();
const testFile = join(tmp, `p3-pathexists-${Date.now()}.txt`);
writeFileSync(testFile, "x");
try {
const exists = await MemoryStore.pathExists(testFile);
assert.strictEqual(exists, true);
} finally {
unlinkSync(testFile);
}
});

it("should return false for non-existent file", async () => {
const result = await MemoryStore.pathExists("/tmp/does-not-exist-xyz123.txt");
assert.strictEqual(result, false);
});

it("pathExists should not block event loop (must yield to microtask queue)", async () => {
const tmp = tmpdir();
const testFile = join(tmp, `p3-noblock-${Date.now()}.txt`);
writeFileSync(testFile, "x");
let yielded = false;
const checker = new Promise(resolve => {
setTimeout(() => { yielded = true; resolve(); }, 0);
});
// Before yielding → yielded should still be false
const p = MemoryStore.pathExists(testFile);
await checker; // wait for setTimeout(0) to fire
assert.strictEqual(yielded, true, "pathExists must await (yield to event loop)");
await p;
unlinkSync(testFile);
});
});

describe("async mkdir + writeFile in init block", () => {
it("should create lock directory and file using async I/O", async () => {
const { store, dir } = makeStore();
const lockPath = join(dir, ".memory-write.lock");
try {
// Ensure the lock file gets created via async path
await store.hasId("probe").catch(() => {});
// Lock file may or may not exist depending on whether init succeeded
// The important thing is no sync blocking happened
} finally {
await store.destroy().catch(() => {});
rmSync(dir, { recursive: true, force: true });
}
});
});

describe("async stat + unlink in stale check", () => {
it("should clear stale locks using async stat + unlink", async () => {
const { store, dir } = makeStore();
const lockPath = join(dir, ".memory-write.lock");
try {
writeFileSync(lockPath, "", { flag: "wx" });
await store.hasId("probe").catch(() => {});
// No throw = async path succeeded
} finally {
await store.destroy().catch(() => {});
rmSync(dir, { recursive: true, force: true });
}
});
});
});
Loading