@dshtrading/dsh-home 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ //#region src/file-lease.d.ts
2
+ interface HomeFileLease {
3
+ dispose(): void;
4
+ }
5
+ /** 取得该数据文件的 writer lease;已被活实例持有时返回 undefined(明确失败)。 */
6
+ declare function tryAcquireHomeFileLeaseSync(filePath: string): HomeFileLease | undefined;
7
+ //#endregion
8
+ export { HomeFileLease, tryAcquireHomeFileLeaseSync };
@@ -0,0 +1,57 @@
1
+ import { createRequire } from "node:module";
2
+ import { chmodSync, mkdirSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ //#region src/file-lease.ts
5
+ /**
6
+ * 本地文件系统的 writer lease(SQLite 进程锁)。业务状态仍是 JSON,这个稳定
7
+ * sidecar 只承载内核级互斥:持锁进程退出即自动释放,不依赖按路径 rename 冒充
8
+ * CAS。Node >=22.19 内置 node:sqlite,无需新增依赖;运行时不提供该能力时
9
+ * fail-closed 报明确错误,绝不退回「先检查后覆盖」的不安全路径。
10
+ *
11
+ * 只在可能有活实例时保留 `*.writer-lease.sqlite` 侧文件:删除它等于放弃互斥。
12
+ */
13
+ const require = createRequire(import.meta.url);
14
+ let cachedSqlite;
15
+ function loadSqlite() {
16
+ if (cachedSqlite !== void 0) return cachedSqlite;
17
+ try {
18
+ cachedSqlite = require("node:sqlite");
19
+ return cachedSqlite;
20
+ } catch (error) {
21
+ throw new Error("this runtime provides no node:sqlite writer lease; refusing to run the task ledger without kernel-level mutual exclusion: " + (error instanceof Error ? error.message : String(error)));
22
+ }
23
+ }
24
+ /** SQLITE_BUSY(5) / SQLITE_LOCKED(6):另一个活实例正持有该文件的 writer lease。 */
25
+ function isBusy(error) {
26
+ const code = typeof error === "object" && error !== null ? error.errcode : void 0;
27
+ return typeof code === "number" && (code & 255) === 5 || code === 6;
28
+ }
29
+ /** 取得该数据文件的 writer lease;已被活实例持有时返回 undefined(明确失败)。 */
30
+ function tryAcquireHomeFileLeaseSync(filePath) {
31
+ const leasePath = `${filePath}.writer-lease.sqlite`;
32
+ mkdirSync(dirname(leasePath), { recursive: true });
33
+ const database = new (loadSqlite()).DatabaseSync(leasePath);
34
+ try {
35
+ database.exec("PRAGMA busy_timeout = 0");
36
+ database.exec("BEGIN IMMEDIATE");
37
+ } catch (error) {
38
+ database.close();
39
+ if (isBusy(error)) return void 0;
40
+ throw error;
41
+ }
42
+ try {
43
+ chmodSync(leasePath, 384);
44
+ } catch {}
45
+ let disposed = false;
46
+ return { dispose() {
47
+ if (disposed) return;
48
+ disposed = true;
49
+ try {
50
+ database.exec("ROLLBACK");
51
+ } finally {
52
+ database.close();
53
+ }
54
+ } };
55
+ }
56
+ //#endregion
57
+ export { tryAcquireHomeFileLeaseSync };
@@ -2,16 +2,34 @@
2
2
  interface HomeFileLockOptions {
3
3
  /** 等锁上限(毫秒);超时抛错,不静默降级。 */
4
4
  readonly timeoutMs?: number;
5
- /** 锁文件 mtime 超过该毫秒数视为陈旧(持锁者已崩溃),可被后来者回收。 */
5
+ /** 锁文件 mtime 超过该毫秒数视为可能陈旧;仍需 owner 已死才回收。 */
6
6
  readonly staleMs?: number;
7
7
  }
8
+ /** 锁文件记录的 owner 身份:pid + host + 每次获取唯一的 token + 获取时刻。 */
9
+ interface HomeFileLockOwner {
10
+ readonly pid: number;
11
+ readonly host: string;
12
+ readonly token: string;
13
+ readonly at: number;
14
+ }
8
15
  /** 等锁超时:本次调用按「明确失败」处理,磁盘上的数据保持持锁者的版本。 */
9
16
  declare class HomeFileLockTimeoutError extends Error {
10
17
  readonly lockPath: string;
11
18
  readonly timeoutMs: number;
12
19
  constructor(lockPath: string, timeoutMs: number);
13
20
  }
21
+ /** 提交前复核发现锁已不归本实例(被回收/顶替):本次写入放弃,不覆盖新 owner。 */
22
+ declare class HomeFileLockLostError extends Error {
23
+ readonly lockPath: string;
24
+ constructor(lockPath: string);
25
+ }
26
+ /** 临界区句柄:进入后可在提交前复核归属。 */
27
+ interface HomeFileLock {
28
+ readonly owner: HomeFileLockOwner;
29
+ /** 复核此刻仍持有该锁(token 未变);被顶替时抛 {@link HomeFileLockLostError}。 */
30
+ assertOwned(): Promise<void>;
31
+ }
14
32
  /** 在跨进程锁内执行 work;无论成功失败都释放锁(进程崩溃时由陈旧阈值兜底)。 */
15
- declare function withHomeFileLock<T>(filePath: string, work: () => Promise<T>, options?: HomeFileLockOptions): Promise<T>;
33
+ declare function withHomeFileLock<T>(filePath: string, work: (lock: HomeFileLock) => Promise<T>, options?: HomeFileLockOptions): Promise<T>;
16
34
  //#endregion
17
- export { HomeFileLockOptions, HomeFileLockTimeoutError, withHomeFileLock };
35
+ export { HomeFileLock, HomeFileLockLostError, HomeFileLockOptions, HomeFileLockOwner, HomeFileLockTimeoutError, withHomeFileLock };
package/lib/file-lock.js CHANGED
@@ -1,13 +1,26 @@
1
+ import { tryAcquireHomeFileLeaseSync } from "./file-lease.js";
1
2
  import { hostname } from "node:os";
2
3
  import { dirname } from "node:path";
3
- import { mkdir, open, rename, stat, unlink, utimes } from "node:fs/promises";
4
+ import { link, mkdir, open, readFile, rename, stat, unlink, utimes } from "node:fs/promises";
4
5
  //#region src/file-lock.ts
5
6
  /**
6
7
  * home 数据文件的跨进程排他锁(2026-10-09 事故「多进程整表回写互相覆盖」的机制基座)。
7
8
  *
8
- * 为什么不是 flock/fcntl:Node 无内建 flock,Windows 与网络盘语义不一致;锁文件
9
- * (O_EXCL 创建 + pid/host/心跳 mtime)三平台一致,且持锁进程崩溃后可由后来者按
10
- * 陈旧阈值回收——不依赖持锁者自己清理。
9
+ * 本地文件系统由 Node 内置 SQLite 的进程锁持有临界区,获取/接管/释放均在
10
+ * 同一内核排他边界内;业务状态仍是 JSON。持有进程退出自动释放内核租约,
11
+ * owner 标记继续按可证明的进程死亡与心跳证据回收,不靠路径 rename 冒充 CAS。
12
+ * 不支持跨机器共 home;writer-lease.sqlite 侧文件在可能有活实例时不得删除。
13
+ *
14
+ * owner 身份(2026-10-12 补强):锁路径一旦出现就带有完整身份(pid + host + 每次
15
+ * 获取唯一的 token),不经过「先创建空锁、再补写身份」的未知窗口。
16
+ *
17
+ * 回收判据不是「mtime 旧就抢」:只有 owner 在本机(host 相同)、其 pid **确认已不
18
+ * 存在**、且 mtime 已过陈旧阈值,才允许回收。活着的 owner、别的机器的 owner、读不出
19
+ * 身份的锁一律不回收(等锁明确超时)——宁可按明确错误失败,也绝不冒双写风险。
20
+ *
21
+ * 归属纪律:临界区内提交前({@link HomeFileLock.assertOwned})与释放时都按 token
22
+ * 复核「这把锁现在是不是我的」。被回收/顶替的实例既不会再写盘,也不会删掉新
23
+ * owner 的锁(按路径盲删会让互斥失效)。
11
24
  *
12
25
  * 语义:进入临界区前必得锁;拿不到时等待(缺省 5s),仍拿不到抛
13
26
  * {@link HomeFileLockTimeoutError}——「明确告知失败」这条出路,绝不静默按陈旧
@@ -24,17 +37,106 @@ var HomeFileLockTimeoutError = class extends Error {
24
37
  this.timeoutMs = timeoutMs;
25
38
  }
26
39
  };
40
+ /** 提交前复核发现锁已不归本实例(被回收/顶替):本次写入放弃,不覆盖新 owner。 */
41
+ var HomeFileLockLostError = class extends Error {
42
+ lockPath;
43
+ constructor(lockPath) {
44
+ super(`home file lock ${lockPath} is no longer owned by this process (it was reclaimed or replaced); this process wrote nothing`);
45
+ this.name = "HomeFileLockLostError";
46
+ this.lockPath = lockPath;
47
+ }
48
+ };
27
49
  const DEFAULT_TIMEOUT_MS = 5e3;
28
50
  const DEFAULT_STALE_MS = 3e4;
29
51
  const RETRY_MS = 15;
52
+ /** 文件系统不支持硬链接时,退回 O_EXCL 创建 + 写身份的 errno。 */
53
+ const LINK_UNSUPPORTED_CODES = /* @__PURE__ */ new Set([
54
+ "EPERM",
55
+ "EACCES",
56
+ "ENOTSUP",
57
+ "ENOSYS",
58
+ "EOPNOTSUPP",
59
+ "EXDEV"
60
+ ]);
30
61
  const delay = (ms) => new Promise((resolve) => {
31
62
  setTimeout(resolve, ms);
32
63
  });
64
+ function errorCode(error) {
65
+ return typeof error === "object" && error !== null ? error.code : void 0;
66
+ }
67
+ function randomToken() {
68
+ return `${process.pid.toString(36)}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
69
+ }
70
+ /** 读出锁的 owner 身份;锁不存在、内容不是合法身份 JSON 都返回 undefined(身份未知)。 */
71
+ async function readOwner(lockPath) {
72
+ let text;
73
+ try {
74
+ text = await readFile(lockPath, "utf8");
75
+ } catch {
76
+ return;
77
+ }
78
+ try {
79
+ const parsed = JSON.parse(text);
80
+ if (typeof parsed?.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0 && typeof parsed.host === "string" && parsed.host.length > 0 && typeof parsed.token === "string" && parsed.token.length > 0) return {
81
+ pid: parsed.pid,
82
+ host: parsed.host,
83
+ token: parsed.token,
84
+ at: typeof parsed.at === "number" ? parsed.at : 0
85
+ };
86
+ } catch {}
87
+ }
88
+ /** 本机 pid 是否仍存在(EPERM = 存在但无权限,同样算活着)。 */
89
+ function isProcessAlive(pid) {
90
+ try {
91
+ process.kill(pid, 0);
92
+ return true;
93
+ } catch (error) {
94
+ return errorCode(error) === "EPERM";
95
+ }
96
+ }
97
+ /**
98
+ * 创建「带身份」的锁:先把完整身份写进唯一 staging 文件,再原子 link 到锁路径。
99
+ * 锁路径一旦存在,内容就是完整身份——不存在「空锁 / 半截身份」的未知窗口,也就
100
+ * 不会出现「未知 owner 永远无法回收」的死锁。返回 false 表示锁已被别人持有。
101
+ */
102
+ async function createLockWithOwner(lockPath, owner) {
103
+ const stagingPath = `${lockPath}.owner-${owner.token}`;
104
+ const text = JSON.stringify(owner);
105
+ const staging = await open(stagingPath, "wx");
106
+ try {
107
+ await staging.writeFile(text, "utf8");
108
+ } finally {
109
+ await staging.close();
110
+ }
111
+ try {
112
+ await link(stagingPath, lockPath);
113
+ return true;
114
+ } catch (error) {
115
+ const code = errorCode(error);
116
+ if (code === "EEXIST") return false;
117
+ if (code === void 0 || !LINK_UNSUPPORTED_CODES.has(code)) throw error;
118
+ try {
119
+ const fallback = await open(lockPath, "wx");
120
+ try {
121
+ await fallback.writeFile(text, "utf8");
122
+ } finally {
123
+ await fallback.close().catch(() => {});
124
+ }
125
+ return true;
126
+ } catch (fallbackError) {
127
+ if (errorCode(fallbackError) === "EEXIST") return false;
128
+ throw fallbackError;
129
+ }
130
+ } finally {
131
+ await unlink(stagingPath).catch(() => {});
132
+ }
133
+ }
33
134
  /**
34
135
  * 陈旧锁回收:返回 true 表示可立刻重试拿锁(锁已不在,或已被本次回收)。
35
136
  *
36
- * 抢占用 rename 做原子裁决(同一陈旧 inode 只可能被一个进程搬走);搬走后复核
37
- * mtime,若发现抢到的是别人刚重建的新锁(极窄窗口),原样搬回,避免误删活锁。
137
+ * 只有「本机 owner + pid 确认不存在 + mtime 超过陈旧阈值」才回收。抢占用 rename
138
+ * 做原子裁决,抢到后复核 token:若抢到的是窗口里被重建的新锁(尤其是活着的
139
+ * owner),用 link 原样放回(link 遇已存在不覆盖),绝不误删活锁。
38
140
  */
39
141
  async function reclaimIfStale(lockPath, staleMs) {
40
142
  let ageMs;
@@ -44,19 +146,25 @@ async function reclaimIfStale(lockPath, staleMs) {
44
146
  return true;
45
147
  }
46
148
  if (ageMs <= staleMs) return false;
47
- const claimPath = `${lockPath}.stale-${process.pid}-${Date.now()}`;
149
+ const observed = await readOwner(lockPath);
150
+ if (observed === void 0) return false;
151
+ if (observed.host !== hostname()) return false;
152
+ if (isProcessAlive(observed.pid)) return false;
153
+ const claimPath = `${lockPath}.stale-${process.pid.toString(36)}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
48
154
  try {
49
155
  await rename(lockPath, claimPath);
50
156
  } catch {
51
157
  return true;
52
158
  }
53
- const claimed = await stat(claimPath).catch(() => void 0);
54
- if (claimed !== void 0 && Date.now() - claimed.mtimeMs <= staleMs) {
55
- await rename(claimPath, lockPath).catch(() => unlink(claimPath).catch(() => {}));
159
+ const claimed = await readOwner(claimPath);
160
+ const claimedIsObserved = claimed !== void 0 && claimed.token === observed.token;
161
+ const claimedIsLiveHere = claimed !== void 0 && claimed.host === hostname() && isProcessAlive(claimed.pid);
162
+ if (!claimedIsObserved || claimedIsLiveHere) {
163
+ if (await link(claimPath, lockPath).then(() => true, () => false) || !claimedIsLiveHere) await unlink(claimPath).catch(() => {});
56
164
  return false;
57
165
  }
58
166
  await unlink(claimPath).catch(() => {});
59
- console.warn(`[dsh-home] reclaimed stale home file lock ${lockPath} (mtime older than ${staleMs}ms; the holding process likely crashed)`);
167
+ console.warn(`[dsh-home] reclaimed stale home file lock ${lockPath} (owner pid ${observed.pid} on this host is gone; mtime older than ${staleMs}ms)`);
60
168
  return true;
61
169
  }
62
170
  /** 在跨进程锁内执行 work;无论成功失败都释放锁(进程崩溃时由陈旧阈值兜底)。 */
@@ -65,36 +173,65 @@ async function withHomeFileLock(filePath, work, options = {}) {
65
173
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
66
174
  const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
67
175
  await mkdir(dirname(filePath), { recursive: true });
176
+ const owner = {
177
+ pid: process.pid,
178
+ host: hostname(),
179
+ token: randomToken(),
180
+ at: Date.now()
181
+ };
68
182
  const deadline = Date.now() + timeoutMs;
69
- let handle;
70
- while (handle === void 0) {
71
- try {
72
- handle = await open(lockPath, "wx");
73
- } catch (error) {
74
- if (error.code !== "EEXIST") throw error;
183
+ let lease;
184
+ for (;;) {
185
+ lease = tryAcquireHomeFileLeaseSync(filePath);
186
+ if (lease !== void 0) {
187
+ try {
188
+ if (await createLockWithOwner(lockPath, owner)) break;
189
+ if (await reclaimIfStale(lockPath, staleMs) && await createLockWithOwner(lockPath, owner)) break;
190
+ } catch (error) {
191
+ lease.dispose();
192
+ throw error;
193
+ }
194
+ lease.dispose();
195
+ lease = void 0;
75
196
  }
76
- if (handle !== void 0) break;
77
197
  if (Date.now() >= deadline) throw new HomeFileLockTimeoutError(lockPath, timeoutMs);
78
- if (await reclaimIfStale(lockPath, staleMs)) continue;
79
198
  await delay(RETRY_MS);
80
199
  }
200
+ const stillOurs = async () => {
201
+ const current = await readOwner(lockPath);
202
+ return current !== void 0 && current.token === owner.token;
203
+ };
204
+ const heartbeatJobs = /* @__PURE__ */ new Set();
81
205
  const heartbeat = setInterval(() => {
82
- const now = /* @__PURE__ */ new Date();
83
- utimes(lockPath, now, now).catch(() => {});
206
+ const job = (async () => {
207
+ if (!await stillOurs()) return;
208
+ const now = /* @__PURE__ */ new Date();
209
+ await utimes(lockPath, now, now).catch(() => {});
210
+ })();
211
+ heartbeatJobs.add(job);
212
+ job.finally(() => {
213
+ heartbeatJobs.delete(job);
214
+ });
84
215
  }, Math.max(1e3, Math.floor(staleMs / 3)));
85
216
  heartbeat.unref?.();
217
+ const lock = {
218
+ owner,
219
+ async assertOwned() {
220
+ if (!await stillOurs()) throw new HomeFileLockLostError(lockPath);
221
+ }
222
+ };
86
223
  try {
87
- await handle.writeFile(JSON.stringify({
88
- pid: process.pid,
89
- host: hostname(),
90
- at: Date.now()
91
- }), "utf8");
92
- return await work();
224
+ return await work(lock);
93
225
  } finally {
94
226
  clearInterval(heartbeat);
95
- await handle.close().catch(() => {});
96
- await unlink(lockPath).catch(() => {});
227
+ await Promise.all(heartbeatJobs);
228
+ try {
229
+ if (await stillOurs()) await unlink(lockPath).catch(() => {});
230
+ else console.warn(`[dsh-home] home file lock ${lockPath} was reclaimed or replaced while held by pid ${owner.pid}; leaving the current owner's lock in place`);
231
+ } finally {
232
+ lease.dispose();
233
+ }
97
234
  }
98
235
  }
99
236
  //#endregion
100
- export { HomeFileLockTimeoutError, withHomeFileLock };
237
+ export { HomeFileLockLostError, HomeFileLockTimeoutError, withHomeFileLock };
package/lib/fs-atomic.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { dirname } from "node:path";
2
- import { mkdir, rename, unlink, writeFile } from "node:fs/promises";
2
+ import { mkdir, open, rename, unlink } from "node:fs/promises";
3
3
  //#region src/fs-atomic.ts
4
4
  /**
5
5
  * home 数据文件原子写单一实现(2026-09-09 收敛,原为 7 个包的 9+ 处同体副本):
@@ -7,29 +7,104 @@ import { mkdir, rename, unlink, writeFile } from "node:fs/promises";
7
7
  * 重试耗尽或任何失败保留旧文件、清理 tmp、log + throw——目标文件永远只被
8
8
  * 原子 rename 触碰,绝不非原子直写(半截写会损坏 JSON 导致 load 静默重置)。
9
9
  *
10
+ * 耐久性(2026-10-12 补强):rename 前对 tmp 的**文件描述符** fsync,rename 后对
11
+ * **父目录** fsync——新内容与「改名已发生」各有明确同步点,而不是只把数据交给
12
+ * 页缓存。同步失败不静默降级:目录 fsync 只在平台/文件系统**明确不支持**时
13
+ * (Windows 无法以句柄打开目录;部分网络盘)记一行告警后跳过,其余同步错误一律
14
+ * 上抛——「无法确认耐久」必须响亮,不能装成功。
15
+ *
10
16
  * logPrefix 是失败日志在文件路径之前的完整前缀,逐字保留各调用方既有日志
11
17
  * (如 '[dsh-trading/knowledge] failed to atomic flush knowledge cards to')。
12
18
  */
19
+ /** rename 目标被占用(Windows 常见)时可退避重试的 errno。 */
20
+ const RENAME_BUSY_CODES = /* @__PURE__ */ new Set(["EPERM", "EBUSY"]);
21
+ /**
22
+ * 目录 fsync 在平台/文件系统层面不支持时的 errno:Windows 无法以文件句柄打开
23
+ * 目录(EISDIR/EPERM/EINVAL),部分网络盘/容器返回 ENOTSUP/ENOSYS。只有这类
24
+ * 「能力缺失」可容忍;EACCES/ENOENT/IO 错误等真正的同步失败一律上抛。
25
+ */
26
+ const DIR_SYNC_UNSUPPORTED_CODES = /* @__PURE__ */ new Set([
27
+ "EISDIR",
28
+ "EPERM",
29
+ "EINVAL",
30
+ "ENOTSUP",
31
+ "ENOSYS"
32
+ ]);
33
+ function errorCode(error) {
34
+ return typeof error === "object" && error !== null ? error.code : void 0;
35
+ }
36
+ let warnedDirSyncUnsupported = false;
37
+ /** 平台不支持目录 fsync 时明确告知一次(明确受限 ≠ 静默吞掉所有错误)。 */
38
+ function warnDirSyncUnsupported(dir, code) {
39
+ if (warnedDirSyncUnsupported) return;
40
+ warnedDirSyncUnsupported = true;
41
+ console.warn(`[dsh-home] directory fsync is unsupported on this platform/fs (${code ?? "unknown"}) for ${dir}; rename durability is limited to the file fsync (other sync errors are still surfaced)`);
42
+ }
43
+ const delay = (ms) => new Promise((resolve) => {
44
+ setTimeout(resolve, ms);
45
+ });
46
+ /** 对父目录 fsync,让 rename 本身持久化;不支持的平台明确受限,其余错误上抛。 */
47
+ async function fsyncDirectory(dir) {
48
+ let handle;
49
+ try {
50
+ handle = await open(dir, "r");
51
+ } catch (error) {
52
+ if (!DIR_SYNC_UNSUPPORTED_CODES.has(errorCode(error) ?? "")) throw error;
53
+ warnDirSyncUnsupported(dir, errorCode(error));
54
+ return;
55
+ }
56
+ let failure;
57
+ try {
58
+ await handle.sync();
59
+ } catch (error) {
60
+ if (DIR_SYNC_UNSUPPORTED_CODES.has(errorCode(error) ?? "")) warnDirSyncUnsupported(dir, errorCode(error));
61
+ else failure = error;
62
+ }
63
+ try {
64
+ await handle.close();
65
+ } catch (error) {
66
+ failure ??= error;
67
+ }
68
+ if (failure !== void 0) throw failure;
69
+ }
13
70
  async function writeJsonAtomic(filePath, data, logPrefix) {
14
71
  const dir = dirname(filePath);
15
- const tmpPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
72
+ const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
16
73
  const text = JSON.stringify(data, null, 2);
74
+ let tmpPresent = false;
17
75
  try {
18
76
  await mkdir(dir, { recursive: true });
19
- await writeFile(tmpPath, text, "utf8");
20
- let lastError;
21
- for (let attempt = 0; attempt < 3; attempt++) try {
77
+ const handle = await open(tmpPath, "wx");
78
+ tmpPresent = true;
79
+ let failure;
80
+ try {
81
+ await handle.writeFile(text, "utf8");
82
+ await handle.sync();
83
+ } catch (error) {
84
+ failure = error;
85
+ }
86
+ try {
87
+ await handle.close();
88
+ } catch (error) {
89
+ failure ??= error;
90
+ }
91
+ if (failure !== void 0) throw failure;
92
+ let renameError;
93
+ let renamed = false;
94
+ for (let attempt = 0; attempt < 3 && !renamed; attempt++) try {
22
95
  await rename(tmpPath, filePath);
23
- return;
24
- } catch (err) {
25
- if (err?.code !== "EPERM" && err?.code !== "EBUSY") throw err;
26
- lastError = err;
27
- await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1)));
96
+ renamed = true;
97
+ } catch (error) {
98
+ if (!RENAME_BUSY_CODES.has(errorCode(error) ?? "")) throw error;
99
+ renameError = error;
100
+ await delay(25 * (attempt + 1));
28
101
  }
29
- throw lastError;
102
+ if (!renamed) throw renameError;
103
+ tmpPresent = false;
104
+ await fsyncDirectory(dir);
30
105
  } catch (error) {
31
106
  console.error(`${logPrefix} ${filePath}:`, error);
32
- await unlink(tmpPath).catch(() => {});
107
+ if (tmpPresent) await unlink(tmpPath).catch(() => {});
33
108
  throw error;
34
109
  }
35
110
  }
package/lib/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { writeJsonAtomic } from "./fs-atomic.js";
2
- import { HomeFileLockOptions, HomeFileLockTimeoutError, withHomeFileLock } from "./file-lock.js";
3
- import { SKIP_WRITE, SkipWrite, TransactStoreOptions, transactStore } from "./store-transaction.js";
2
+ import { HomeFileLock, HomeFileLockLostError, HomeFileLockOptions, HomeFileLockOwner, HomeFileLockTimeoutError, withHomeFileLock } from "./file-lock.js";
3
+ import { SKIP_WRITE, SkipWrite, StoreCache, TransactStoreOptions, createStoreCache, transactStore } from "./store-transaction.js";
4
+ import { RecordStoreAdapter, RecordStoreAdapterOptions, RecordStoreMutate, createRecordStoreAdapter, readStoreText } from "./record-store.js";
5
+ import { HomeFileLease, tryAcquireHomeFileLeaseSync } from "./file-lease.js";
4
6
  //#region src/index.d.ts
5
7
  /**
6
8
  * 解析 dsh home 根目录。
@@ -61,4 +63,4 @@ interface LegacyHomeMigrationResult {
61
63
  */
62
64
  declare function migrateLegacyTradingHome(options?: LegacyHomeMigrationOptions): LegacyHomeMigrationResult | null;
63
65
  //#endregion
64
- export { HOME_MIGRATION_MARKER, type HomeFileLockOptions, HomeFileLockTimeoutError, LegacyHomeMigrationOptions, LegacyHomeMigrationResult, SKIP_WRITE, type SkipWrite, TRADING_HOME_ENTRIES, type TransactStoreOptions, defaultDshHome, dshHomeDir, migrateLegacyTradingHome, transactStore, withHomeFileLock, writeJsonAtomic };
66
+ export { HOME_MIGRATION_MARKER, type HomeFileLease, type HomeFileLock, HomeFileLockLostError, type HomeFileLockOptions, type HomeFileLockOwner, HomeFileLockTimeoutError, LegacyHomeMigrationOptions, LegacyHomeMigrationResult, type RecordStoreAdapter, type RecordStoreAdapterOptions, type RecordStoreMutate, SKIP_WRITE, type SkipWrite, type StoreCache, TRADING_HOME_ENTRIES, type TransactStoreOptions, createRecordStoreAdapter, createStoreCache, defaultDshHome, dshHomeDir, migrateLegacyTradingHome, readStoreText, transactStore, tryAcquireHomeFileLeaseSync, withHomeFileLock, writeJsonAtomic };
package/lib/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { writeJsonAtomic } from "./fs-atomic.js";
2
- import { HomeFileLockTimeoutError, withHomeFileLock } from "./file-lock.js";
3
- import { SKIP_WRITE, transactStore } from "./store-transaction.js";
2
+ import { tryAcquireHomeFileLeaseSync } from "./file-lease.js";
3
+ import { HomeFileLockLostError, HomeFileLockTimeoutError, withHomeFileLock } from "./file-lock.js";
4
+ import { SKIP_WRITE, createStoreCache, transactStore } from "./store-transaction.js";
5
+ import { createRecordStoreAdapter, readStoreText } from "./record-store.js";
4
6
  import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
5
7
  import { homedir } from "node:os";
6
8
  import { join, resolve } from "node:path";
@@ -141,4 +143,4 @@ function migrateLegacyTradingHome(options = {}) {
141
143
  };
142
144
  }
143
145
  //#endregion
144
- export { HOME_MIGRATION_MARKER, HomeFileLockTimeoutError, SKIP_WRITE, TRADING_HOME_ENTRIES, defaultDshHome, dshHomeDir, migrateLegacyTradingHome, transactStore, withHomeFileLock, writeJsonAtomic };
146
+ export { HOME_MIGRATION_MARKER, HomeFileLockLostError, HomeFileLockTimeoutError, SKIP_WRITE, TRADING_HOME_ENTRIES, createRecordStoreAdapter, createStoreCache, defaultDshHome, dshHomeDir, migrateLegacyTradingHome, readStoreText, transactStore, tryAcquireHomeFileLeaseSync, withHomeFileLock, writeJsonAtomic };
@@ -0,0 +1,56 @@
1
+ import { SkipWrite } from "./store-transaction.js";
2
+ //#region src/record-store.d.ts
3
+ /** mutate 的返回形态,与 {@link transactStore} 的 mutate 完全一致。 */
4
+ type RecordStoreMutate<T> = (onDisk: T) => T | SkipWrite | Promise<T | SkipWrite>;
5
+ interface RecordStoreAdapterOptions<T> {
6
+ /** 目标 JSON 文件。 */
7
+ readonly filePath: string;
8
+ /**
9
+ * 写失败的日志前缀,逐字透传给 {@link transactStore}(各 store 文案不同,属可观察行为)。
10
+ */
11
+ readonly logPrefix: string;
12
+ /**
13
+ * **纯读**:把文件读成文本。无文件(ENOENT)与读失败都以 `undefined` 上报,
14
+ * 由 decode 决定降级成什么。不得回传内存缓存,也不得借它发布缓存。
15
+ */
16
+ readonly readFromDisk: () => Promise<string | undefined>;
17
+ /**
18
+ * 文本 → 内存值:解析 + 校验 + 降级(坏 JSON / 形状不符 / 无文件的口径由各 store 决定,
19
+ * 领域类型与降级纪律都不出包)。
20
+ */
21
+ readonly decode: (text: string | undefined) => T;
22
+ /** 内存值 → 落盘形状(数组、Set、容器包裹皆可)。 */
23
+ readonly encode: (value: T) => unknown;
24
+ /** 等锁上限(毫秒),透传 {@link transactStore}。 */
25
+ readonly timeoutMs?: number;
26
+ /** 陈旧锁回收阈值(毫秒),透传 {@link transactStore}。 */
27
+ readonly staleMs?: number;
28
+ }
29
+ interface RecordStoreAdapter<T> {
30
+ /** 取当前新鲜值:磁盘签名未变时用缓存,变了(或没发布过签名)则重新纯读 + decode。 */
31
+ load(): Promise<T>;
32
+ /**
33
+ * 跨进程锁内:新鲜纯读 + decode → mutate → encode + 原子写 → **落盘成功后才** replace
34
+ * 缓存。落盘失败时抛出且缓存保持旧值(未确认的值绝不提前进入缓存)。
35
+ */
36
+ commit(mutate: RecordStoreMutate<T>): Promise<T>;
37
+ }
38
+ /**
39
+ * 纯读一个 store 文件为文本。
40
+ *
41
+ * - ENOENT(首启的正常路径)静默返回 `undefined`;
42
+ * - 其他读失败按 `errorMessage` 记一条 error 后同样返回 `undefined`(降级值由 decode 决定,
43
+ * 与各 store 原来的读盘口径逐字一致);
44
+ * - 不做任何解析,也不发布缓存——调用链上唯一的发布点是 {@link RecordStoreAdapter.commit}
45
+ * 成功之后。
46
+ */
47
+ declare function readStoreText(filePath: string, errorMessage: string): Promise<string | undefined>;
48
+ /**
49
+ * 建一个文件版记录 store 的适配器:缓存 + 跨进程事务 + 「成功才发布」三件事一次装好。
50
+ *
51
+ * @param options - 文件路径、写失败日志前缀与三个回调(纯读 / decode / encode)。
52
+ * @returns {@link RecordStoreAdapter}:`load()` 供各 store 的 list/get 用,`commit()` 供写路径用。
53
+ */
54
+ declare function createRecordStoreAdapter<T>(options: RecordStoreAdapterOptions<T>): RecordStoreAdapter<T>;
55
+ //#endregion
56
+ export { RecordStoreAdapter, RecordStoreAdapterOptions, RecordStoreMutate, createRecordStoreAdapter, readStoreText };
@@ -0,0 +1,68 @@
1
+ import { createStoreCache, transactStore } from "./store-transaction.js";
2
+ import { readFile } from "node:fs/promises";
3
+ //#region src/record-store.ts
4
+ /**
5
+ * 「记录 store 适配器」:把各包文件版 store 各自复制的同一段骨架收敛成一处——
6
+ * **锁内新鲜读盘 → 事务写 → 落盘成功后才发布缓存**({@link transactStore} 与
7
+ * {@link createStoreCache} 的组合形态,2026-10-09 跨进程覆盖事故 + 2026-10-12
8
+ * 读新鲜度两次修复的共享入口)。
9
+ *
10
+ * 分工(本模块刻意不认识任何领域类型,也不 import 任何业务包):
11
+ * - dsh-home 只拥有「怎么读、怎么写、什么时候发布缓存」的骨架;
12
+ * - 三个回调由各 store 提供:{@link RecordStoreAdapterOptions.readFromDisk} 是
13
+ * **纯读**(把文件读成文本,不发布缓存、不就地改缓存对象),
14
+ * {@link RecordStoreAdapterOptions.decode} 把文本变成内存值(解析 + 校验 +
15
+ * **各自 store 的降级口径**),{@link RecordStoreAdapterOptions.encode} 把内存值
16
+ * 变回落盘形状。
17
+ * - 因此 knowledge 的作者别名归一、chart-activations 的 sanitizeInstance、holdings
18
+ * 的三字段形状校验、tombstones 的 Set 形态、watchlist 的多文件形态,全部留在各包
19
+ * decode 里,逐字不变。
20
+ *
21
+ * 为什么读盘要拆成「纯读 + decode」两半:坏 JSON 与非 ENOENT 读失败在各 store 里都
22
+ * 是同一条 catch 分支(同一条日志文案、同一个降级值),所以「读失败日志」由纯读一侧
23
+ * 负责({@link readStoreText}),「解析失败降级」由 decode 一侧负责,两半合起来与
24
+ * 各 store 原来的单函数逐分支等价。
25
+ */
26
+ /**
27
+ * 纯读一个 store 文件为文本。
28
+ *
29
+ * - ENOENT(首启的正常路径)静默返回 `undefined`;
30
+ * - 其他读失败按 `errorMessage` 记一条 error 后同样返回 `undefined`(降级值由 decode 决定,
31
+ * 与各 store 原来的读盘口径逐字一致);
32
+ * - 不做任何解析,也不发布缓存——调用链上唯一的发布点是 {@link RecordStoreAdapter.commit}
33
+ * 成功之后。
34
+ */
35
+ async function readStoreText(filePath, errorMessage) {
36
+ try {
37
+ return await readFile(filePath, "utf8");
38
+ } catch (error) {
39
+ if (error.code !== "ENOENT") console.error(errorMessage, error);
40
+ return;
41
+ }
42
+ }
43
+ /**
44
+ * 建一个文件版记录 store 的适配器:缓存 + 跨进程事务 + 「成功才发布」三件事一次装好。
45
+ *
46
+ * @param options - 文件路径、写失败日志前缀与三个回调(纯读 / decode / encode)。
47
+ * @returns {@link RecordStoreAdapter}:`load()` 供各 store 的 list/get 用,`commit()` 供写路径用。
48
+ */
49
+ function createRecordStoreAdapter(options) {
50
+ const { filePath, logPrefix, readFromDisk, decode, encode } = options;
51
+ const lockOptions = {
52
+ ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
53
+ ...options.staleMs !== void 0 ? { staleMs: options.staleMs } : {}
54
+ };
55
+ /** 新鲜读:纯读 + decode,不发布缓存;缓存与锁内重读共用同一入口。 */
56
+ const fresh = async () => decode(await readFromDisk());
57
+ const cache = createStoreCache(filePath, fresh);
58
+ return {
59
+ load: () => cache.load(),
60
+ async commit(mutate) {
61
+ const next = await transactStore(filePath, fresh, mutate, encode, logPrefix, lockOptions);
62
+ cache.replace(next);
63
+ return next;
64
+ }
65
+ };
66
+ }
67
+ //#endregion
68
+ export { createRecordStoreAdapter, readStoreText };
@@ -13,8 +13,8 @@ interface TransactStoreOptions {
13
13
  * 在跨进程锁内读-改-写一份 JSON 表。
14
14
  *
15
15
  * @param filePath - 目标 JSON 文件
16
- * @param readFromDisk - **新鲜**读盘(不得回传内存缓存);由各 store 提供 parse
17
- * 感知的读取,使坏形/坏 JSON 的降级口径与自身 load() 一致
16
+ * @param readFromDisk - **新鲜**读盘(不得回传内存缓存,也不得借它发布缓存);由各
17
+ * store 提供 parse 感知的读取,使坏形/坏 JSON 的降级口径与自身 load() 一致
18
18
  * @param mutate - 在锁内新鲜值上做本次修改;返回新内存值,或 {@link SKIP_WRITE}
19
19
  * 表示无变化不落盘
20
20
  * @param serialize - 新内存值 → 落盘形状(数组、按 id 为键的对象、容器包裹皆可)
@@ -22,5 +22,31 @@ interface TransactStoreOptions {
22
22
  * @returns 落盘后的当前值(SKIP_WRITE 时即锁内新鲜值)
23
23
  */
24
24
  declare function transactStore<T>(filePath: string, readFromDisk: () => Promise<T>, mutate: (onDisk: T) => T | SkipWrite | Promise<T | SkipWrite>, serialize: (value: T) => unknown, logPrefix: string, options?: TransactStoreOptions): Promise<T>;
25
+ /**
26
+ * 跨实例读新鲜度缓存:进程内内存缓存 + 每次读前核对磁盘签名。
27
+ *
28
+ * 为什么不能只 `if (cache !== null) return cache`(2026-10-12 补强):整表 store 的
29
+ * 缓存是「本实例上次读盘的值」,另一实例(桌面端/CLI)确认落盘的新行永远进不来,
30
+ * list() 会一直返回陈旧快照。此缓存每次 load() 先 stat 目标文件,签名(dev/ino/
31
+ * size/mtimeNs/ctimeNs)变了就重新读盘;因此「别人已确认的写」在下一次读取可见,
32
+ * 缓存只省重复读,不会永久命中。
33
+ *
34
+ * 写失败的发布纪律:只有 {@link transactStore} 成功返回后才 replace();写失败时缓存
35
+ * 保持旧值,且 replace() 之后的下一次 load() 仍会核对磁盘签名——即使发布的值没真正
36
+ * 落盘也会被磁盘纠正,不会出现「缓存里有、盘上没有」。
37
+ */
38
+ interface StoreCache<T> {
39
+ /** 取当前新鲜值:磁盘签名未变时用缓存,变了(或被 replace/invalidate)则重新读盘。 */
40
+ load(): Promise<T>;
41
+ /** 一次成功落盘后发布本次值;签名置为待核对,下次 load 仍以磁盘为准。 */
42
+ replace(value: T): void;
43
+ /** 丢弃缓存,下次 load 必定重新读盘。 */
44
+ invalidate(): void;
45
+ }
46
+ /**
47
+ * 建一个「每次读前核对磁盘签名」的缓存。readFromDisk 必须是**纯读**(不发布缓存、
48
+ * 不就地改缓存对象),这样写失败时未确认的值不会提前进入缓存。
49
+ */
50
+ declare function createStoreCache<T>(filePath: string, readFromDisk: () => Promise<T>): StoreCache<T>;
25
51
  //#endregion
26
- export { SKIP_WRITE, SkipWrite, TransactStoreOptions, transactStore };
52
+ export { SKIP_WRITE, SkipWrite, StoreCache, TransactStoreOptions, createStoreCache, transactStore };
@@ -1,8 +1,10 @@
1
1
  import { writeJsonAtomic } from "./fs-atomic.js";
2
2
  import { withHomeFileLock } from "./file-lock.js";
3
+ import { stat } from "node:fs/promises";
3
4
  //#region src/store-transaction.ts
4
5
  /**
5
- * 整表 JSON 的跨进程「锁内读 - 改 - 原子写」(2026-10-09 事故修复的共享入口)。
6
+ * 整表 JSON 的跨进程「锁内读 - 改 - 原子写」(2026-10-09 事故修复的共享入口),
7
+ * 以及跨实例读新鲜度缓存 {@link createStoreCache}(2026-10-12 补强)。
6
8
  *
7
9
  * 背景:下列 store 都是「进程内内存缓存 + 整表回写」,两个宿主进程共用同一
8
10
  * DSH_HOME 时后写者用自己那份陈旧快照覆盖先写者(last-writer-wins,静默丢数据;
@@ -12,13 +14,14 @@ import { withHomeFileLock } from "./file-lock.js";
12
14
  * 1. 取该文件的跨进程排他锁({@link withHomeFileLock});
13
15
  * 2. 锁内**新鲜重读磁盘**(readFromDisk,不是进临界区前的缓存);
14
16
  * 3. mutate 在这份新鲜值上做本次修改并返回新的内存值;
15
- * 4. tmp+rename 原子写落盘,返回值即调用方应缓存的当前值。
17
+ * 4. **提交前复核锁仍归本实例**(临界区较长时锁可能已被回收/顶替),再 tmp+rename
18
+ * 原子写落盘,返回值即调用方应缓存的当前值。
16
19
  *
17
20
  * 「读 - 改」整体在锁内,所以两个进程各自的**新增、删除**都不丢:后到者是在先到者的
18
21
  * 结果上继续改,而不是用陈旧快照覆盖。这是「先读回并合并双方数据」的具体形态,
19
22
  * 而不是「拒绝第二实例写」——本机桌面端与 CLI 长期共用同一 home,拒绝写会让第二
20
- * 实例完全存不了设置。等锁超时抛 {@link HomeFileLockTimeoutError}:那是明确失败,
21
- * 绝不静默按陈旧快照覆盖。
23
+ * 实例完全存不了设置。等锁超时抛 {@link HomeFileLockTimeoutError},提交前发现锁被
24
+ * 顶替抛 {@link HomeFileLockLostError}:两种都是明确失败,绝不静默按陈旧快照覆盖。
22
25
  *
23
26
  * 合并粒度是条目(行表按 id、嵌套表按各自键):同一条目被两个进程同时改时,后写者
24
27
  * 在该条目上的整条值胜出(不做字段级三方合并,见 note 的 Residue)。
@@ -41,8 +44,8 @@ function lockOptionsOf(options) {
41
44
  * 在跨进程锁内读-改-写一份 JSON 表。
42
45
  *
43
46
  * @param filePath - 目标 JSON 文件
44
- * @param readFromDisk - **新鲜**读盘(不得回传内存缓存);由各 store 提供 parse
45
- * 感知的读取,使坏形/坏 JSON 的降级口径与自身 load() 一致
47
+ * @param readFromDisk - **新鲜**读盘(不得回传内存缓存,也不得借它发布缓存);由各
48
+ * store 提供 parse 感知的读取,使坏形/坏 JSON 的降级口径与自身 load() 一致
46
49
  * @param mutate - 在锁内新鲜值上做本次修改;返回新内存值,或 {@link SKIP_WRITE}
47
50
  * 表示无变化不落盘
48
51
  * @param serialize - 新内存值 → 落盘形状(数组、按 id 为键的对象、容器包裹皆可)
@@ -50,13 +53,56 @@ function lockOptionsOf(options) {
50
53
  * @returns 落盘后的当前值(SKIP_WRITE 时即锁内新鲜值)
51
54
  */
52
55
  async function transactStore(filePath, readFromDisk, mutate, serialize, logPrefix, options = {}) {
53
- return withHomeFileLock(filePath, async () => {
56
+ return withHomeFileLock(filePath, async (lock) => {
54
57
  const onDisk = await readFromDisk();
55
58
  const next = await mutate(onDisk);
56
59
  if (next === SKIP_WRITE) return onDisk;
60
+ await lock.assertOwned();
57
61
  await writeJsonAtomic(filePath, serialize(next), logPrefix);
58
62
  return next;
59
63
  }, lockOptionsOf(options));
60
64
  }
65
+ /**
66
+ * 磁盘签名:路径不存在为 'missing'。stat 的其他失败给出永不匹配的签名,强制本次
67
+ * 重新读盘——读盘自身的错误/降级口径由各 store 的 readFromDisk 决定,这里不引入
68
+ * 新的读语义。
69
+ */
70
+ async function fileSignature(filePath) {
71
+ try {
72
+ const info = await stat(filePath, { bigint: true });
73
+ return `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`;
74
+ } catch (error) {
75
+ if (error.code === "ENOENT") return "missing";
76
+ return `unavailable:${Date.now()}:${Math.random()}`;
77
+ }
78
+ }
79
+ /**
80
+ * 建一个「每次读前核对磁盘签名」的缓存。readFromDisk 必须是**纯读**(不发布缓存、
81
+ * 不就地改缓存对象),这样写失败时未确认的值不会提前进入缓存。
82
+ */
83
+ function createStoreCache(filePath, readFromDisk) {
84
+ let entry;
85
+ return {
86
+ async load() {
87
+ const signature = await fileSignature(filePath);
88
+ if (entry !== void 0 && entry.signature !== void 0 && entry.signature === signature) return entry.value;
89
+ const value = await readFromDisk();
90
+ entry = {
91
+ value,
92
+ signature
93
+ };
94
+ return value;
95
+ },
96
+ replace(value) {
97
+ entry = {
98
+ value,
99
+ signature: void 0
100
+ };
101
+ },
102
+ invalidate() {
103
+ entry = void 0;
104
+ }
105
+ };
106
+ }
61
107
  //#endregion
62
- export { SKIP_WRITE, transactStore };
108
+ export { SKIP_WRITE, createStoreCache, transactStore };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dshtrading/dsh-home",
3
3
  "description": "dsh home 根目录解析($DSH_HOME 环境变量感知,缺省 ~/.dsh,对齐宿主 dsh-home-paths 语义)——dsh-trading 各包 home 数据文件的唯一路径来源",
4
- "version": "0.6.1",
4
+ "version": "0.6.3",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "./lib/index.d.ts",