Skip to content

Commit ac54f9d

Browse files
Merge pull request #10 from Vectorial1024/multirun
Fix file cache evictor race condition due to bad iteration style
2 parents 042cadd + bf70864 commit ac54f9d

File tree

6 files changed

+41
-29
lines changed

6 files changed

+41
-29
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
Note: you may refer to `README.md` for description of features.
33

44
## Dev (WIP)
5+
- Fixed file cache evictor sometimes throwing `UnexpectedValueException` due to race conditions
6+
- This could happen when multiple cleaners are running at the same time
7+
- Minor general codebase cleanup
58

69
## 2.0.1 (2025-01-13)
710
- Added fallback of Laravel's `Number::fileSize()` if `ext-intl` is not available

src/AbstractEvictStrategy.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<?php
22

3+
declare(strict_types=1);
4+
35
namespace Vectorial1024\LaravelCacheEvict;
46

57
use Illuminate\Console\OutputStyle;

src/CacheEvictStrategies.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<?php
22

3+
declare(strict_types=1);
4+
35
namespace Vectorial1024\LaravelCacheEvict;
46

57
use Vectorial1024\LaravelCacheEvict\Database\DatabaseEvictStrategy;

src/Database/DatabaseEvictStrategy.php

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
<?php
22

3+
declare(strict_types=1);
4+
35
namespace Vectorial1024\LaravelCacheEvict\Database;
46

5-
use Illuminate\Contracts\Cache\Repository;
7+
use Illuminate\Cache\DatabaseStore;
68
use Illuminate\Database\Connection;
79
use Illuminate\Support\Facades\Cache;
810
use Illuminate\Support\Facades\DB;
@@ -16,7 +18,7 @@ class DatabaseEvictStrategy extends AbstractEvictStrategy
1618

1719
protected string $dbTable;
1820

19-
protected Repository $cacheStore;
21+
protected DatabaseStore $cacheStore;
2022

2123
protected int $deletedRecords = 0;
2224
protected int $deletedRecordSizes = 0;
@@ -31,7 +33,7 @@ public function __construct(string $storeName)
3133
$storeConn = config("cache.stores.{$storeName}.connection");
3234
$this->dbConn = DB::connection($storeConn);
3335
$this->dbTable = config("cache.stores.{$storeName}.table");
34-
$this->cacheStore = Cache::store($this->storeName);
36+
$this->cacheStore = Cache::store($this->storeName)->getStore();
3537
}
3638

3739
public function execute(): void
@@ -65,7 +67,7 @@ public function execute(): void
6567
$currentExpiration = $cacheItem->expiration;
6668
$currentActualKey = "{$cachePrefix}{$currentUserKey}";
6769
// currently timestamps are 32-bit, so are 4 bytes
68-
$estimatedBytes = $cacheItem->key_bytes + $cacheItem->value_bytes + 4;
70+
$estimatedBytes = (int) ($cacheItem->key_bytes + $cacheItem->value_bytes + 4);
6971
$progressBar->advance();
7072

7173
if (time() < $currentExpiration) {
@@ -103,22 +105,19 @@ public function execute(): void
103105
protected function yieldCacheTableItems(): \Generator
104106
{
105107
// there might be a prefix for the cache store!
106-
// not sure how to properly type-cast to DatabaseStore, but this should exist.
107108
$cachePrefix = $this->cacheStore->getPrefix();
108109
$currentUserKey = "";
109110
// loop until no more items
110111
while (true) {
111112
// find the next key
112113
$actualKey = "{$cachePrefix}{$currentUserKey}";
113-
// Partyline::info("Checking DB key $actualKey");
114114
$record = $this->dbConn
115115
->table($this->dbTable)
116116
->select(['key', 'expiration', DB::raw('LENGTH(key) AS key_bytes'), DB::raw('LENGTH(value) AS value_bytes')])
117117
->where('key', '>', $actualKey)
118118
->where('key', 'LIKE', "$cachePrefix%")
119119
->limit(1)
120120
->first();
121-
// Partyline::info(var_dump($record));
122121
if (!$record) {
123122
// nothing more to get
124123
break;

src/EvictionRefusedFeatureExistsException.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<?php
22

3+
declare(strict_types=1);
4+
35
namespace Vectorial1024\LaravelCacheEvict;
46

57
use Exception;

src/File/FileEvictStrategy.php

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
<?php
22

3+
declare(strict_types=1);
4+
35
namespace Vectorial1024\LaravelCacheEvict\File;
46

57
use DirectoryIterator;
68
use ErrorException;
79
use Illuminate\Contracts\Filesystem\Filesystem;
810
use Illuminate\Support\Facades\Storage;
911
use Symfony\Component\Console\Helper\ProgressBar;
12+
use UnexpectedValueException;
1013
use Vectorial1024\LaravelCacheEvict\AbstractEvictStrategy;
1114
use Wilderborn\Partyline\Facade as Partyline;
1215

@@ -54,30 +57,25 @@ public function execute(): void
5457
$progressBar = $this->output->createProgressBar();
5558
$progressBar->setMaxSteps(count($allDirs));
5659

57-
foreach ($allDirs as $dir) {
58-
// we will have some verbose printing for now to test this feature.
60+
// since allDir is an array with contents like "0", "0/0", "0/1", ... "1", ...
61+
// and we are trying to remove items during iteration
62+
// we should iterate it in reverse as per literation best practices
63+
// the reversal also cleanly avoids possible race conditions by aligning iteration direction across all cleaners
64+
foreach (array_reverse($allDirs) as $dir) {
65+
// handle cache files, then delete the directory in the same place
5966
$this->handleCacheFilesInDirectory($dir);
6067
$progressBar->advance();
61-
// sleep(1);
68+
// it's OK if we cannot remove directories; this usually means the directory is not empty.
69+
$localPath = $this->filesystem->path($dir);
70+
@rmdir($localPath);
71+
$this->deletedDirs++;
6272
}
6373

6474
$progressBar->finish();
6575
unset($progressBar);
6676
// progress bar next empty line
6777
Partyline::info("");
68-
Partyline::info("Expired cache files evicted; checking empty directories...");
69-
// since allDir is an array with contents like "0", "0/0", "0/1", ... "1", ...
70-
// we can reverse it to effectively remove directories
71-
// in theory removing directories is very fast, so no progress bars here
72-
foreach (array_reverse($allDirs) as $dir) {
73-
try {
74-
$localPath = $this->filesystem->path($dir);
75-
rmdir($localPath);
76-
$this->deletedDirs++;
77-
} catch (ErrorException) {
78-
// it's OK if we cannot remove directories; this usually means the directory is not empty.
79-
}
80-
}
78+
Partyline::info("Expired cache files evicted.");
8179

8280
// all is done; print some stats
8381
$endUnix = microtime(true);
@@ -89,14 +87,23 @@ public function execute(): void
8987
Partyline::info("Removed {$this->deletedDirs} empty directories.");
9088
}
9189

92-
protected function handleCacheFilesInDirectory(string $dirName)
90+
protected function handleCacheFilesInDirectory(string $dirName): void
9391
{
9492
$localPath = $this->filesystem->path($dirName);
95-
// Partyline::info("Checking $localPath...");
9693

9794
// remove files inside directory
95+
try {
96+
$dirIter = new DirectoryIterator($localPath);
97+
} catch (UnexpectedValueException $x) {
98+
// this indicates the directory is gone
99+
// this might be caused by race conditions
100+
// this should be rare (later execution catching up to earlier run), but better be safe than sorry
101+
Partyline::warn("Cache directory {$dirName} was deleted earlier than expected; skipping.");
102+
// then we have nothing to do here
103+
return;
104+
}
98105
/** @var \SplFileInfo $fileInfo */
99-
foreach (new DirectoryIterator($localPath) as $fileInfo) {
106+
foreach ($dirIter as $fileInfo) {
100107
if ($fileInfo->isDot()) {
101108
continue;
102109
}
@@ -106,7 +113,6 @@ protected function handleCacheFilesInDirectory(string $dirName)
106113

107114
$realPath = $fileInfo->getRealPath();
108115
$shortFileName = $dirName . DIRECTORY_SEPARATOR . $fileInfo->getFilename();
109-
// Partyline::info("Checking file $realPath...");
110116
try {
111117
// read expiry
112118
// the first 10 characters form the expiry timestamp
@@ -115,10 +121,8 @@ protected function handleCacheFilesInDirectory(string $dirName)
115121
$expiry = (int) file_get_contents($realPath, length: 10);
116122
if (time() < $expiry) {
117123
// not expired yet
118-
// Partyline::info("Not expired");
119124
continue;
120125
}
121-
// Partyline::info("Expired");
122126
} catch (ErrorException) {
123127
// it's OK if we cannot read the file, this can happen when e.g. the cache file is deleted by other Laravel code
124128
Partyline::warn("Could not read details of cache file $shortFileName; skipping.");

0 commit comments

Comments
 (0)