Skip to content

Commit 029661d

Browse files
authored
fix: use atomic writes for data store file operations (#12715)
* fix: use atomic writes for data store file operations * Put tmp alongside the target * Implement locking * Refactor * Wording
1 parent 799c867 commit 029661d

File tree

2 files changed

+48
-5
lines changed

2 files changed

+48
-5
lines changed

.changeset/tame-bags-remember.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Fixes a bug that caused errors in dev when editing sites with large numbers of MDX pages

packages/astro/src/content/mutable-data-store.ts

+43-5
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { contentModuleToId } from './utils.js';
99

1010
const SAVE_DEBOUNCE_MS = 500;
1111

12+
const MAX_DEPTH = 10;
13+
1214
/**
1315
* Extends the DataStore with the ability to change entries and write them to disk.
1416
* This is kept as a separate class to avoid needing node builtins at runtime, when read-only access is all that is needed.
@@ -86,7 +88,7 @@ export class MutableDataStore extends ImmutableDataStore {
8688

8789
if (this.#assetImports.size === 0) {
8890
try {
89-
await fs.writeFile(filePath, 'export default new Map();');
91+
await this.#writeFileAtomic(filePath, 'export default new Map();');
9092
} catch (err) {
9193
throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: err });
9294
}
@@ -110,7 +112,7 @@ ${imports.join('\n')}
110112
export default new Map([${exports.join(', ')}]);
111113
`;
112114
try {
113-
await fs.writeFile(filePath, code);
115+
await this.#writeFileAtomic(filePath, code);
114116
} catch (err) {
115117
throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: err });
116118
}
@@ -122,7 +124,7 @@ export default new Map([${exports.join(', ')}]);
122124

123125
if (this.#moduleImports.size === 0) {
124126
try {
125-
await fs.writeFile(filePath, 'export default new Map();');
127+
await this.#writeFileAtomic(filePath, 'export default new Map();');
126128
} catch (err) {
127129
throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: err });
128130
}
@@ -143,7 +145,7 @@ export default new Map([${exports.join(', ')}]);
143145
export default new Map([\n${lines.join(',\n')}]);
144146
`;
145147
try {
146-
await fs.writeFile(filePath, code);
148+
await this.#writeFileAtomic(filePath, code);
147149
} catch (err) {
148150
throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: err });
149151
}
@@ -190,6 +192,42 @@ export default new Map([\n${lines.join(',\n')}]);
190192
}
191193
}
192194

195+
#writing = new Set<string>();
196+
#pending = new Set<string>();
197+
198+
async #writeFileAtomic(filePath: PathLike, data: string, depth = 0) {
199+
if(depth > MAX_DEPTH) {
200+
// If we hit the max depth, we skip a write to prevent the stack from growing too large
201+
// In theory this means we may miss the latest data, but in practice this will only happen when the file is being written to very frequently
202+
// so it will be saved on the next write. This is unlikely to ever happen in practice, as the writes are debounced. It requires lots of writes to very large files.
203+
return;
204+
}
205+
const fileKey = filePath.toString();
206+
// If we are already writing this file, instead of writing now, flag it as pending and write it when we're done.
207+
if (this.#writing.has(fileKey)) {
208+
this.#pending.add(fileKey);
209+
return;
210+
}
211+
// Prevent concurrent writes to this file by flagging it as being written
212+
this.#writing.add(fileKey);
213+
214+
const tempFile = filePath instanceof URL ? new URL(`${filePath.href}.tmp`) : `${filePath}.tmp`;
215+
try {
216+
// Write it to a temporary file first and then move it to prevent partial reads.
217+
await fs.writeFile(tempFile, data);
218+
await fs.rename(tempFile, filePath);
219+
} finally {
220+
// We're done writing. Unflag the file and check if there are any pending writes for this file.
221+
this.#writing.delete(fileKey);
222+
// If there are pending writes, we need to write again to ensure we flush the latest data.
223+
if (this.#pending.has(fileKey)) {
224+
this.#pending.delete(fileKey);
225+
// Call ourself recursively to write the file again
226+
await this.#writeFileAtomic(filePath, data, depth + 1);
227+
}
228+
}
229+
}
230+
193231
scopedStore(collectionName: string): DataStore {
194232
return {
195233
get: <TData extends Record<string, unknown> = Record<string, unknown>>(key: string) =>
@@ -298,7 +336,7 @@ export default new Map([\n${lines.join(',\n')}]);
298336
return;
299337
}
300338
try {
301-
await fs.writeFile(filePath, this.toString());
339+
await this.#writeFileAtomic(filePath, this.toString());
302340
this.#file = filePath;
303341
this.#dirty = false;
304342
} catch (err) {

0 commit comments

Comments
 (0)