Skip to content
Open
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
4 changes: 4 additions & 0 deletions flutter_cache_manager/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## [3.4.9] - 2026-11-03

* Fixes JSON cache metadata consistency and preserves the previous file when replacement persistence fails.

## [3.4.8] - 2026-11-02

* Uses the URL's file extension when a download's content type is missing or generic, such as `application/octet-stream` or `binary/octet-stream` ([#489](https://github.com/Baseflow/flutter_cache_manager/issues/489)).
Expand Down
35 changes: 13 additions & 22 deletions flutter_cache_manager/lib/src/cache_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,24 +55,11 @@ class CacheStore {
}

Future<void> putFile(CacheObject cacheObject) async {
final previousCacheObject = _memCache[cacheObject.key];
_memCache[cacheObject.key] = 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) {
_memCache[cacheObject.key] = cacheObject.copyWith(id: out.id);
}
final out = await _updateCacheDataInDatabase(cacheObject);
// Do not expose a replacement in memory before its metadata is persisted.
_memCache[cacheObject.key] = out is CacheObject && out.id != null
? cacheObject.copyWith(id: out.id)
: cacheObject;
}

Future<CacheObject?> retrieveCacheData(
Expand Down Expand Up @@ -152,10 +139,7 @@ class CacheStore {
final data = await provider.get(key);
if (await _fileExists(data)) {
unawaited(
_updateCacheDataInDatabase(data!).catchError((
Object e,
StackTrace s,
) {
_touchCacheData(provider, data!).catchError((Object e, StackTrace s) {
cacheLogger.log(
'CacheStore: Failed to update cache metadata for $key with error:\n$e\n$s',
CacheManagerLogLevel.warning,
Expand All @@ -175,6 +159,13 @@ class CacheStore {
}
}

Future<dynamic> _touchCacheData(
CacheInfoRepository provider,
CacheObject data,
) => provider is JsonCacheInfoRepository
? provider.touch(data.key)
: provider.updateOrInsert(data);

void _scheduleCleanup() {
if (_scheduledCleanup != null) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ class JsonCacheInfoRepository extends CacheInfoRepository
final Map<String, CacheObject> _cacheObjects = {};
final Map<int, Map<String, dynamic>> _jsonCache = {};

bool _dirty = false;
Future<void> _writeQueue = Future.value();

@override
Expand Down Expand Up @@ -64,12 +63,11 @@ class JsonCacheInfoRepository extends CacheInfoRepository
if (cacheObject.id != null) {
throw ArgumentError("Inserted objects shouldn't have an existing id.");
}
final keys = _jsonCache.keys;
final lastId = keys.isEmpty ? 0 : keys.reduce(max);
final id = lastId + 1;

cacheObject = cacheObject.copyWith(id: id);
return _put(cacheObject, setTouchedToNow);
return _enqueue(() async {
final keys = _jsonCache.keys;
final id = (keys.isEmpty ? 0 : keys.reduce(max)) + 1;
return _put(cacheObject.copyWith(id: id), setTouchedToNow);
});
}

@override
Expand All @@ -80,10 +78,18 @@ class JsonCacheInfoRepository extends CacheInfoRepository
if (cacheObject.id == null) {
throw ArgumentError('Updated objects should have an existing id.');
}
await _put(cacheObject, setTouchedToNow);
return 1;
return _enqueue(() async {
await _put(cacheObject, setTouchedToNow);
return 1;
});
}

/// Updates the access time of the current entry without writing a stale path.
Future<void> touch(String key) => _enqueue(() async {
final current = _cacheObjects[key];
if (current != null) await _put(current, true);
});

@override
Future<dynamic> updateOrInsert(CacheObject cacheObject) {
return cacheObject.id == null ? insert(cacheObject) : update(cacheObject);
Expand All @@ -106,34 +112,34 @@ class JsonCacheInfoRepository extends CacheInfoRepository
}

@override
Future<int> delete(int id) async {
if (!_removeById(id)) {
return 0;
}
await _schedulePersist();
return 1;
}
Future<int> delete(int id) => deleteAll([id]);

@override
Future<int> deleteAll(Iterable<int> ids) async {
var deleted = 0;
for (final id in ids) {
if (_removeById(id)) deleted++;
}
if (deleted > 0) {
await _schedulePersist();
}
return deleted;
Future<int> deleteAll(Iterable<int> ids) {
final requestedIds = ids.toSet();
return _enqueue(() async {
final nextObjects = Map<String, CacheObject>.of(_cacheObjects);
final nextJson = Map<int, Map<String, dynamic>>.of(_jsonCache);
var deleted = 0;
for (final id in requestedIds) {
final object = nextObjects.values.firstWhereOrNull(
(item) => item.id == id,
);
if (object != null) {
nextObjects.remove(object.key);
nextJson.remove(id);
deleted++;
}
}
if (deleted > 0) await _commit(nextObjects, nextJson);
return deleted;
});
}

@override
Future<bool> close() async {
final shouldCloseRepo = shouldClose();
if (_dirty) {
await _schedulePersist();
} else {
await _writeQueue;
}
await _writeQueue;
return shouldCloseRepo;
}

Expand Down Expand Up @@ -172,65 +178,57 @@ class JsonCacheInfoRepository extends CacheInfoRepository
bool setTouchedToNow,
) async {
final map = cacheObject.toMap(setTouchedToNow: setTouchedToNow);
_jsonCache[cacheObject.id!] = map;
final updatedCacheObject = CacheObject.fromMap(map);
_cacheObjects[cacheObject.key] = updatedCacheObject;
await _schedulePersist();
return updatedCacheObject;
final updated = CacheObject.fromMap(map);
final nextObjects = Map<String, CacheObject>.of(_cacheObjects)
..[updated.key] = updated;
final nextJson = Map<int, Map<String, dynamic>>.of(_jsonCache)
..[updated.id!] = map;
await _commit(nextObjects, nextJson);
return updated;
}

bool _removeById(int id) {
final cacheObject = _cacheObjects.values.firstWhereOrNull(
(element) => element.id == id,
/// Each operation derives its snapshot from the last successful operation.
/// The recovery future keeps a failure from poisoning later queued writes;
/// the operation's own future still reports its error to its caller.
Future<T> _enqueue<T>(Future<T> Function() operation) {
final result = _writeQueue.then((_) => operation());
_writeQueue = result.then<void>(
(_) {},
onError: (Object _, StackTrace _) {},
);
if (cacheObject == null) {
return false;
}
_cacheObjects.remove(cacheObject.key);
_jsonCache.remove(cacheObject.id);
return true;
}

/// Queues a write of the current cache info.
///
/// The returned future completes when the changes are on disk. Changes made
/// while a write is in progress are written by that same write or the one
/// directly after it, so a burst of changes doesn't cause a write per change.
Future<void> _schedulePersist() {
_dirty = true;
_writeQueue = _writeQueue.then((_) => _flushIfDirty());
return _writeQueue;
return result;
}

Future<void> _flushIfDirty() async {
while (_dirty) {
_dirty = false;
try {
await _saveFile();
} on Object catch (e, stacktrace) {
// Keep the changes dirty so a later change or close retries the write,
// but stop here to avoid retrying a persistent failure in a loop.
_dirty = true;
FlutterError.reportError(
FlutterErrorDetails(
exception: e,
stack: stacktrace,
library: 'flutter cache manager',
context: ErrorDescription(
'Thrown when writing the file containing cache info. '
'The cache info could not be persisted and may be lost when the '
'app is closed.',
),
Future<void> _commit(
Map<String, CacheObject> nextObjects,
Map<int, Map<String, dynamic>> nextJson,
) async {
try {
await _saveFile(nextJson);
} on Object catch (error, stack) {
FlutterError.reportError(
FlutterErrorDetails(
exception: error,
stack: stack,
library: 'flutter cache manager',
context: ErrorDescription(
'Thrown when writing the file containing cache info.',
),
);
return;
}
),
);
rethrow;
}
_cacheObjects
..clear()
..addAll(nextObjects);
_jsonCache
..clear()
..addAll(nextJson);
}

Future<void> _saveFile() async {
Future<void> _saveFile(Map<int, Map<String, dynamic>> snapshot) async {
final file = await _getFile();
final content = jsonEncode(_jsonCache.values.toList());
final content = jsonEncode(snapshot.values.toList());
final tempFile = _createSiblingFile('${file.path}.tmp');
await tempFile.writeAsString(content, flush: true);
await tempFile.rename(file.path);
Expand All @@ -249,7 +247,7 @@ class JsonCacheInfoRepository extends CacheInfoRepository
}

@override
Future<void> deleteDataFile() async {
Future<void> deleteDataFile() => _enqueue(() async {
final file = await _getFile();
if (await file.exists()) {
await file.delete();
Expand All @@ -258,7 +256,7 @@ class JsonCacheInfoRepository extends CacheInfoRepository
if (await tempFile.exists()) {
await tempFile.delete();
}
}
});

@override
Future<bool> exists() async {
Expand Down
4 changes: 0 additions & 4 deletions flutter_cache_manager/lib/src/web/web_helper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -188,10 +188,6 @@ class WebHelper {
var filePath = cacheObject.relativePath;

if (!statusCodesFileNotChanged.contains(response.statusCode)) {
if (!filePath.endsWith(fileExtension)) {
//Delete old file directly when file extension changed
_removeOldFile(filePath);
}
// Store new file on different path
filePath = '${const Uuid().v1()}$fileExtension';
}
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.8
version: 3.4.9
homepage: https://github.com/Baseflow/flutter_cache_manager
topics:
- cache
Expand Down
Loading
Loading