Skip to content

Commit aaa67ea

Browse files
bug #60502 [HttpCache] Hit the backend only once after waiting for the cache lock (mpdude)
This PR was squashed before being merged into the 6.4 branch. Discussion ---------- [HttpCache] Hit the backend only once after waiting for the cache lock | Q | A | ------------- | --- | Branch? | 6.4 | Bug fix? | yes | New feature? | no | Deprecations? | no | Issues | | License | MIT When the `HttpCache` has to wait for a lock held by another, concurrent process, the `lock()` method wants to make sure we continue operation based on the most recent cache entry, possibly updated by the concurrent process. So, after waiting for lock release, it calls `lookup()` to obtain this cache entry. This is, in fact, a reentrant call up into the current call stack. Having `lookup()` multiple times on the call stack opens up a way to call the backend for validation multiple times. I have observed this in practice at least in combination with the `no-cache` cache-control header, causing surprising side effects™️ ✨. Also without `no-cache` you can get strange-looking cache traces like `stale, valid, store, fresh`. Those occur only when concurrent locking is a issue. I am not super happy with using an exception for a control flow issue like this. But, rolling back to `lookup` seems to be the most sensible decision for me, and using special return values to indicate this condition isn't really pretty either. Commits ------- df064b0 [HttpCache] Hit the backend only once after waiting for the cache lock
2 parents c69e4ab + df064b0 commit aaa67ea

File tree

4 files changed

+96
-12
lines changed

4 files changed

+96
-12
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpKernel\HttpCache;
13+
14+
/**
15+
* @internal
16+
*/
17+
class CacheWasLockedException extends \Exception
18+
{
19+
}

src/Symfony/Component/HttpKernel/HttpCache/HttpCache.php

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,13 @@ public function handle(Request $request, int $type = HttpKernelInterface::MAIN_R
219219
$this->record($request, 'reload');
220220
$response = $this->fetch($request, $catch);
221221
} else {
222-
$response = $this->lookup($request, $catch);
222+
$response = null;
223+
do {
224+
try {
225+
$response = $this->lookup($request, $catch);
226+
} catch (CacheWasLockedException) {
227+
}
228+
} while (null === $response);
223229
}
224230

225231
$this->restoreResponseBody($request, $response);
@@ -576,15 +582,7 @@ protected function lock(Request $request, Response $entry): bool
576582

577583
// wait for the lock to be released
578584
if ($this->waitForLock($request)) {
579-
// replace the current entry with the fresh one
580-
$new = $this->lookup($request);
581-
$entry->headers = $new->headers;
582-
$entry->setContent($new->getContent());
583-
$entry->setStatusCode($new->getStatusCode());
584-
$entry->setProtocolVersion($new->getProtocolVersion());
585-
foreach ($new->headers->getCookies() as $cookie) {
586-
$entry->headers->setCookie($cookie);
587-
}
585+
throw new CacheWasLockedException(); // unwind back to handle(), try again
588586
} else {
589587
// backend is slow as hell, send a 503 response (to avoid the dog pile effect)
590588
$entry->setStatusCode(503);

src/Symfony/Component/HttpKernel/Tests/HttpCache/HttpCacheTest.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Symfony\Component\HttpKernel\Event\TerminateEvent;
1919
use Symfony\Component\HttpKernel\HttpCache\Esi;
2020
use Symfony\Component\HttpKernel\HttpCache\HttpCache;
21+
use Symfony\Component\HttpKernel\HttpCache\Store;
2122
use Symfony\Component\HttpKernel\HttpCache\StoreInterface;
2223
use Symfony\Component\HttpKernel\HttpKernelInterface;
2324
use Symfony\Component\HttpKernel\Kernel;
@@ -717,6 +718,7 @@ public function testDegradationWhenCacheLocked()
717718
*/
718719
sleep(10);
719720

721+
$this->store = $this->createStore(); // create another store instance that does not hold the current lock
720722
$this->request('GET', '/');
721723
$this->assertHttpKernelIsNotCalled();
722724
$this->assertEquals(200, $this->response->getStatusCode());
@@ -735,6 +737,64 @@ public function testDegradationWhenCacheLocked()
735737
$this->assertEquals('Old response', $this->response->getContent());
736738
}
737739

740+
public function testHitBackendOnlyOnceWhenCacheWasLocked()
741+
{
742+
// Disable stale-while-revalidate, it circumvents waiting for the lock
743+
$this->cacheConfig['stale_while_revalidate'] = 0;
744+
745+
$this->setNextResponses([
746+
[
747+
'status' => 200,
748+
'body' => 'initial response',
749+
'headers' => [
750+
'Cache-Control' => 'public, no-cache',
751+
'Last-Modified' => 'some while ago',
752+
],
753+
],
754+
[
755+
'status' => 304,
756+
'body' => '',
757+
'headers' => [
758+
'Cache-Control' => 'public, no-cache',
759+
'Last-Modified' => 'some while ago',
760+
],
761+
],
762+
[
763+
'status' => 500,
764+
'body' => 'The backend should not be called twice during revalidation',
765+
'headers' => [],
766+
],
767+
]);
768+
769+
$this->request('GET', '/'); // warm the cache
770+
771+
// Use a store that simulates a cache entry being locked upon first attempt
772+
$this->store = new class(sys_get_temp_dir() . '/http_cache') extends Store {
773+
private bool $hasLock = false;
774+
775+
public function lock(Request $request): bool
776+
{
777+
$hasLock = $this->hasLock;
778+
$this->hasLock = true;
779+
780+
return $hasLock;
781+
}
782+
783+
public function isLocked(Request $request): bool
784+
{
785+
return false;
786+
}
787+
};
788+
789+
$this->request('GET', '/'); // hit the cache with simulated lock/concurrency block
790+
791+
$this->assertEquals(200, $this->response->getStatusCode());
792+
$this->assertEquals('initial response', $this->response->getContent());
793+
794+
$traces = $this->cache->getTraces();
795+
$this->assertSame(['stale', 'valid', 'store'], current($traces));
796+
}
797+
738798
public function testHitsCachedResponseWithSMaxAgeDirective()
739799
{
740800
$time = \DateTimeImmutable::createFromFormat('U', time() - 5);

src/Symfony/Component/HttpKernel/Tests/HttpCache/HttpCacheTestCase.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ abstract class HttpCacheTestCase extends TestCase
3030
protected $responses;
3131
protected $catch;
3232
protected $esi;
33-
protected Store $store;
33+
protected ?Store $store = null;
3434

3535
protected function setUp(): void
3636
{
@@ -115,7 +115,9 @@ public function request($method, $uri = '/', $server = [], $cookies = [], $esi =
115115

116116
$this->kernel->reset();
117117

118-
$this->store = new Store(sys_get_temp_dir().'/http_cache');
118+
if (! $this->store) {
119+
$this->store = $this->createStore();
120+
}
119121

120122
if (!isset($this->cacheConfig['debug'])) {
121123
$this->cacheConfig['debug'] = true;
@@ -183,4 +185,9 @@ public static function clearDirectory($directory)
183185

184186
closedir($fp);
185187
}
188+
189+
protected function createStore(): Store
190+
{
191+
return new Store(sys_get_temp_dir() . '/http_cache');
192+
}
186193
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy