Repository navigation
Conversation
…info The cleanup started removing files without waiting and then deleted the cache info right away, so the cache info could be gone while the files were still on disk. If the app was killed in between, those files were left with nothing referencing them. The cleanup now waits for the file removals first. The cache info of a file that could not be removed is kept, so the next cleanup retries it.
3d58574 to
519ad8b
Compare
|
Hey @rickdijk , Please could you confirm that the following pull request #542 fixes the issue presented here in this pull request? I believe it does so thank you 🙏 I do have data to back up the orphaned files scenario that we are seeing and before closing this pull request I would like to confirm that this issue is fixed please. Please could you confirm this? Thanks |
rickdijk
left a comment
There was a problem hiding this comment.
Thanks for the detailed write-up, @junsworth. To answer your question first: yes, #542 (released in 3.4.6) already fixes the ordering problem this PR describes. On current main, _cleanupCache awaits Future.wait(futures) before provider.deleteAll(toRemove), so if the app is killed between the two steps you get a row without a file, not a file without a row. Your branch is based on that commit.
Does it fix something? Not the headline bug, since that is already fixed. There is still one real improvement, though. On main, if a single file can't be deleted (for example a file locked on Windows), Future.wait throws and deleteAll is skipped for the whole batch. getOldObjects and getObjectsOverCapacity return at most 100 rows each, so one file that stays undeletable makes every cleanup reselect the same rows and stop there. Keeping only the failed row and deleting the rest fixes that.
Does it add value? Yes, but only the partial-failure part. The title, description and CHANGELOG should describe that change rather than the ordering fix.
Tests: The second test (should keep cache info of a file that cannot be removed) covers the actual change and fails on main. On main it fails because deleteAll is never called, not because the call includes id 2. The first test already passes on main since #542. It's fine to keep as a regression guard, but it doesn't verify this PR.
Breaking change? No. There are no API changes, and the only behavior change is in the failure path.
Contribution guidelines: The template is filled in, there's one package, and the version is bumped with a dated entry. Some of the text is out of date: the description still says both PRs bump to 3.4.6 and that both tests fail on current main, and the CHANGELOG entry credits 3.4.7 with a fix that 3.4.6 already shipped. "All projects build" is left unchecked, so CI will settle that.
I've left inline comments on the details.
|
|
||
| await Future.wait(futures); | ||
| await provider.deleteAll(toRemove); | ||
| try { |
There was a problem hiding this comment.
This ordering is already on main via #542: the code before this PR awaited Future.wait(futures) and only then called deleteAll. The new behavior in this hunk is the partial-failure handling: the failed row is kept and every other row is still deleted. On main, one failure skips deleteAll for the whole batch. Could you reword the comment and the PR around that?
There was a problem hiding this comment.
Agreed, reworded the PR and removed that comment. The partial-failure handling is the only thing this adds now.
| // Only delete the cache info once the files are gone. When the app is | ||
| // killed in between, a cache info without a file is cleaned up on the next | ||
| // read, but a file without a cache info would never be found again. | ||
| await provider.deleteAll(toRemove); |
There was a problem hiding this comment.
When Future.wait has already thrown and deleteAll then throws too (the SQLite error 14 case from #535), the deleteAll exception replaces the file-deletion error. _scheduleCleanup then logs only the DB error. It's minor, but you might want to catch and log the deleteAll failure here, or rethrow the original error.
There was a problem hiding this comment.
Good catch. The deleteAll failure is now logged and the original file error is rethrown, so _scheduleCleanup logs the more useful one. I added a test for it. If deleteAll is the only thing that fails, it still propagates as before.
| } | ||
| } | ||
|
|
||
| Future<void> emptyCache() async { |
There was a problem hiding this comment.
emptyCache still uses the same all-or-nothing pattern (Future.wait and then deleteAll), so one undeletable file there still skips deleteAll for every row. Could the fix live in _removeCachedFile instead, adding the id to toRemove only after the file is gone or confirmed missing (with a separate set for the in-flight dedup)? That would cover both callers and remove the need for the remove closure, the rethrow and the finally.
There was a problem hiding this comment.
That's a nicer place for it, thanks. The id now only goes into the list after the file is deleted or already missing, and a separate started set does the dedup. One thing I found along the way: Future.wait still throws on a failed removal, so there's still a small shared helper that waits, deletes the cache info of the successful ones and then rethrows. It's used by _cleanupCache, emptyCache and removeCachedFile. I also added an emptyCache test for the partial failure.
| @@ -1,3 +1,7 @@ | |||
| ## [3.4.7] - 2026-10-28 | |||
|
|
|||
| * Fixes the cache cleanup deleting the cache info before the cached files were removed. When the app was killed in between, files stayed on disk with nothing referencing them. The cache info of a file that could not be removed is now kept, so the next cleanup retries it. | |||
There was a problem hiding this comment.
3.4.6 already shipped this fix (#542), so this entry would suggest 3.4.6 still had the bug. Suggested wording for what actually changes:
| * Fixes the cache cleanup deleting the cache info before the cached files were removed. When the app was killed in between, files stayed on disk with nothing referencing them. The cache info of a file that could not be removed is now kept, so the next cleanup retries it. | |
| * Keeps cache cleanup going when a cached file cannot be deleted: the cache info of the other removed files is still deleted, and only the failed file's cache info is kept so the next cleanup retries it. |
There was a problem hiding this comment.
Good point, updated with your wording.
| }); | ||
|
|
||
| test( | ||
| 'Store should not remove cache info before the cached file is removed', |
There was a problem hiding this comment.
You're right. I've kept it as a regression guard and fixed the description, which no longer says both tests fail on main.
| expect(log, contains('Cannot delete')); | ||
| verify( | ||
| config.mockRepo.deleteAll( | ||
| argThat(allOf(contains(okObject.id), isNot(contains(2)))), |
There was a problem hiding this comment.
Nit: use brokenObject.id instead of the literal 2, to match okObject.id in the same matcher.
| argThat(allOf(contains(okObject.id), isNot(contains(2)))), | |
| argThat(allOf(contains(okObject.id), isNot(contains(brokenObject.id)))), |
Move the partial-failure handling into _removeCachedFile so cleanup, emptyCache and removeCachedFile all delete the cache info of removed files and keep only the failed one. A failing deleteAll is logged instead of replacing the file removal error.
|
Thanks a lot for the thorough review, @rickdijk, and for confirming #542 covers the ordering problem. That makes sense, my branch was just based on a point before it landed. I've reworked this PR around the partial-failure part you described: the title, description and CHANGELOG now say that, and I used your suggested CHANGELOG wording. I've also moved the fix into |
✨ What kind of change does this PR introduce? (Bug fix, feature, docs update...)
Bug fix.
If a cached file can't be deleted (for example a file locked on Windows),
Future.waitthrows anddeleteAllis skipped for the whole batch.getOldObjectsandgetObjectsOverCapacityreturn at most 100 rows each, so one file that stays undeletable makes every cleanup reselect the same rows and stop there.emptyCachehas the same all-or-nothing behavior.🆕 What is the new behavior (if this is a feature change)?
_removeCachedFileonly marks a file for cache-info deletion once the file is gone or confirmed missing. Cleanup,emptyCacheandremoveCachedFilethen delete the cache info of every removed file and keep only the failed file's cache info, so the next cleanup retries it. The file error is still reported, and ifdeleteAllalso fails that is logged instead of replacing it.💥 Does this PR introduce a breaking change?
No.
🐛 Recommendations for testing
New tests in
cache_store_test.dartcover a failed removal during cleanup, a failed removal inemptyCache, and the file error not being masked whendeleteAllalso fails. The ordering test ("should not remove cache info before the cached file is removed") already passes onmainsince #542, I kept it as a regression guard.flutter test,dart format --set-exit-if-changed .andflutter analyzepass locally on Flutter 3.47.4. I haven't built the example apps locally, CI does that.📝 Links to relevant issues/docs
Follows up on #542, which already fixed the ordering of file removal and cache-info deletion. Related: #535.
🤔 Checklist before submitting