Skip to content

Commit 1587b1c

Browse files
fix: catch unhandled errors when updating cache-hit metadata (#535) (#542)
* fix: catch unhandled errors when updating cache-hit metadata (#535) * fix: address review feedback on cache error handling and cleanup * fix: clean up orphaned file on putFile failure and polish tests * fix: revert memory cache on putFile database failure
1 parent 6670e36 commit 1587b1c

6 files changed

Lines changed: 299 additions & 28 deletions

File tree

‎flutter_cache_manager/CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## [3.4.6] - 2026-10-23
2+
3+
* Catches unhandled asynchronous errors when updating cache-hit metadata and during scheduled cleanup in `CacheStore` ([#535](https://github.com/Baseflow/flutter_cache_manager/issues/535)).
4+
* Treats failed metadata reads as a cache miss with a warning log so downloads can fall back to the network, and cleans up downloaded files if persisting fails.
5+
16
## [3.4.5] - 2026-09-18
27

38
* Prevents image resize decoding from completing more than once and propagates decoding errors ([#400](https://github.com/Baseflow/flutter_cache_manager/issues/400)).

‎flutter_cache_manager/lib/src/cache_store.dart‎

Lines changed: 81 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,19 @@ class CacheStore {
5555
}
5656

5757
Future<void> putFile(CacheObject cacheObject) async {
58+
final previousCacheObject = _memCache[cacheObject.key];
5859
_memCache[cacheObject.key] = cacheObject;
59-
final dynamic out = await _updateCacheDataInDatabase(cacheObject);
60+
final dynamic out;
61+
try {
62+
out = await _updateCacheDataInDatabase(cacheObject);
63+
} catch (e) {
64+
if (previousCacheObject != null) {
65+
_memCache[cacheObject.key] = previousCacheObject;
66+
} else {
67+
_memCache.remove(cacheObject.key);
68+
}
69+
rethrow;
70+
}
6071

6172
// We update the cache object with the id if returned by the repository
6273
if (out is CacheObject && out.id != null) {
@@ -74,25 +85,43 @@ class CacheStore {
7485
}
7586
}
7687
if (!_futureCache.containsKey(key)) {
77-
final completer = Completer<CacheObject?>();
78-
_getCacheDataFromDatabase(key).then((cacheObject) async {
79-
if (cacheObject?.id != null && !await _fileExists(cacheObject)) {
88+
late final Future<CacheObject?> future;
89+
future = _loadCacheData(key, () => future);
90+
_futureCache[key] = future;
91+
}
92+
return _futureCache[key];
93+
}
94+
95+
Future<CacheObject?> _loadCacheData(
96+
String key,
97+
Future<CacheObject?> Function() currentFuture,
98+
) async {
99+
try {
100+
var cacheObject = await _getCacheDataFromDatabase(key);
101+
if (cacheObject?.id != null && !await _fileExists(cacheObject)) {
102+
try {
80103
final provider = await _cacheInfoRepository;
81104
await provider.delete(cacheObject!.id!);
82-
cacheObject = null;
105+
} catch (e, s) {
106+
cacheLogger.log(
107+
'CacheStore: Failed to delete non-existing file metadata for $key with error:\n$e\n$s',
108+
CacheManagerLogLevel.warning,
109+
);
83110
}
111+
cacheObject = null;
112+
}
84113

85-
if (cacheObject == null) {
86-
_memCache.remove(key);
87-
} else {
88-
_memCache[key] = cacheObject;
89-
}
90-
completer.complete(cacheObject);
114+
if (cacheObject == null) {
115+
_memCache.remove(key);
116+
} else {
117+
_memCache[key] = cacheObject;
118+
}
119+
return cacheObject;
120+
} finally {
121+
if (identical(_futureCache[key], currentFuture())) {
91122
_futureCache.remove(key);
92-
});
93-
_futureCache[key] = completer.future;
123+
}
94124
}
95-
return _futureCache[key];
96125
}
97126

98127
Future<FileInfo?> getFileFromMemory(String key) async {
@@ -118,13 +147,32 @@ class CacheStore {
118147
}
119148

120149
Future<CacheObject?> _getCacheDataFromDatabase(String key) async {
121-
final provider = await _cacheInfoRepository;
122-
final data = await provider.get(key);
123-
if (await _fileExists(data)) {
124-
_updateCacheDataInDatabase(data!);
150+
try {
151+
final provider = await _cacheInfoRepository;
152+
final data = await provider.get(key);
153+
if (await _fileExists(data)) {
154+
unawaited(
155+
_updateCacheDataInDatabase(data!).catchError((
156+
Object e,
157+
StackTrace s,
158+
) {
159+
cacheLogger.log(
160+
'CacheStore: Failed to update cache metadata for $key with error:\n$e\n$s',
161+
CacheManagerLogLevel.warning,
162+
);
163+
return null;
164+
}),
165+
);
166+
}
167+
_scheduleCleanup();
168+
return data;
169+
} catch (e, s) {
170+
cacheLogger.log(
171+
'CacheStore: Failed to read cache metadata for $key with error:\n$e\n$s',
172+
CacheManagerLogLevel.warning,
173+
);
174+
return null;
125175
}
126-
_scheduleCleanup();
127-
return data;
128176
}
129177

130178
void _scheduleCleanup() {
@@ -133,7 +181,14 @@ class CacheStore {
133181
}
134182
_scheduledCleanup = Timer(cleanupRunMinInterval, () {
135183
_scheduledCleanup = null;
136-
_cleanupCache();
184+
unawaited(
185+
_cleanupCache().catchError((Object e, StackTrace s) {
186+
cacheLogger.log(
187+
'CacheStore: Failed to cleanup cache with error:\n$e\n$s',
188+
CacheManagerLogLevel.warning,
189+
);
190+
}),
191+
);
137192
});
138193
}
139194

@@ -147,15 +202,17 @@ class CacheStore {
147202
final provider = await _cacheInfoRepository;
148203

149204
final overCapacity = await provider.getObjectsOverCapacity(_capacity);
205+
final futures = <Future>[];
150206
for (final cacheObject in overCapacity) {
151-
_removeCachedFile(cacheObject, toRemove);
207+
futures.add(_removeCachedFile(cacheObject, toRemove));
152208
}
153209

154210
final oldObjects = await provider.getOldObjects(_maxAge);
155211
for (final cacheObject in oldObjects) {
156-
_removeCachedFile(cacheObject, toRemove);
212+
futures.add(_removeCachedFile(cacheObject, toRemove));
157213
}
158214

215+
await Future.wait(futures);
159216
await provider.deleteAll(toRemove);
160217
}
161218

@@ -192,9 +249,7 @@ class CacheStore {
192249
if (_memCache.containsKey(cacheObject.key)) {
193250
_memCache.remove(cacheObject.key);
194251
}
195-
if (_futureCache.containsKey(cacheObject.key)) {
196-
await _futureCache.remove(cacheObject.key);
197-
}
252+
_futureCache.remove(cacheObject.key);
198253
final file = await fileSystem.createFile(cacheObject.relativePath);
199254

200255
if (file.existsSync()) {

‎flutter_cache_manager/lib/src/web/web_helper.dart‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,14 @@ class WebHelper {
156156
newCacheObject = newCacheObject.copyWith(length: savedBytes);
157157
}
158158

159-
await _store.putFile(newCacheObject);
159+
try {
160+
await _store.putFile(newCacheObject);
161+
} catch (e) {
162+
if (statusCodesNewFile.contains(response.statusCode)) {
163+
await _removeOldFile(newCacheObject.relativePath);
164+
}
165+
rethrow;
166+
}
160167
if (newCacheObject.relativePath != oldCacheObject.relativePath) {
161168
await _removeOldFile(oldCacheObject.relativePath);
162169
}

‎flutter_cache_manager/pubspec.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: flutter_cache_manager
22
description: Generic cache manager for flutter. Saves web files on the storages of the device and saves the cache info using sqflite.
3-
version: 3.4.5
3+
version: 3.4.6
44
homepage: https://github.com/Baseflow/flutter_cache_manager
55
topics:
66
- cache

‎flutter_cache_manager/test/cache_store_test.dart‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import 'dart:async';
2+
13
import 'package:clock/clock.dart';
24
import 'package:file/file.dart';
35
import 'package:file/memory.dart';
46
import 'package:flutter_cache_manager/src/cache_store.dart';
7+
import 'package:flutter_cache_manager/src/logger.dart';
58
import 'package:flutter_cache_manager/src/storage/cache_object.dart';
69
import 'package:flutter_test/flutter_test.dart';
710
import 'package:mockito/mockito.dart';
@@ -54,6 +57,81 @@ void main() {
5457
expect(await store.getFile('baseflow.com/test.png'), isNotNull);
5558
});
5659

60+
test(
61+
'Store should return FileInfo and handle error when database update fails on cache hit',
62+
() async {
63+
final testLogger = setUpTestLogger();
64+
65+
var config = createTestConfig();
66+
await config.returnsFile(fileName);
67+
config.returnsCacheObject(
68+
fileUrl,
69+
fileName,
70+
DateTime.now(),
71+
id: fileId,
72+
);
73+
when(
74+
config.mockRepo.updateOrInsert(any),
75+
).thenAnswer((_) => Future.error(Exception('Database update failed')));
76+
77+
var store = CacheStore(config);
78+
79+
final fileInfo = await store.getFile(fileUrl);
80+
expect(fileInfo, isNotNull);
81+
82+
await untilCalled(config.mockRepo.updateOrInsert(any));
83+
verify(config.mockRepo.updateOrInsert(any)).called(1);
84+
await testLogger.waitForLog('Failed to update cache metadata');
85+
},
86+
);
87+
88+
test(
89+
'Store should treat failed database read as cache miss and clean up pending cache',
90+
() async {
91+
final testLogger = setUpTestLogger();
92+
93+
var config = createTestConfig();
94+
when(
95+
config.mockRepo.get(any),
96+
).thenAnswer((_) => Future.error(Exception('Database read failed')));
97+
98+
var store = CacheStore(config);
99+
100+
final result1 = await store.retrieveCacheData('failed-key');
101+
expect(result1, isNull);
102+
103+
final result2 = await store.retrieveCacheData('failed-key');
104+
expect(result2, isNull);
105+
106+
verify(config.mockRepo.get(any)).called(2);
107+
await testLogger.waitForLog('Failed to read cache metadata');
108+
},
109+
);
110+
111+
test(
112+
'Store cleanup error should be handled without unhandled async error',
113+
() async {
114+
final testLogger = setUpTestLogger();
115+
116+
var config = createTestConfig();
117+
var store = CacheStore(config);
118+
store.cleanupRunMinInterval = const Duration(milliseconds: 1);
119+
await config.returnsFile(fileName);
120+
config.returnsCacheObject(fileUrl, fileName, validTill, id: fileId);
121+
122+
when(
123+
config.mockRepo.getObjectsOverCapacity(any),
124+
).thenAnswer((_) => Future.error(Exception('Cleanup failure')));
125+
126+
expect(await store.getFile(fileUrl), isNotNull);
127+
128+
await untilCalled(config.mockRepo.getObjectsOverCapacity(any));
129+
verify(config.mockRepo.getObjectsOverCapacity(any)).called(1);
130+
131+
await testLogger.waitForLog('Failed to cleanup cache');
132+
},
133+
);
134+
57135
test('Store should return null if file is not cached', () async {
58136
var config = createTestConfig();
59137
await config.returnsFile(fileName);
@@ -211,6 +289,54 @@ void main() {
211289
expect(result!.id, isNotNull);
212290
},
213291
);
292+
293+
test(
294+
'putFile restores or removes memory cache entry when updateOrInsert fails',
295+
() async {
296+
var config = createTestConfig();
297+
var store = CacheStore(config);
298+
299+
var initialObject = CacheObject(
300+
'baseflow.com/test.png',
301+
relativePath: 'testimage_old.png',
302+
validTill: clock.now().add(const Duration(days: 7)),
303+
);
304+
var newObject = CacheObject(
305+
'baseflow.com/test.png',
306+
relativePath: 'testimage_new.png',
307+
validTill: clock.now().add(const Duration(days: 7)),
308+
);
309+
310+
// Case 1: Nothing was in memory cache before, putFile fails
311+
when(
312+
config.mockRepo.updateOrInsert(newObject),
313+
).thenAnswer((_) => Future.error(Exception('DB error')));
314+
315+
await expectLater(
316+
() => store.putFile(newObject),
317+
throwsA(isA<Exception>()),
318+
);
319+
expect(store.memoryCacheContainsKey(newObject.key), isFalse);
320+
321+
// Case 2: initialObject succeeded, then updating to newObject fails
322+
when(
323+
config.mockRepo.updateOrInsert(initialObject),
324+
).thenAnswer((_) => Future.value(initialObject));
325+
await store.putFile(initialObject);
326+
expect(store.memoryCacheContainsKey(initialObject.key), isTrue);
327+
328+
await expectLater(
329+
() => store.putFile(newObject),
330+
throwsA(isA<Exception>()),
331+
);
332+
// Should have restored initialObject, not left pointing at newObject
333+
expect(store.memoryCacheContainsKey(initialObject.key), isTrue);
334+
expect(
335+
(await store.getFileFromMemory(initialObject.key))?.file.path,
336+
contains('testimage_old.png'),
337+
);
338+
},
339+
);
214340
});
215341

216342
group('Removing files in store', () {
@@ -464,3 +590,37 @@ Future<Directory> createDir() async {
464590
final fileSystem = MemoryFileSystem();
465591
return fileSystem.systemTempDirectory.createTemp('test');
466592
}
593+
594+
class TestCacheLogger extends CacheLogger {
595+
final logs = <String>[];
596+
Completer<String>? _nextLogCompleter;
597+
598+
Future<String> waitForLog([Pattern? pattern]) async {
599+
while (true) {
600+
if (pattern != null) {
601+
final existing = logs.where((l) => l.contains(pattern)).firstOrNull;
602+
if (existing != null) return existing;
603+
} else if (logs.isNotEmpty) {
604+
return logs.first;
605+
}
606+
_nextLogCompleter ??= Completer<String>();
607+
await _nextLogCompleter!.future;
608+
}
609+
}
610+
611+
@override
612+
void log(String message, CacheManagerLogLevel level) {
613+
logs.add(message);
614+
final c = _nextLogCompleter;
615+
_nextLogCompleter = null;
616+
c?.complete(message);
617+
}
618+
}
619+
620+
TestCacheLogger setUpTestLogger() {
621+
final originalLogger = cacheLogger;
622+
final testLogger = TestCacheLogger();
623+
cacheLogger = testLogger;
624+
addTearDown(() => cacheLogger = originalLogger);
625+
return testLogger;
626+
}

0 commit comments

Comments
 (0)