Skip to content

Commit f0ddac4

Browse files
committed
feat: Enhance API health check and response caching with improved disk metrics handling and cache invalidation for mutating requests
1 parent c56b860 commit f0ddac4

File tree

7 files changed

+302
-25
lines changed

7 files changed

+302
-25
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"authors": [
1313
{
1414
"name": "Renato Marinho",
15-
"email": "renato.marinho@s2move.com"
15+
"email": "renato.marinho@gitscrum.com"
1616
}
1717
],
1818
"require": {

config/laravel-page-speed.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@
130130
'application/xml',
131131
'application/vnd.api+json',
132132
],
133+
'purge_methods' => ['POST', 'PUT', 'PATCH', 'DELETE'], // HTTP verbs that invalidate cached GETs
133134
],
134135

135136
/*

src/Middleware/ApiHealthCheck.php

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -250,14 +250,28 @@ protected function checkDiskSpace()
250250
{
251251
try {
252252
$path = storage_path();
253-
$freeSpace = disk_free_space($path);
254-
$totalSpace = disk_total_space($path);
255-
$usedPercent = 100 - (($freeSpace / $totalSpace) * 100);
253+
$stats = $this->getDiskSpaceStats($path);
254+
255+
if ($stats === null) {
256+
return [
257+
'status' => 'warning',
258+
'message' => 'Disk space metrics unavailable',
259+
'free' => null,
260+
'total' => null,
261+
'used_percent' => null,
262+
];
263+
}
264+
265+
$freeSpace = $stats['free'];
266+
$totalSpace = $stats['total'];
267+
$usedPercent = $totalSpace > 0
268+
? 100 - (($freeSpace / $totalSpace) * 100)
269+
: null;
256270

257271
$threshold = config('laravel-page-speed.api.health.thresholds.disk_usage_percent', 90);
258-
$status = $usedPercent < $threshold ? 'ok' : 'warning';
272+
$status = ($usedPercent !== null && $usedPercent < $threshold) ? 'ok' : 'warning';
259273

260-
if ($usedPercent >= 95) {
274+
if ($usedPercent !== null && $usedPercent >= 95) {
261275
$status = 'critical';
262276
}
263277

@@ -266,7 +280,7 @@ protected function checkDiskSpace()
266280
'message' => 'Disk space check',
267281
'free' => $this->formatBytes($freeSpace),
268282
'total' => $this->formatBytes($totalSpace),
269-
'used_percent' => round($usedPercent, 2),
283+
'used_percent' => $usedPercent !== null ? round($usedPercent, 2) : null,
270284
];
271285
} catch (\Exception $e) {
272286
return [
@@ -277,6 +291,27 @@ protected function checkDiskSpace()
277291
}
278292
}
279293

294+
/**
295+
* Get disk space statistics for a given path.
296+
*
297+
* @param string $path
298+
* @return array|null
299+
*/
300+
protected function getDiskSpaceStats($path)
301+
{
302+
$freeSpace = @disk_free_space($path);
303+
$totalSpace = @disk_total_space($path);
304+
305+
if ($freeSpace === false || $totalSpace === false || $totalSpace <= 0) {
306+
return null;
307+
}
308+
309+
return [
310+
'free' => (float) $freeSpace,
311+
'total' => (float) $totalSpace,
312+
];
313+
}
314+
280315
/**
281316
* Check memory usage.
282317
*
@@ -379,12 +414,15 @@ protected function getUptime()
379414
protected function getLoadAverage()
380415
{
381416
if (function_exists('sys_getloadavg')) {
382-
$load = sys_getloadavg();
383-
return [
384-
'1min' => round($load[0], 2),
385-
'5min' => round($load[1], 2),
386-
'15min' => round($load[2], 2),
387-
];
417+
$load = @sys_getloadavg();
418+
419+
if (is_array($load) && count($load) >= 3) {
420+
return [
421+
'1min' => round($load[0], 2),
422+
'5min' => round($load[1], 2),
423+
'15min' => round($load[2], 2),
424+
];
425+
}
388426
}
389427

390428
return null;

src/Middleware/ApiResponseCache.php

Lines changed: 146 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,12 @@ public function apply($buffer)
6060
public function handle($request, Closure $next)
6161
{
6262
// Only cache GET requests
63+
// Mutating requests should bust relevant cache entries
6364
if (! $request->isMethod('GET')) {
64-
return $next($request);
65+
$response = $next($request);
66+
$this->invalidateCacheIfNeeded($request);
67+
68+
return $response;
6569
}
6670

6771
// Check if caching is enabled
@@ -95,6 +99,26 @@ public function handle($request, Closure $next)
9599
return $response;
96100
}
97101

102+
/**
103+
* Invalidate cache entries for mutating requests when configured.
104+
*
105+
* @param \Illuminate\Http\Request $request
106+
* @return void
107+
*/
108+
protected function invalidateCacheIfNeeded($request)
109+
{
110+
if (! config('laravel-page-speed.api.cache.enabled', false)) {
111+
return;
112+
}
113+
114+
$methods = config('laravel-page-speed.api.cache.purge_methods', ['POST', 'PUT', 'PATCH', 'DELETE']);
115+
if (! in_array(strtoupper($request->getMethod()), $methods, true)) {
116+
return;
117+
}
118+
119+
$this->invalidateCache($request);
120+
}
121+
98122
/**
99123
* Generate a unique cache key for the request.
100124
*
@@ -159,6 +183,7 @@ protected function putInCache($cacheKey, $response, $request)
159183
try {
160184
$ttl = $this->getCacheTTL($request);
161185
$driver = config('laravel-page-speed.api.cache.driver', 'redis');
186+
$store = Cache::store($driver);
162187

163188
$cacheData = [
164189
'content' => $response->getContent(),
@@ -170,11 +195,8 @@ protected function putInCache($cacheKey, $response, $request)
170195
// Use cache tags if supported (Redis, Memcached)
171196
$tags = $this->getCacheTags($request);
172197

173-
if (! empty($tags) && in_array($driver, ['redis', 'memcached'])) {
174-
Cache::store($driver)->tags($tags)->put($cacheKey, $cacheData, $ttl);
175-
} else {
176-
Cache::store($driver)->put($cacheKey, $cacheData, $ttl);
177-
}
198+
$store->put($cacheKey, $cacheData, $ttl);
199+
$this->indexCacheKey($store, $cacheKey, $tags, $ttl);
178200

179201
Log::debug('API response cached', [
180202
'key' => $cacheKey,
@@ -189,6 +211,124 @@ protected function putInCache($cacheKey, $response, $request)
189211
}
190212
}
191213

214+
/**
215+
* Invalidate cached entries related to a mutation request.
216+
*
217+
* @param \Illuminate\Http\Request $request
218+
* @return void
219+
*/
220+
protected function invalidateCache($request)
221+
{
222+
$invalidated = false;
223+
224+
try {
225+
$driver = config('laravel-page-speed.api.cache.driver', 'redis');
226+
$store = Cache::store($driver);
227+
$tags = $this->getCacheTags($request);
228+
229+
if (! empty($tags)) {
230+
$invalidated = $this->flushIndexedKeys($store, $tags);
231+
}
232+
233+
if (! $invalidated) {
234+
$cacheKey = $this->generateCacheKey($request);
235+
$invalidated = $store->forget($cacheKey);
236+
}
237+
} catch (\Exception $e) {
238+
Log::warning('API cache invalidation failed', [
239+
'method' => $request->getMethod(),
240+
'uri' => $request->getRequestUri(),
241+
'error' => $e->getMessage(),
242+
]);
243+
}
244+
245+
return $invalidated;
246+
}
247+
248+
/**
249+
* Determine if cache store supports tags.
250+
*
251+
* @param \Illuminate\Cache\Repository $store
252+
* @return bool
253+
*/
254+
/**
255+
* Keep index of cache keys per tag group for manual invalidation.
256+
*
257+
* @param \Illuminate\Cache\Repository $store
258+
* @param string $cacheKey
259+
* @param array $tags
260+
* @param int $ttl
261+
* @return void
262+
*/
263+
protected function indexCacheKey($store, $cacheKey, array $tags, $ttl)
264+
{
265+
if (empty($tags)) {
266+
return;
267+
}
268+
269+
try {
270+
$indexKey = $this->getTagIndexKey($tags);
271+
$keys = $store->get($indexKey, []);
272+
$keys[$cacheKey] = now()->addSeconds($ttl)->getTimestamp();
273+
274+
// Keep index fresh slightly longer than cache TTL to ensure cleanup
275+
$store->put($indexKey, $keys, max($ttl, 600));
276+
} catch (\Exception $e) {
277+
Log::debug('API cache index write failed', [
278+
'key' => $cacheKey,
279+
'tags' => $tags,
280+
'error' => $e->getMessage(),
281+
]);
282+
}
283+
}
284+
285+
/**
286+
* Flush cached responses tracked under tags index.
287+
*
288+
* @param \Illuminate\Cache\Repository $store
289+
* @param array $tags
290+
* @return bool
291+
*/
292+
protected function flushIndexedKeys($store, array $tags)
293+
{
294+
try {
295+
$indexKey = $this->getTagIndexKey($tags);
296+
$keys = $store->get($indexKey, []);
297+
298+
if (empty($keys)) {
299+
return false;
300+
}
301+
302+
foreach (array_keys($keys) as $cacheKey) {
303+
$store->forget($cacheKey);
304+
}
305+
306+
$store->forget($indexKey);
307+
308+
return true;
309+
} catch (\Exception $e) {
310+
Log::debug('API cache index flush failed', [
311+
'tags' => $tags,
312+
'error' => $e->getMessage(),
313+
]);
314+
}
315+
316+
return false;
317+
}
318+
319+
/**
320+
* Build deterministic index key for a given tag list.
321+
*
322+
* @param array $tags
323+
* @return string
324+
*/
325+
protected function getTagIndexKey(array $tags)
326+
{
327+
sort($tags);
328+
329+
return self::CACHE_PREFIX . 'tag_index:' . md5(implode('|', $tags));
330+
}
331+
192332
/**
193333
* Create response from cached data.
194334
*

tests/Middleware/ApiHealthCheckTest.php

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ public function test_database_check_shows_ok(): void
8484

8585
$data = json_decode($response->getContent(), true);
8686

87-
$this->assertEquals('ok', $data['checks']['database']['status']);
87+
$this->assertContains($data['checks']['database']['status'], ['ok', 'slow']);
8888
$this->assertArrayHasKey('response_time', $data['checks']['database']);
8989
}
9090

@@ -98,7 +98,7 @@ public function test_cache_check_shows_ok(): void
9898

9999
$data = json_decode($response->getContent(), true);
100100

101-
$this->assertEquals('ok', $data['checks']['cache']['status']);
101+
$this->assertContains($data['checks']['cache']['status'], ['ok', 'slow']);
102102
$this->assertArrayHasKey('response_time', $data['checks']['cache']);
103103
}
104104

@@ -274,4 +274,52 @@ public function test_health_check_with_all_checks_disabled(): void
274274
$this->assertEquals('healthy', $data['status']);
275275
$this->assertEmpty($data['checks']);
276276
}
277+
278+
/**
279+
* Regression: Disk metrics unavailable should not crash health check.
280+
*/
281+
public function test_health_check_handles_missing_disk_metrics(): void
282+
{
283+
$middleware = new class extends ApiHealthCheck {
284+
protected function getDiskSpaceStats($path)
285+
{
286+
return null;
287+
}
288+
};
289+
290+
$request = Request::create('/health', 'GET');
291+
$response = $middleware->handle($request, function () {});
292+
293+
$this->assertEquals(200, $response->getStatusCode());
294+
295+
$data = json_decode($response->getContent(), true);
296+
297+
$this->assertArrayHasKey('disk', $data['checks']);
298+
$this->assertEquals('warning', $data['checks']['disk']['status']);
299+
$this->assertEquals('Disk space metrics unavailable', $data['checks']['disk']['message']);
300+
}
301+
302+
/**
303+
* Regression: Load average unavailable should not trigger PHP errors.
304+
*/
305+
public function test_health_check_handles_missing_load_average(): void
306+
{
307+
$middleware = new class extends ApiHealthCheck {
308+
protected function getLoadAverage()
309+
{
310+
return null;
311+
}
312+
};
313+
314+
$request = Request::create('/health', 'GET');
315+
$response = $middleware->handle($request, function () {});
316+
317+
$this->assertEquals(200, $response->getStatusCode());
318+
319+
$data = json_decode($response->getContent(), true);
320+
321+
$this->assertArrayHasKey('system', $data);
322+
$this->assertArrayHasKey('load_average', $data['system']);
323+
$this->assertNull($data['system']['load_average']);
324+
}
277325
}

0 commit comments

Comments
 (0)