Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions flutter_cache_manager/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)).
Comment thread
rickdijk marked this conversation as resolved.
* 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)).
Expand Down
107 changes: 81 additions & 26 deletions flutter_cache_manager/lib/src/cache_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,19 @@ class CacheStore {
}

Future<void> 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) {
Expand All @@ -74,25 +85,43 @@ class CacheStore {
}
}
if (!_futureCache.containsKey(key)) {
final completer = Completer<CacheObject?>();
_getCacheDataFromDatabase(key).then((cacheObject) async {
if (cacheObject?.id != null && !await _fileExists(cacheObject)) {
late final Future<CacheObject?> future;
future = _loadCacheData(key, () => future);
_futureCache[key] = future;
}
return _futureCache[key];
}

Future<CacheObject?> _loadCacheData(
String key,
Future<CacheObject?> 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<FileInfo?> getFileFromMemory(String key) async {
Expand All @@ -118,13 +147,32 @@ class CacheStore {
}

Future<CacheObject?> _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;
Comment thread
rickdijk marked this conversation as resolved.
}
_scheduleCleanup();
return data;
}

void _scheduleCleanup() {
Expand All @@ -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,
);
}),
);
});
}

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

final overCapacity = await provider.getObjectsOverCapacity(_capacity);
final futures = <Future>[];
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);
}

Expand Down Expand Up @@ -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()) {
Expand Down
9 changes: 8 additions & 1 deletion flutter_cache_manager/lib/src/web/web_helper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,14 @@ class WebHelper {
newCacheObject = newCacheObject.copyWith(length: savedBytes);
}

await _store.putFile(newCacheObject);
try {
await _store.putFile(newCacheObject);
} catch (e) {
Comment thread
rickdijk marked this conversation as resolved.
if (statusCodesNewFile.contains(response.statusCode)) {
await _removeOldFile(newCacheObject.relativePath);
}
rethrow;
}
if (newCacheObject.relativePath != oldCacheObject.relativePath) {
await _removeOldFile(oldCacheObject.relativePath);
}
Expand Down
2 changes: 1 addition & 1 deletion flutter_cache_manager/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
160 changes: 160 additions & 0 deletions flutter_cache_manager/test/cache_store_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<Exception>()),
);
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<Exception>()),
);
// 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', () {
Expand Down Expand Up @@ -464,3 +590,37 @@ Future<Directory> createDir() async {
final fileSystem = MemoryFileSystem();
return fileSystem.systemTempDirectory.createTemp('test');
}

class TestCacheLogger extends CacheLogger {
final logs = <String>[];
Completer<String>? _nextLogCompleter;

Future<String> 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<String>();
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;
}
Loading
Loading