Skip to content

Commit 760415b

Browse files
committed
Fix DeviceDetectorCache cache eviction flakiness
1 parent a92e044 commit 760415b

3 files changed

Lines changed: 60 additions & 5 deletions

File tree

CachedEntry.php

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ public static function getCached(string $userAgent): ?array
6060
if ($exists) {
6161
$values = @include($path);
6262
if (!empty($values) && is_array($values) && isset($values['os'])) {
63+
// Keep the filesystem access metadata in sync with cache reads so later eviction
64+
// can preserve entries that were just reused during the warm-cache command.
65+
self::refreshAccessTime($path);
6366
return $values;
6467
}
6568
}
@@ -165,6 +168,26 @@ private static function getCacheFilesInCacheDir(): array
165168
});
166169
}
167170

171+
private static function refreshAccessTime(string $path): void
172+
{
173+
// Read the current mtime first so we can move the cache file's recency marker forward even
174+
// when the file was created earlier in the same second as the cache hit.
175+
$modifiedTime = @filemtime($path);
176+
if ($modifiedTime === false) {
177+
return;
178+
}
179+
180+
// Use a strictly newer timestamp for both mtime and atime so cache eviction has a stable
181+
// ordering even on filesystems with one-second timestamp granularity.
182+
$refreshedTime = max(time(), $modifiedTime + 1);
183+
184+
// Refresh the stat cache around touch() so the subsequent file metadata reads in the same
185+
// PHP process observe the updated recency instead of stale stat data.
186+
clearstatcache(true, $path);
187+
@touch($path, $refreshedTime, $refreshedTime);
188+
clearstatcache(true, $path);
189+
}
190+
168191
public static function deleteLeastAccessedFiles(int $numFilesToDelete): void
169192
{
170193
if ($numFilesToDelete < 1) {
@@ -173,7 +196,9 @@ public static function deleteLeastAccessedFiles(int $numFilesToDelete): void
173196
$files = self::getCacheFilesInCacheDir();
174197
$accessed = [];
175198
foreach ($files as $file) {
176-
$accessed[$file] = fileatime($file);
199+
// Read the same recency marker that getCached() updates so eviction keeps the cache
200+
// entry that was actually reused during the current warm-cache run.
201+
$accessed[$file] = self::getLastAccessTimestamp($file);
177202
}
178203

179204
// have most recently accessed files at the end of the array and delete entries from the beginning of the array
@@ -189,4 +214,18 @@ public static function deleteLeastAccessedFiles(int $numFilesToDelete): void
189214
}
190215
}
191216
}
217+
218+
private static function getLastAccessTimestamp(string $path): int
219+
{
220+
// Clear stat cache before reading metadata so the eviction pass sees the timestamp updates
221+
// performed earlier in the same process by refreshAccessTime().
222+
clearstatcache(true, $path);
223+
224+
// Prefer the newest of atime and mtime because refreshAccessTime() updates both values to
225+
// give us a deterministic recency order on filesystems with coarse timestamp precision.
226+
$accessTime = @fileatime($path);
227+
$modifiedTime = @filemtime($path);
228+
229+
return max((int) $accessTime, (int) $modifiedTime);
230+
}
192231
}

tests/Integration/CachedEntryTest.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,18 @@ public function test_writeToCache_GetCached()
190190
);
191191
}
192192

193+
public function testGetCached_refreshesAccessTime()
194+
{
195+
$filePath = CachedEntry::writeToCache('foo', []);
196+
$accessTimeBefore = fileatime($filePath);
197+
198+
sleep(1);
199+
CachedEntry::getCached('foo', []);
200+
clearstatcache(true, $filePath);
201+
202+
$this->assertGreaterThan($accessTimeBefore, fileatime($filePath));
203+
}
204+
193205
public function test_getCachePath()
194206
{
195207
$path1 = CachedEntry::getCachePath('foo');

tests/Integration/WarmDeviceDetectorCacheTest.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,12 @@ public function testDoesNotClearExistingFilesFromCacheByDefault()
106106

107107
public function testDoesClearExistingFilesFromCacheByDefaultWhenTooManyEntriesExist()
108108
{
109-
// was last accessed
110-
$userAgentKept = 'Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko';
111-
$userAgentDeleted = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36';
109+
// The first user agent is the most frequent entry in useragents1.csv, so the second warm
110+
// run will read it from cache again and should therefore keep it after eviction.
111+
$userAgentKept = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36';
112+
// This entry is less frequent and should be one of the files evicted once the cache is
113+
// reduced back down to a single entry.
114+
$userAgentDeleted = 'Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko';
112115
$cacheFilePathKept = CachedEntry::getCachePath($userAgentKept, true);
113116
$cacheFilePathDeleted = CachedEntry::getCachePath($userAgentDeleted, true);
114117

@@ -128,7 +131,8 @@ public function testDoesClearExistingFilesFromCacheByDefaultWhenTooManyEntriesEx
128131

129132
$this->setCountProcessNumEntries(1);
130133

131-
// now we run again and it should delete 2 of the entries
134+
// Re-running with a single allowed entry exercises the same request path as production:
135+
// the kept entry is read again, its atime is refreshed, and eviction should delete older files.
132136
$this->applicationTester->run([
133137
'command' => WarmDeviceDetectorCache::COMMAND_NAME,
134138
]);

0 commit comments

Comments
 (0)