Skip to content

Commit 87eb8e7

Browse files
authored
Merge pull request #519 from Baseflow/fix/491_metaa_data_loss
Fix JsonCacheInfoRepository metadata loss on quick exit (#491)
2 parents 94bf4fc + 6292965 commit 87eb8e7

5 files changed

Lines changed: 158 additions & 37 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
devtools_options.yaml
1313
.fvmrc
1414
.fvm/
15+
AGENTS.md
1516

1617
# Environment files
1718
ios/Flutter/Dart-Defines.xcconfig

flutter_cache_manager/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
## [Unreleased]
22

3+
* Fixes `JsonCacheInfoRepository` losing metadata when the app exits within 3 seconds of a cache change by writing through promptly with serialized, atomic file writes ([#491](https://github.com/Baseflow/flutter_cache_manager/issues/491))
34
* Modernizes GitHub Actions CI (combined quality job, pinned Flutter 3.44.4, Dependabot for actions)
45
* Updates example Android project to AGP 9.0.1 / Gradle 9.1 / Kotlin 2.3.20
56
* Migrates example Android app to built-in Kotlin

flutter_cache_manager/lib/src/storage/cache_info_repositories/json_cache_info_repository.dart

Lines changed: 98 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import 'dart:async';
21
import 'dart:convert';
32
import 'dart:io';
43
import 'dart:math';
54

65
import 'package:collection/collection.dart';
6+
import 'package:file/file.dart' as pf;
77
import 'package:flutter/widgets.dart';
88
import 'package:flutter_cache_manager/src/storage/cache_info_repositories/cache_info_repository.dart';
99
import 'package:flutter_cache_manager/src/storage/cache_info_repositories/helper_methods.dart';
@@ -31,6 +31,9 @@ class JsonCacheInfoRepository extends CacheInfoRepository
3131
final Map<String, CacheObject> _cacheObjects = {};
3232
final Map<int, Map<String, dynamic>> _jsonCache = {};
3333

34+
bool _dirty = false;
35+
Future<void> _writeQueue = Future.value();
36+
3437
@override
3538
Future<bool> open() async {
3639
if (!shouldOpenOnNewConnection()) {
@@ -77,7 +80,7 @@ class JsonCacheInfoRepository extends CacheInfoRepository
7780
if (cacheObject.id == null) {
7881
throw ArgumentError('Updated objects should have an existing id.');
7982
}
80-
_put(cacheObject, setTouchedToNow);
83+
await _put(cacheObject, setTouchedToNow);
8184
return 1;
8285
}
8386

@@ -104,32 +107,34 @@ class JsonCacheInfoRepository extends CacheInfoRepository
104107

105108
@override
106109
Future<int> delete(int id) async {
107-
final cacheObject = _cacheObjects.values.firstWhereOrNull(
108-
(element) => element.id == id,
109-
);
110-
if (cacheObject == null) {
110+
if (!_removeById(id)) {
111111
return 0;
112112
}
113-
_remove(cacheObject);
113+
await _schedulePersist();
114114
return 1;
115115
}
116116

117117
@override
118118
Future<int> deleteAll(Iterable<int> ids) async {
119119
var deleted = 0;
120120
for (final id in ids) {
121-
deleted += await delete(id);
121+
if (_removeById(id)) deleted++;
122+
}
123+
if (deleted > 0) {
124+
await _schedulePersist();
122125
}
123126
return deleted;
124127
}
125128

126129
@override
127130
Future<bool> close() async {
128-
if (!shouldClose()) {
129-
return false;
131+
final shouldCloseRepo = shouldClose();
132+
if (_dirty) {
133+
await _schedulePersist();
134+
} else {
135+
await _writeQueue;
130136
}
131-
await _saveFile();
132-
return true;
137+
return shouldCloseRepo;
133138
}
134139

135140
Future<void> _readFile(File file) async {
@@ -162,33 +167,85 @@ class JsonCacheInfoRepository extends CacheInfoRepository
162167
}
163168
}
164169

165-
CacheObject _put(CacheObject cacheObject, bool setTouchedToNow) {
170+
Future<CacheObject> _put(
171+
CacheObject cacheObject,
172+
bool setTouchedToNow,
173+
) async {
166174
final map = cacheObject.toMap(setTouchedToNow: setTouchedToNow);
167175
_jsonCache[cacheObject.id!] = map;
168176
final updatedCacheObject = CacheObject.fromMap(map);
169177
_cacheObjects[cacheObject.key] = updatedCacheObject;
170-
_cacheUpdated();
178+
await _schedulePersist();
171179
return updatedCacheObject;
172180
}
173181

174-
void _remove(CacheObject cacheObject) {
182+
bool _removeById(int id) {
183+
final cacheObject = _cacheObjects.values.firstWhereOrNull(
184+
(element) => element.id == id,
185+
);
186+
if (cacheObject == null) {
187+
return false;
188+
}
175189
_cacheObjects.remove(cacheObject.key);
176190
_jsonCache.remove(cacheObject.id);
177-
_cacheUpdated();
191+
return true;
178192
}
179193

180-
void _cacheUpdated() {
181-
timer?.cancel();
182-
timer = Timer(timerDuration, _saveFile);
194+
/// Queues a write of the current cache info.
195+
///
196+
/// The returned future completes when the changes are on disk. Changes made
197+
/// while a write is in progress are written by that same write or the one
198+
/// directly after it, so a burst of changes doesn't cause a write per change.
199+
Future<void> _schedulePersist() {
200+
_dirty = true;
201+
_writeQueue = _writeQueue.then((_) => _flushIfDirty());
202+
return _writeQueue;
183203
}
184204

185-
Timer? timer;
186-
Duration timerDuration = const Duration(seconds: 3);
205+
Future<void> _flushIfDirty() async {
206+
while (_dirty) {
207+
_dirty = false;
208+
try {
209+
await _saveFile();
210+
} on Object catch (e, stacktrace) {
211+
// Keep the changes dirty so a later change or close retries the write,
212+
// but stop here to avoid retrying a persistent failure in a loop.
213+
_dirty = true;
214+
FlutterError.reportError(
215+
FlutterErrorDetails(
216+
exception: e,
217+
stack: stacktrace,
218+
library: 'flutter cache manager',
219+
context: ErrorDescription(
220+
'Thrown when writing the file containing cache info. '
221+
'The cache info could not be persisted and may be lost when the '
222+
'app is closed.',
223+
),
224+
),
225+
);
226+
return;
227+
}
228+
}
229+
}
187230

188231
Future<void> _saveFile() async {
189-
timer?.cancel();
190-
timer = null;
191-
await _file!.writeAsString(jsonEncode(_jsonCache.values.toList()));
232+
final file = await _getFile();
233+
final content = jsonEncode(_jsonCache.values.toList());
234+
final tempFile = _createSiblingFile('${file.path}.tmp');
235+
await tempFile.writeAsString(content, flush: true);
236+
await tempFile.rename(file.path);
237+
}
238+
239+
/// Creates a file next to [_file] on the same file system.
240+
///
241+
/// [_file] can be backed by an alternative [pf.FileSystem], in which case a
242+
/// plain [File] would resolve against the local file system instead.
243+
File _createSiblingFile(String siblingPath) {
244+
final file = _file!;
245+
if (file is pf.File) {
246+
return file.fileSystem.file(siblingPath);
247+
}
248+
return File(siblingPath);
192249
}
193250

194251
@override
@@ -197,6 +254,10 @@ class JsonCacheInfoRepository extends CacheInfoRepository
197254
if (await file.exists()) {
198255
await file.delete();
199256
}
257+
final tempFile = _createSiblingFile('${file.path}.tmp');
258+
if (await tempFile.exists()) {
259+
await tempFile.delete();
260+
}
200261
}
201262

202263
@override
@@ -206,18 +267,20 @@ class JsonCacheInfoRepository extends CacheInfoRepository
206267
}
207268

208269
Future<File> _getFile() async {
209-
if (_file == null) {
210-
if (path != null) {
211-
directory = File(path!).parent;
212-
} else {
213-
directory ??= await getApplicationSupportDirectory();
214-
}
215-
await directory!.create(recursive: true);
216-
if (path == null || !path!.endsWith('.json')) {
217-
path = join(directory!.path, '$databaseName.json');
218-
}
219-
_file = File(path!);
270+
if (_file != null) {
271+
return _file!;
272+
}
273+
274+
if (path != null) {
275+
directory = File(path!).parent;
276+
} else {
277+
directory ??= await getApplicationSupportDirectory();
278+
}
279+
await directory!.create(recursive: true);
280+
if (path == null || !path!.endsWith('.json')) {
281+
path = join(directory!.path, '$databaseName.json');
220282
}
283+
_file = File(path!);
221284
return _file!;
222285
}
223286
}

flutter_cache_manager/test/helpers/json_repo_helpers.dart

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,17 @@ class JsonRepoHelpers {
1818
static Future<JsonCacheInfoRepository> createRepository({
1919
bool open = true,
2020
}) async {
21-
var directory = await _createDirectory();
22-
var file = await _createFile(directory);
21+
var file = await createDatabaseFile();
2322
var repository = JsonCacheInfoRepository.withFile(file);
2423
if (open) await repository.open();
2524
return repository;
2625
}
2726

27+
static Future<File> createDatabaseFile() async {
28+
var directory = await _createDirectory();
29+
return _createFile(directory);
30+
}
31+
2832
static Future<Directory> _createDirectory() async {
2933
var testDir = await MemoryFileSystem().systemTempDirectory.createTemp(
3034
'testFolder',

flutter_cache_manager/test/repositories/json_file_repository_test.dart

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import 'dart:convert';
12
import 'dart:io';
23

34
import 'package:collection/collection.dart';
5+
import 'package:flutter/foundation.dart';
46
import 'package:flutter_cache_manager/src/storage/cache_info_repositories/json_cache_info_repository.dart';
57
import 'package:flutter_cache_manager/src/storage/cache_object.dart';
68
import 'package:flutter_test/flutter_test.dart';
@@ -230,6 +232,56 @@ void main() {
230232
JsonRepoHelpers.startCacheObjects.length + 1,
231233
);
232234
});
235+
236+
test('Changes are persisted when the mutating future completes', () async {
237+
final file = await JsonRepoHelpers.createDatabaseFile();
238+
final repo = JsonCacheInfoRepository.withFile(file);
239+
await repo.open();
240+
await repo.insert(JsonRepoHelpers.extraCacheObject);
241+
242+
// New instance reads from disk without closing the first repository.
243+
final repo2 = JsonCacheInfoRepository.withFile(file);
244+
await repo2.open();
245+
final allObjects = await repo2.getAllObjects();
246+
expect(allObjects.length, JsonRepoHelpers.startCacheObjects.length + 1);
247+
});
248+
249+
test('Persist does not leave a temp file', () async {
250+
final file = await JsonRepoHelpers.createDatabaseFile();
251+
final repo = JsonCacheInfoRepository.withFile(file);
252+
await repo.open();
253+
await repo.insert(JsonRepoHelpers.extraCacheObject);
254+
255+
final tempFile = file.fileSystem.file('${file.path}.tmp');
256+
expect(await tempFile.exists(), false);
257+
expect(await file.exists(), true);
258+
expect(jsonDecode(await file.readAsString()), isA<List<dynamic>>());
259+
});
260+
261+
test('A failing write is reported and retried on close', () async {
262+
final file = await JsonRepoHelpers.createDatabaseFile();
263+
final repo = JsonCacheInfoRepository.withFile(file);
264+
await repo.open();
265+
266+
// Writing is impossible while the containing directory is gone.
267+
await file.parent.delete(recursive: true);
268+
269+
final errors = <FlutterErrorDetails>[];
270+
final originalOnError = FlutterError.onError;
271+
FlutterError.onError = errors.add;
272+
await repo.insert(JsonRepoHelpers.extraCacheObject);
273+
FlutterError.onError = originalOnError;
274+
275+
expect(errors, hasLength(1));
276+
277+
await file.parent.create(recursive: true);
278+
expect(await repo.close(), true);
279+
280+
final repo2 = JsonCacheInfoRepository.withFile(file);
281+
await repo2.open();
282+
final allObjects = await repo2.getAllObjects();
283+
expect(allObjects.length, JsonRepoHelpers.startCacheObjects.length + 1);
284+
});
233285
});
234286
}
235287

0 commit comments

Comments
 (0)