diff --git a/flutter_cache_manager/CHANGELOG.md b/flutter_cache_manager/CHANGELOG.md index 9513fb80..d1e96100 100644 --- a/flutter_cache_manager/CHANGELOG.md +++ b/flutter_cache_manager/CHANGELOG.md @@ -1,3 +1,8 @@ +## [3.4.6] - 2026-10-23 + +* 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)). +* 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. + ## [3.4.5] - 2026-09-18 * Prevents image resize decoding from completing more than once and propagates decoding errors ([#400](https://github.com/Baseflow/flutter_cache_manager/issues/400)). diff --git a/flutter_cache_manager/lib/src/cache_store.dart b/flutter_cache_manager/lib/src/cache_store.dart index 44ffbfeb..eaf4d189 100644 --- a/flutter_cache_manager/lib/src/cache_store.dart +++ b/flutter_cache_manager/lib/src/cache_store.dart @@ -55,8 +55,19 @@ class CacheStore { } Future putFile(CacheObject cacheObject) async { + final previousCacheObject = _memCache[cacheObject.key]; _memCache[cacheObject.key] = cacheObject; - final dynamic out = await _updateCacheDataInDatabase(cacheObject); + final dynamic out; + try { + out = await _updateCacheDataInDatabase(cacheObject); + } catch (e) { + if (previousCacheObject != null) { + _memCache[cacheObject.key] = previousCacheObject; + } else { + _memCache.remove(cacheObject.key); + } + rethrow; + } // We update the cache object with the id if returned by the repository if (out is CacheObject && out.id != null) { @@ -74,25 +85,43 @@ class CacheStore { } } if (!_futureCache.containsKey(key)) { - final completer = Completer(); - _getCacheDataFromDatabase(key).then((cacheObject) async { - if (cacheObject?.id != null && !await _fileExists(cacheObject)) { + late final Future future; + future = _loadCacheData(key, () => future); + _futureCache[key] = future; + } + return _futureCache[key]; + } + + Future _loadCacheData( + String key, + Future Function() currentFuture, + ) async { + try { + var cacheObject = await _getCacheDataFromDatabase(key); + if (cacheObject?.id != null && !await _fileExists(cacheObject)) { + try { final provider = await _cacheInfoRepository; await provider.delete(cacheObject!.id!); - cacheObject = null; + } catch (e, s) { + cacheLogger.log( + 'CacheStore: Failed to delete non-existing file metadata for $key with error:\n$e\n$s', + CacheManagerLogLevel.warning, + ); } + cacheObject = null; + } - if (cacheObject == null) { - _memCache.remove(key); - } else { - _memCache[key] = cacheObject; - } - completer.complete(cacheObject); + if (cacheObject == null) { + _memCache.remove(key); + } else { + _memCache[key] = cacheObject; + } + return cacheObject; + } finally { + if (identical(_futureCache[key], currentFuture())) { _futureCache.remove(key); - }); - _futureCache[key] = completer.future; + } } - return _futureCache[key]; } Future getFileFromMemory(String key) async { @@ -118,13 +147,32 @@ class CacheStore { } Future _getCacheDataFromDatabase(String key) async { - final provider = await _cacheInfoRepository; - final data = await provider.get(key); - if (await _fileExists(data)) { - _updateCacheDataInDatabase(data!); + try { + final provider = await _cacheInfoRepository; + final data = await provider.get(key); + if (await _fileExists(data)) { + unawaited( + _updateCacheDataInDatabase(data!).catchError(( + Object e, + StackTrace s, + ) { + cacheLogger.log( + 'CacheStore: Failed to update cache metadata for $key with error:\n$e\n$s', + CacheManagerLogLevel.warning, + ); + return null; + }), + ); + } + _scheduleCleanup(); + return data; + } catch (e, s) { + cacheLogger.log( + 'CacheStore: Failed to read cache metadata for $key with error:\n$e\n$s', + CacheManagerLogLevel.warning, + ); + return null; } - _scheduleCleanup(); - return data; } void _scheduleCleanup() { @@ -133,7 +181,14 @@ class CacheStore { } _scheduledCleanup = Timer(cleanupRunMinInterval, () { _scheduledCleanup = null; - _cleanupCache(); + unawaited( + _cleanupCache().catchError((Object e, StackTrace s) { + cacheLogger.log( + 'CacheStore: Failed to cleanup cache with error:\n$e\n$s', + CacheManagerLogLevel.warning, + ); + }), + ); }); } @@ -147,15 +202,17 @@ class CacheStore { final provider = await _cacheInfoRepository; final overCapacity = await provider.getObjectsOverCapacity(_capacity); + final futures = []; for (final cacheObject in overCapacity) { - _removeCachedFile(cacheObject, toRemove); + futures.add(_removeCachedFile(cacheObject, toRemove)); } final oldObjects = await provider.getOldObjects(_maxAge); for (final cacheObject in oldObjects) { - _removeCachedFile(cacheObject, toRemove); + futures.add(_removeCachedFile(cacheObject, toRemove)); } + await Future.wait(futures); await provider.deleteAll(toRemove); } @@ -192,9 +249,7 @@ class CacheStore { if (_memCache.containsKey(cacheObject.key)) { _memCache.remove(cacheObject.key); } - if (_futureCache.containsKey(cacheObject.key)) { - await _futureCache.remove(cacheObject.key); - } + _futureCache.remove(cacheObject.key); final file = await fileSystem.createFile(cacheObject.relativePath); if (file.existsSync()) { diff --git a/flutter_cache_manager/lib/src/web/web_helper.dart b/flutter_cache_manager/lib/src/web/web_helper.dart index d5b77146..5fe120e3 100644 --- a/flutter_cache_manager/lib/src/web/web_helper.dart +++ b/flutter_cache_manager/lib/src/web/web_helper.dart @@ -156,7 +156,14 @@ class WebHelper { newCacheObject = newCacheObject.copyWith(length: savedBytes); } - await _store.putFile(newCacheObject); + try { + await _store.putFile(newCacheObject); + } catch (e) { + if (statusCodesNewFile.contains(response.statusCode)) { + await _removeOldFile(newCacheObject.relativePath); + } + rethrow; + } if (newCacheObject.relativePath != oldCacheObject.relativePath) { await _removeOldFile(oldCacheObject.relativePath); } diff --git a/flutter_cache_manager/pubspec.yaml b/flutter_cache_manager/pubspec.yaml index 3f0c9ce3..8456c4b5 100644 --- a/flutter_cache_manager/pubspec.yaml +++ b/flutter_cache_manager/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_cache_manager description: Generic cache manager for flutter. Saves web files on the storages of the device and saves the cache info using sqflite. -version: 3.4.5 +version: 3.4.6 homepage: https://github.com/Baseflow/flutter_cache_manager topics: - cache diff --git a/flutter_cache_manager/test/cache_store_test.dart b/flutter_cache_manager/test/cache_store_test.dart index 412a35f1..91bdf864 100644 --- a/flutter_cache_manager/test/cache_store_test.dart +++ b/flutter_cache_manager/test/cache_store_test.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:clock/clock.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_cache_manager/src/cache_store.dart'; +import 'package:flutter_cache_manager/src/logger.dart'; import 'package:flutter_cache_manager/src/storage/cache_object.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; @@ -54,6 +57,81 @@ void main() { expect(await store.getFile('baseflow.com/test.png'), isNotNull); }); + test( + 'Store should return FileInfo and handle error when database update fails on cache hit', + () async { + final testLogger = setUpTestLogger(); + + var config = createTestConfig(); + await config.returnsFile(fileName); + config.returnsCacheObject( + fileUrl, + fileName, + DateTime.now(), + id: fileId, + ); + when( + config.mockRepo.updateOrInsert(any), + ).thenAnswer((_) => Future.error(Exception('Database update failed'))); + + var store = CacheStore(config); + + final fileInfo = await store.getFile(fileUrl); + expect(fileInfo, isNotNull); + + await untilCalled(config.mockRepo.updateOrInsert(any)); + verify(config.mockRepo.updateOrInsert(any)).called(1); + await testLogger.waitForLog('Failed to update cache metadata'); + }, + ); + + test( + 'Store should treat failed database read as cache miss and clean up pending cache', + () async { + final testLogger = setUpTestLogger(); + + var config = createTestConfig(); + when( + config.mockRepo.get(any), + ).thenAnswer((_) => Future.error(Exception('Database read failed'))); + + var store = CacheStore(config); + + final result1 = await store.retrieveCacheData('failed-key'); + expect(result1, isNull); + + final result2 = await store.retrieveCacheData('failed-key'); + expect(result2, isNull); + + verify(config.mockRepo.get(any)).called(2); + await testLogger.waitForLog('Failed to read cache metadata'); + }, + ); + + test( + 'Store cleanup error should be handled without unhandled async error', + () async { + final testLogger = setUpTestLogger(); + + var config = createTestConfig(); + var store = CacheStore(config); + store.cleanupRunMinInterval = const Duration(milliseconds: 1); + await config.returnsFile(fileName); + config.returnsCacheObject(fileUrl, fileName, validTill, id: fileId); + + when( + config.mockRepo.getObjectsOverCapacity(any), + ).thenAnswer((_) => Future.error(Exception('Cleanup failure'))); + + expect(await store.getFile(fileUrl), isNotNull); + + await untilCalled(config.mockRepo.getObjectsOverCapacity(any)); + verify(config.mockRepo.getObjectsOverCapacity(any)).called(1); + + await testLogger.waitForLog('Failed to cleanup cache'); + }, + ); + test('Store should return null if file is not cached', () async { var config = createTestConfig(); await config.returnsFile(fileName); @@ -211,6 +289,54 @@ void main() { expect(result!.id, isNotNull); }, ); + + test( + 'putFile restores or removes memory cache entry when updateOrInsert fails', + () async { + var config = createTestConfig(); + var store = CacheStore(config); + + var initialObject = CacheObject( + 'baseflow.com/test.png', + relativePath: 'testimage_old.png', + validTill: clock.now().add(const Duration(days: 7)), + ); + var newObject = CacheObject( + 'baseflow.com/test.png', + relativePath: 'testimage_new.png', + validTill: clock.now().add(const Duration(days: 7)), + ); + + // Case 1: Nothing was in memory cache before, putFile fails + when( + config.mockRepo.updateOrInsert(newObject), + ).thenAnswer((_) => Future.error(Exception('DB error'))); + + await expectLater( + () => store.putFile(newObject), + throwsA(isA()), + ); + expect(store.memoryCacheContainsKey(newObject.key), isFalse); + + // Case 2: initialObject succeeded, then updating to newObject fails + when( + config.mockRepo.updateOrInsert(initialObject), + ).thenAnswer((_) => Future.value(initialObject)); + await store.putFile(initialObject); + expect(store.memoryCacheContainsKey(initialObject.key), isTrue); + + await expectLater( + () => store.putFile(newObject), + throwsA(isA()), + ); + // Should have restored initialObject, not left pointing at newObject + expect(store.memoryCacheContainsKey(initialObject.key), isTrue); + expect( + (await store.getFileFromMemory(initialObject.key))?.file.path, + contains('testimage_old.png'), + ); + }, + ); }); group('Removing files in store', () { @@ -464,3 +590,37 @@ Future createDir() async { final fileSystem = MemoryFileSystem(); return fileSystem.systemTempDirectory.createTemp('test'); } + +class TestCacheLogger extends CacheLogger { + final logs = []; + Completer? _nextLogCompleter; + + Future waitForLog([Pattern? pattern]) async { + while (true) { + if (pattern != null) { + final existing = logs.where((l) => l.contains(pattern)).firstOrNull; + if (existing != null) return existing; + } else if (logs.isNotEmpty) { + return logs.first; + } + _nextLogCompleter ??= Completer(); + await _nextLogCompleter!.future; + } + } + + @override + void log(String message, CacheManagerLogLevel level) { + logs.add(message); + final c = _nextLogCompleter; + _nextLogCompleter = null; + c?.complete(message); + } +} + +TestCacheLogger setUpTestLogger() { + final originalLogger = cacheLogger; + final testLogger = TestCacheLogger(); + cacheLogger = testLogger; + addTearDown(() => cacheLogger = originalLogger); + return testLogger; +} diff --git a/flutter_cache_manager/test/web_helper_test.dart b/flutter_cache_manager/test/web_helper_test.dart index 9bc655a8..5761ccb7 100644 --- a/flutter_cache_manager/test/web_helper_test.dart +++ b/flutter_cache_manager/test/web_helper_test.dart @@ -289,6 +289,50 @@ void main() { await download; }); + test( + 'downloadFile cleans up downloaded file if store persist fails', + () async { + const imageUrl = 'baseflow.com/testimage'; + + final config = createTestConfig(); + final store = CacheStore(config); + + when( + config.mockRepo.updateOrInsert(any), + ).thenAnswer((_) => Future.error(Exception('Persist failed'))); + + final fileService = MockFileService(); + when( + fileService.get(imageUrl, headers: anyNamed('headers')), + ).thenAnswer((_) { + return Future.value( + MockFileFetcherResponse( + Stream.value([0, 1, 2, 3, 4, 5]), + 6, + 'testv1', + '.jpg', + 200, + DateTime.now(), + ), + ); + }); + + final webHelper = WebHelper(store, fileService); + await expectLater( + () => webHelper.downloadFile(imageUrl).drain(), + throwsA(isA()), + ); + + final testFileSystem = config.fileSystem as TestFileSystem; + final dir = await testFileSystem.directoryFuture; + expect( + dir.listSync().isEmpty, + isTrue, + reason: 'Downloaded file should be deleted on putFile failure', + ); + }, + ); + test('File should be removed if extension changed', () async { const imageUrl = 'baseflow.com/testimage'; var imageName = 'image.png';