Skip to content

Commit 166477e

Browse files
robhoganfacebook-github-bot
authored andcommitted
Serialize tree structure in file map cache (#1006)
Summary: Pull Request resolved: #1006 Serialise `metro-file-map`'s `TreeFS` by cloning the tree, instead of converting to and from a flat `Map`. - Cache `fileSystemData` is now fully cross-platform (it contains no path separators). - Serialised data is now a closer match with the internal representation. The new structure is as fast to write, faster to read, and smaller. Changelog: ``` - **[Performance]**: Improved startup speed via a new file map cache format. ``` Reviewed By: motiz88 Differential Revision: D46598820 fbshipit-source-id: c6ab0cf33e28e7dcb2e7c3896676b2104b8808b5
1 parent 9b9711c commit 166477e

File tree

4 files changed

+110
-73
lines changed

4 files changed

+110
-73
lines changed

packages/metro-file-map/src/__tests__/index-test.js

Lines changed: 51 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import crypto from 'crypto';
1212
import * as path from 'path';
13+
import {serialize} from 'v8';
1314

1415
jest.useRealTimers();
1516

@@ -153,7 +154,13 @@ jest.mock('fs', () => ({
153154
}));
154155

155156
const object = data => Object.assign(Object.create(null), data);
156-
const createMap = obj => new Map(Object.keys(obj).map(key => [key, obj[key]]));
157+
const createMap = obj => new Map(Object.entries(obj));
158+
const assertFileSystemEqual = (fileSystem: FileSystem, fileData: FileData) => {
159+
expect(fileSystem.getDifference(fileData)).toEqual({
160+
changedFiles: new Map(),
161+
removedFiles: new Set(),
162+
});
163+
};
157164

158165
// Jest toEqual does not match Map instances from different contexts
159166
// This normalizes them for the uses cases in this test
@@ -406,11 +413,12 @@ describe('HasteMap', () => {
406413
mocksPattern: '__mocks__',
407414
});
408415

409-
await hasteMap.build();
416+
const {fileSystem} = await hasteMap.build();
410417

411418
expect(cacheContent.clocks).toEqual(mockClocks);
412419

413-
expect(cacheContent.files).toEqual(
420+
assertFileSystemEqual(
421+
fileSystem,
414422
createMap({
415423
[path.join('fruits', 'Banana.js')]: [
416424
'Banana',
@@ -581,7 +589,7 @@ describe('HasteMap', () => {
581589

582590
await hasteMap.build();
583591

584-
expect(cacheContent.files).toEqual(
592+
expect(
585593
createMap({
586594
[path.join('fruits', 'Banana.js')]: [
587595
'Banana',
@@ -693,11 +701,14 @@ describe('HasteMap', () => {
693701
roots: [...defaultConfig.roots, path.join('/', 'project', 'video')],
694702
});
695703

696-
await hasteMap.build();
704+
const {fileSystem} = await hasteMap.build();
697705
const data = cacheContent;
698706

699707
expect(data.map.get('IRequireAVideo')).toBeDefined();
700-
expect(data.files.get(path.join('video', 'video.mp4'))).toBeDefined();
708+
expect(fileSystem.linkStats(path.join('video', 'video.mp4'))).toEqual({
709+
fileType: 'f',
710+
modifiedTime: 32,
711+
});
701712
expect(fs.readFileSync.mock.calls.map(call => call[0])).not.toContain(
702713
path.join('video', 'video.mp4'),
703714
);
@@ -716,15 +727,15 @@ describe('HasteMap', () => {
716727
retainAllFiles: true,
717728
});
718729

719-
await hasteMap.build();
730+
const {fileSystem} = await hasteMap.build();
720731

721732
// Expect the node module to be part of files but make sure it wasn't
722733
// read.
723734
expect(
724-
cacheContent.files.get(
735+
fileSystem.linkStats(
725736
path.join('fruits', 'node_modules', 'fbjs', 'fbjs.js'),
726737
),
727-
).toEqual(['', 32, 42, 0, [], null, 0]);
738+
).toEqual({fileType: 'f', modifiedTime: 32});
728739

729740
expect(cacheContent.map.get('fbjs')).not.toBeDefined();
730741

@@ -835,9 +846,10 @@ describe('HasteMap', () => {
835846
const Blackberry = require("Blackberry");
836847
`;
837848

838-
await new HasteMap(defaultConfig).build();
849+
const {fileSystem} = await new HasteMap(defaultConfig).build();
839850

840-
expect(cacheContent.files).toEqual(
851+
assertFileSystemEqual(
852+
fileSystem,
841853
createMap({
842854
[path.join('fruits', 'Strawberry.android.js')]: [
843855
'Strawberry',
@@ -915,13 +927,22 @@ describe('HasteMap', () => {
915927
// Expect no fs reads, because there have been no changes
916928
expect(fs.readFileSync.mock.calls.length).toBe(0);
917929
expect(deepNormalize(data.clocks)).toEqual(mockClocks);
918-
expect(deepNormalize(data.files)).toEqual(initialData.files);
930+
expect(serialize(data.fileSystem)).toEqual(
931+
serialize(initialData.fileSystem),
932+
);
919933
expect(deepNormalize(data.map)).toEqual(initialData.map);
920934
});
921935

922936
it('only does minimal file system access when files change', async () => {
923937
// Run with a cold cache initially
924-
await new HasteMap(defaultConfig).build();
938+
const {fileSystem: initialFileSystem} = await new HasteMap(
939+
defaultConfig,
940+
).build();
941+
942+
expect(
943+
initialFileSystem.getDependencies(path.join('fruits', 'Banana.js')),
944+
).toEqual(['Strawberry']);
945+
925946
const initialData = cacheContent;
926947
fs.readFileSync.mockClear();
927948
expect(mockCacheManager.read).toHaveBeenCalledTimes(1);
@@ -939,7 +960,7 @@ describe('HasteMap', () => {
939960
vegetables: 'c:fake-clock:2',
940961
});
941962

942-
await new HasteMap(defaultConfig).build();
963+
const {fileSystem} = await new HasteMap(defaultConfig).build();
943964
const data = cacheContent;
944965

945966
expect(mockCacheManager.read).toHaveBeenCalledTimes(2);
@@ -950,18 +971,9 @@ describe('HasteMap', () => {
950971

951972
expect(deepNormalize(data.clocks)).toEqual(mockClocks);
952973

953-
const files = new Map(initialData.files);
954-
files.set(path.join('fruits', 'Banana.js'), [
955-
'Banana',
956-
32,
957-
42,
958-
1,
959-
'Kiwi',
960-
null,
961-
0,
962-
]);
963-
964-
expect(deepNormalize(data.files)).toEqual(files);
974+
expect(
975+
fileSystem.getDependencies(path.join('fruits', 'Banana.js')),
976+
).toEqual(['Kiwi']);
965977

966978
const map = new Map(initialData.map);
967979
expect(deepNormalize(data.map)).toEqual(map);
@@ -984,16 +996,13 @@ describe('HasteMap', () => {
984996
vegetables: 'c:fake-clock:2',
985997
});
986998

987-
await new HasteMap(defaultConfig).build();
988-
const data = cacheContent;
999+
const {fileSystem} = await new HasteMap(defaultConfig).build();
9891000

990-
const files = new Map(initialData.files);
991-
files.delete(path.join('fruits', 'Banana.js'));
992-
expect(deepNormalize(data.files)).toEqual(files);
1001+
expect(fileSystem.exists(path.join('fruits', 'Banana.js'))).toEqual(false);
9931002

9941003
const map = new Map(initialData.map);
9951004
map.delete('Banana');
996-
expect(deepNormalize(data.map)).toEqual(map);
1005+
expect(deepNormalize(cacheContent.map)).toEqual(map);
9971006
});
9981007

9991008
it('correctly handles platform-specific file additions', async () => {
@@ -1258,11 +1267,11 @@ describe('HasteMap', () => {
12581267
};
12591268
});
12601269

1261-
await new HasteMap(defaultConfig).build();
1262-
expect(cacheContent.files.size).toBe(5);
1270+
const {fileSystem} = await new HasteMap(defaultConfig).build();
1271+
expect(fileSystem.getDifference(new Map()).removedFiles.size).toBe(5);
12631272

12641273
// Ensure this file is not part of the file list.
1265-
expect(cacheContent.files.get(invalidFilePath)).toBe(undefined);
1274+
expect(fileSystem.exists(invalidFilePath)).toBe(false);
12661275
});
12671276

12681277
it('distributes work across workers', async () => {
@@ -1362,11 +1371,13 @@ describe('HasteMap', () => {
13621371
});
13631372
});
13641373

1365-
await new HasteMap(defaultConfig).build();
1374+
const {fileSystem} = await new HasteMap(defaultConfig).build();
1375+
13661376
expect(watchman).toBeCalled();
13671377
expect(node).toBeCalled();
13681378

1369-
expect(cacheContent.files).toEqual(
1379+
assertFileSystemEqual(
1380+
fileSystem,
13701381
createMap({
13711382
[path.join('fruits', 'Banana.js')]: [
13721383
'Banana',
@@ -1399,12 +1410,13 @@ describe('HasteMap', () => {
13991410
});
14001411
});
14011412

1402-
await new HasteMap(defaultConfig).build();
1413+
const {fileSystem} = await new HasteMap(defaultConfig).build();
14031414

14041415
expect(watchman).toBeCalled();
14051416
expect(node).toBeCalled();
14061417

1407-
expect(cacheContent.files).toEqual(
1418+
assertFileSystemEqual(
1419+
fileSystem,
14081420
createMap({
14091421
[path.join('fruits', 'Banana.js')]: [
14101422
'Banana',

packages/metro-file-map/src/flow-types.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export type CacheData = $ReadOnly<{
5050
map: RawModuleMap['map'],
5151
mocks: RawModuleMap['mocks'],
5252
duplicates: RawModuleMap['duplicates'],
53-
files: FileData,
53+
fileSystemData: mixed,
5454
}>;
5555

5656
export type CacheDelta = $ReadOnly<{
@@ -177,7 +177,7 @@ export interface FileSystem {
177177
};
178178
getModuleName(file: Path): ?string;
179179
getRealPath(file: Path): ?string;
180-
getSerializableSnapshot(): FileData;
180+
getSerializableSnapshot(): CacheData['fileSystemData'];
181181
getSha1(file: Path): ?string;
182182

183183
/**

packages/metro-file-map/src/index.js

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ export type {
143143
// This should be bumped whenever a code change to `metro-file-map` itself
144144
// would cause a change to the cache data structure and/or content (for a given
145145
// filesystem state and build parameters).
146-
const CACHE_BREAKER = '4';
146+
const CACHE_BREAKER = '5';
147147

148148
const CHANGE_INTERVAL = 30;
149149
const NODE_MODULES = path.sep + 'node_modules' + path.sep;
@@ -341,30 +341,29 @@ export default class HasteMap extends EventEmitter {
341341
}
342342
if (!initialData) {
343343
debug('Not using a cache');
344-
initialData = {
345-
files: new Map(),
346-
map: new Map(),
347-
duplicates: new Map(),
348-
clocks: new Map(),
349-
mocks: new Map(),
350-
};
351344
} else {
352-
debug(
353-
'Cache loaded (%d file(s), %d clock(s))',
354-
initialData.files.size,
355-
initialData.clocks.size,
356-
);
345+
debug('Cache loaded (%d clock(s))', initialData.clocks.size);
357346
}
358347

359348
const rootDir = this._options.rootDir;
360-
const fileData = initialData.files;
361349
this._startupPerfLogger?.point('constructFileSystem_start');
362-
const fileSystem = new TreeFS({
363-
files: fileData,
364-
rootDir,
365-
});
350+
const fileSystem =
351+
initialData != null
352+
? TreeFS.fromDeserializedSnapshot({
353+
rootDir,
354+
// Typed `mixed` because we've read this from an external
355+
// source. It'd be too expensive to validate at runtime, so
356+
// trust our cache manager that this is correct.
357+
// $FlowIgnore
358+
fileSystemData: initialData.fileSystemData,
359+
})
360+
: new TreeFS({rootDir});
366361
this._startupPerfLogger?.point('constructFileSystem_end');
367-
const {map, mocks, duplicates} = initialData;
362+
const {map, mocks, duplicates} = initialData ?? {
363+
map: new Map(),
364+
mocks: new Map(),
365+
duplicates: new Map(),
366+
};
368367
const rawModuleMap: RawModuleMap = {
369368
duplicates,
370369
map,
@@ -374,7 +373,7 @@ export default class HasteMap extends EventEmitter {
374373

375374
const fileDelta = await this._buildFileDelta({
376375
fileSystem,
377-
clocks: initialData.clocks,
376+
clocks: initialData?.clocks ?? new Map(),
378377
});
379378

380379
await this._applyFileDelta(fileSystem, rawModuleMap, fileDelta);
@@ -386,7 +385,11 @@ export default class HasteMap extends EventEmitter {
386385
fileDelta.changedFiles,
387386
fileDelta.removedFiles,
388387
);
389-
debug('Finished mapping %d files.', fileData.size);
388+
debug(
389+
'Finished mapping files (%d changes, %d removed).',
390+
fileDelta.changedFiles.size,
391+
fileDelta.removedFiles.size,
392+
);
390393

391394
await this._watch(fileSystem, rawModuleMap);
392395
return {
@@ -802,7 +805,7 @@ export default class HasteMap extends EventEmitter {
802805
const {map, duplicates, mocks} = deepCloneRawModuleMap(moduleMap);
803806
await this._cacheManager.write(
804807
{
805-
files: fileSystem.getSerializableSnapshot(),
808+
fileSystemData: fileSystem.getSerializableSnapshot(),
806809
map,
807810
clocks: new Map(clocks),
808811
duplicates,

packages/metro-file-map/src/lib/TreeFS.js

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010

1111
import type {
12+
CacheData,
1213
FileData,
1314
FileMetaData,
1415
FileStats,
@@ -35,20 +36,29 @@ type MixedNode = FileNode | DirectoryNode;
3536
export default class TreeFS implements MutableFileSystem {
3637
+#cachedNormalSymlinkTarkets: WeakMap<FileNode, Path> = new WeakMap();
3738
+#rootDir: Path;
38-
+#rootNode: DirectoryNode = new Map();
39+
#rootNode: DirectoryNode = new Map();
3940

40-
constructor({rootDir, files}: {rootDir: Path, files: FileData}) {
41+
constructor({rootDir, files}: {rootDir: Path, files?: FileData}) {
4142
this.#rootDir = rootDir;
42-
this.bulkAddOrModify(files);
43+
if (files != null) {
44+
this.bulkAddOrModify(files);
45+
}
4346
}
4447

45-
getSerializableSnapshot(): FileData {
46-
return new Map(
47-
Array.from(
48-
this._metadataIterator(this.#rootNode, {includeSymlinks: true}),
49-
({normalPath, metadata}) => [normalPath, [...metadata]],
50-
),
51-
);
48+
getSerializableSnapshot(): CacheData['fileSystemData'] {
49+
return this._cloneTree(this.#rootNode);
50+
}
51+
52+
static fromDeserializedSnapshot({
53+
rootDir,
54+
fileSystemData,
55+
}: {
56+
rootDir: string,
57+
fileSystemData: DirectoryNode,
58+
}): TreeFS {
59+
const tfs = new TreeFS({rootDir});
60+
tfs.#rootNode = fileSystemData;
61+
return tfs;
5262
}
5363

5464
getModuleName(mixedPath: Path): ?string {
@@ -548,4 +558,16 @@ export default class TreeFS implements MutableFileSystem {
548558
}
549559
return result.node;
550560
}
561+
562+
_cloneTree(root: DirectoryNode): DirectoryNode {
563+
const clone: DirectoryNode = new Map();
564+
for (const [name, node] of root) {
565+
if (node instanceof Map) {
566+
clone.set(name, this._cloneTree(node));
567+
} else {
568+
clone.set(name, [...node]);
569+
}
570+
}
571+
return clone;
572+
}
551573
}

0 commit comments

Comments
 (0)