Skip to content

Commit 2f2eca0

Browse files
authored
Refactor CachingAssetReader to FilesystemCache. (#3869)
* Refactor `CachingAssetReader` to `FilesystemCache`. * Address review comments.
1 parent 36936be commit 2f2eca0

31 files changed

+489
-349
lines changed

build/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- Refactor `MultiPackageAssetReader` to internal `AssetFinder`.
99
- Add internal `Filesystem` that backs `AssetReader` and `AssetWriter`
1010
implementations.
11+
- Refactor `CachingAssetReader` to `FilesystemCache`.
1112

1213
## 2.4.2
1314

build/lib/src/builder/build_step_impl.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import '../resource/resource.dart';
2121
import '../state/asset_finder.dart';
2222
import '../state/asset_path_provider.dart';
2323
import '../state/filesystem.dart';
24+
import '../state/filesystem_cache.dart';
2425
import '../state/input_tracker.dart';
2526
import '../state/reader_state.dart';
2627
import 'build_step.dart';
@@ -83,6 +84,19 @@ class BuildStepImpl implements BuildStep, AssetReaderState {
8384
_stageTracker = stageTracker ?? NoOpStageTracker.instance,
8485
_reportUnusedAssets = reportUnusedAssets;
8586

87+
@override
88+
BuildStepImpl copyWith({FilesystemCache? cache}) => BuildStepImpl(
89+
inputId,
90+
allowedOutputs,
91+
_reader.copyWith(cache: cache),
92+
_writer,
93+
_resolvers,
94+
_resourceManager,
95+
_resolvePackageConfig,
96+
stageTracker: _stageTracker,
97+
reportUnusedAssets: _reportUnusedAssets,
98+
);
99+
86100
@override
87101
Filesystem get filesystem => _reader.filesystem;
88102

build/lib/src/internal.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ library;
99
export 'state/asset_finder.dart';
1010
export 'state/asset_path_provider.dart';
1111
export 'state/filesystem.dart';
12+
export 'state/filesystem_cache.dart';
1213
export 'state/input_tracker.dart';
1314
export 'state/reader_state.dart';

build/lib/src/state/filesystem.dart

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,39 @@ import 'package:pool/pool.dart';
1010

1111
import '../asset/id.dart';
1212
import 'asset_path_provider.dart';
13+
import 'filesystem_cache.dart';
1314

1415
/// The filesystem the build is running on.
1516
///
1617
/// Methods behave as the `dart:io` methods with the same names, with some
17-
/// exceptions noted.
18+
/// exceptions noted in the docs.
19+
///
20+
/// Some methods cache, all uses of the cache are noted in the docs.
21+
///
22+
/// The cache might be a [PassthroughFilesystemCache] in which case it has no
23+
/// effect.
24+
///
25+
/// TODO(davidmorgan): extend caching to sync methods, deletes, writes.
1826
abstract interface class Filesystem {
27+
FilesystemCache get cache;
28+
29+
/// Returns a new instance with optionally updated [cache].
30+
Filesystem copyWith({FilesystemCache? cache});
31+
32+
/// Whether the file exists.
33+
///
34+
/// Uses [cache].
1935
Future<bool> exists(AssetId id);
2036

37+
/// Reads a file as a string.
38+
///
39+
/// Uses [cache]. For `utf8`, the `String` is cached; for any other encoding
40+
/// the bytes are cached but the conversion runs on every read.
2141
Future<String> readAsString(AssetId id, {Encoding encoding = utf8});
42+
43+
/// Reads a file as bytes.
44+
///
45+
/// Uses [cache].
2246
Future<Uint8List> readAsBytes(AssetId id);
2347

2448
/// Deletes a file.
@@ -62,23 +86,48 @@ abstract interface class Filesystem {
6286

6387
/// A filesystem using [assetPathProvider] to map to the `dart:io` filesystem.
6488
class IoFilesystem implements Filesystem {
89+
@override
90+
final FilesystemCache cache;
91+
6592
final AssetPathProvider assetPathProvider;
6693

6794
/// Pool for async file operations.
6895
final _pool = Pool(32);
6996

70-
IoFilesystem({required this.assetPathProvider});
97+
IoFilesystem({
98+
required this.assetPathProvider,
99+
this.cache = const PassthroughFilesystemCache(),
100+
});
101+
102+
@override
103+
IoFilesystem copyWith({FilesystemCache? cache}) => IoFilesystem(
104+
assetPathProvider: assetPathProvider,
105+
cache: cache ?? this.cache,
106+
);
71107

72108
@override
73-
Future<bool> exists(AssetId id) => _pool.withResource(_fileFor(id).exists);
109+
Future<bool> exists(AssetId id) =>
110+
cache.exists(id, ifAbsent: () => _pool.withResource(_fileFor(id).exists));
74111

75112
@override
76-
Future<Uint8List> readAsBytes(AssetId id) =>
77-
_pool.withResource(_fileFor(id).readAsBytes);
113+
Future<Uint8List> readAsBytes(AssetId id) => cache.readAsBytes(
114+
id,
115+
ifAbsent: () => _pool.withResource(_fileFor(id).readAsBytes),
116+
);
78117

79118
@override
80-
Future<String> readAsString(AssetId id, {Encoding encoding = utf8}) =>
81-
_pool.withResource(_fileFor(id).readAsString);
119+
Future<String> readAsString(AssetId id, {Encoding encoding = utf8}) async {
120+
// The cache only directly supports utf8, for other encodings get the
121+
// bytes via the cache then convert.
122+
if (encoding == utf8) {
123+
return cache.readAsString(
124+
id,
125+
ifAbsent: () => _pool.withResource(_fileFor(id).readAsString),
126+
);
127+
} else {
128+
return encoding.decode(await readAsBytes(id));
129+
}
130+
}
82131

83132
@override
84133
void deleteSync(AssetId id) {
@@ -146,17 +195,42 @@ class IoFilesystem implements Filesystem {
146195

147196
/// An in-memory [Filesystem].
148197
class InMemoryFilesystem implements Filesystem {
149-
final Map<AssetId, List<int>> assets = {};
198+
@override
199+
FilesystemCache cache;
200+
201+
final Map<AssetId, List<int>> assets;
202+
203+
InMemoryFilesystem({FilesystemCache? cache})
204+
: cache = cache ?? const PassthroughFilesystemCache(),
205+
assets = {};
206+
207+
InMemoryFilesystem._({required this.cache, required this.assets});
150208

151209
@override
152-
Future<bool> exists(AssetId id) async => assets.containsKey(id);
210+
InMemoryFilesystem copyWith({FilesystemCache? cache}) =>
211+
InMemoryFilesystem._(assets: assets, cache: cache ?? this.cache);
153212

154213
@override
155-
Future<Uint8List> readAsBytes(AssetId id) async => assets[id] as Uint8List;
214+
Future<bool> exists(AssetId id) async =>
215+
cache.exists(id, ifAbsent: () async => assets.containsKey(id));
156216

157217
@override
158-
Future<String> readAsString(AssetId id, {Encoding encoding = utf8}) async =>
159-
encoding.decode(assets[id] as Uint8List);
218+
Future<Uint8List> readAsBytes(AssetId id) async =>
219+
cache.readAsBytes(id, ifAbsent: () async => assets[id] as Uint8List);
220+
221+
@override
222+
Future<String> readAsString(AssetId id, {Encoding encoding = utf8}) async {
223+
// The cache only directly supports utf8, for other encodings get the
224+
// bytes via the cache then convert.
225+
if (encoding == utf8) {
226+
return cache.readAsString(
227+
id,
228+
ifAbsent: () async => encoding.decode(assets[id]!),
229+
);
230+
} else {
231+
return encoding.decode(await readAsBytes(id));
232+
}
233+
}
160234

161235
@override
162236
Future<void> delete(AssetId id) async {
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:async';
6+
import 'dart:convert';
7+
import 'dart:typed_data';
8+
9+
import '../asset/id.dart';
10+
import 'lru_cache.dart';
11+
12+
/// Cache for file existence and contents.
13+
///
14+
/// TODO(davidmorgan): benchmark, optimize the caching strategy.
15+
abstract interface class FilesystemCache {
16+
/// Clears all [ids] from all caches.
17+
///
18+
/// Waits for any pending reads to complete first.
19+
Future<void> invalidate(Iterable<AssetId> ids);
20+
21+
/// Whether [id] exists.
22+
///
23+
/// Returns a cached result if available, or caches and returns `ifAbsent()`.
24+
Future<bool> exists(AssetId id, {required Future<bool> Function() ifAbsent});
25+
26+
/// Reads [id] as bytes.
27+
///
28+
/// Returns a cached result if available, or caches and returns `ifAbsent()`.
29+
Future<Uint8List> readAsBytes(
30+
AssetId id, {
31+
required Future<Uint8List> Function() ifAbsent,
32+
});
33+
34+
/// Reads [id] as a `String`.
35+
///
36+
/// Returns a cached result if available, or caches and returns `ifAbsent()`.
37+
///
38+
/// The encoding used is always `utf8`. For other encodings, use
39+
/// [readAsBytes].
40+
Future<String> readAsString(
41+
AssetId id, {
42+
required Future<String> Function() ifAbsent,
43+
});
44+
}
45+
46+
/// [FilesystemCache] that always reads from the underlying source.
47+
class PassthroughFilesystemCache implements FilesystemCache {
48+
const PassthroughFilesystemCache();
49+
50+
@override
51+
Future<void> invalidate(Iterable<AssetId> ids) async {}
52+
53+
@override
54+
Future<bool> exists(
55+
AssetId id, {
56+
required Future<bool> Function() ifAbsent,
57+
}) => ifAbsent();
58+
59+
@override
60+
Future<Uint8List> readAsBytes(
61+
AssetId id, {
62+
required Future<Uint8List> Function() ifAbsent,
63+
}) => ifAbsent();
64+
65+
@override
66+
Future<String> readAsString(
67+
AssetId id, {
68+
required Future<String> Function() ifAbsent,
69+
}) => ifAbsent();
70+
}
71+
72+
/// [FilesystemCache] that stores data in memory.
73+
class InMemoryFilesystemCache implements FilesystemCache {
74+
/// Cached results of [readAsBytes].
75+
final _bytesContentCache = LruCache<AssetId, Uint8List>(
76+
1024 * 1024,
77+
1024 * 1024 * 512,
78+
(value) => value.lengthInBytes,
79+
);
80+
81+
/// Pending [readAsBytes] operations.
82+
final _pendingBytesContentCache = <AssetId, Future<Uint8List>>{};
83+
84+
/// Cached results of [exists].
85+
///
86+
/// Don't bother using an LRU cache for this since it's just booleans.
87+
final _canReadCache = <AssetId, Future<bool>>{};
88+
89+
/// Cached results of [readAsString].
90+
///
91+
/// These are computed and stored lazily using [readAsBytes].
92+
///
93+
/// Only files read with [utf8] encoding (the default) will ever be cached.
94+
final _stringContentCache = LruCache<AssetId, String>(
95+
1024 * 1024,
96+
1024 * 1024 * 512,
97+
(value) => value.length,
98+
);
99+
100+
/// Pending `readAsString` operations.
101+
final _pendingStringContentCache = <AssetId, Future<String>>{};
102+
103+
@override
104+
Future<void> invalidate(Iterable<AssetId> ids) async {
105+
// First finish all pending operations, as they will write to the cache.
106+
for (var id in ids) {
107+
await _canReadCache.remove(id);
108+
await _pendingBytesContentCache.remove(id);
109+
await _pendingStringContentCache.remove(id);
110+
}
111+
for (var id in ids) {
112+
_bytesContentCache.remove(id);
113+
_stringContentCache.remove(id);
114+
}
115+
}
116+
117+
@override
118+
Future<bool> exists(
119+
AssetId id, {
120+
required Future<bool> Function() ifAbsent,
121+
}) => _canReadCache.putIfAbsent(id, ifAbsent);
122+
123+
@override
124+
Future<Uint8List> readAsBytes(
125+
AssetId id, {
126+
required Future<Uint8List> Function() ifAbsent,
127+
}) {
128+
var cached = _bytesContentCache[id];
129+
if (cached != null) return Future.value(cached);
130+
131+
return _pendingBytesContentCache.putIfAbsent(id, () async {
132+
final result = await ifAbsent();
133+
_bytesContentCache[id] = result;
134+
unawaited(_pendingBytesContentCache.remove(id));
135+
return result;
136+
});
137+
}
138+
139+
@override
140+
Future<String> readAsString(
141+
AssetId id, {
142+
required Future<String> Function() ifAbsent,
143+
}) async {
144+
var cached = _stringContentCache[id];
145+
if (cached != null) return cached;
146+
147+
return _pendingStringContentCache.putIfAbsent(id, () async {
148+
final result = await ifAbsent();
149+
_stringContentCache[id] = result;
150+
unawaited(_pendingStringContentCache.remove(id));
151+
return result;
152+
});
153+
}
154+
}

build/lib/src/state/reader_state.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,17 @@ import '../asset/reader.dart';
66
import 'asset_finder.dart';
77
import 'asset_path_provider.dart';
88
import 'filesystem.dart';
9+
import 'filesystem_cache.dart';
910
import 'input_tracker.dart';
1011

1112
/// Provides access to the state backing an [AssetReader].
1213
extension AssetReaderStateExtension on AssetReader {
14+
/// Returns a new instance with optionally updated [cache].
15+
AssetReader copyWith({FilesystemCache? cache}) {
16+
_requireIsAssetReaderState();
17+
return (this as AssetReaderState).copyWith(cache: cache);
18+
}
19+
1320
Filesystem get filesystem {
1421
_requireIsAssetReaderState();
1522
return (this as AssetReaderState).filesystem;
@@ -52,6 +59,9 @@ extension AssetReaderStateExtension on AssetReader {
5259

5360
/// The state backing an [AssetReader].
5461
abstract interface class AssetReaderState {
62+
/// Returns a new instance with optionally updated [cache].
63+
AssetReader copyWith({FilesystemCache? cache});
64+
5565
/// The [Filesystem] that this reader reads from.
5666
///
5767
/// Warning: this access to the filesystem bypasses reader functionality

0 commit comments

Comments
 (0)