Skip to content

Commit 2360f2b

Browse files
committed
feat: cache size limit
1 parent b763569 commit 2360f2b

File tree

5 files changed

+270
-26
lines changed

5 files changed

+270
-26
lines changed

config/image-transform-url.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@
8282
'enabled' => env('IMAGE_TRANSFORM_CACHE_ENABLED', true),
8383
'lifetime' => env('IMAGE_TRANSFORM_CACHE_LIFETIME', 60 * 24 * 7), // 7 days
8484
'disk' => env('IMAGE_TRANSFORM_CACHE_DISK', 'local'),
85+
'max_size_mb' => env('IMAGE_TRANSFORM_CACHE_MAX_SIZE_MB', 100), // 100 MB
86+
'clear_to_percent' => env('IMAGE_TRANSFORM_CACHE_CLEAR_TO_PERCENT', 80), // 80% of max size
8587
],
8688

8789
/*

src/Http/Controllers/ImageTransformerController.php

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use AceOfAces\LaravelImageTransformUrl\Enums\AllowedMimeTypes;
88
use AceOfAces\LaravelImageTransformUrl\Enums\AllowedOptions;
9+
use AceOfAces\LaravelImageTransformUrl\Traits\ManagesImageCache;
910
use AceOfAces\LaravelImageTransformUrl\Traits\ResolvesOptions;
1011
use Illuminate\Http\Request;
1112
use Illuminate\Http\Response;
@@ -14,7 +15,6 @@
1415
use Illuminate\Support\Facades\Cache;
1516
use Illuminate\Support\Facades\File;
1617
use Illuminate\Support\Facades\RateLimiter;
17-
use Illuminate\Support\Facades\Storage;
1818
use Illuminate\Support\Str;
1919
use Intervention\Image\Drivers\Gd\Encoders\WebpEncoder;
2020
use Intervention\Image\Encoders\AutoEncoder;
@@ -25,7 +25,7 @@
2525

2626
class ImageTransformerController extends \Illuminate\Routing\Controller
2727
{
28-
use ResolvesOptions;
28+
use ManagesImageCache, ResolvesOptions;
2929

3030
public function transformWithPrefix(Request $request, string $pathPrefix, string $options, string $path)
3131
{
@@ -127,19 +127,7 @@ protected function handleTransform(Request $request, ?string $pathPrefix, string
127127

128128
if (config()->boolean('image-transform-url.cache.enabled')) {
129129
defer(function () use ($pathPrefix, $path, $options, $encoded) {
130-
131-
$cachePath = $this->getCachePath($pathPrefix, $path, $options);
132-
133-
$cacheDir = dirname($cachePath);
134-
135-
File::ensureDirectoryExists($cacheDir);
136-
File::put($cachePath, $encoded->toString());
137-
138-
Cache::put(
139-
key: 'image-transform-url:'.$cachePath,
140-
value: true,
141-
ttl: config()->integer('image-transform-url.cache.lifetime'),
142-
);
130+
$this->storeCachedImage($pathPrefix, $path, $options, $encoded);
143131
});
144132
}
145133

@@ -235,16 +223,6 @@ protected static function parseOptions(string $options): array
235223
})->toArray();
236224
}
237225

238-
/**
239-
* Get the cache path for the given path and options.
240-
*/
241-
protected static function getCachePath(string $pathPrefix, string $path, array $options): string
242-
{
243-
$optionsHash = md5(json_encode($options));
244-
245-
return Storage::disk(config()->string('image-transform-url.cache.disk'))->path('_cache/image-transform-url/'.$pathPrefix.'/'.$optionsHash.'_'.$path);
246-
}
247-
248226
/**
249227
* Respond with the image content.
250228
*/

src/Traits/ManagesImageCache.php

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AceOfAces\LaravelImageTransformUrl\Traits;
6+
7+
use Illuminate\Contracts\Filesystem\Filesystem;
8+
use Illuminate\Support\Facades\Cache;
9+
use Illuminate\Support\Facades\Storage;
10+
use Intervention\Image\Interfaces\EncodedImageInterface;
11+
12+
trait ManagesImageCache
13+
{
14+
/**
15+
* Check if cache is within size limits and cleanup if necessary.
16+
*/
17+
protected function manageCacheSize(): void
18+
{
19+
$maxSizeBytes = config()->integer('image-transform-url.cache.max_size_mb', 100) * 1024 * 1024;
20+
21+
$disk = Storage::disk(config()->string('image-transform-url.cache.disk'));
22+
$cacheBasePath = '_cache/image-transform-url';
23+
24+
if (! $disk->exists($cacheBasePath)) {
25+
return;
26+
}
27+
28+
$currentSize = $this->calculateCacheSize($disk, $cacheBasePath);
29+
30+
if ($currentSize <= $maxSizeBytes) {
31+
return;
32+
}
33+
34+
$this->cleanupOldCacheFiles($disk, $cacheBasePath, $maxSizeBytes);
35+
}
36+
37+
/**
38+
* Calculate the total size of the cache directory.
39+
*/
40+
protected function calculateCacheSize(Filesystem $disk, string $cacheBasePath): int
41+
{
42+
$totalSize = 0;
43+
$files = $disk->allFiles($cacheBasePath);
44+
45+
foreach ($files as $file) {
46+
$totalSize += $disk->size($file);
47+
}
48+
49+
return $totalSize;
50+
}
51+
52+
/**
53+
* Clean up old cache files to make room for new ones.
54+
*/
55+
protected function cleanupOldCacheFiles(Filesystem $disk, string $cacheBasePath, int $maxSizeBytes): void
56+
{
57+
$files = $disk->allFiles($cacheBasePath);
58+
59+
$fileInfo = [];
60+
61+
foreach ($files as $file) {
62+
63+
$fullPath = $disk->path($file);
64+
65+
if ($disk->exists($file)) {
66+
$fileInfo[] = [
67+
'path' => $file,
68+
'full_path' => $fullPath,
69+
'size' => $disk->size($file),
70+
'modified' => $disk->lastModified($file),
71+
];
72+
}
73+
}
74+
75+
usort($fileInfo, fn ($a, $b) => $a['modified'] <=> $b['modified']);
76+
77+
$currentSize = array_sum(array_column($fileInfo, 'size'));
78+
79+
$clearToPercent = config()->integer('image-transform-url.cache.clear_to_percent', 80);
80+
$clearToPercent = max(0, min(99, $clearToPercent));
81+
82+
$targetSize = (int) ($maxSizeBytes * ($clearToPercent / 100));
83+
84+
foreach ($fileInfo as $file) {
85+
if ($currentSize <= $targetSize) {
86+
break;
87+
}
88+
89+
$disk->delete($file['path']);
90+
91+
$cacheKey = 'image-transform-url:'.$file['full_path'];
92+
Cache::forget($cacheKey);
93+
94+
$currentSize -= $file['size'];
95+
}
96+
}
97+
98+
/**
99+
* Store image in cache with size management.
100+
*/
101+
protected function storeCachedImage(?string $pathPrefix, ?string $path, array $options, EncodedImageInterface $encoded): void
102+
{
103+
$cachePath = $this->getCachePath($pathPrefix, $path, $options);
104+
$disk = Storage::disk(config()->string('image-transform-url.cache.disk'));
105+
106+
$endPath = $this->getCacheEndPath($pathPrefix, $path, $options);
107+
108+
$disk->put($endPath, $encoded->toString());
109+
110+
$this->manageCacheSize();
111+
112+
Cache::put(
113+
key: 'image-transform-url:'.$cachePath,
114+
value: true,
115+
ttl: config()->integer('image-transform-url.cache.lifetime'),
116+
);
117+
}
118+
119+
/**
120+
* Get the cache path for the given path and options.
121+
*/
122+
protected function getCachePath(?string $pathPrefix, ?string $path, array $options): string
123+
{
124+
$cachePath = $this->getCacheEndPath($pathPrefix, $path, $options);
125+
126+
return Storage::disk(config()->string('image-transform-url.cache.disk'))->path($cachePath);
127+
}
128+
129+
protected function getCacheEndPath(?string $pathPrefix, ?string $path, array $options): string
130+
{
131+
$optionsHash = md5(json_encode($options));
132+
133+
return '_cache/image-transform-url/'.$pathPrefix.'/'.$optionsHash.'_'.$path;
134+
}
135+
}

tests/Feature/CacheTest.php

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,131 @@
5656
$differentVersionResponse->assertOk();
5757
$differentVersionResponse->assertHeader('X-Cache', 'MISS');
5858
});
59+
60+
it('can disable the cache', function () {
61+
/** @var TestCase $this */
62+
config()->set('image-transform-url.cache.enabled', false);
63+
64+
for($i = 0; $i < 2; $i++) {
65+
$response = $this->get(route('image.transform.default', [
66+
'options' => 'width=500',
67+
'path' => 'cat.jpg',
68+
]));
69+
70+
$response->assertOk();
71+
$response->assertHeaderMissing('X-Cache');
72+
}
73+
});
74+
75+
it('can manage cache size limit by cleaning up old files', function () {
76+
/** @var TestCase $this */
77+
// Set a high cache size limit first
78+
config()->set('image-transform-url.cache.max_size_mb', 100);
79+
80+
$responses = [];
81+
$totalSizeMB = 0;
82+
83+
// Fill up the cache with some images
84+
for ($i = 0; $i < 10; $i++) {
85+
$response = $this->get(route('image.transform.default', [
86+
'options' => "width=1200,version={$i}",
87+
'path' => 'cat.jpg',
88+
]));
89+
90+
$disk = Storage::disk(config()->string('image-transform-url.cache.disk'));
91+
$size = $disk->size($this->getCacheEndPath('test-data', 'cat.jpg', ['width' => 1200, 'version' => $i]));
92+
93+
$totalSizeMB += $size / (1024 * 1024);
94+
95+
$response->assertOk();
96+
$response->assertHeader('X-Cache', 'MISS');
97+
98+
$responses[] = $response;
99+
}
100+
101+
// Set the used cache size as the upper limit, so we can test the cleanup
102+
config()->set('image-transform-url.cache.max_size_mb', (int) ceil($totalSizeMB));
103+
104+
// Create one more image that should trigger cache cleanup
105+
$finalResponse = $this->get(route('image.transform.default', [
106+
'options' => 'width=2400,version=99',
107+
'path' => 'cat.jpg',
108+
]));
109+
110+
$finalResponse->assertOk();
111+
$finalResponse->assertHeader('X-Cache', 'MISS');
112+
113+
$disk = Storage::disk(config()->string('image-transform-url.cache.disk'));
114+
115+
expect($disk->exists('_cache/image-transform-url'))->toBeTrue();
116+
117+
$allFiles = $disk->allFiles('_cache/image-transform-url');
118+
119+
$totalSizeAfterClearMB = 0;
120+
121+
foreach ($allFiles as $filePath) {
122+
$size = $disk->size($filePath);
123+
$totalSizeAfterClearMB += $size / (1024 * 1024);
124+
}
125+
126+
$maxAfterClearSizeMB = config()->integer('image-transform-url.cache.max_size_mb') * (config()->integer('image-transform-url.cache.clear_to_percent') / 100);
127+
128+
expect($totalSizeAfterClearMB)->toBeLessThanOrEqual($maxAfterClearSizeMB);
129+
});
130+
131+
it('deletes files in the right order when cleaning up the cache', function () {
132+
/** @var TestCase $this */
133+
config()->set('image-transform-url.cache.max_size_mb', 100);
134+
135+
$responses = [];
136+
$cacheFilePaths = [];
137+
$totalSizeMB = 0;
138+
139+
// Fill up the cache with some images
140+
for ($i = 0; $i < 10; $i++) {
141+
$response = $this->get(route('image.transform.default', [
142+
'options' => "width=1200,version={$i}",
143+
'path' => 'cat.jpg',
144+
]));
145+
146+
$disk = Storage::disk(config()->string('image-transform-url.cache.disk'));
147+
$endPath = $this->getCacheEndPath('test-data', 'cat.jpg', ['width' => 1200, 'version' => $i]);
148+
149+
$cacheFilePaths[] = $endPath;
150+
151+
$size = $disk->size($endPath);
152+
153+
$totalSizeMB += $size / (1024 * 1024);
154+
155+
$response->assertOk();
156+
$response->assertHeader('X-Cache', 'MISS');
157+
158+
$responses[] = $response;
159+
160+
usleep(50_000);
161+
}
162+
163+
$disk = Storage::disk(config()->string('image-transform-url.cache.disk'));
164+
165+
expect($disk->exists('_cache/image-transform-url'))->toBeTrue();
166+
167+
// Set the used cache size as the upper limit, so we can test the cleanup
168+
config()->set('image-transform-url.cache.max_size_mb', (int) $totalSizeMB);
169+
170+
// Create one more image that should trigger cache cleanup
171+
$finalResponse = $this->get(route('image.transform.default', [
172+
'options' => 'width=2400,version=99',
173+
'path' => 'cat.jpg',
174+
]));
175+
176+
$lastCacheFilePath = $this->getCacheEndPath('test-data', 'cat.jpg', ['width' => 2400, 'version' => 99]);
177+
178+
$finalResponse->assertOk();
179+
$finalResponse->assertHeader('X-Cache', 'MISS');
180+
181+
expect($disk->exists('_cache/image-transform-url'))->toBeTrue();
182+
183+
expect($disk->exists($lastCacheFilePath))->toBeTrue(); // The last file should still exist
184+
185+
expect($disk->exists($cacheFilePaths[0]))->toBeFalse(); // The oldest file should at be deleted
186+
});

tests/TestCase.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use AceOfAces\LaravelImageTransformUrl\Enums\AllowedOptions;
66
use AceOfAces\LaravelImageTransformUrl\LaravelImageTransformUrlServiceProvider;
7+
use AceOfAces\LaravelImageTransformUrl\Traits\ManagesImageCache;
78
use Illuminate\Contracts\Config\Repository;
89
use Illuminate\Support\Facades\Storage;
910
use Intervention\Image\Laravel\ServiceProvider as InterventionImageServiceProvider;
@@ -12,7 +13,7 @@
1213

1314
class TestCase extends Orchestra
1415
{
15-
use WithWorkbench;
16+
use ManagesImageCache, WithWorkbench;
1617

1718
protected $loadEnvironmentVariables = false;
1819

0 commit comments

Comments
 (0)