Skip to content

Commit

Permalink
Add Memcached cache backend
Browse files Browse the repository at this point in the history
  • Loading branch information
alexandre-daubois committed Feb 26, 2024
1 parent 92ea72c commit f8b7e8b
Show file tree
Hide file tree
Showing 7 changed files with 298 additions and 5 deletions.
8 changes: 7 additions & 1 deletion .github/workflows/ci.yaml
Expand Up @@ -12,14 +12,20 @@ jobs:

runs-on: ubuntu-latest

services:
memcached:
image: memcached:1.6.5
ports:
- 11211:11211

steps:
- uses: actions/checkout@v3

- name: Setup PHP with fail-fast
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: apcu
extensions: apcu, memcached
ini-values: |
apc.enable_cli=1
Expand Down
5 changes: 3 additions & 2 deletions README.md
Expand Up @@ -250,5 +250,6 @@ If you have your own cache system, you can use the `Pkl::setCache()` method to s

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.
* `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 ;
* `MemcachedCache`, which uses the Memcached extension to store the cache in memory.
3 changes: 2 additions & 1 deletion composer.json
Expand Up @@ -30,7 +30,8 @@
"sort-packages": true
},
"suggest": {
"ext-apcu": "To use the APCu cache backend"
"ext-apcu": "To use the APCu cache backend",
"ext-memcached": "To use the Memcached cache backend"
},
"bin": [
"phikl"
Expand Down
2 changes: 1 addition & 1 deletion src/Cache/ApcuCache.php
Expand Up @@ -16,7 +16,7 @@ public function __construct()
throw new \RuntimeException('APCu extension is not loaded');
}

if (!function_exists('apcu_enabled') || !apcu_enabled()) {
if (!\function_exists('apcu_enabled') || !apcu_enabled()) {
throw new \RuntimeException('APCu is not enabled');
}
}
Expand Down
124 changes: 124 additions & 0 deletions src/Cache/MemcachedCache.php
@@ -0,0 +1,124 @@
<?php

namespace Phikl\Cache;

use Psr\SimpleCache\CacheInterface;

final class MemcachedCache implements CacheInterface
{
private \Memcached $memcached;

/**
* @param MemcachedServer|array<MemcachedServer> $servers
*/
public function __construct(MemcachedServer|array $servers)
{
if (!\extension_loaded('memcached')) {
throw new \RuntimeException('Memcached extension is not loaded');
}

$servers = \is_array($servers) ? $servers : [$servers];

$this->memcached = new \Memcached('phikl');
foreach ($servers as $server) {
$this->memcached->addServer($server->host, $server->port);
}
}

/**
* @param non-empty-string $key
*/
public function get(string $key, mixed $default = null): Entry|null
{
if ($default !== null && !$default instanceof Entry) {
throw new \InvalidArgumentException('Default value must be null or an instance of Entry');
}

$entry = $this->memcached->get($key);
if ($this->memcached->getResultCode() === \Memcached::RES_NOTFOUND) {
return $default;
}

$entry = @unserialize($entry);
if ($entry === false) {
return $default;
}

return $entry;
}

public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool
{
if (!$value instanceof Entry) {
return false;
}

return $this->memcached->set(
$key,
serialize($value),
$ttl instanceof \DateInterval ? (int) ($ttl->format('U')) - \time() : ($ttl ?? 0)
);
}

public function delete(string $key): bool
{
return $this->memcached->delete($key);
}

/**
* Caution, this method will clear the entire cache, not just the cache for this application.
*/
public function clear(): bool
{
return $this->memcached->flush();
}

/**
* @param iterable<non-empty-string> $keys
*
* @return array<non-empty-string, Entry|null>
*/
public function getMultiple(iterable $keys, mixed $default = null): array
{
$entries = [];
foreach ($keys as $key) {
$entries[$key] = $this->get($key, $default);
}

return $entries;
}

/**
* @param iterable<string, Entry> $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<string> $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 $this->memcached->get($key) !== false;
}
}
12 changes: 12 additions & 0 deletions src/Cache/MemcachedServer.php
@@ -0,0 +1,12 @@
<?php

namespace Phikl\Cache;

final readonly class MemcachedServer
{
public function __construct(
public string $host,
public int $port,
) {
}
}
149 changes: 149 additions & 0 deletions tests/Cache/MemcachedCacheTest.php
@@ -0,0 +1,149 @@
<?php

namespace Phikl\Tests\Cache;

use Phikl\Cache\Entry;
use Phikl\Cache\MemcachedCache;
use Phikl\Cache\MemcachedServer;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
use PHPUnit\Framework\TestCase;

#[RequiresPhpExtension('memcached')]
#[CoversClass(MemcachedCache::class)]
class MemcachedCacheTest extends TestCase
{
private function createMemcachedCache(): MemcachedCache
{
return new MemcachedCache(new MemcachedServer('localhost', 11211));
}

public function testGetWithDefaultOtherThanEntryInstance(): void
{
$cache = $this->createMemcachedCache();

$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Default value must be null or an instance of Entry');

$cache->get('key', 'invalid');
}

public function testGetReturnsDefaultIfKeyDoesNotExist(): void
{
$cache = $this->createMemcachedCache();

$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 = $this->createMemcachedCache();

$entry = new Entry('content', 'hash', $time = \time());

$this->assertTrue($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 = $this->createMemcachedCache();

$this->assertFalse($cache->set('key', 'invalid'));
}

public function testDeleteEntry(): void
{
$cache = $this->createMemcachedCache();

$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 = $this->createMemcachedCache();

$entry = new Entry('content', 'hash', 0);
$cache->set('key', $entry);

$this->assertTrue($cache->clear());
$this->assertNull($cache->get('key'));
}

public function testGetSetMultiple(): void
{
$cache = $this->createMemcachedCache();

$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 = $this->createMemcachedCache();

$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 = $this->createMemcachedCache();

$entry = new Entry('content', 'hash', 0);
$cache->set('key', $entry);

$this->assertTrue($cache->has('key'));
$this->assertFalse($cache->has('invalid'));
}
}

0 comments on commit f8b7e8b

Please sign in to comment.