diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 0a5801a..9d37261 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ CacheLayer is a standalone cache toolkit for modern PHP applications. It provides: - PSR-6 and PSR-16 compatible `Cache` facade -- Adapters for `APCu`, `File`, `Memcached`, `Redis`, `Redis Cluster`, `SQLite`, `PostgreSQL` +- Adapters for `APCu`, `File`, `Memcached`, `Redis`, `Redis Cluster`, `PDO (default SQLite; also MySQL/MariaDB/PostgreSQL/etc.)` - In-process adapters: `memory` (array), `weakMap`, `nullStore`, `chain` - Filesystem/Opcode adapter: `phpFiles` - Shared-memory adapter: `sharedMemory` (sysvshm) @@ -40,8 +40,9 @@ Cache::phpFiles('ns', __DIR__ . '/storage/cache'); Cache::memcache('ns'); Cache::redis('ns'); Cache::redisCluster('ns', ['127.0.0.1:6379']); +Cache::pdo('ns'); // defaults to sqlite file in sys temp dir Cache::sqlite('ns'); -Cache::postgres('ns'); +Cache::pdo('ns', 'mysql:host=127.0.0.1;port=3306;dbname=app', 'user', 'pass'); Cache::memory('ns'); Cache::weakMap('ns'); Cache::sharedMemory('ns'); diff --git a/composer.json b/composer.json index c6ab843..65db77d 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,9 @@ "redis", "memcached", "sqlite", + "pdo", + "mysql", + "mariadb", "postgres", "mongodb", "dynamodb", @@ -30,9 +33,10 @@ "ext-apcu": "For APCu-based caching (in-memory, per-process)", "ext-redis": "For Redis-based caching (persistent, networked)", "ext-memcached": "For Memcached-based caching (distributed, RAM)", - "ext-sqlite3": "For SQLite-based caching (file-based, portable)", - "ext-pdo": "For SQLite-based caching (file-based, portable)", - "ext-pdo_pgsql": "For PostgreSQL-based caching via PostgresCacheAdapter", + "ext-pdo": "For PDO-based SQL caching (MySQL/MariaDB/PostgreSQL/etc.)", + "ext-pdo_sqlite": "For default SQLite usage via Cache::pdo(...) or Cache::sqlite(...)", + "ext-pdo_pgsql": "For PostgreSQL usage via Cache::pdo(...)", + "ext-pdo_mysql": "For MySQL/MariaDB usage via Cache::pdo(...)", "ext-sysvshm": "For shared-memory caching via SharedMemoryCacheAdapter", "mongodb/mongodb": "For MongoDB caching via MongoDbCacheAdapter", "aws/aws-sdk-php": "For DynamoDB and S3 adapters", diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..9930de3 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/theme.css b/docs/_static/theme.css new file mode 100644 index 0000000..ae81ea7 --- /dev/null +++ b/docs/_static/theme.css @@ -0,0 +1,3 @@ +.highlight-php .k { + color: #0077aa; /* Example: make PHP keywords a different color */ +} diff --git a/docs/adapters/apcu.rst b/docs/adapters/apcu.rst new file mode 100644 index 0000000..b4d370e --- /dev/null +++ b/docs/adapters/apcu.rst @@ -0,0 +1,21 @@ +.. _adapters.apcu: + +======================= +APCu Adapter (`apcu`) +======================= + +Factory: `Cache::apcu(string $namespace = 'default')` + +Requirements: + +* `ext-apcu` +* APCu enabled (`apcu_enabled()`) +* for CLI usage/tests: `apcu.enable_cli=1` + +Highlights: + +* in-memory shared cache in the PHP runtime environment +* namespace-prefixed keys (`:`) +* efficient bulk fetch through APCu array fetch path + +`Cache::local()` will choose APCu automatically when available. diff --git a/docs/adapters/array-memory.rst b/docs/adapters/array-memory.rst new file mode 100644 index 0000000..bc76f00 --- /dev/null +++ b/docs/adapters/array-memory.rst @@ -0,0 +1,23 @@ +.. _adapters.array_memory: + +============================ +Array Adapter (`memory`) +============================ + +Factory: `Cache::memory(string $namespace = 'default')` + +In-process array-backed adapter for fast ephemeral caching. + +Characteristics: + +* no external dependencies +* not shared across processes +* TTL support via encoded expiration timestamps +* suitable for tests and simple local memo/cache layers + +Example: + +.. code-block:: php + + $cache = Cache::memory('local'); + $cache->set('foo', 'bar', 10); diff --git a/docs/adapters/chain.rst b/docs/adapters/chain.rst new file mode 100644 index 0000000..2a04ace --- /dev/null +++ b/docs/adapters/chain.rst @@ -0,0 +1,32 @@ +.. _adapters.chain: + +========================= +Chain Adapter (`chain`) +========================= + +Factory: `Cache::chain(array $pools)` + +Composes multiple PSR-6 pools into a tiered cache. + +Behavior: + +* writes are propagated to all tiers +* reads search from first tier to last tier +* hit in lower tier is promoted upward + +Typical layout: + +* L1: in-memory (`ArrayCacheAdapter`) +* L2: network cache (`RedisCacheAdapter`) + +Example: + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Adapter\ArrayCacheAdapter; + use Infocyph\CacheLayer\Cache\Adapter\RedisCacheAdapter; + + $cache = Cache::chain([ + new ArrayCacheAdapter('l1'), + new RedisCacheAdapter('l2'), + ]); diff --git a/docs/adapters/dynamodb.rst b/docs/adapters/dynamodb.rst new file mode 100644 index 0000000..25a9084 --- /dev/null +++ b/docs/adapters/dynamodb.rst @@ -0,0 +1,28 @@ +.. _adapters.dynamodb: + +================================ +DynamoDB Adapter (`dynamoDb`) +================================ + +Factory: + +`Cache::dynamoDb(string $namespace = 'default', string $table = 'cachelayer_entries', ?object $client = null, array $config = [])` + +Requirements: + +* `aws/aws-sdk-php` for default client path, or +* injected client implementing required DynamoDB methods + +Highlights: + +* namespace-scoped row storage +* clear via scan + chunked `batchWriteItem` delete requests +* TTL stored as absolute timestamp in `expires` + +Supported injected client methods: + +* `getItem` +* `putItem` +* `deleteItem` +* `scan` +* `batchWriteItem` diff --git a/docs/adapters/file.rst b/docs/adapters/file.rst new file mode 100644 index 0000000..59b4ec3 --- /dev/null +++ b/docs/adapters/file.rst @@ -0,0 +1,24 @@ +.. _adapters.file: + +======================= +File Adapter (`file`) +======================= + +Factory: `Cache::file(string $namespace = 'default', ?string $dir = null)` + +Stores one cache payload per file under a namespace directory. + +Path layout: + +* base dir: provided `$dir` or `sys_get_temp_dir()` +* namespace dir: `cache_` +* file name: `hash('xxh128', $key) . '.cache'` + +Highlights: + +* zero service dependencies +* persists across process restarts +* atomic write flow (`tempnam` + `rename`) +* `setNamespaceAndDirectory()` supported + +Best for local/single-host environments. diff --git a/docs/adapters/index.rst b/docs/adapters/index.rst new file mode 100644 index 0000000..ae61adf --- /dev/null +++ b/docs/adapters/index.rst @@ -0,0 +1,29 @@ +.. _adapters: + +=================== +Cache Adapters +=================== + +CacheLayer ships multiple adapters for different runtime and infrastructure +needs. + +.. toctree:: + :maxdepth: 1 + + array-memory + weak-map + null-store + chain + file + php-files + apcu + memcached + redis + redis-cluster + sqlite + pdo + shared-memory + mongodb + dynamodb + s3 + serialization diff --git a/docs/adapters/memcached.rst b/docs/adapters/memcached.rst new file mode 100644 index 0000000..277517d --- /dev/null +++ b/docs/adapters/memcached.rst @@ -0,0 +1,22 @@ +.. _adapters.memcached: + +================================= +Memcached Adapter (`memcache`) +================================= + +Factory: + +`Cache::memcache(string $namespace = 'default', array $servers = [['127.0.0.1', 11211, 0]], ?Memcached $client = null)` + +Requirements: + +* `ext-memcached` +* reachable Memcached server(s) + +Highlights: + +* distributed in-memory cache +* `getMulti` based batch reads +* factory auto-configures `MemcachedLockProvider` for `remember()` when using this adapter + +You may pass your own preconfigured `Memcached` client. diff --git a/docs/adapters/mongodb.rst b/docs/adapters/mongodb.rst new file mode 100644 index 0000000..6807a2b --- /dev/null +++ b/docs/adapters/mongodb.rst @@ -0,0 +1,29 @@ +.. _adapters.mongodb: + +============================= +MongoDB Adapter (`mongodb`) +============================= + +Factories: + +* `Cache::mongodb(...)` +* `MongoDbCacheAdapter::fromClient(...)` (adapter-level) + +Requirements: + +* `mongodb/mongodb` package for default client path, or +* injected collection/client compatible with expected methods + +Highlights: + +* namespace-scoped document storage +* base64-encoded payload persistence +* TTL-aware read-time pruning + +Supported injected collection methods: + +* `findOne` +* `updateOne` +* `deleteOne` +* `deleteMany` +* `countDocuments` diff --git a/docs/adapters/null-store.rst b/docs/adapters/null-store.rst new file mode 100644 index 0000000..ba55de8 --- /dev/null +++ b/docs/adapters/null-store.rst @@ -0,0 +1,17 @@ +.. _adapters.null_store: + +============================ +Null Adapter (`nullStore`) +============================ + +Factory: `Cache::nullStore()` + +No-op adapter that never persists values. + +Behavior: + +* `set()` returns true +* `get()` always misses unless default/callable path is used +* `remember()` recomputes every call + +Useful for disabling caching without changing calling code. diff --git a/docs/adapters/pdo.rst b/docs/adapters/pdo.rst new file mode 100644 index 0000000..757ddc5 --- /dev/null +++ b/docs/adapters/pdo.rst @@ -0,0 +1,49 @@ +.. _adapters.pdo: + +========================= +PDO Adapter (`pdo`) +========================= + +Factory: + +`Cache::pdo(string $namespace = 'default', ?string $dsn = null, ?string $username = null, ?string $password = null, ?PDO $pdo = null, string $table = 'cachelayer_entries')` + +Requirements: + +* `ext-pdo` +* the target PDO driver for your DSN (`pdo_mysql`, `pdo_pgsql`, etc.) + +Highlights: + +* unified SQL adapter for MySQL, MariaDB, PostgreSQL, and other PDO drivers +* defaults to SQLite when no DSN/PDO is provided +* namespace-prefixed row keys (`:`) +* automatic table/index initialization +* driver-aware upsert strategy: + - PostgreSQL/SQLite: native `ON CONFLICT` + - MySQL/MariaDB: native `ON DUPLICATE KEY UPDATE` + - fallback path for other PDO drivers +* batched `multiFetch()` via single `IN (...)` query + +Examples: + +.. code-block:: php + + // Default sqlite file under sys temp dir + $cacheDefault = Cache::pdo('app'); + + // MySQL / MariaDB + $cache = Cache::pdo( + 'app', + 'mysql:host=127.0.0.1;port=3306;dbname=app', + 'user', + 'pass', + ); + + // PostgreSQL + $cachePg = Cache::pdo( + 'app', + 'pgsql:host=127.0.0.1;port=5432;dbname=app', + 'postgres', + 'postgres', + ); diff --git a/docs/adapters/php-files.rst b/docs/adapters/php-files.rst new file mode 100644 index 0000000..adb9eff --- /dev/null +++ b/docs/adapters/php-files.rst @@ -0,0 +1,23 @@ +.. _adapters.php_files: + +============================== +PHP Files Adapter (`phpFiles`) +============================== + +Factory: `Cache::phpFiles(string $namespace = 'default', ?string $dir = null)` + +Persists cache records as PHP files that return payload arrays. + +Path layout: + +* base dir: provided `$dir` or `sys_get_temp_dir()` +* namespace dir: `phpcache_` +* file name: `hash('xxh128', $key) . '.php'` + +Highlights: + +* persistent local cache +* opcode-cache aware (`opcache_invalidate` on writes/deletes when available) +* `setNamespaceAndDirectory()` supported + +Good for environments where opcode cache integration is desired. diff --git a/docs/adapters/redis-cluster.rst b/docs/adapters/redis-cluster.rst new file mode 100644 index 0000000..17297dd --- /dev/null +++ b/docs/adapters/redis-cluster.rst @@ -0,0 +1,21 @@ +.. _adapters.redis_cluster: + +======================================== +Redis Cluster Adapter (`redisCluster`) +======================================== + +Factory: + +`Cache::redisCluster(string $namespace = 'default', array $seeds = ['127.0.0.1:6379'], float $timeout = 1.0, float $readTimeout = 1.0, bool $persistent = false, ?object $client = null)` + +Requirements: + +* RedisCluster support via `ext-redis`, or +* injected client exposing expected methods (`get`, `set`, `setex`, `del`, `exists`, `sAdd`, `sRem`, `sCard`, `sMembers`) + +Highlights: + +* cluster-aware storage +* tracks namespace key membership through an index set (`:__keys`) for clear/count operations + +Useful when using Redis Cluster topology. diff --git a/docs/adapters/redis.rst b/docs/adapters/redis.rst new file mode 100644 index 0000000..4808fce --- /dev/null +++ b/docs/adapters/redis.rst @@ -0,0 +1,26 @@ +.. _adapters.redis: + +========================= +Redis Adapter (`redis`) +========================= + +Factory: + +`Cache::redis(string $namespace = 'default', string $dsn = 'redis://127.0.0.1:6379', ?Redis $client = null)` + +Requirements: + +* `ext-redis` (phpredis) +* reachable Redis server + +Highlights: + +* distributed cache with namespace key prefixing +* `MGET` batch retrieval +* TTL via `SETEX` when expiration is set +* factory auto-configures `RedisLockProvider` for `remember()` when using this adapter + +DSN notes: + +* host/port parsed from DSN +* optional password and DB selection (`/db-index`) are supported diff --git a/docs/adapters/s3.rst b/docs/adapters/s3.rst new file mode 100644 index 0000000..40f1038 --- /dev/null +++ b/docs/adapters/s3.rst @@ -0,0 +1,28 @@ +.. _adapters.s3: + +===================== +S3 Adapter (`s3`) +===================== + +Factory: + +`Cache::s3(string $namespace = 'default', string $bucket = 'cachelayer', ?object $client = null, array $config = [], string $prefix = 'cachelayer')` + +Requirements: + +* `aws/aws-sdk-php` for default client path, or +* injected S3-compatible client implementing required methods + +Highlights: + +* object-key based cache persistence in S3 bucket +* namespace + hashed key object naming +* clear/count over namespace prefix listing + +Supported injected client methods: + +* `putObject` +* `getObject` +* `deleteObject` +* `listObjectsV2` +* `deleteObjects` diff --git a/docs/adapters/serialization.rst b/docs/adapters/serialization.rst new file mode 100644 index 0000000..2bf723a --- /dev/null +++ b/docs/adapters/serialization.rst @@ -0,0 +1,16 @@ +.. _adapters.serialization: + +=================================== +Serialization in Adapters +=================================== + +All adapters rely on `CachePayloadCodec` and `ValueSerializer` to persist +arbitrary values consistently. + +The payload format stores: + +* value +* absolute expiration timestamp (or null) +* internal format marker + +See :ref:`serializer` for resource handlers, closure support, and serializer API details. diff --git a/docs/adapters/shared-memory.rst b/docs/adapters/shared-memory.rst new file mode 100644 index 0000000..7b11134 --- /dev/null +++ b/docs/adapters/shared-memory.rst @@ -0,0 +1,22 @@ +.. _adapters.shared_memory: + +======================================== +Shared Memory Adapter (`sharedMemory`) +======================================== + +Factory: `Cache::sharedMemory(string $namespace = 'default', int $segmentSize = 16777216)` + +Requirements: + +* `ext-sysvshm` + +Highlights: + +* values shared across PHP processes on the same host +* namespace-specific segment key strategy +* good for host-local IPC cache use cases + +Notes: + +* data is not portable across hosts +* capacity limited by shared memory segment size diff --git a/docs/adapters/sqlite.rst b/docs/adapters/sqlite.rst new file mode 100644 index 0000000..eb0a95b --- /dev/null +++ b/docs/adapters/sqlite.rst @@ -0,0 +1,17 @@ +.. _adapters.sqlite: + +=========================== +SQLite Adapter (`sqlite`) +=========================== + +Factory: `Cache::sqlite(string $namespace = 'default', ?string $file = null)` + +`sqlite` is a convenience wrapper over the PDO adapter. + +Equivalent behavior: + +* `Cache::sqlite($namespace, $file)` forwards to `Cache::pdo($namespace, 'sqlite:' . $file)` +* when `$file` is `null`, default path is `sys_get_temp_dir() . "/cache_.sqlite"` + +Use `Cache::pdo(...)` directly if you want to switch to MySQL/MariaDB/PostgreSQL +without changing the rest of your cache usage pattern. diff --git a/docs/adapters/weak-map.rst b/docs/adapters/weak-map.rst new file mode 100644 index 0000000..2d243a9 --- /dev/null +++ b/docs/adapters/weak-map.rst @@ -0,0 +1,17 @@ +.. _adapters.weak_map: + +=============================== +WeakMap Adapter (`weakMap`) +=============================== + +Factory: `Cache::weakMap(string $namespace = 'default')` + +Hybrid in-process adapter: + +* scalar/array values stored as encoded blobs +* object values stored via `WeakReference`/`WeakMap` + +Object entries remain available while strongly referenced elsewhere. When an +object is collected, its cache entry can disappear naturally. + +Use when you specifically want object lifecycle-aware caching. diff --git a/docs/cache.rst b/docs/cache.rst new file mode 100644 index 0000000..264019e --- /dev/null +++ b/docs/cache.rst @@ -0,0 +1,242 @@ +.. _cache: + +============================ +Cache Facade (`Cache`) +============================ + +`Infocyph\CacheLayer\Cache\Cache` is the unified facade for CacheLayer. +It implements: + +* PSR-6 (`CacheItemPoolInterface`) +* PSR-16 (`Psr\SimpleCache\CacheInterface`) +* `ArrayAccess` +* `Countable` + +It also adds tagged invalidation, stampede-safe `remember()`, lock provider +selection, metrics hooks, and payload compression controls. + +Installation +------------ + +.. code-block:: bash + + composer require infocyph/cachelayer + +Quick Example +------------- + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::file('app', __DIR__ . '/storage/cache'); + + $user = $cache->remember('user:42', function ($item) { + $item->expiresAfter(300); + return fetchUserFromDatabase(42); + }, tags: ['users']); + + $cache->invalidateTag('users'); + +Factory Methods +--------------- + +The facade exposes factory methods for all bundled adapters: + +* `Cache::local(string $namespace = 'default', ?string $dir = null)` +* `Cache::file(string $namespace = 'default', ?string $dir = null)` +* `Cache::phpFiles(string $namespace = 'default', ?string $dir = null)` +* `Cache::apcu(string $namespace = 'default')` +* `Cache::memcache(string $namespace = 'default', array $servers = [['127.0.0.1', 11211, 0]], ?Memcached $client = null)` +* `Cache::redis(string $namespace = 'default', string $dsn = 'redis://127.0.0.1:6379', ?Redis $client = null)` +* `Cache::redisCluster(string $namespace = 'default', array $seeds = ['127.0.0.1:6379'], float $timeout = 1.0, float $readTimeout = 1.0, bool $persistent = false, ?object $client = null)` +* `Cache::sqlite(string $namespace = 'default', ?string $file = null)` +* `Cache::pdo(string $namespace = 'default', ?string $dsn = null, ?string $username = null, ?string $password = null, ?PDO $pdo = null, string $table = 'cachelayer_entries')` +* `Cache::memory(string $namespace = 'default')` +* `Cache::weakMap(string $namespace = 'default')` +* `Cache::sharedMemory(string $namespace = 'default', int $segmentSize = 16777216)` +* `Cache::nullStore()` +* `Cache::chain(array $pools)` +* `Cache::mongodb(string $namespace = 'default', ?object $collection = null, ?object $client = null, string $database = 'cachelayer', string $collectionName = 'entries', string $uri = 'mongodb://127.0.0.1:27017')` +* `Cache::dynamoDb(string $namespace = 'default', string $table = 'cachelayer_entries', ?object $client = null, array $config = [])` +* `Cache::s3(string $namespace = 'default', string $bucket = 'cachelayer', ?object $client = null, array $config = [], string $prefix = 'cachelayer')` + +`local()` chooses APCu when available (`extension_loaded('apcu')` and `apcu_enabled()`), otherwise File cache. + +`pdo()` defaults to SQLite (temp-file database per namespace) when DSN/PDO is not provided. +`sqlite()` is a convenience wrapper over `pdo()` for explicit SQLite file selection. + +Key and TTL Rules +----------------- + +Key validation is strict and shared across PSR-6/PSR-16 calls: + +* Allowed characters: `A-Z`, `a-z`, `0-9`, `_`, `.`, `-` +* Empty keys or keys with spaces are rejected +* Invalid keys throw `Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException` + +TTL handling: + +* Supported types: `null`, `int`, `DateInterval` +* Negative TTL is rejected +* TTL `0` behaves as immediate expiry (adapters treat it as delete/expired) + +PSR-16 Methods +-------------- + +Common helpers: + +* `get(string $key, mixed $default = null): mixed` +* `set(string $key, mixed $value, int|DateInterval|null $ttl = null): bool` +* `delete(string $key): bool` +* `clear(): bool` +* `getMultiple(iterable $keys, mixed $default = null): iterable` +* `setMultiple(iterable $values, int|DateInterval|null $ttl = null): bool` +* `deleteMultiple(iterable $keys): bool` +* `has(string $key): bool` + +`get()` callable default +~~~~~~~~~~~~~~~~~~~~~~~~ + +If `$default` is callable, `get()` internally uses `remember()` semantics. +On miss, the callable is executed and the result is persisted. + +.. code-block:: php + + $value = $cache->get('profile:42', function ($item) { + $item->expiresAfter(120); + return computeProfile(); + }); + +PSR-6 Methods +------------- + +Standard pool methods are available and delegated to the underlying adapter: + +* `getItem()` +* `getItems()` +* `hasItem()` +* `save()` +* `saveDeferred()` +* `commit()` +* `deleteItem()` +* `deleteItems()` +* `clear()` + +For adapters that implement `multiFetch(array $keys)`, `getItems()` uses it +for efficient batch retrieval. + +Tagged Caching +-------------- + +CacheLayer uses tag-version invalidation (no full key scans required): + +* `setTagged(string $key, mixed $value, array $tags, mixed $ttl = null): bool` +* `invalidateTag(string $tag): bool` +* `invalidateTags(array $tags): bool` + +When a tag is invalidated, its internal version is incremented. Entries tagged +with older versions become stale and are treated as misses on read. + +.. code-block:: php + + $cache->setTagged('home:feed', $payload, ['feed', 'home'], 300); + + $cache->invalidateTag('feed'); + $cache->get('home:feed'); // null (stale) + +Stampede-Safe `remember()` +-------------------------- + +`remember()` protects expensive recomputation with a lock provider: + +.. code-block:: php + + $value = $cache->remember('report:daily', function ($item) { + $item->expiresAfter(60); + return buildDailyReport(); + }, tags: ['reports']); + +Behavior: + +1. Read existing value. +2. On miss, acquire lock (`FileLockProvider` by default). +3. Re-check value under lock. +4. Compute and save value. +5. Apply jitter to TTL to reduce herd effects. +6. Release lock. + +Lock provider selection: + +* `setLockProvider(LockProviderInterface $provider): self` +* `useRedisLock(?Redis $client = null, string $prefix = 'cachelayer:lock:'): self` +* `useMemcachedLock(?Memcached $client = null, string $prefix = 'cachelayer:lock:'): self` + +Factory defaults: + +* `Cache::redis(...)` auto-configures `RedisLockProvider` +* `Cache::memcache(...)` auto-configures `MemcachedLockProvider` +* `Cache::pdo(...)` / `Cache::sqlite(...)` auto-configure `PdoLockProvider` +* other adapters default to `FileLockProvider` + +Metrics and Export Hooks +------------------------ + +Methods: + +* `setMetricsCollector(CacheMetricsCollectorInterface $metrics): self` +* `exportMetrics(): array` +* `setMetricsExportHook(?callable $hook): self` + +Default collector is `InMemoryCacheMetricsCollector`. + +Metrics are grouped by adapter class and metric name, for example: + +.. code-block:: php + + [ + 'Infocyph\\CacheLayer\\Cache\\Adapter\\FileCacheAdapter' => [ + 'hit' => 10, + 'miss' => 4, + 'set' => 3, + ], + ] + +Payload Compression +------------------- + +Use `configurePayloadCompression(?int $thresholdBytes = null, int $level = 6)` +to enable compression for encoded payloads. + +Notes: + +* Compression is applied when payload size meets/exceeds threshold. +* Requires `gzencode`/`gzdecode` functions. +* Compression configuration is global (`CachePayloadCodec` static state). + +Convenience Features +-------------------- + +Array and magic access: + +* `$cache['key'] = 'value';` +* `$cache['key'];` +* `$cache->key = 'value';` +* `$cache->key;` + +Counting: + +* `count($cache)` delegates to adapter `Countable` support when available. + +Namespace/Directory Mutation +---------------------------- + +`setNamespaceAndDirectory(string $namespace, ?string $dir = null): void` +forwards to adapters that support runtime namespace/directory changes. + +Supported by: + +* File cache adapter +* PHP files cache adapter + +Unsupported adapters throw `BadMethodCallException`. diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..9c84786 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,100 @@ +from __future__ import annotations +import os, datetime +from subprocess import Popen, PIPE + +project = "infocyph/cachelayer" +author = "Infocyph" +year_now = datetime.date.today().strftime("%Y") +copyright = f"2025-{year_now}" + +def get_version() -> str: + if os.environ.get("READTHEDOCS") == "True": + v = os.environ.get("READTHEDOCS_VERSION") + if v: + return v + try: + pipe = Popen("git rev-parse --abbrev-ref HEAD", stdout=PIPE, shell=True, universal_newlines=True) + v = (pipe.stdout.read() or "").strip() + return v or "latest" + except Exception: + return "latest" + +version = get_version() +release = version +language = "en" +root_doc = "index" + +from pygments.lexers.web import PhpLexer +from sphinx.highlighting import lexers +highlight_language = "php" +lexers["php"] = PhpLexer(startinline=True) +lexers["php-annotations"] = PhpLexer(startinline=True) + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.todo", + "sphinx.ext.napoleon", + "sphinx.ext.autosectionlabel", + "sphinx.ext.intersphinx", + "sphinx_copybutton", + "sphinx_design", + "sphinxcontrib.phpdomain", + "sphinx.ext.extlinks", +] + +source_suffix = ".rst" + +autodoc_default_options = { + "members": True, + "undoc-members": True, + "show-inheritance": True, +} +napoleon_google_docstring = True +napoleon_numpy_docstring = False + +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), +} + +extlinks = { + "php": ("https://www.php.net/%s", "%s"), +} + +html_theme = "sphinx_book_theme" +html_theme_options = { + "repository_url": "https://github.com/infocyph/CacheLayer", + "repository_branch": "main", + "path_to_docs": "docs", + "use_repository_button": True, + "use_issues_button": True, + "use_download_button": True, + "home_page_in_toc": True, + "show_toc_level": 2, +} +templates_path = ["_templates"] +html_static_path = ["_static"] +html_css_files = ["theme.css"] +html_title = f"CacheLayer Manual ({version})" +html_show_sourcelink = True +html_show_sphinx = False +html_last_updated_fmt = "%Y-%m-%d" + +latex_engine = "xelatex" +latex_elements = { + "papersize": "a4paper", + "pointsize": "11pt", + "preamble": "", + "figure_align": "H", +} + +html_context = { + "display_github": False, + "github_user": "infocyph", + "github_repo": "CacheLayer", + "github_version": version, + "conf_py_path": "/docs/", +} + +rst_prolog = f""" +.. |current_year| replace:: {year_now} +""" diff --git a/docs/functions.rst b/docs/functions.rst new file mode 100644 index 0000000..82cc87c --- /dev/null +++ b/docs/functions.rst @@ -0,0 +1,77 @@ +.. _functions: + +========================== +Global Helper Functions +========================== + +CacheLayer autoloads helper functions from `src/functions.php`. + +sanitize_cache_ns() +------------------- + +.. php:function:: sanitize_cache_ns(string $ns): string + +Normalizes namespaces into safe key prefixes. + +Behavior: + +* Replaces any character outside `[A-Za-z0-9_-]` with `_` +* Uses an internal static memoization map for repeated inputs + +Example: + +.. code-block:: php + + sanitize_cache_ns('tenant/acme.v1'); + // "tenant_acme_v1" + +memoize() +--------- + +.. php:function:: memoize(?callable $callable = null, array $params = []): mixed + +Two modes: + +* `memoize()` returns the singleton `Infocyph\CacheLayer\Memoize\Memoizer` +* `memoize($callable, $params)` executes memoized call lookup for global/static scope + +Example: + +.. code-block:: php + + $f = fn (int $x): int => $x + 1; + + $a = memoize($f, [5]); + $b = memoize($f, [5]); + + // same cached result + +remember() +---------- + +.. php:function:: remember(?object $object = null, ?callable $callable = null, array $params = []): mixed + +Object-scoped memoization helper. + +Two modes: + +* `remember()` returns the singleton `Memoizer` +* `remember($object, $callable, $params)` caches value per object instance + +If object is provided but callable is missing, it throws `InvalidArgumentException`. + +once() +------ + +.. php:function:: once(callable $callback): mixed + +Executes callback once per call site context using +`Infocyph\CacheLayer\Memoize\OnceMemoizer`. + +Useful for one-time initialization inside request/process scope. + +.. code-block:: php + + $config = once(function () { + return loadLargeConfigArray(); + }); diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..9773e8d --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,41 @@ +================= +CacheLayer Manual +================= + +CacheLayer is a standalone caching toolkit for PHP 8.3+ with: + +* PSR-6 and PSR-16 support behind one facade (`Cache`) +* local, distributed, and cloud cache adapters +* tag-version invalidation (`setTagged`, `invalidateTag`, `invalidateTags`) +* stampede-safe `remember()` with pluggable lock providers +* adapter-level metrics export hooks +* payload compression controls +* value serialization for closures and resources +* process-local memoization helpers (`memoize`, `remember`, `once`) + +Quick Start +----------- + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::memory('app'); + + $profile = $cache->remember('user:42', function ($item) { + $item->expiresAfter(300); + return ['id' => 42, 'name' => 'Ada']; + }, tags: ['users']); + + $cache->invalidateTag('users'); + +.. toctree:: + :maxdepth: 2 + :caption: Guide + + cache + adapters/index + metrics-and-locking + serializer + memoize + functions diff --git a/docs/memoize.rst b/docs/memoize.rst new file mode 100644 index 0000000..d9fc6a3 --- /dev/null +++ b/docs/memoize.rst @@ -0,0 +1,21 @@ +.. _memoize: + +=================== +Memoization +=================== + +CacheLayer includes process-local memoization primitives for fast repeated +in-process calls. + +Available components: + +* `Infocyph\CacheLayer\Memoize\Memoizer` +* `Infocyph\CacheLayer\Memoize\OnceMemoizer` +* `Infocyph\CacheLayer\Memoize\MemoizeTrait` +* global helpers `memoize()`, `remember()`, and `once()` + +.. toctree:: + :maxdepth: 1 + + memoize/functions + memoize/trait diff --git a/docs/memoize/functions.rst b/docs/memoize/functions.rst new file mode 100644 index 0000000..41da329 --- /dev/null +++ b/docs/memoize/functions.rst @@ -0,0 +1,58 @@ +.. _memoize.functions: + +========================= +Memoize Function Helpers +========================= + +memoize(callable, params) +------------------------- + +`memoize($callable, $params)` caches return values by: + +* callable signature +* normalized parameters hash + +Internally this uses `Memoizer::get()`. + +.. code-block:: php + + $sum = memoize(fn (int $a, int $b) => $a + $b, [2, 3]); + +remember(object, callable, params) +---------------------------------- + +`remember($object, $callable, $params)` caches values per object instance +(using `WeakMap` inside `Memoizer`). + +When the object is garbage-collected, its memoized bucket is removable. + +.. code-block:: php + + $svc = new MyService(); + + $value = remember($svc, fn () => expensiveLookup()); + +once(callback) +-------------- + +`once($callback)` is call-site based memoization via `OnceMemoizer`. + +Key details: + +* cache key includes caller context + callback fingerprint +* closure source fingerprinting is memoized +* bounded cache size (2048 entries), oldest entry evicted + +.. code-block:: php + + $token = once(fn () => bin2hex(random_bytes(16))); + +Inspecting/Resetting Memoizer State +----------------------------------- + +.. code-block:: php + + $memo = memoize(); + $stats = $memo->stats(); // ['hits' => ..., 'misses' => ..., 'total' => ...] + + $memo->flush(); diff --git a/docs/memoize/trait.rst b/docs/memoize/trait.rst new file mode 100644 index 0000000..902d99c --- /dev/null +++ b/docs/memoize/trait.rst @@ -0,0 +1,38 @@ +.. _memoize.trait: + +===================== +`MemoizeTrait` +===================== + +`Infocyph\CacheLayer\Memoize\MemoizeTrait` provides lightweight per-object +memoization for class internals. + +API +--- + +* `memoize(string $key, callable $producer): mixed` +* `memoizeClear(?string $key = null): void` + +Example +------- + +.. code-block:: php + + use Infocyph\CacheLayer\Memoize\MemoizeTrait; + + final class ReportService + { + use MemoizeTrait; + + public function expensiveCount(): int + { + return $this->memoize(__METHOD__, function (): int { + return computeCount(); + }); + } + + public function clearMemoizedCount(): void + { + $this->memoizeClear(__METHOD__); + } + } diff --git a/docs/metrics-and-locking.rst b/docs/metrics-and-locking.rst new file mode 100644 index 0000000..58836c6 --- /dev/null +++ b/docs/metrics-and-locking.rst @@ -0,0 +1,77 @@ +.. _metrics_and_locking: + +==================== +Metrics and Locking +==================== + +Metrics +------- + +Cache facade metrics API: + +* `setMetricsCollector(CacheMetricsCollectorInterface $metrics): self` +* `exportMetrics(): array` +* `setMetricsExportHook(?callable $hook): self` + +Default collector is `InMemoryCacheMetricsCollector`. + +Metric counters are tracked per adapter class, for example: + +* `hit` +* `miss` +* `set` +* `delete` +* `delete_batch` +* `remember_hit` +* `remember_miss` + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Metrics\InMemoryCacheMetricsCollector; + + $cache->setMetricsCollector(new InMemoryCacheMetricsCollector()); + + $cache->set('k', 'v'); + $cache->get('k'); + + $metrics = $cache->exportMetrics(); + +Locking and Stampede Protection +------------------------------- + +`Cache::remember()` acquires a lock to prevent duplicate recomputation. + +Default: + +* `FileLockProvider` + +Optional providers: + +* `RedisLockProvider` +* `MemcachedLockProvider` +* `PdoLockProvider` + +Facade helpers: + +* `setLockProvider(LockProviderInterface $provider): self` +* `useRedisLock(?Redis $client = null, string $prefix = 'cachelayer:lock:'): self` +* `useMemcachedLock(?Memcached $client = null, string $prefix = 'cachelayer:lock:'): self` + +Custom lock providers can implement `LockProviderInterface`: + +.. code-block:: php + + interface LockProviderInterface + { + public function acquire(string $key, float $waitSeconds): ?LockHandle; + public function release(?LockHandle $handle): void; + } + +`LockHandle` carries key/token/resource metadata used by providers to release locks safely. + +Adapter defaults: + +* Redis adapter factory sets `RedisLockProvider` +* Memcached adapter factory sets `MemcachedLockProvider` +* PDO/SQLite adapter factories set `PdoLockProvider` +* all other adapters use `FileLockProvider` by default diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..aa47239 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,5 @@ +sphinx>=8.0,<9 +sphinx-book-theme>=1.1 +sphinxcontrib-phpdomain>=0.9 +sphinx-copybutton>=0.5.2 +sphinx-design>=0.6 diff --git a/docs/serializer.rst b/docs/serializer.rst new file mode 100644 index 0000000..6eca444 --- /dev/null +++ b/docs/serializer.rst @@ -0,0 +1,61 @@ +.. _serializer: + +===================== +Value Serialization +===================== + +`Infocyph\CacheLayer\Serializer\ValueSerializer` is used by adapters to encode +and decode cached payloads. + +What it handles +--------------- + +* scalar values and arrays +* closures (via `opis/closure`) +* registered resource types + +Core Methods +------------ + +* `serialize(mixed $value): string` +* `unserialize(string $blob): mixed` +* `encode(mixed $value, bool $base64 = true): string` +* `decode(string $payload, bool $base64 = true): mixed` +* `wrap(mixed $value): mixed` +* `unwrap(mixed $value): mixed` +* `registerResourceHandler(string $type, callable $wrapFn, callable $restoreFn): void` +* `clearResourceHandlers(): void` + +Resource Handler Example +------------------------ + +.. code-block:: php + + use Infocyph\CacheLayer\Serializer\ValueSerializer; + + ValueSerializer::registerResourceHandler( + 'stream', + function ($res): array { + $meta = stream_get_meta_data($res); + rewind($res); + + return [ + 'mode' => $meta['mode'], + 'content' => stream_get_contents($res), + ]; + }, + function (array $data) { + $s = fopen('php://memory', $data['mode']); + fwrite($s, $data['content']); + rewind($s); + + return $s; + }, + ); + +Notes +----- + +* Registering the same resource type twice throws `InvalidArgumentException`. +* Wrapping/serializing unregistered resources throws `InvalidArgumentException`. +* Closure detection has an internal bounded memo cache. diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php new file mode 100644 index 0000000..fa04e1c --- /dev/null +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -0,0 +1,397 @@ +ns = sanitize_cache_ns($namespace); + $this->table = $table; + $resolvedDsn = $dsn; + if ($pdo === null && $resolvedDsn === null) { + $resolvedDsn = 'sqlite:' . sys_get_temp_dir() . "/cache_{$this->ns}.sqlite"; + } + + $this->pdo = $pdo ?? new PDO((string) $resolvedDsn, $username, $password); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->driver = (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + + $this->configureDriverDefaults(); + $this->createSchemaIfMissing(); + } + + public function clear(): bool + { + $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey LIKE :prefix"); + $ok = $stmt->execute([':prefix' => $this->ns . ':%']); + $this->deferred = []; + return $ok; + } + + public function count(): int + { + $stmt = $this->pdo->prepare( + "SELECT COUNT(*) FROM {$this->table} + WHERE ckey LIKE :prefix + AND (expires IS NULL OR expires > :now)", + ); + $stmt->execute([ + ':prefix' => $this->ns . ':%', + ':now' => time(), + ]); + + return (int) $stmt->fetchColumn(); + } + + public function deleteItem(string $key): bool + { + $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey = :k"); + return $stmt->execute([':k' => $this->map($key)]); + } + + public function deleteItems(array $keys): bool + { + if ($keys === []) { + return true; + } + + $mapped = array_map($this->map(...), array_map(strval(...), $keys)); + $marks = implode(',', array_fill(0, count($mapped), '?')); + $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey IN ($marks)"); + return $stmt->execute($mapped); + } + + public function getClient(): PDO + { + return $this->pdo; + } + + public function getItem(string $key): GenericCacheItem + { + $stmt = $this->pdo->prepare( + "SELECT payload, expires FROM {$this->table} WHERE ckey = :k LIMIT 1", + ); + $stmt->execute([':k' => $this->map($key)]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!is_array($row)) { + return new GenericCacheItem($this, $key); + } + + $expiresAt = is_numeric($row['expires'] ?? null) ? (int) $row['expires'] : null; + if (CachePayloadCodec::isExpired($expiresAt)) { + $this->deleteItem($key); + return new GenericCacheItem($this, $key); + } + + $blob = base64_decode((string) ($row['payload'] ?? ''), true); + if (!is_string($blob)) { + $this->deleteItem($key); + return new GenericCacheItem($this, $key); + } + + $record = CachePayloadCodec::decode($blob); + if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + $this->deleteItem($key); + return new GenericCacheItem($this, $key); + } + + return new GenericCacheItem( + $this, + $key, + $record['value'], + true, + CachePayloadCodec::toDateTime($record['expires']), + ); + } + + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + public function multiFetch(array $keys): array + { + if ($keys === []) { + return []; + } + + $mappedByLogical = []; + foreach ($keys as $key) { + $logical = (string) $key; + $mapped = $this->map($logical); + $mappedByLogical[$logical] = $mapped; + } + + $rows = $this->fetchRowsByMappedKeys(array_values($mappedByLogical)); + $items = []; + $staleMapped = []; + + foreach ($keys as $key) { + $logical = (string) $key; + $mapped = $mappedByLogical[$logical]; + $row = $rows[$mapped] ?? null; + + if (!is_array($row)) { + $items[$logical] = new GenericCacheItem($this, $logical); + continue; + } + + $item = $this->hydrateItemFromRow($logical, $row); + if ($item instanceof GenericCacheItem) { + $items[$logical] = $item; + continue; + } + + $staleMapped[] = $mapped; + $items[$logical] = new GenericCacheItem($this, $logical); + } + + if ($staleMapped !== []) { + $this->deleteMappedItems($staleMapped); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + return false; + } + + $expires = CachePayloadCodec::expirationFromItem($item); + if ($expires['ttl'] === 0) { + return $this->deleteItem($item->getKey()); + } + + $params = [ + ':k' => $this->map($item->getKey()), + ':p' => base64_encode(CachePayloadCodec::encode($item->get(), $expires['expiresAt'])), + ':e' => $expires['expiresAt'], + ]; + + return $this->upsert($params, $this->map($item->getKey())); + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof GenericCacheItem; + } + + private function configureDriverDefaults(): void + { + if ($this->driver !== 'sqlite') { + return; + } + + try { + $this->pdo->exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;'); + } catch (PDOException) { + // Best effort sqlite tuning. + } + } + + private function createExpiresIndexIfMissing(): void + { + $index = "{$this->table}_expires_idx"; + + try { + if (in_array($this->driver, ['pgsql', 'sqlite', 'mysql', 'mariadb'], true)) { + $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$index} ON {$this->table}(expires)"); + return; + } + + $this->pdo->exec("CREATE INDEX {$index} ON {$this->table}(expires)"); + } catch (PDOException) { + // Retry once for engines that do not support IF NOT EXISTS on indexes. + try { + $this->pdo->exec("CREATE INDEX {$index} ON {$this->table}(expires)"); + } catch (PDOException) { + // Ignore duplicate index/feature support errors. + } + } + } + + private function createSchemaIfMissing(): void + { + $keyType = in_array($this->driver, ['mysql', 'mariadb'], true) ? 'VARCHAR(191)' : 'TEXT'; + + $this->pdo->exec( + "CREATE TABLE IF NOT EXISTS {$this->table} ( + ckey {$keyType} PRIMARY KEY, + payload TEXT NOT NULL, + expires BIGINT NULL + )", + ); + + $this->createExpiresIndexIfMissing(); + } + + /** + * @param array $mappedKeys + */ + private function deleteMappedItems(array $mappedKeys): void + { + if ($mappedKeys === []) { + return; + } + + $marks = implode(',', array_fill(0, count($mappedKeys), '?')); + $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey IN ($marks)"); + $stmt->execute($mappedKeys); + } + + /** + * @param array $mappedKeys + * @return array + */ + private function fetchRowsByMappedKeys(array $mappedKeys): array + { + if ($mappedKeys === []) { + return []; + } + + $marks = implode(',', array_fill(0, count($mappedKeys), '?')); + $stmt = $this->pdo->prepare( + "SELECT ckey, payload, expires + FROM {$this->table} + WHERE ckey IN ($marks)", + ); + $stmt->execute($mappedKeys); + + $rows = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $key = (string) ($row['ckey'] ?? ''); + if ($key === '' || !is_string($row['payload'] ?? null)) { + continue; + } + + $rows[$key] = [ + 'payload' => $row['payload'], + 'expires' => is_numeric($row['expires'] ?? null) ? (int) $row['expires'] : null, + ]; + } + + return $rows; + } + + /** + * @param array{payload:string,expires:int|null} $row + */ + private function hydrateItemFromRow(string $key, array $row): ?GenericCacheItem + { + if (CachePayloadCodec::isExpired($row['expires'])) { + return null; + } + + $blob = base64_decode($row['payload'], true); + if (!is_string($blob)) { + return null; + } + + $record = CachePayloadCodec::decode($blob); + if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + return null; + } + + return new GenericCacheItem( + $this, + $key, + $record['value'], + true, + CachePayloadCodec::toDateTime($record['expires']), + ); + } + + private function map(string $key): string + { + return $this->ns . ':' . $key; + } + + private function nativeUpsertSql(): ?string + { + return match ($this->driver) { + 'pgsql', 'sqlite' => "INSERT INTO {$this->table} (ckey, payload, expires) + VALUES (:k, :p, :e) + ON CONFLICT (ckey) + DO UPDATE SET payload = EXCLUDED.payload, expires = EXCLUDED.expires", + 'mysql', 'mariadb' => "INSERT INTO {$this->table} (ckey, payload, expires) + VALUES (:k, :p, :e) + ON DUPLICATE KEY UPDATE payload = VALUES(payload), expires = VALUES(expires)", + default => null, + }; + } + + /** + * @param array{':k':string,':p':string,':e':int|null} $params + */ + private function upsert(array $params, string $mappedKey): bool + { + $nativeSql = $this->nativeUpsertSql(); + if ($nativeSql !== null) { + $stmt = $this->pdo->prepare($nativeSql); + return $stmt->execute($params); + } + + $update = $this->pdo->prepare( + "UPDATE {$this->table} + SET payload = :p, expires = :e + WHERE ckey = :k", + ); + + if (!$update->execute($params)) { + return false; + } + + if ($update->rowCount() > 0) { + return true; + } + + $insert = $this->pdo->prepare( + "INSERT INTO {$this->table} (ckey, payload, expires) + VALUES (:k, :p, :e)", + ); + + try { + return $insert->execute($params); + } catch (PDOException) { + // Another process may have inserted concurrently. + $updateByKey = $this->pdo->prepare( + "UPDATE {$this->table} + SET payload = :p, expires = :e + WHERE ckey = :k", + ); + + return $updateByKey->execute([ + ':k' => $mappedKey, + ':p' => $params[':p'], + ':e' => $params[':e'], + ]); + } + } +} diff --git a/src/Cache/Adapter/PostgresCacheAdapter.php b/src/Cache/Adapter/PostgresCacheAdapter.php deleted file mode 100644 index f67e19e..0000000 --- a/src/Cache/Adapter/PostgresCacheAdapter.php +++ /dev/null @@ -1,177 +0,0 @@ -ns = sanitize_cache_ns($namespace); - $this->table = $table; - $this->pdo = $pdo ?? new PDO( - $dsn ?? 'pgsql:host=127.0.0.1;port=5432;dbname=postgres', - $username, - $password, - ); - $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - - $this->pdo->exec( - "CREATE TABLE IF NOT EXISTS {$this->table} ( - ckey TEXT PRIMARY KEY, - payload TEXT NOT NULL, - expires BIGINT NULL - )", - ); - $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$this->table}_expires_idx ON {$this->table}(expires)"); - } - - public function clear(): bool - { - $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey LIKE :prefix"); - $ok = $stmt->execute([':prefix' => $this->ns . ':%']); - $this->deferred = []; - return $ok; - } - - public function count(): int - { - $stmt = $this->pdo->prepare( - "SELECT COUNT(*) FROM {$this->table} - WHERE ckey LIKE :prefix - AND (expires IS NULL OR expires > :now)", - ); - $stmt->execute([ - ':prefix' => $this->ns . ':%', - ':now' => time(), - ]); - - return (int) $stmt->fetchColumn(); - } - - public function deleteItem(string $key): bool - { - $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey = :k"); - return $stmt->execute([':k' => $this->map($key)]); - } - - public function deleteItems(array $keys): bool - { - $ok = true; - foreach ($keys as $key) { - $ok = $this->deleteItem((string) $key) && $ok; - } - - return $ok; - } - - public function getItem(string $key): GenericCacheItem - { - $stmt = $this->pdo->prepare( - "SELECT payload, expires FROM {$this->table} WHERE ckey = :k LIMIT 1", - ); - $stmt->execute([':k' => $this->map($key)]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - - if (!is_array($row)) { - return new GenericCacheItem($this, $key); - } - - $expiresAt = is_numeric($row['expires'] ?? null) ? (int) $row['expires'] : null; - if (CachePayloadCodec::isExpired($expiresAt)) { - $this->deleteItem($key); - return new GenericCacheItem($this, $key); - } - - $blob = base64_decode((string) ($row['payload'] ?? ''), true); - if (!is_string($blob)) { - $this->deleteItem($key); - return new GenericCacheItem($this, $key); - } - - $record = CachePayloadCodec::decode($blob); - if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { - $this->deleteItem($key); - return new GenericCacheItem($this, $key); - } - - $item = new GenericCacheItem($this, $key); - $item->set($record['value']); - if ($record['expires'] !== null) { - $item->expiresAt(CachePayloadCodec::toDateTime($record['expires'])); - } - - return $item; - } - - public function hasItem(string $key): bool - { - return $this->getItem($key)->isHit(); - } - - public function multiFetch(array $keys): array - { - $items = []; - foreach ($keys as $key) { - $items[(string) $key] = $this->getItem((string) $key); - } - - return $items; - } - - public function save(CacheItemInterface $item): bool - { - if (!$this->supportsItem($item)) { - return false; - } - - $expires = CachePayloadCodec::expirationFromItem($item); - if ($expires['ttl'] === 0) { - return $this->deleteItem($item->getKey()); - } - - $blob = base64_encode(CachePayloadCodec::encode($item->get(), $expires['expiresAt'])); - $stmt = $this->pdo->prepare( - "INSERT INTO {$this->table} (ckey, payload, expires) - VALUES (:k, :p, :e) - ON CONFLICT (ckey) - DO UPDATE SET payload = EXCLUDED.payload, expires = EXCLUDED.expires", - ); - - return $stmt->execute([ - ':k' => $this->map($item->getKey()), - ':p' => $blob, - ':e' => $expires['expiresAt'], - ]); - } - - protected function supportsItem(CacheItemInterface $item): bool - { - return $item instanceof GenericCacheItem; - } - - private function map(string $key): string - { - return $this->ns . ':' . $key; - } -} diff --git a/src/Cache/Adapter/SqliteCacheAdapter.php b/src/Cache/Adapter/SqliteCacheAdapter.php deleted file mode 100644 index 4c203f8..0000000 --- a/src/Cache/Adapter/SqliteCacheAdapter.php +++ /dev/null @@ -1,235 +0,0 @@ -ns = sanitize_cache_ns($namespace); - $file = $dbPath ?: sys_get_temp_dir() . "/cache_$this->ns.sqlite"; - - $this->pdo = new PDO('sqlite:' . $file); - $this->pdo->exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;'); - $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - - $this->pdo->exec( - 'CREATE TABLE IF NOT EXISTS cache ( - key TEXT PRIMARY KEY, - value BLOB NOT NULL, - expires INTEGER - )', - ); - $this->pdo->exec('CREATE INDEX IF NOT EXISTS exp_idx ON cache(expires)'); - } - - public function clear(): bool - { - $this->pdo->exec('DELETE FROM cache'); - $this->deferred = []; - return true; - } - - public function count(): int - { - return (int) $this->pdo->query( - 'SELECT COUNT(*) FROM cache WHERE expires IS NULL OR expires > ' . time(), - )->fetchColumn(); - } - - public function deleteItem(string $key): bool - { - $this->pdo->prepare('DELETE FROM cache WHERE key = :k')->execute([':k' => $key]); - return true; - } - - public function deleteItems(array $keys): bool - { - foreach ($keys as $k) { - $this->deleteItem($k); - } - return true; - } - - public function getItem(string $key): SqliteCacheItem - { - $stmt = $this->pdo->prepare( - 'SELECT value, expires FROM cache WHERE key = :k LIMIT 1', - ); - $stmt->execute([':k' => $key]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - - $now = time(); - if ($row && (!$row['expires'] || $row['expires'] > $now)) { - $record = CachePayloadCodec::decode($row['value']); - $expiresAt = is_numeric($row['expires']) ? (int) $row['expires'] : null; - if ($record !== null) { - $expiresAt = $record['expires'] ?? $expiresAt; - if (!CachePayloadCodec::isExpired($expiresAt, $now)) { - return new SqliteCacheItem( - $this, - $key, - $record['value'], - true, - CachePayloadCodec::toDateTime($expiresAt), - ); - } - } - } - - $this->pdo->prepare('DELETE FROM cache WHERE key = :k')->execute([':k' => $key]); - return new SqliteCacheItem($this, $key); - } - - public function hasItem(string $key): bool - { - return $this->getItem($key)->isHit(); - } - - public function multiFetch(array $keys): array - { - if ($keys === []) { - return []; - } - - $rows = $this->fetchRowsByKeys($keys); - $items = []; - $now = time(); - $staleKeys = []; - - foreach ($keys as $k) { - $items[$k] = $this->buildFetchedItem($k, $rows, $now, $staleKeys); - } - - if ($staleKeys !== []) { - $this->deleteMany($staleKeys); - } - - return $items; - } - - public function save(CacheItemInterface $item): bool - { - if (!$this->supportsItem($item)) { - throw new CacheInvalidArgumentException('Wrong item class'); - } - - $expires = CachePayloadCodec::expirationFromItem($item); - $ttl = $expires['ttl']; - if ($ttl === 0) { - return $this->deleteItem($item->getKey()); - } - - $blob = CachePayloadCodec::encode($item->get(), $expires['expiresAt']); - - $stmt = $this->pdo->prepare( - 'REPLACE INTO cache(key, value, expires) VALUES(:k, :v, :e)', - ); - return $stmt->execute([ - ':k' => $item->getKey(), - ':v' => $blob, - ':e' => $expires['expiresAt'], - ]); - } - - protected function supportsItem(CacheItemInterface $item): bool - { - return $item instanceof SqliteCacheItem; - } - - private function buildFetchedItem( - string $key, - array $rows, - int $now, - array &$staleKeys, - ): SqliteCacheItem { - if (isset($rows[$key])) { - $item = $this->hydrateFetchedItem($key, $rows[$key], $now); - if ($item !== null) { - return $item; - } - - $staleKeys[] = $key; - } - - return new SqliteCacheItem($this, $key); - } - - /** - * @param array $keys - */ - private function deleteMany(array $keys): void - { - if ($keys === []) { - return; - } - - $marks = implode(',', array_fill(0, count($keys), '?')); - $stmt = $this->pdo->prepare("DELETE FROM cache WHERE key IN ($marks)"); - $stmt->execute($keys); - } - - /** - * @return array - */ - private function fetchRowsByKeys(array $keys): array - { - $marks = implode(',', array_fill(0, count($keys), '?')); - $stmt = $this->pdo->prepare( - "SELECT key, value, expires - FROM cache - WHERE key IN ($marks)", - ); - $stmt->execute($keys); - - /** @var array $rows */ - $rows = []; - foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) as $r) { - $rows[$r['key']] = ['value' => $r['value'], 'expires' => $r['expires']]; - } - - return $rows; - } - - private function hydrateFetchedItem( - string $key, - array $row, - int $now, - ): ?SqliteCacheItem { - if ($row['expires'] !== null && $row['expires'] <= $now) { - return null; - } - - $record = CachePayloadCodec::decode($row['value']); - $expiresAt = $row['expires']; - - if ($record === null) { - return null; - } - - $expiresAt = $record['expires'] ?? $expiresAt; - if (CachePayloadCodec::isExpired($expiresAt, $now)) { - return null; - } - - return new SqliteCacheItem( - $this, - $key, - $record['value'], - true, - CachePayloadCodec::toDateTime($expiresAt), - ); - } -} diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index d841144..0c4ac13 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -232,12 +232,7 @@ public static function nullStore(): self return new self(new Adapter\NullCacheAdapter()); } - public static function phpFiles(string $namespace = 'default', ?string $dir = null): self - { - return new self(new Adapter\PhpFilesCacheAdapter($namespace, $dir)); - } - - public static function postgres( + public static function pdo( string $namespace = 'default', ?string $dsn = null, ?string $username = null, @@ -245,7 +240,22 @@ public static function postgres( ?\PDO $pdo = null, string $table = 'cachelayer_entries', ): self { - return new self(new Adapter\PostgresCacheAdapter($namespace, $dsn, $username, $password, $pdo, $table)); + $adapter = new Adapter\PdoCacheAdapter($namespace, $dsn, $username, $password, $pdo, $table); + $lockProvider = new FileLockProvider(); + $pdoLockProviderClass = \Infocyph\CacheLayer\Cache\Lock\PdoLockProvider::class; + if (class_exists($pdoLockProviderClass)) { + /** @var LockProviderInterface $lockProvider */ + $lockProvider = new $pdoLockProviderClass($adapter->getClient()); + } + + return (new self($adapter))->setLockProvider( + $lockProvider, + ); + } + + public static function phpFiles(string $namespace = 'default', ?string $dir = null): self + { + return new self(new Adapter\PhpFilesCacheAdapter($namespace, $dir)); } /** @@ -324,7 +334,12 @@ public static function sharedMemory(string $namespace = 'default', int $segmentS */ public static function sqlite(string $namespace = 'default', ?string $file = null): self { - return new self(new Adapter\SqliteCacheAdapter($namespace, $file)); + $dbPath = $file ?? (sys_get_temp_dir() . '/cache_' . sanitize_cache_ns($namespace) . '.sqlite'); + + return self::pdo( + namespace: $namespace, + dsn: 'sqlite:' . $dbPath, + ); } public static function weakMap(string $namespace = 'default'): self diff --git a/src/Cache/Item/SqliteCacheItem.php b/src/Cache/Item/SqliteCacheItem.php deleted file mode 100644 index d1ff7b0..0000000 --- a/src/Cache/Item/SqliteCacheItem.php +++ /dev/null @@ -1,7 +0,0 @@ -retrySleepMicros = max(1_000, $retrySleepMicros); + $this->driver = (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $this->fallback = $fallback ?? new FileLockProvider(); + } + + public function acquire(string $key, float $waitSeconds): ?LockHandle + { + return match ($this->driver) { + 'mysql', 'mariadb' => $this->acquireMysql($key, $waitSeconds), + 'pgsql' => $this->acquirePgsql($key, $waitSeconds), + default => $this->fallback->acquire($key, $waitSeconds), + }; + } + + public function release(?LockHandle $handle): void + { + if (!$handle instanceof LockHandle) { + return; + } + + match ($this->driver) { + 'mysql', 'mariadb' => $this->releaseMysql($handle), + 'pgsql' => $this->releasePgsql($handle), + default => $this->fallback->release($handle), + }; + } + + private static function signedCrc32(string $value): int + { + $u = crc32($value); + if ($u === false) { + throw new RuntimeException('Unable to hash advisory lock key.'); + } + + return $u > 0x7fffffff ? $u - 0x100000000 : $u; + } + + private function acquireMysql(string $key, float $waitSeconds): ?LockHandle + { + $deadline = microtime(true) + max(0.0, $waitSeconds); + $lockKey = $this->prefix . hash('xxh128', $key); + $token = hash('xxh128', uniqid($key, true)); + + do { + try { + $stmt = $this->pdo->prepare('SELECT GET_LOCK(:k, 0)'); + $stmt->execute([':k' => $lockKey]); + $result = $stmt->fetchColumn(); + if ((string) $result === '1') { + return new LockHandle($lockKey, $token); + } + } catch (Throwable) { + return null; + } + + if (microtime(true) >= $deadline) { + return null; + } + + usleep($this->retrySleepMicros); + } while (true); + } + + private function acquirePgsql(string $key, float $waitSeconds): ?LockHandle + { + $deadline = microtime(true) + max(0.0, $waitSeconds); + $lockKey = $this->prefix . hash('xxh128', $key); + $advisoryKey = self::signedCrc32($lockKey); + $token = hash('xxh128', uniqid($key, true)); + + do { + try { + $stmt = $this->pdo->prepare('SELECT pg_try_advisory_lock(:k)'); + $stmt->execute([':k' => $advisoryKey]); + $result = $stmt->fetchColumn(); + if ($result === true || $result === 1 || $result === 't' || $result === '1') { + return new LockHandle($lockKey, $token, $advisoryKey); + } + } catch (Throwable) { + return null; + } + + if (microtime(true) >= $deadline) { + return null; + } + + usleep($this->retrySleepMicros); + } while (true); + } + + private function releaseMysql(LockHandle $handle): void + { + try { + $stmt = $this->pdo->prepare('SELECT RELEASE_LOCK(:k)'); + $stmt->execute([':k' => $handle->key]); + } catch (Throwable) { + // Best effort unlock. + } + } + + private function releasePgsql(LockHandle $handle): void + { + $advisoryKey = is_int($handle->resource) + ? $handle->resource + : self::signedCrc32($handle->key); + + try { + $stmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:k)'); + $stmt->execute([':k' => $advisoryKey]); + } catch (Throwable) { + // Best effort unlock. + } + } +} diff --git a/tests/Cache/PdoCachePoolTest.php b/tests/Cache/PdoCachePoolTest.php new file mode 100644 index 0000000..fcca69b --- /dev/null +++ b/tests/Cache/PdoCachePoolTest.php @@ -0,0 +1,64 @@ +skip(); + return; +} + +beforeEach(function () { + $this->cache = Cache::pdo('pdo-tests', 'sqlite::memory:'); +}); + +test('pdo adapter set and get with sqlite', function () { + expect($this->cache->set('alpha', 11))->toBeTrue() + ->and($this->cache->get('alpha'))->toBe(11); +}); + +test('pdo adapter ttl expiry with sqlite', function () { + $this->cache->set('ttl', 'v', 1); + usleep(2_000_000); + + expect($this->cache->get('ttl'))->toBeNull(); +}); + +test('pdo adapter delete and count with sqlite', function () { + $this->cache->set('a', 'A'); + $this->cache->set('b', 'B'); + + expect($this->cache->count())->toBe(2); + + $this->cache->delete('a'); + expect($this->cache->count())->toBe(1) + ->and($this->cache->get('a'))->toBeNull() + ->and($this->cache->get('b'))->toBe('B'); +}); + +test('pdo defaults to sqlite driver when no dsn is provided', function () { + $namespace = 'pdo-default-' . uniqid(); + $cache = Cache::pdo($namespace); + $cache->set('x', 'X'); + + $again = Cache::pdo($namespace); + expect($again->get('x'))->toBe('X'); + + $dbFile = sys_get_temp_dir() . '/cache_' . sanitize_cache_ns($namespace) . '.sqlite'; + $cache->clear(); + @unlink($dbFile); +}); + +test('pdo factory configures pdo lock provider', function () { + $cache = Cache::pdo('pdo-lock-tests', 'sqlite::memory:'); + $prop = (new ReflectionObject($cache))->getProperty('lockProvider'); + $provider = $prop->getValue($cache); + $pdoLockProviderClass = 'Infocyph\\CacheLayer\\Cache\\Lock\\PdoLockProvider'; + + if (class_exists($pdoLockProviderClass)) { + expect($provider)->toBeInstanceOf($pdoLockProviderClass); + return; + } + + expect($provider)->toBeInstanceOf(FileLockProvider::class); +}); diff --git a/tests/Cache/PdoMysqlCachePoolTest.php b/tests/Cache/PdoMysqlCachePoolTest.php new file mode 100644 index 0000000..95d88ce --- /dev/null +++ b/tests/Cache/PdoMysqlCachePoolTest.php @@ -0,0 +1,57 @@ +skip(); + return; +} + +$dsn = getenv('CACHELAYER_MYSQL_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=cachelayer'; +$user = getenv('CACHELAYER_MYSQL_USER') ?: 'root'; +$pass = getenv('CACHELAYER_MYSQL_PASS'); +if ($pass === false) { + $pass = ''; +} + +try { + $probe = new PDO($dsn, $user, $pass); + $probe->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $probe->query('SELECT 1'); +} catch (Throwable) { + test('MySQL server unreachable')->skip(); + return; +} + +beforeEach(function () use ($dsn, $user, $pass) { + $this->cache = Cache::pdo('mysql-tests', $dsn, $user, $pass, null, 'cachelayer_entries'); + $this->cache->clear(); +}); + +afterEach(function () { + $this->cache->clear(); +}); + +test('pdo adapter set and get on mysql', function () { + expect($this->cache->set('alpha', 11))->toBeTrue() + ->and($this->cache->get('alpha'))->toBe(11); +}); + +test('pdo adapter ttl expiry on mysql', function () { + $this->cache->set('ttl', 'v', 1); + usleep(2_000_000); + + expect($this->cache->get('ttl'))->toBeNull(); +}); + +test('pdo adapter delete and count on mysql', function () { + $this->cache->set('a', 'A'); + $this->cache->set('b', 'B'); + + expect($this->cache->count())->toBe(2); + + $this->cache->delete('a'); + expect($this->cache->count())->toBe(1) + ->and($this->cache->get('a'))->toBeNull() + ->and($this->cache->get('b'))->toBe('B'); +}); diff --git a/tests/Cache/PostgresCachePoolTest.php b/tests/Cache/PdoPgsqlCachePoolTest.php similarity index 82% rename from tests/Cache/PostgresCachePoolTest.php rename to tests/Cache/PdoPgsqlCachePoolTest.php index c7efda8..4862fbf 100644 --- a/tests/Cache/PostgresCachePoolTest.php +++ b/tests/Cache/PdoPgsqlCachePoolTest.php @@ -21,7 +21,7 @@ } beforeEach(function () use ($dsn, $user, $pass) { - $this->cache = Cache::postgres('pg-tests', $dsn, $user, $pass, null, 'cachelayer_entries'); + $this->cache = Cache::pdo('pg-tests', $dsn, $user, $pass, null, 'cachelayer_entries'); $this->cache->clear(); }); @@ -29,19 +29,19 @@ $this->cache->clear(); }); -test('postgres adapter set and get', function () { +test('pdo adapter set and get on pgsql', function () { expect($this->cache->set('alpha', 11))->toBeTrue() ->and($this->cache->get('alpha'))->toBe(11); }); -test('postgres adapter ttl expiry', function () { +test('pdo adapter ttl expiry on pgsql', function () { $this->cache->set('ttl', 'v', 1); usleep(2_000_000); expect($this->cache->get('ttl'))->toBeNull(); }); -test('postgres adapter delete and count', function () { +test('pdo adapter delete and count on pgsql', function () { $this->cache->set('a', 'A'); $this->cache->set('b', 'B'); diff --git a/tests/Cache/SqliteCachePoolTest.php b/tests/Cache/SqliteCachePoolTest.php index 6bf5849..e0394ee 100644 --- a/tests/Cache/SqliteCachePoolTest.php +++ b/tests/Cache/SqliteCachePoolTest.php @@ -7,7 +7,7 @@ */ use Infocyph\CacheLayer\Cache\Cache; -use Infocyph\CacheLayer\Cache\Item\SqliteCacheItem; +use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; use Infocyph\CacheLayer\Serializer\ValueSerializer; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use Psr\Cache\InvalidArgumentException as Psr6InvalidArgumentException; @@ -67,7 +67,7 @@ function (array $data): mixed { test('get returns default when key missing (sqlite)', function () { expect($this->cache->get('none', 'dflt'))->toBe('dflt'); - $val = $this->cache->get('compute', function (SqliteCacheItem $item) { + $val = $this->cache->get('compute', function (GenericCacheItem $item) { $item->expiresAfter(1); return 'val'; }); @@ -87,7 +87,7 @@ function (array $data): mixed { /* ── 2. PSR-6 behaviour ─────────────────────────────────────────── */ test('getItem()/save() (sqlite)', function () { $item = $this->cache->getItem('psr'); - expect($item)->toBeInstanceOf(SqliteCacheItem::class) + expect($item)->toBeInstanceOf(GenericCacheItem::class) ->and($item->isHit())->toBeFalse(); $item->set(42)->save(); @@ -160,4 +160,3 @@ function (array $data): mixed { ->and($items['void']->isHit())->toBeFalse(); }); -