Skip to content

Commit 4879a4a

Browse files
committed
fix(cache): fixed lock exception
1 parent 3870d5c commit 4879a4a

2 files changed

Lines changed: 151 additions & 49 deletions

File tree

app/Http/Middleware/CacheMiddleware.php

Lines changed: 46 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
**/
1414

1515
use Closure;
16+
use Illuminate\Contracts\Cache\LockTimeoutException;
1617
use Illuminate\Http\JsonResponse;
1718
use Illuminate\Support\Facades\Cache;
1819
use Illuminate\Support\Facades\Log;
@@ -112,11 +113,7 @@ public function handle($request, Closure $next, $cache_lifetime, $cache_region =
112113

113114
if ($encoded !== null) {
114115
Log::debug("CacheMiddleware: cache HIT", $logCtx);
115-
116-
$data = $this->decode($encoded);
117-
if ($data === null) $data = is_array($encoded) ? $encoded : [];
118-
119-
$response = new JsonResponse($data, 200, ['Content-Type' => 'application/json']);
116+
$response = $this->buildCachedResponse($encoded);
120117
$wasHit = true;
121118
} else {
122119
// Phase 2: cache miss — acquire lock so only one request executes the handler
@@ -125,65 +122,65 @@ public function handle($request, Closure $next, $cache_lifetime, $cache_region =
125122
$wasHit = false;
126123

127124
try {
128-
if ($lock->block(self::LOCK_WAIT)) {
129-
// Won the lock — double-check: another request may have populated the cache
130-
$encoded = $cache->get($key);
131-
132-
if ($encoded !== null) {
133-
Log::debug("CacheMiddleware: cache HIT (after lock)", $logCtx);
134-
135-
$data = $this->decode($encoded);
136-
if ($data === null) $data = is_array($encoded) ? $encoded : [];
137-
138-
$response = new JsonResponse($data, 200, ['Content-Type' => 'application/json']);
139-
$wasHit = true;
140-
} else {
141-
Log::debug("CacheMiddleware: cache MISS (executing handler)", $logCtx);
142-
143-
$resp = $next($request);
125+
$lock->block(self::LOCK_WAIT);
144126

145-
// Only cache 200 JSON responses; let everything else pass through as-is
146-
if ($resp instanceof JsonResponse && $resp->getStatusCode() === 200) {
147-
$cache->put($key, $this->encode($resp->getData(true)), $cache_lifetime);
148-
} else {
149-
return $resp;
150-
}
127+
// Won the lock — double-check: another request may have populated the cache
128+
$encoded = $cache->get($key);
151129

152-
$response = $resp;
153-
}
130+
if ($encoded !== null) {
131+
Log::debug("CacheMiddleware: cache HIT (after lock)", $logCtx);
132+
$response = $this->buildCachedResponse($encoded);
133+
$wasHit = true;
154134
} else {
155-
// Could not acquire lock within LOCK_WAIT seconds — fall through without lock
156-
Log::warning("CacheMiddleware: lock timeout, executing handler without lock", $logCtx);
157-
158-
$resp = $next($request);
159-
160-
if ($resp instanceof JsonResponse && $resp->getStatusCode() === 200) {
161-
$cache->put($key, $this->encode($resp->getData(true)), $cache_lifetime);
162-
} else {
163-
return $resp;
164-
}
165-
166-
$response = $resp;
135+
Log::debug("CacheMiddleware: cache MISS (executing handler)", $logCtx);
136+
$response = $this->executeAndCache($next, $request, $cache, $key, $cache_lifetime);
167137
}
138+
} catch (LockTimeoutException $e) {
139+
Log::warning("CacheMiddleware: lock timeout, executing handler without lock", $logCtx);
140+
$response = $this->executeAndCache($next, $request, $cache, $key, $cache_lifetime);
168141
} finally {
169142
$lock->release();
170143
}
171144
}
172145

173-
// Mark for revalidation so ETag middleware can return 304 when unchanged
174-
$response->setPublic();
175-
$response->setMaxAge(0);
176-
$response->headers->addCacheControlDirective('must-revalidate', true);
177-
$response->headers->addCacheControlDirective('proxy-revalidate', true);
178-
$response->headers->add([
179-
'X-Cache-Result' => $wasHit ? 'HIT' : 'MISS',
180-
]);
146+
// Cache headers only for cacheable (200 JSON) responses
147+
if ($response instanceof JsonResponse && $response->getStatusCode() === 200) {
148+
$response->setPublic();
149+
$response->setMaxAge(0);
150+
$response->headers->addCacheControlDirective('must-revalidate', true);
151+
$response->headers->addCacheControlDirective('proxy-revalidate', true);
152+
$response->headers->add([
153+
'X-Cache-Result' => $wasHit ? 'HIT' : 'MISS',
154+
]);
155+
}
181156

182157
Log::debug("CacheMiddleware: returning response", $logCtx);
183158

184159
return $response;
185160
}
186161

162+
/**
163+
* Decode a cached value and wrap it in a JsonResponse.
164+
*/
165+
private function buildCachedResponse($encoded): JsonResponse
166+
{
167+
$data = $this->decode($encoded);
168+
if ($data === null) $data = is_array($encoded) ? $encoded : [];
169+
return new JsonResponse($data, 200, ['Content-Type' => 'application/json']);
170+
}
171+
172+
/**
173+
* Execute the next handler and cache the response if it is a 200 JSON response.
174+
*/
175+
private function executeAndCache(Closure $next, $request, $cache, string $key, int $cache_lifetime)
176+
{
177+
$resp = $next($request);
178+
if ($resp instanceof JsonResponse && $resp->getStatusCode() === 200) {
179+
$cache->put($key, $this->encode($resp->getData(true)), $cache_lifetime);
180+
}
181+
return $resp;
182+
}
183+
187184
/**
188185
* Build a cache key based on path + sorted query params
189186
*/

tests/Unit/CacheMiddlewareTest.php

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php namespace Tests\Unit;
2+
/**
3+
* Copyright 2026 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
15+
use App\Http\Middleware\CacheMiddleware;
16+
use Illuminate\Contracts\Cache\LockTimeoutException;
17+
use Illuminate\Http\JsonResponse;
18+
use Illuminate\Http\Request;
19+
use Illuminate\Support\Facades\Cache;
20+
use Illuminate\Support\Facades\Log;
21+
use models\oauth2\IResourceServerContext;
22+
use Tests\TestCase;
23+
24+
/**
25+
* Class CacheMiddlewareTest
26+
* Tests for CacheMiddleware lock timeout handling
27+
*/
28+
final class CacheMiddlewareTest extends TestCase
29+
{
30+
/**
31+
* Test that LockTimeoutException is caught and handled gracefully
32+
* When block() throws LockTimeoutException, the middleware should:
33+
* 1. Log a WARNING (not propagate the exception as ERROR)
34+
* 2. Execute the handler without the lock
35+
* 3. Return a valid response
36+
*
37+
* @return void
38+
*/
39+
public function test_lock_timeout_exception_handled_gracefully()
40+
{
41+
// Arrange: Create a GET request
42+
$request = Request::create('/api/v1/summits/123', 'GET');
43+
44+
// Mock the resource server context (no user for this test)
45+
$context = $this->createMock(IResourceServerContext::class);
46+
$context->method('getCurrentUser')->willReturn(null);
47+
48+
// Create the middleware instance
49+
$middleware = new CacheMiddleware($context);
50+
51+
// Mock the next handler to return a JSON response
52+
$expectedData = ['data' => 'test_response'];
53+
$next = function ($req) use ($expectedData) {
54+
return new JsonResponse($expectedData, 200);
55+
};
56+
57+
// Mock Cache facade behavior
58+
$mockCache = $this->createMock(\Illuminate\Contracts\Cache\Repository::class);
59+
$mockLock = $this->createMock(\Illuminate\Contracts\Cache\Lock::class);
60+
61+
// First get() returns null (cache miss)
62+
$mockCache->expects($this->once())
63+
->method('get')
64+
->willReturn(null);
65+
66+
// Lock is created
67+
$mockCache->expects($this->once())
68+
->method('lock')
69+
->willReturn($mockLock);
70+
71+
// block() throws LockTimeoutException (simulating timeout)
72+
$mockLock->expects($this->once())
73+
->method('block')
74+
->willThrowException(new LockTimeoutException());
75+
76+
// Lock release is attempted in finally block
77+
$mockLock->expects($this->once())
78+
->method('release');
79+
80+
// Cache put should be called (caching the 200 response)
81+
$mockCache->expects($this->once())
82+
->method('put');
83+
84+
Cache::shouldReceive('store')
85+
->once()
86+
->andReturn($mockCache);
87+
88+
// Expect WARNING log (not ERROR)
89+
Log::shouldReceive('warning')
90+
->once()
91+
->with('CacheMiddleware: lock timeout, executing handler without lock', $this->anything());
92+
93+
Log::shouldReceive('debug')
94+
->zeroOrMoreTimes();
95+
96+
// Act: Handle the request through the middleware
97+
$response = $middleware->handle($request, $next, 60);
98+
99+
// Assert: Response is valid, not a 500 error
100+
$this->assertInstanceOf(JsonResponse::class, $response);
101+
$this->assertEquals(200, $response->getStatusCode());
102+
$this->assertEquals($expectedData, $response->getData(true));
103+
$this->assertEquals('MISS', $response->headers->get('X-Cache-Result'));
104+
}
105+
}

0 commit comments

Comments
 (0)