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
24 changes: 19 additions & 5 deletions src/Illuminate/CachedRouting/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,18 @@ public function cache($filename, Closure $callback, $cacheMinutes = 1440)
$cacher = $this->getRouteCacher();
$cacheKey = $this->getCacheKey($filename);

// Check if the current route group is cached.
if (($cache = $cacher->get($cacheKey)) !== null) {
// Route caching is best-effort: an unreadable/corrupt entry or an
// unwritable cache directory (e.g. permissions, or a torn write from
// concurrent boots after a deploy) must never break application boot.
// On any cache I/O failure we fall back to defining the routes directly.
$cache = null;
try {
$cache = $cacher->get($cacheKey);
} catch (\Throwable $e) {
// Treat an unreadable cache as a miss and rebuild below.
}

if ($cache !== null) {
$this->routes->restoreRouteCache($cache);
} else {
// Back up current RouteCollection contents.
Expand All @@ -95,9 +105,13 @@ public function cache($filename, Closure $callback, $cacheMinutes = 1440)
// Call closure to define routes that should be cached.
call_user_func($callback, $this);

// Put routes in cache.
$cache = $this->routes->getCacheableRoutes();
$cacher->put($cacheKey, $cache, $cacheMinutes);
// Persist the routes, ignoring failures so a broken cache store
// never propagates out of boot (routes stay defined in memory).
try {
$cacher->put($cacheKey, $this->routes->getCacheableRoutes(), $cacheMinutes);
} catch (\Throwable $e) {
// Best-effort cache; a write failure is non-fatal.
}

// And restore the routes that shouldn't be cached.
$this->routes->restoreRouteCollection();
Expand Down
44 changes: 44 additions & 0 deletions tests/CachedRouting/RoutingIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -390,4 +390,48 @@ public function testCanClearCache(): void
$router->clearCache(__FILE__);
static::assertFalse($this->app->cache->has($key), 'Routes must no longer be cached');
}

public function testRebuildsWhenCachedFileIsCorrupt(): void
{
$router = $this->getRouter();
$router->cache(__FILE__, function () use ($router) {
$router->get('/', 'HomeController@actionIndex');
});

// Simulate a torn/partial write: keep a far-future expiry prefix so the
// entry is not treated as expired, but leave an unserializable body.
foreach (glob(self::$cachePath . '/*/*/*') as $file) {
file_put_contents($file, '9999999999corrupt-payload');
}

$rebuilt = false;
$router = $this->getRouter();
$router->cache(__FILE__, function () use ($router, &$rebuilt) {
$rebuilt = true;
$router->get('/', 'HomeController@actionIndex');
});

static::assertTrue($rebuilt, 'Corrupt cache must trigger a rebuild, not a failure');
static::assertEquals(1, $router->getRoutes()->count(), 'Routes must be rebuilt from the callback');
}

public function testBootStillWorksWhenCacheIsUnwritable(): void
{
// Point the cache at a path that cannot be created (a file where a
// directory is expected), so the underlying write fails — mirroring an
// unwritable cache directory on a production node.
$files = new Filesystem;
$files->makeDirectory(self::$cachePath, 0777, true, true);
$blocker = self::$cachePath . '/blocker';
file_put_contents($blocker, 'x');
$this->app['config']['cache.path'] = $blocker . '/nested';

$router = $this->getRouter();
$key = $router->cache(__FILE__, function () use ($router) {
$router->get('/', 'HomeController@actionIndex');
});

static::assertNotNull($key, 'cache() must return normally despite the write failure');
static::assertEquals(1, $router->getRoutes()->count(), 'Routes must still be defined when caching fails');
}
}
Loading