diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7c40ea8..90f7710 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,6 +19,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.2' + extensions: apcu env: fail-fast: true diff --git a/README.md b/README.md index abe845c..d8bcde2 100644 --- a/README.md +++ b/README.md @@ -208,10 +208,14 @@ name in the PHP class. ## Caching You can (**and should**) cache the PKL modules to improve performance. This is especially useful when evaluating the same PKL file -multiple times. You can use the `warmup` command to dump the PKL modules to a cache file. Phikl will then use the cache file automatically when evaluating a PKL file. If the PKL file is not found in the cache, Phikl will evaluate the PKL file on the go. +multiple times. **⚠️ Using Phikl with the cache avoids the PKL CLI tool to be executed to evaluate modules and should be done when deploying your application for better performances.** +### Warmup the Cache + +You can use the `warmup` command to dump the PKL modules to a cache file by default. Phikl will then use the cache file automatically when evaluating a PKL file. If the PKL file is not found in the cache, Phikl will evaluate the PKL file on the go. + Phikl will go through all `.pkl` files of your project and dump them to the cache file. Here's an example of how to use the `warmup` command: @@ -240,4 +244,11 @@ Here are a few things to note about Phikl cache: - Phikl will automatically refresh the cache if a PKL module is modified since last warmup - Any corrupted cache entry will be automatically refreshed +### Cache Backends + If you have your own cache system, you can use the `Pkl::setCache()` method to set the cache system to use. You can pass it any instance of compliant PSR-16 cache system implementing `Psr\SimpleCache\CacheInterface`. This is useful you want to use, for example, a Redis server as a cache system for your Pkl modules. + +Phikl comes with the following cache backends: + + * `PersistentCache`, which is the default one used by Phikl. It uses a file to store the cache. + * `APCuCache`, which uses the APCu extension to store the cache in memory. diff --git a/src/Cache/ApcuCache.php b/src/Cache/ApcuCache.php new file mode 100644 index 0000000..f20300f --- /dev/null +++ b/src/Cache/ApcuCache.php @@ -0,0 +1,109 @@ +format('U')) - \time() : ($ttl ?? 0) + ); + } + + public function delete(string $key): bool + { + return \apcu_delete($key); + } + + /** + * Caution, this method will clear the entire cache, not just the cache for this application. + */ + public function clear(): bool + { + return \apcu_clear_cache(); + } + + /** + * @param iterable $keys + * + * @return array + */ + public function getMultiple(iterable $keys, mixed $default = null): array + { + $entries = []; + foreach ($keys as $key) { + $entries[$key] = $this->get($key, $default); + } + + return $entries; + } + + /** + * @param iterable $values + */ + public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool + { + foreach ($values as $key => $value) { + if (!$this->set($key, $value, $ttl)) { + return false; + } + } + + return true; + } + + /** + * @param iterable $keys + */ + public function deleteMultiple(iterable $keys): bool + { + $success = true; + foreach ($keys as $key) { + if (!$this->delete($key)) { + $success = false; + } + } + + return $success; + } + + public function has(string $key): bool + { + return \apcu_exists($key); + } +} diff --git a/src/Cache/PersistentCache.php b/src/Cache/PersistentCache.php index bd3eb4e..aa0ac38 100644 --- a/src/Cache/PersistentCache.php +++ b/src/Cache/PersistentCache.php @@ -13,6 +13,10 @@ use Phikl\Exception\EmptyCacheException; use Psr\SimpleCache\CacheInterface; +/** + * Simple implementation of the PSR-16 CacheInterface using a file for + * Pkl modules evaluation cache. + */ final class PersistentCache implements CacheInterface { private const DEFAULT_CACHE_FILE = '.phikl.cache'; diff --git a/tests/Cache/ApcuCacheTest.php b/tests/Cache/ApcuCacheTest.php new file mode 100644 index 0000000..bf97be8 --- /dev/null +++ b/tests/Cache/ApcuCacheTest.php @@ -0,0 +1,147 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Default value must be null or an instance of Entry'); + + $cache->get('key', 'invalid'); + } + + public function testGetReturnsDefaultIfKeyDoesNotExist(): void + { + $cache = new ApcuCache(); + + $entry = new Entry('content', 'hash', 0); + + $this->assertNull($cache->get('nonexistent')); + $this->assertSame($entry, $cache->get('nonexistent', $entry)); + $this->assertFalse($cache->has('nonexistent')); + } + + public function testGetOnValidSetEntry(): void + { + $cache = new ApcuCache(); + + $entry = new Entry('content', 'hash', $time = \time()); + $cache->set('key', $entry); + + $entry = $cache->get('key'); + $this->assertInstanceOf(Entry::class, $entry); + $this->assertSame('content', $entry->content); + $this->assertSame('hash', $entry->hash); + $this->assertSame($time, $entry->timestamp); + } + + public function testSetReturnsFalseOnInvalidEntry(): void + { + $cache = new ApcuCache(); + + $this->assertFalse($cache->set('key', 'invalid')); + } + + public function testDeleteEntry(): void + { + $cache = new ApcuCache(); + + $entry = new Entry('content', 'hash', 0); + $cache->set('key', $entry); + + $this->assertTrue($cache->delete('key')); + $this->assertNull($cache->get('key')); + } + + public function testClear(): void + { + $cache = new ApcuCache(); + + $entry = new Entry('content', 'hash', 0); + $cache->set('key', $entry); + + $this->assertTrue($cache->clear()); + $this->assertNull($cache->get('key')); + } + + public function testGetSetMultiple(): void + { + $cache = new ApcuCache(); + + $entry1 = new Entry('content1', 'hash1', 0); + $entry2 = new Entry('content2', 'hash2', 0); + $entry3 = new Entry('content3', 'hash3', 0); + + $cache->setMultiple([ + 'key1' => $entry1, + 'key2' => $entry2, + 'key3' => $entry3, + ]); + + $entries = $cache->getMultiple(['key1', 'key2', 'key3']); + + $this->assertArrayHasKey('key1', $entries); + $this->assertArrayHasKey('key2', $entries); + $this->assertArrayHasKey('key3', $entries); + + $this->assertInstanceOf(Entry::class, $entries['key1']); + $this->assertSame('content1', $entries['key1']->content); + $this->assertSame('hash1', $entries['key1']->hash); + + $this->assertInstanceOf(Entry::class, $entries['key2']); + $this->assertSame('content2', $entries['key2']->content); + $this->assertSame('hash2', $entries['key2']->hash); + + $this->assertInstanceOf(Entry::class, $entries['key3']); + $this->assertSame('content3', $entries['key3']->content); + $this->assertSame('hash3', $entries['key3']->hash); + } + + public function testDeleteMultiple(): void + { + $cache = new ApcuCache(); + + $entry1 = new Entry('content1', 'hash1', 0); + $entry2 = new Entry('content2', 'hash2', 0); + $entry3 = new Entry('content3', 'hash3', 0); + + $cache->setMultiple([ + 'key1' => $entry1, + 'key2' => $entry2, + 'key3' => $entry3, + ]); + + $this->assertTrue($cache->deleteMultiple(['key1', 'key2'])); + $this->assertNull($cache->get('key1')); + $this->assertNull($cache->get('key2')); + $this->assertNotNull($cache->get('key3')); + } + + public function testHas(): void + { + $cache = new ApcuCache(); + + $entry = new Entry('content', 'hash', 0); + $cache->set('key', $entry); + + $this->assertTrue($cache->has('key')); + $this->assertFalse($cache->has('invalid')); + } +}