@optimystic/db-p2p-storage-fs 1.14.0 → 1.16.0
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.
- package/README.md +142 -33
- package/dist/src/file-batch-backend.d.ts +69 -0
- package/dist/src/file-batch-backend.d.ts.map +1 -0
- package/dist/src/file-batch-backend.js +400 -0
- package/dist/src/file-batch-backend.js.map +1 -0
- package/dist/src/file-kv-store.d.ts +37 -3
- package/dist/src/file-kv-store.d.ts.map +1 -1
- package/dist/src/file-kv-store.js +100 -34
- package/dist/src/file-kv-store.js.map +1 -1
- package/dist/src/file-storage.d.ts +20 -58
- package/dist/src/file-storage.d.ts.map +1 -1
- package/dist/src/file-storage.js +16 -430
- package/dist/src/file-storage.js.map +1 -1
- package/dist/src/file-write-log.d.ts +361 -0
- package/dist/src/file-write-log.d.ts.map +1 -0
- package/dist/src/file-write-log.js +848 -0
- package/dist/src/file-write-log.js.map +1 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/store-lock.d.ts +42 -0
- package/dist/src/store-lock.d.ts.map +1 -0
- package/dist/src/store-lock.js +116 -0
- package/dist/src/store-lock.js.map +1 -0
- package/package.json +3 -3
- package/src/file-batch-backend.ts +430 -0
- package/src/file-kv-store.ts +112 -32
- package/src/file-storage.ts +22 -468
- package/src/file-write-log.ts +906 -0
- package/src/index.ts +3 -0
- package/src/store-lock.ts +123 -0
- package/dist/src/atomic-write.d.ts +0 -22
- package/dist/src/atomic-write.d.ts.map +0 -1
- package/dist/src/atomic-write.js +0 -90
- package/dist/src/atomic-write.js.map +0 -1
- package/src/atomic-write.ts +0 -89
package/src/index.ts
CHANGED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { createLogger } from './logger.js';
|
|
5
|
+
|
|
6
|
+
const log = createLogger('store-lock');
|
|
7
|
+
|
|
8
|
+
export const STORE_LOCK_FILENAME = 'store.lock';
|
|
9
|
+
|
|
10
|
+
/** What the lock file records about the process that took it. */
|
|
11
|
+
export interface StoreLockHolder {
|
|
12
|
+
readonly pid: number;
|
|
13
|
+
readonly hostname: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Thrown by every operation of a store whose directory another live process on this machine has
|
|
18
|
+
* open. Invariant 5 in `packages/db-p2p/docs/storage.md`: a store is owned by exactly one process,
|
|
19
|
+
* and the write-ahead log makes two writers in one file a corruption rather than a stale cache.
|
|
20
|
+
*/
|
|
21
|
+
export class StoreLockedError extends Error {
|
|
22
|
+
constructor(readonly lockPath: string, readonly holder: StoreLockHolder) {
|
|
23
|
+
super(`store at ${path.dirname(lockPath)} is open in process ${holder.pid} on ${holder.hostname}; a store is owned by one process at a time`);
|
|
24
|
+
this.name = 'StoreLockedError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface StoreLock {
|
|
29
|
+
readonly path: string;
|
|
30
|
+
/** Remove the lock file. Idempotent. */
|
|
31
|
+
release(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** How many stale locks one open will replace before giving up on a directory whose lock keeps reappearing. */
|
|
35
|
+
const REPLACE_ATTEMPTS = 3;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Take `<basePath>/store.lock` for this process. The file is created exclusively (`wx`) and holds
|
|
39
|
+
* `{ pid, hostname }`. An existing lock naming this machine and a live pid refuses the open with
|
|
40
|
+
* {@link StoreLockedError}; any other existing lock — another machine's, a dead pid's, or one whose
|
|
41
|
+
* content cannot be read — is stale and replaced. The liveness test is `process.kill(pid, 0)`, so
|
|
42
|
+
* only one machine is protected, which is the one case ever supported (two machines over one
|
|
43
|
+
* network share never were; Invariant 5).
|
|
44
|
+
*
|
|
45
|
+
* NOTE: the content is written in the same call that creates the file, but a second process that
|
|
46
|
+
* opens the directory in the microseconds between the create and the write reads an empty file,
|
|
47
|
+
* judges the lock stale and replaces it. Two processes starting over one directory at the same
|
|
48
|
+
* instant is already the violation the lock exists to refuse; if that race is ever seen, judge an
|
|
49
|
+
* unreadable lock by its age instead of replacing it at once.
|
|
50
|
+
* NOTE: the holder is identified by pid and hostname alone. A dead holder's pid reused by an
|
|
51
|
+
* unrelated process reads as live, and the open is refused until the lock file is removed by hand;
|
|
52
|
+
* a machine whose hostname changed between two starts reads its own live lock as another
|
|
53
|
+
* machine's and replaces it. Both need a start-time stamp in the lock to tell apart; add one if
|
|
54
|
+
* either is seen.
|
|
55
|
+
*/
|
|
56
|
+
export async function acquireStoreLock(basePath: string): Promise<StoreLock> {
|
|
57
|
+
const lockPath = path.join(basePath, STORE_LOCK_FILENAME);
|
|
58
|
+
const self: StoreLockHolder = { pid: process.pid, hostname: os.hostname() };
|
|
59
|
+
for (let attempt = 0; attempt < REPLACE_ATTEMPTS; attempt++) {
|
|
60
|
+
if (await tryCreate(lockPath, self)) {
|
|
61
|
+
return { path: lockPath, release: () => removeLock(lockPath) };
|
|
62
|
+
}
|
|
63
|
+
const holder = await readHolder(lockPath);
|
|
64
|
+
if (holder && holder.hostname === self.hostname && processExists(holder.pid)) {
|
|
65
|
+
throw new StoreLockedError(lockPath, holder);
|
|
66
|
+
}
|
|
67
|
+
log('replacing stale lock %s held by %o', lockPath, holder);
|
|
68
|
+
await removeLock(lockPath);
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`could not take ${lockPath}: it was replaced ${REPLACE_ATTEMPTS} times by another opener`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function tryCreate(lockPath: string, holder: StoreLockHolder): Promise<boolean> {
|
|
74
|
+
try {
|
|
75
|
+
await fs.writeFile(lockPath, JSON.stringify(holder), { flag: 'wx' });
|
|
76
|
+
return true;
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false;
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The holder a lock file names, or `undefined` for a missing, unreadable or malformed one. */
|
|
84
|
+
async function readHolder(lockPath: string): Promise<StoreLockHolder | undefined> {
|
|
85
|
+
let text: string;
|
|
86
|
+
try {
|
|
87
|
+
text = await fs.readFile(lockPath, 'utf8');
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const parsed: unknown = JSON.parse(text);
|
|
94
|
+
return isHolder(parsed) ? parsed : undefined;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (err instanceof SyntaxError) return undefined;
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isHolder(value: unknown): value is StoreLockHolder {
|
|
102
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
103
|
+
const { pid, hostname } = value as Record<string, unknown>;
|
|
104
|
+
return Number.isInteger(pid) && (pid as number) > 0 && typeof hostname === 'string';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Signal 0 tests for existence without sending anything; EPERM means the process exists but is not ours. */
|
|
108
|
+
function processExists(pid: number): boolean {
|
|
109
|
+
try {
|
|
110
|
+
process.kill(pid, 0);
|
|
111
|
+
return true;
|
|
112
|
+
} catch (err) {
|
|
113
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function removeLock(lockPath: string): Promise<void> {
|
|
118
|
+
try {
|
|
119
|
+
await fs.unlink(lockPath);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Atomically write `content` to `filePath`.
|
|
3
|
-
*
|
|
4
|
-
* Writes to a unique `*.tmp` sibling, fsyncs the data, then renames it into
|
|
5
|
-
* place. `rename` over an existing file is atomic on POSIX and NTFS, so a
|
|
6
|
-
* concurrent reader only ever sees the complete old file or the complete new
|
|
7
|
-
* file — never a torn/half-written one. After the rename we best-effort fsync
|
|
8
|
-
* the containing directory so the rename itself survives power loss on POSIX;
|
|
9
|
-
* that directory fsync is unsupported on win32 and is skipped there.
|
|
10
|
-
*
|
|
11
|
-
* On any failure the temp file is removed (best-effort) so a crashed write does
|
|
12
|
-
* not leave the canonical path damaged. A crash *between* the temp write and the
|
|
13
|
-
* rename leaves only an inert `*.tmp` sibling — never read (reads target
|
|
14
|
-
* canonical paths) and skipped by `.json` directory scans.
|
|
15
|
-
*
|
|
16
|
-
* `content` is `string | Uint8Array`: `FileKVStore` writes strings, while the
|
|
17
|
-
* `KvRawStorage`-backed `FileStoreDriver` writes the kernel's raw value bytes.
|
|
18
|
-
* `FileHandle.writeFile` writes either losslessly — a `Uint8Array` is written
|
|
19
|
-
* byte-for-byte, so non-ASCII JSON round-trips exactly.
|
|
20
|
-
*/
|
|
21
|
-
export declare function atomicWriteFile(filePath: string, content: string | Uint8Array): Promise<void>;
|
|
22
|
-
//# sourceMappingURL=atomic-write.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"atomic-write.d.ts","sourceRoot":"","sources":["../../src/atomic-write.ts"],"names":[],"mappings":"AAUA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAkCnG"}
|
package/dist/src/atomic-write.js
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { promises as fs } from 'fs';
|
|
2
|
-
import * as path from 'path';
|
|
3
|
-
// Per-process monotonic counter for unique temp names. Combined with the pid it
|
|
4
|
-
// makes each temp path unique across concurrent writers to the same logical
|
|
5
|
-
// target — even two FileRawStorage/FileKVStore instances in one process, which
|
|
6
|
-
// do not lock (see the proper-lockfile TODO in file-storage.ts). Deterministic
|
|
7
|
-
// and collision-safe, unlike Math.random()/Date.now().
|
|
8
|
-
let tempCounter = 0;
|
|
9
|
-
/**
|
|
10
|
-
* Atomically write `content` to `filePath`.
|
|
11
|
-
*
|
|
12
|
-
* Writes to a unique `*.tmp` sibling, fsyncs the data, then renames it into
|
|
13
|
-
* place. `rename` over an existing file is atomic on POSIX and NTFS, so a
|
|
14
|
-
* concurrent reader only ever sees the complete old file or the complete new
|
|
15
|
-
* file — never a torn/half-written one. After the rename we best-effort fsync
|
|
16
|
-
* the containing directory so the rename itself survives power loss on POSIX;
|
|
17
|
-
* that directory fsync is unsupported on win32 and is skipped there.
|
|
18
|
-
*
|
|
19
|
-
* On any failure the temp file is removed (best-effort) so a crashed write does
|
|
20
|
-
* not leave the canonical path damaged. A crash *between* the temp write and the
|
|
21
|
-
* rename leaves only an inert `*.tmp` sibling — never read (reads target
|
|
22
|
-
* canonical paths) and skipped by `.json` directory scans.
|
|
23
|
-
*
|
|
24
|
-
* `content` is `string | Uint8Array`: `FileKVStore` writes strings, while the
|
|
25
|
-
* `KvRawStorage`-backed `FileStoreDriver` writes the kernel's raw value bytes.
|
|
26
|
-
* `FileHandle.writeFile` writes either losslessly — a `Uint8Array` is written
|
|
27
|
-
* byte-for-byte, so non-ASCII JSON round-trips exactly.
|
|
28
|
-
*/
|
|
29
|
-
export async function atomicWriteFile(filePath, content) {
|
|
30
|
-
const dir = path.dirname(filePath);
|
|
31
|
-
await fs.mkdir(dir, { recursive: true });
|
|
32
|
-
// Suffix ends in `.tmp` (not `.json`) so listPendingTransactions / FileKVStore.list,
|
|
33
|
-
// which filter on `.json`, never surface an in-flight temp file.
|
|
34
|
-
// NOTE: a crash between open and rename leaves an inert `*.tmp` orphan (never read,
|
|
35
|
-
// skipped by `.json` scans). No cleanup sweep exists; if a crash-looping writer ever
|
|
36
|
-
// accumulates many, add a startup sweep of stale `*.tmp` siblings.
|
|
37
|
-
const tmpPath = path.join(dir, `${path.basename(filePath)}.${process.pid}.${tempCounter++}.tmp`);
|
|
38
|
-
let handle;
|
|
39
|
-
try {
|
|
40
|
-
handle = await fs.open(tmpPath, 'w');
|
|
41
|
-
await handle.writeFile(content);
|
|
42
|
-
await handle.sync();
|
|
43
|
-
await handle.close();
|
|
44
|
-
handle = undefined;
|
|
45
|
-
// NOTE: on win32, rename-over-existing can throw EPERM/EACCES/EBUSY if a
|
|
46
|
-
// concurrent reader holds the target open without FILE_SHARE_DELETE (Node
|
|
47
|
-
// readIfExists/get open→read→close in a tiny window, so it's rare). Modern
|
|
48
|
-
// libuv retries some cases; if this ever surfaces as spurious write failures
|
|
49
|
-
// under concurrent read+write on Windows, add a bounded retry loop here (the
|
|
50
|
-
// write-file-atomic package does exactly this). Conditional — the adapter is
|
|
51
|
-
// already last-writer-wins with no cross-process lock (proper-lockfile TODO
|
|
52
|
-
// in file-storage.ts), so it is not reachable under current single-writer use.
|
|
53
|
-
await fs.rename(tmpPath, filePath);
|
|
54
|
-
}
|
|
55
|
-
catch (err) {
|
|
56
|
-
if (handle)
|
|
57
|
-
await handle.close().catch(() => { });
|
|
58
|
-
await fs.unlink(tmpPath).catch(() => { });
|
|
59
|
-
throw err;
|
|
60
|
-
}
|
|
61
|
-
await fsyncDir(dir);
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* Best-effort fsync of a directory so a completed rename is durable. POSIX needs
|
|
65
|
-
* this; platforms that reject opening a directory for fsync throw, and we ignore
|
|
66
|
-
* that rather than failing the write.
|
|
67
|
-
*
|
|
68
|
-
* Skipped outright on win32: there `fs.open(dir, 'r')` SUCCEEDS and only the
|
|
69
|
-
* following `handle.sync()` fails with EPERM (swallowed), so the open/close pair
|
|
70
|
-
* was guaranteed wasted work — exactly half of all `fs.open` calls a write
|
|
71
|
-
* workload makes. Durability is unchanged on win32 (the sync never succeeded
|
|
72
|
-
* there); the on-disk layout is untouched.
|
|
73
|
-
*/
|
|
74
|
-
async function fsyncDir(dir) {
|
|
75
|
-
if (process.platform === 'win32')
|
|
76
|
-
return;
|
|
77
|
-
let handle;
|
|
78
|
-
try {
|
|
79
|
-
handle = await fs.open(dir, 'r');
|
|
80
|
-
await handle.sync();
|
|
81
|
-
}
|
|
82
|
-
catch {
|
|
83
|
-
// Directory fsync unsupported here — nothing to do.
|
|
84
|
-
}
|
|
85
|
-
finally {
|
|
86
|
-
if (handle)
|
|
87
|
-
await handle.close().catch(() => { });
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
//# sourceMappingURL=atomic-write.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"atomic-write.js","sourceRoot":"","sources":["../../src/atomic-write.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,IAAI,CAAC;AACpC,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AAE7B,gFAAgF;AAChF,4EAA4E;AAC5E,+EAA+E;AAC/E,+EAA+E;AAC/E,uDAAuD;AACvD,IAAI,WAAW,GAAG,CAAC,CAAC;AAEpB;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,QAAgB,EAAE,OAA4B;IACnF,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IACnC,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAEzC,qFAAqF;IACrF,iEAAiE;IACjE,oFAAoF;IACpF,qFAAqF;IACrF,mEAAmE;IACnE,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,OAAO,CAAC,GAAG,IAAI,WAAW,EAAE,MAAM,CAAC,CAAC;IAEjG,IAAI,MAAiC,CAAC;IACtC,IAAI,CAAC;QACJ,MAAM,GAAG,MAAM,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC;QACrC,MAAM,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;QAChC,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;QACrB,MAAM,GAAG,SAAS,CAAC;QACnB,yEAAyE;QACzE,0EAA0E;QAC1E,2EAA2E;QAC3E,6EAA6E;QAC7E,6EAA6E;QAC7E,6EAA6E;QAC7E,4EAA4E;QAC5E,+EAA+E;QAC/E,MAAM,EAAE,CAAC,MAAM,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IACpC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACd,IAAI,MAAM;YAAE,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAqB,CAAC,CAAC,CAAC;QACpE,MAAM,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAyC,CAAC,CAAC,CAAC;QAChF,MAAM,GAAG,CAAC;IACX,CAAC;IAED,MAAM,QAAQ,CAAC,GAAG,CAAC,CAAC;AACrB,CAAC;AAED;;;;;;;;;;GAUG;AACH,KAAK,UAAU,QAAQ,CAAC,GAAW;IAClC,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO;QAAE,OAAO;IACzC,IAAI,MAAiC,CAAC;IACtC,IAAI,CAAC;QACJ,MAAM,GAAG,MAAM,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QACjC,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;IACrB,CAAC;IAAC,MAAM,CAAC;QACR,oDAAoD;IACrD,CAAC;YAAS,CAAC;QACV,IAAI,MAAM;YAAE,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAqB,CAAC,CAAC,CAAC;IACrE,CAAC;AACF,CAAC"}
|
package/src/atomic-write.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { promises as fs } from 'fs';
|
|
2
|
-
import * as path from 'path';
|
|
3
|
-
|
|
4
|
-
// Per-process monotonic counter for unique temp names. Combined with the pid it
|
|
5
|
-
// makes each temp path unique across concurrent writers to the same logical
|
|
6
|
-
// target — even two FileRawStorage/FileKVStore instances in one process, which
|
|
7
|
-
// do not lock (see the proper-lockfile TODO in file-storage.ts). Deterministic
|
|
8
|
-
// and collision-safe, unlike Math.random()/Date.now().
|
|
9
|
-
let tempCounter = 0;
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Atomically write `content` to `filePath`.
|
|
13
|
-
*
|
|
14
|
-
* Writes to a unique `*.tmp` sibling, fsyncs the data, then renames it into
|
|
15
|
-
* place. `rename` over an existing file is atomic on POSIX and NTFS, so a
|
|
16
|
-
* concurrent reader only ever sees the complete old file or the complete new
|
|
17
|
-
* file — never a torn/half-written one. After the rename we best-effort fsync
|
|
18
|
-
* the containing directory so the rename itself survives power loss on POSIX;
|
|
19
|
-
* that directory fsync is unsupported on win32 and is skipped there.
|
|
20
|
-
*
|
|
21
|
-
* On any failure the temp file is removed (best-effort) so a crashed write does
|
|
22
|
-
* not leave the canonical path damaged. A crash *between* the temp write and the
|
|
23
|
-
* rename leaves only an inert `*.tmp` sibling — never read (reads target
|
|
24
|
-
* canonical paths) and skipped by `.json` directory scans.
|
|
25
|
-
*
|
|
26
|
-
* `content` is `string | Uint8Array`: `FileKVStore` writes strings, while the
|
|
27
|
-
* `KvRawStorage`-backed `FileStoreDriver` writes the kernel's raw value bytes.
|
|
28
|
-
* `FileHandle.writeFile` writes either losslessly — a `Uint8Array` is written
|
|
29
|
-
* byte-for-byte, so non-ASCII JSON round-trips exactly.
|
|
30
|
-
*/
|
|
31
|
-
export async function atomicWriteFile(filePath: string, content: string | Uint8Array): Promise<void> {
|
|
32
|
-
const dir = path.dirname(filePath);
|
|
33
|
-
await fs.mkdir(dir, { recursive: true });
|
|
34
|
-
|
|
35
|
-
// Suffix ends in `.tmp` (not `.json`) so listPendingTransactions / FileKVStore.list,
|
|
36
|
-
// which filter on `.json`, never surface an in-flight temp file.
|
|
37
|
-
// NOTE: a crash between open and rename leaves an inert `*.tmp` orphan (never read,
|
|
38
|
-
// skipped by `.json` scans). No cleanup sweep exists; if a crash-looping writer ever
|
|
39
|
-
// accumulates many, add a startup sweep of stale `*.tmp` siblings.
|
|
40
|
-
const tmpPath = path.join(dir, `${path.basename(filePath)}.${process.pid}.${tempCounter++}.tmp`);
|
|
41
|
-
|
|
42
|
-
let handle: fs.FileHandle | undefined;
|
|
43
|
-
try {
|
|
44
|
-
handle = await fs.open(tmpPath, 'w');
|
|
45
|
-
await handle.writeFile(content);
|
|
46
|
-
await handle.sync();
|
|
47
|
-
await handle.close();
|
|
48
|
-
handle = undefined;
|
|
49
|
-
// NOTE: on win32, rename-over-existing can throw EPERM/EACCES/EBUSY if a
|
|
50
|
-
// concurrent reader holds the target open without FILE_SHARE_DELETE (Node
|
|
51
|
-
// readIfExists/get open→read→close in a tiny window, so it's rare). Modern
|
|
52
|
-
// libuv retries some cases; if this ever surfaces as spurious write failures
|
|
53
|
-
// under concurrent read+write on Windows, add a bounded retry loop here (the
|
|
54
|
-
// write-file-atomic package does exactly this). Conditional — the adapter is
|
|
55
|
-
// already last-writer-wins with no cross-process lock (proper-lockfile TODO
|
|
56
|
-
// in file-storage.ts), so it is not reachable under current single-writer use.
|
|
57
|
-
await fs.rename(tmpPath, filePath);
|
|
58
|
-
} catch (err) {
|
|
59
|
-
if (handle) await handle.close().catch(() => { /* best-effort */ });
|
|
60
|
-
await fs.unlink(tmpPath).catch(() => { /* best-effort: temp may not exist */ });
|
|
61
|
-
throw err;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
await fsyncDir(dir);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Best-effort fsync of a directory so a completed rename is durable. POSIX needs
|
|
69
|
-
* this; platforms that reject opening a directory for fsync throw, and we ignore
|
|
70
|
-
* that rather than failing the write.
|
|
71
|
-
*
|
|
72
|
-
* Skipped outright on win32: there `fs.open(dir, 'r')` SUCCEEDS and only the
|
|
73
|
-
* following `handle.sync()` fails with EPERM (swallowed), so the open/close pair
|
|
74
|
-
* was guaranteed wasted work — exactly half of all `fs.open` calls a write
|
|
75
|
-
* workload makes. Durability is unchanged on win32 (the sync never succeeded
|
|
76
|
-
* there); the on-disk layout is untouched.
|
|
77
|
-
*/
|
|
78
|
-
async function fsyncDir(dir: string): Promise<void> {
|
|
79
|
-
if (process.platform === 'win32') return;
|
|
80
|
-
let handle: fs.FileHandle | undefined;
|
|
81
|
-
try {
|
|
82
|
-
handle = await fs.open(dir, 'r');
|
|
83
|
-
await handle.sync();
|
|
84
|
-
} catch {
|
|
85
|
-
// Directory fsync unsupported here — nothing to do.
|
|
86
|
-
} finally {
|
|
87
|
-
if (handle) await handle.close().catch(() => { /* best-effort */ });
|
|
88
|
-
}
|
|
89
|
-
}
|