diff --git a/src/Symfony/Component/HttpKernel/HttpCache/HttpCache.php b/src/Symfony/Component/HttpKernel/HttpCache/HttpCache.php index 941d4c6fa033d..ef1068d568340 100644 --- a/src/Symfony/Component/HttpKernel/HttpCache/HttpCache.php +++ b/src/Symfony/Component/HttpKernel/HttpCache/HttpCache.php @@ -549,49 +549,39 @@ protected function lock(Request $request, Response $entry) // try to acquire a lock to call the backend $lock = $this->store->lock($request); + if (true === $lock) { + // we have the lock, call the backend + return false; + } + // there is already another process calling the backend - if (true !== $lock) { - // check if we can serve the stale entry - if (null === $age = $entry->headers->getCacheControlDirective('stale-while-revalidate')) { - $age = $this->options['stale_while_revalidate']; - } - if (abs($entry->getTtl()) < $age) { - $this->record($request, 'stale-while-revalidate'); + // May we serve a stale response? + if ($this->mayServeStaleWhileRevalidate($entry)) { + $this->record($request, 'stale-while-revalidate'); - // server the stale response while there is a revalidation - return true; - } - - // wait for the lock to be released - $wait = 0; - while ($this->store->isLocked($request) && $wait < 5000000) { - usleep(50000); - $wait += 50000; - } + return true; + } - if ($wait < 5000000) { - // replace the current entry with the fresh one - $new = $this->lookup($request); - $entry->headers = $new->headers; - $entry->setContent($new->getContent()); - $entry->setStatusCode($new->getStatusCode()); - $entry->setProtocolVersion($new->getProtocolVersion()); - foreach ($new->headers->getCookies() as $cookie) { - $entry->headers->setCookie($cookie); - } - } else { - // backend is slow as hell, send a 503 response (to avoid the dog pile effect) - $entry->setStatusCode(503); - $entry->setContent('503 Service Unavailable'); - $entry->headers->set('Retry-After', 10); + // wait for the lock to be released + if ($this->waitForLock($request)) { + // replace the current entry with the fresh one + $new = $this->lookup($request); + $entry->headers = $new->headers; + $entry->setContent($new->getContent()); + $entry->setStatusCode($new->getStatusCode()); + $entry->setProtocolVersion($new->getProtocolVersion()); + foreach ($new->headers->getCookies() as $cookie) { + $entry->headers->setCookie($cookie); } - - return true; + } else { + // backend is slow as hell, send a 503 response (to avoid the dog pile effect) + $entry->setStatusCode(503); + $entry->setContent('503 Service Unavailable'); + $entry->headers->set('Retry-After', 10); } - // we have the lock, call the backend - return false; + return true; } /** @@ -710,4 +700,41 @@ private function record(Request $request, $event) } $this->traces[$request->getMethod().' '.$path][] = $event; } + + /** + * Checks whether the given (cached) response may be served as "stale" when a revalidation + * is currently in progress. + * + * @param Response $entry + * + * @return bool True when the stale response may be served, false otherwise. + */ + private function mayServeStaleWhileRevalidate(Response $entry) + { + $timeout = $entry->headers->getCacheControlDirective('stale-while-revalidate'); + + if ($timeout === null) { + $timeout = $this->options['stale_while_revalidate']; + } + + return abs($entry->getTtl()) < $timeout; + } + + /** + * Waits for the store to release a locked entry. + * + * @param Request $request The request to wait for + * + * @return bool True if the lock was released before the internal timeout was hit; false if the wait timeout was exceeded. + */ + private function waitForLock(Request $request) + { + $wait = 0; + while ($this->store->isLocked($request) && $wait < 5000000) { + usleep(50000); + $wait += 50000; + } + + return $wait < 5000000; + } } diff --git a/src/Symfony/Component/HttpKernel/Tests/HttpCache/HttpCacheTest.php b/src/Symfony/Component/HttpKernel/Tests/HttpCache/HttpCacheTest.php index 7751f6e0ccfdc..c300a3afa6614 100644 --- a/src/Symfony/Component/HttpKernel/Tests/HttpCache/HttpCacheTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/HttpCache/HttpCacheTest.php @@ -562,6 +562,42 @@ public function testHitsCachedResponseWithMaxAgeDirective() $this->assertEquals('Hello World', $this->response->getContent()); } + public function testDegradationWhenCacheLocked() + { + $this->cacheConfig['stale_while_revalidate'] = 10; + + // The prescence of Last-Modified makes this cacheable (because Response::isValidateable() then). + $this->setNextResponse(200, array('Cache-Control' => 'public, s-maxage=5', 'Last-Modified' => 'some while ago'), 'Old response'); + $this->request('GET', '/'); // warm the cache + + // Now, lock the cache + $concurrentRequest = Request::create('/', 'GET'); + $this->store->lock($concurrentRequest); + + /* + * After 10s, the cached response has become stale. Yet, we're still within the "stale_while_revalidate" + * timeout so we may serve the stale response. + */ + sleep(10); + + $this->request('GET', '/'); + $this->assertHttpKernelIsNotCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertTraceContains('stale-while-revalidate'); + $this->assertEquals('Old response', $this->response->getContent()); + + /* + * Another 10s later, stale_while_revalidate is over. Resort to serving the old response, but + * do so with a "server unavailable" message. + */ + sleep(10); + + $this->request('GET', '/'); + $this->assertHttpKernelIsNotCalled(); + $this->assertEquals(503, $this->response->getStatusCode()); + $this->assertEquals('Old response', $this->response->getContent()); + } + public function testHitsCachedResponseWithSMaxAgeDirective() { $time = \DateTime::createFromFormat('U', time() - 5); diff --git a/src/Symfony/Component/HttpKernel/Tests/HttpCache/HttpCacheTestCase.php b/src/Symfony/Component/HttpKernel/Tests/HttpCache/HttpCacheTestCase.php index 96a66771a06bc..ed5c690d60bcb 100644 --- a/src/Symfony/Component/HttpKernel/Tests/HttpCache/HttpCacheTestCase.php +++ b/src/Symfony/Component/HttpKernel/Tests/HttpCache/HttpCacheTestCase.php @@ -29,6 +29,10 @@ class HttpCacheTestCase extends TestCase protected $responses; protected $catch; protected $esi; + + /** + * @var Store + */ protected $store; protected function setUp()