Skip to content

Commit a264732

Browse files
robhoganfacebook-github-bot
authored andcommitted
metro-cache: Cleanup FileStore caches, deprecate AutoCleanFileStore (#1570)
Summary: Pull Request resolved: #1570 Modernise `FileStore` and `AutoCleanFileStore` tests and implementations, using built-in recursive directory scans, `memfs` over `metro-memory-fs`, and `#`-private over `_`-private. ## Deprecating `AutoCleanFileStore` Deprecate `AutoCleanFileStore` because the current implementation isn't used by default and has a number of flaws that should prevent us recommending it: - It re-scans all files every interval, even though it's in a position to know when files are read and written by the cache. - It will delete recently used entries just because they weren't recently written. - It's fully synchronous (event-loop blocking) The current mechanism could easily exist outside Metro, e.g., as a simple cron of: ``` find /cache/root -mtime +3 -exec rm {} \; ``` Typically, caches are written to OS "temp" directories whose lifetimes are managed by the OS. A better *Metro* cache solution would be an LRU cache, but this could be fully implemented in userland and we haven't had any requests to include one in core. Changelog: ``` - **[Deprecated]**: metro-cache: Deprecate AutoCleanFileStore ``` Reviewed By: huntie Differential Revision: D81051149 fbshipit-source-id: 2e0d44829a6ecaac7bf93c88b4170cadb5e019bf
1 parent 8e5b6ee commit a264732

File tree

9 files changed

+180
-100
lines changed

9 files changed

+180
-100
lines changed

docs/Caching.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ The main option for configuring the Metro cache is [`cacheStores`](./Configurati
2020
Metro provides a number of built-in cache store implementations for use with the [`cacheStores`](./Configuration.md#cachestores) config option:
2121

2222
* **`FileStore({root: string})`** will store cache entries as files under the directory specified by `root`.
23-
* **`AutoCleanFileStore()`** is a `FileStore` that periodically cleans up old entries. It accepts the same options as `FileStore` plus the following:
23+
* **`AutoCleanFileStore()`** <div class="label deprecated">Deprecated</div> is a `FileStore` that periodically cleans up old entries. It accepts the same options as `FileStore` plus the following:
2424
* **`options.intervalMs: number`** is the time in milliseconds between cleanup attempts. Defaults to 10 minutes.
2525
* **`options.cleanupThresholdMs: number`** is the minimum time in milliseconds since the last modification of an entry before it can be deleted. Defaults to 3 days.
2626
* **`HttpStore(options)`** is a bare-bones remote cache client that reads (`GET`) and writes (`PUT`) compressed cache artifacts over HTTP or HTTPS.

packages/metro-cache/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"metro-core": "0.83.1"
2424
},
2525
"devDependencies": {
26-
"metro-memory-fs": "*"
26+
"memfs": "^4.38.2"
2727
},
2828
"license": "MIT",
2929
"engines": {

packages/metro-cache/src/stores/AutoCleanFileStore.js

Lines changed: 42 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7+
* @flow strict-local
78
* @format
8-
* @flow
99
*/
1010

1111
import type {Options} from './FileStore';
@@ -14,90 +14,72 @@ import FileStore from './FileStore';
1414
import fs from 'fs';
1515
import path from 'path';
1616

17-
type CleanOptions = {
17+
type CleanOptions = $ReadOnly<{
1818
...Options,
1919
intervalMs?: number,
2020
cleanupThresholdMs?: number,
21-
...
22-
};
23-
24-
type FileList = {
25-
path: string,
26-
stats: fs.Stats,
27-
...
28-
};
29-
30-
// List all files in a directory in Node.js recursively in a synchronous fashion
31-
const walkSync = function (
32-
dir: string,
33-
filelist: Array<FileList>,
34-
): Array<FileList> {
35-
const files = fs.readdirSync(dir);
36-
filelist = filelist || [];
37-
files.forEach(function (file) {
38-
const fullPath = path.join(dir, file);
39-
const stats = fs.statSync(fullPath);
40-
if (stats.isDirectory()) {
41-
filelist = walkSync(fullPath + path.sep, filelist);
42-
} else {
43-
filelist.push({path: fullPath, stats});
44-
}
45-
});
46-
return filelist;
47-
};
48-
49-
function get<T>(property: ?T, defaultValue: T): T {
50-
if (property == null) {
51-
return defaultValue;
52-
}
53-
54-
return property;
55-
}
21+
}>;
5622

5723
/**
58-
* A FileStore that cleans itself up in a given interval
24+
* A FileStore that, at a given interval, stats the content of the cache root
25+
* and deletes any file last modified a set threshold in the past.
26+
*
27+
* @deprecated This is not efficiently implemented and may cause significant
28+
* redundant I/O when caches are large. Prefer your own cleanup scripts, or a
29+
* custom Metro cache that uses watches, hooks get/set, and/or implements LRU.
5930
*/
6031
export default class AutoCleanFileStore<T> extends FileStore<T> {
61-
_intervalMs: number;
62-
_cleanupThresholdMs: number;
63-
_root: string;
32+
+#intervalMs: number;
33+
+#cleanupThresholdMs: number;
34+
+#root: string;
6435

6536
constructor(opts: CleanOptions) {
6637
super({root: opts.root});
6738

68-
this._intervalMs = get(opts.intervalMs, 10 * 60 * 1000); // 10 minutes
69-
this._cleanupThresholdMs = get(
70-
opts.cleanupThresholdMs,
71-
3 * 24 * 60 * 60 * 1000, // 3 days
72-
);
39+
this.#root = opts.root;
40+
this.#intervalMs = opts.intervalMs ?? 10 * 60 * 1000; // 10 minutes
41+
this.#cleanupThresholdMs =
42+
opts.cleanupThresholdMs ?? 3 * 24 * 60 * 60 * 1000; // 3 days
7343

74-
this._scheduleCleanup();
44+
this.#scheduleCleanup();
7545
}
7646

77-
_scheduleCleanup() {
78-
// $FlowFixMe[method-unbinding] added when improving typing for this parameters
79-
setTimeout(this._doCleanup.bind(this), this._intervalMs);
47+
#scheduleCleanup() {
48+
setTimeout(() => this.#doCleanup(), this.#intervalMs);
8049
}
8150

82-
_doCleanup() {
83-
const files = walkSync(this._root, []);
51+
#doCleanup() {
52+
const dirents = fs.readdirSync(this.#root, {
53+
recursive: true,
54+
withFileTypes: true,
55+
});
8456

8557
let warned = false;
86-
files.forEach(file => {
87-
if (file.stats.mtimeMs < Date.now() - this._cleanupThresholdMs) {
58+
const minModifiedTime = Date.now() - this.#cleanupThresholdMs;
59+
dirents
60+
.filter(dirent => dirent.isFile())
61+
.forEach(dirent => {
62+
const absolutePath = path.join(
63+
// $FlowFixMe[prop-missing] - dirent.parentPath added in Node 20.12
64+
dirent.parentPath,
65+
dirent.name.toString(),
66+
);
8867
try {
89-
fs.unlinkSync(file.path);
68+
if (fs.statSync(absolutePath).mtimeMs < minModifiedTime) {
69+
fs.unlinkSync(absolutePath);
70+
}
9071
} catch (e) {
9172
if (!warned) {
9273
console.warn(
93-
'Problem cleaning up cache for ' + file.path + ': ' + e.message,
74+
'Problem cleaning up cache for ' +
75+
absolutePath +
76+
': ' +
77+
e.message,
9478
);
9579
warned = true;
9680
}
9781
}
98-
}
99-
});
100-
101-
this._scheduleCleanup();
82+
});
83+
this.#scheduleCleanup();
10284
}
10385
}

packages/metro-cache/src/stores/FileStore.js

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @format
87
* @flow
8+
* @format
99
*/
1010

1111
import fs from 'fs';
@@ -14,20 +14,20 @@ import path from 'path';
1414
const NULL_BYTE = 0x00;
1515
const NULL_BYTE_BUFFER = Buffer.from([NULL_BYTE]);
1616

17-
export type Options = {
17+
export type Options = $ReadOnly<{
1818
root: string,
19-
};
19+
}>;
2020

2121
export default class FileStore<T> {
22-
_root: string;
22+
+#root: string;
2323

2424
constructor(options: Options) {
25-
this._root = options.root;
25+
this.#root = options.root;
2626
}
2727

2828
async get(key: Buffer): Promise<?T> {
2929
try {
30-
const data = await fs.promises.readFile(this._getFilePath(key));
30+
const data = await fs.promises.readFile(this.#getFilePath(key));
3131

3232
if (data[0] === NULL_BYTE) {
3333
return (data.slice(1): any);
@@ -44,20 +44,20 @@ export default class FileStore<T> {
4444
}
4545

4646
async set(key: Buffer, value: T): Promise<void> {
47-
const filePath = this._getFilePath(key);
47+
const filePath = this.#getFilePath(key);
4848
try {
49-
await this._set(filePath, value);
49+
await this.#set(filePath, value);
5050
} catch (err) {
5151
if (err.code === 'ENOENT') {
5252
fs.mkdirSync(path.dirname(filePath), {recursive: true});
53-
await this._set(filePath, value);
53+
await this.#set(filePath, value);
5454
} else {
5555
throw err;
5656
}
5757
}
5858
}
5959

60-
async _set(filePath: string, value: T): Promise<void> {
60+
async #set(filePath: string, value: T): Promise<void> {
6161
let content;
6262
if (value instanceof Buffer) {
6363
content = Buffer.concat([NULL_BYTE_BUFFER, value]);
@@ -68,20 +68,20 @@ export default class FileStore<T> {
6868
}
6969

7070
clear() {
71-
this._removeDirs();
71+
this.#removeDirs();
7272
}
7373

74-
_getFilePath(key: Buffer): string {
74+
#getFilePath(key: Buffer): string {
7575
return path.join(
76-
this._root,
76+
this.#root,
7777
key.slice(0, 1).toString('hex'),
7878
key.slice(1).toString('hex'),
7979
);
8080
}
8181

82-
_removeDirs() {
82+
#removeDirs() {
8383
for (let i = 0; i < 256; i++) {
84-
fs.rmSync(path.join(this._root, ('0' + i.toString(16)).slice(-2)), {
84+
fs.rmSync(path.join(this.#root, ('0' + i.toString(16)).slice(-2)), {
8585
force: true,
8686
recursive: true,
8787
});

packages/metro-cache/src/stores/__tests__/AutoCleanFileStore-test.js

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* @oncall react_native
1010
*/
1111

12-
'use strict';
12+
import {memfs} from 'memfs';
1313

1414
describe('AutoCleanFileStore', () => {
1515
let AutoCleanFileStore;
@@ -19,22 +19,23 @@ describe('AutoCleanFileStore', () => {
1919
jest
2020
.resetModules()
2121
.resetAllMocks()
22-
.mock('fs', () => new (require('metro-memory-fs'))());
23-
22+
.mock('fs', () => memfs().fs);
2423
AutoCleanFileStore = require('../AutoCleanFileStore').default;
2524
fs = require('fs');
25+
jest.spyOn(fs, 'statSync');
2626
jest.spyOn(fs, 'unlinkSync');
2727
});
2828

2929
test('sets and writes into the cache', async () => {
30-
// $FlowFixMe[underconstrained-implicit-instantiation]
31-
const fileStore = new AutoCleanFileStore({
30+
const fileStore = new AutoCleanFileStore<mixed>({
3231
root: '/root',
3332
intervalMs: 49,
34-
cleanupThresholdMs: 0,
33+
cleanupThresholdMs: 90,
3534
});
3635
const cache = Buffer.from([0xfa, 0xce, 0xb0, 0x0c]);
3736

37+
expect(fs.statSync).toHaveBeenCalledTimes(0);
38+
3839
await fileStore.set(cache, {foo: 42});
3940
expect(await fileStore.get(cache)).toEqual({foo: 42});
4041

@@ -43,17 +44,28 @@ describe('AutoCleanFileStore', () => {
4344

4445
expect(await fileStore.get(cache)).toEqual({foo: 42});
4546

47+
// And there should have been no cleanup
48+
expect(fs.statSync).not.toHaveBeenCalled();
49+
4650
// Run to 50ms so that we've exceeded the 49ms cleanup interval
4751
jest.advanceTimersByTime(20);
4852

49-
// mtime doesn't work very well in in-memory-store, so we couldn't test that
50-
// functionality
53+
expect(fs.statSync).toHaveBeenCalledTimes(1);
54+
55+
// At 50ms we should have checked the file, but it's still fresh enough
56+
expect(await fileStore.get(cache)).toEqual({foo: 42});
57+
expect(fs.unlinkSync).not.toHaveBeenCalled();
58+
59+
jest.advanceTimersByTime(50);
60+
61+
// After another 50ms, we should have checked the file again and deleted it
62+
expect(fs.statSync).toHaveBeenCalledTimes(2);
63+
expect(fs.unlinkSync).toHaveBeenCalledTimes(1);
5164
expect(await fileStore.get(cache)).toEqual(null);
5265
});
5366

5467
test('returns null when reading a non-existing file', async () => {
55-
// $FlowFixMe[underconstrained-implicit-instantiation]
56-
const fileStore = new AutoCleanFileStore({root: '/root'});
68+
const fileStore = new AutoCleanFileStore<mixed>({root: '/root'});
5769
const cache = Buffer.from([0xfa, 0xce, 0xb0, 0x0c]);
5870

5971
expect(await fileStore.get(cache)).toEqual(null);

packages/metro-cache/src/stores/__tests__/FileStore-test.js

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,12 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7+
* @flow strict-local
78
* @format
89
* @oncall react_native
910
*/
1011

11-
'use strict';
12-
13-
const {dirname} = require('path');
12+
import {memfs} from 'memfs';
1413

1514
describe('FileStore', () => {
1615
let FileStore;
@@ -20,39 +19,38 @@ describe('FileStore', () => {
2019
jest
2120
.resetModules()
2221
.resetAllMocks()
23-
.mock('fs', () => new (require('metro-memory-fs'))());
22+
.mock('fs', () => memfs().fs);
2423

2524
FileStore = require('../FileStore').default;
2625
fs = require('fs');
2726
jest.spyOn(fs, 'unlinkSync');
2827
});
2928

3029
test('sets and writes into the cache', async () => {
31-
const fileStore = new FileStore({root: '/root'});
30+
const fileStore = new FileStore<mixed>({root: '/root'});
3231
const cache = Buffer.from([0xfa, 0xce, 0xb0, 0x0c]);
3332

3433
await fileStore.set(cache, {foo: 42});
3534
expect(await fileStore.get(cache)).toEqual({foo: 42});
3635
});
3736

3837
test('returns null when reading a non-existing file', async () => {
39-
const fileStore = new FileStore({root: '/root'});
38+
const fileStore = new FileStore<mixed>({root: '/root'});
4039
const cache = Buffer.from([0xfa, 0xce, 0xb0, 0x0c]);
4140

4241
expect(await fileStore.get(cache)).toEqual(null);
4342
});
4443

4544
test('returns null when reading a empty file', async () => {
46-
const fileStore = new FileStore({root: '/root'});
45+
const fileStore = new FileStore<mixed>({root: '/root'});
4746
const cache = Buffer.from([0xfa, 0xce, 0xb0, 0x0c]);
48-
const filePath = fileStore._getFilePath(cache);
49-
fs.mkdirSync(dirname(filePath), {recursive: true});
50-
fs.writeFileSync(filePath, '');
47+
jest.spyOn(fs.promises, 'readFile').mockImplementation(async () => '');
5148
expect(await fileStore.get(cache)).toEqual(null);
49+
expect(fs.promises.readFile).toHaveBeenCalledWith(expect.any(String));
5250
});
5351

5452
test('writes into cache if folder is missing', async () => {
55-
const fileStore = new FileStore({root: '/root'});
53+
const fileStore = new FileStore<mixed>({root: '/root'});
5654
const cache = Buffer.from([0xfa, 0xce, 0xb0, 0x0c]);
5755
const data = Buffer.from([0xca, 0xc4, 0xe5]);
5856

@@ -62,7 +60,7 @@ describe('FileStore', () => {
6260
});
6361

6462
test('reads and writes binary data', async () => {
65-
const fileStore = new FileStore({root: '/root'});
63+
const fileStore = new FileStore<mixed>({root: '/root'});
6664
const cache = Buffer.from([0xfa, 0xce, 0xb0, 0x0c]);
6765
const data = Buffer.from([0xca, 0xc4, 0xe5]);
6866

0 commit comments

Comments
 (0)