ApcuDriverTrait.php
PHP
Path: src/Mini/ApcuDrivers/ApcuDriverTrait.php
<?php
namespace mini\Mini\ApcuDrivers;
/**
* ApcuDriverTrait
*
* Implements APCu-like per-key operations (add/store/fetch/exists/cas/inc/dec/entry)
* using four low-level primitives that the backend MUST provide:
*
* - _fetch(string $key, bool &$found = null): ?string
* - _add(string $key, string $payload, int $ttl): bool // SETNX
* - _store(string $key, string $payload, int $ttl): bool // SET (overwrite)
* - _delete(string $key): bool
*
* The trait owns the serialization format and TTL semantics. The backend uses TTL
* only for coarse eviction; the source of truth for expiry is stored in the payload.
*
* Limitations:
* - No hit counters or detailed stats; if you need those, use real APCu.
* - info()/sma_info()/clear_cache()/enabled() are left to the driver.
*/
trait ApcuDriverTrait
{
/* --------------------------------------------------------------------
* LOW-LEVEL BACKEND PRIMITIVES (MUST IMPLEMENT)
* ------------------------------------------------------------------ */
/**
* Fetch a raw payload for a key.
*
* @param string $key
* @param bool|null $found Set to true if the key exists in the backend.
* @return string|null Raw payload or null if not found.
*/
abstract protected function _fetch(string $key, bool &$found = null): ?string;
/**
* Add a raw payload if the key does not exist (SETNX semantics).
*
* @param string $key
* @param string $payload
* @param int $ttl Backend TTL in seconds (0 = no expiry).
*/
abstract protected function _add(string $key, string $payload, int $ttl): bool;
/**
* Store (overwrite) a raw payload (SET semantics).
*
* @param string $key
* @param string $payload
* @param int $ttl Backend TTL in seconds (0 = no expiry).
*/
abstract protected function _store(string $key, string $payload, int $ttl): bool;
/**
* Delete a key from the backend.
*/
abstract protected function _delete(string $key): bool;
/* --------------------------------------------------------------------
* INTERNAL ENCODING / TTL HELPERS
* ------------------------------------------------------------------ */
/**
* Encode a value and its expiry into a payload string.
*
* @param mixed $value
* @param int|null $expiresAt Unix timestamp or null for no expiry.
*/
protected function packValue(mixed $value, ?int $expiresAt): string
{
return serialize([
'v' => $value,
'expires_at' => $expiresAt,
]);
}
/**
* Decode payload into value + expiry.
*
* @param string $raw
* @param bool $expired Set true if logically expired.
* @param int|null $expiresAt
* @return mixed The stored value (or null if invalid).
*/
protected function unpackValue(string $raw, bool &$expired, ?int &$expiresAt): mixed
{
$expired = false;
$expiresAt = null;
$data = @unserialize($raw);
if (!is_array($data) || !array_key_exists('v', $data)) {
$expired = true;
return null;
}
$expiresAt = $data['expires_at'] ?? null;
if ($expiresAt !== null && $expiresAt <= time()) {
$expired = true;
return null;
}
return $data['v'];
}
/**
* Compute logical expiry from TTL.
*/
protected function computeExpiresAt(int $ttl): ?int
{
return $ttl > 0 ? time() + $ttl : null;
}
/**
* Convert logical expiry to backend TTL.
*/
protected function backendTtl(?int $expiresAt): int
{
if ($expiresAt === null) {
return 0;
}
$remaining = $expiresAt - time();
return $remaining > 0 ? $remaining : 1;
}
/* --------------------------------------------------------------------
* SIMPLE LOCKING USING _add()
* ------------------------------------------------------------------ */
protected function lockKey(string $key): string
{
return "\0lock:" . $key;
}
protected function acquireLock(string $key, int $lockTtl = 5): void
{
$lockKey = $this->lockKey($key);
$attempts = 0;
while (true) {
if ($this->_add($lockKey, '1', $lockTtl)) {
return;
}
usleep(1000); // 1 ms
if (++$attempts > 5000) {
throw new \RuntimeException("APCu polyfill lock timeout for key '$key'");
}
}
}
protected function releaseLock(string $key): void
{
$this->_delete($this->lockKey($key));
}
protected function withLock(string $key, callable $fn)
{
$this->acquireLock($key);
try {
return $fn();
} finally {
$this->releaseLock($key);
}
}
/* --------------------------------------------------------------------
* PROBABILISTIC GARBAGE COLLECTION
* ------------------------------------------------------------------ */
/**
* Probabilistic garbage collection - randomly triggers on write operations.
*
* Probability: 1 in 10,000 (0.01%)
* Override in concrete drivers if they need different GC behavior.
*/
protected function maybeGarbageCollect(): void
{
// Disabled by default - drivers should implement if needed
}
/* --------------------------------------------------------------------
* PUBLIC APCU-LIKE OPERATIONS
* ------------------------------------------------------------------ */
/** apcu_fetch */
public function fetch(mixed $key, bool &$success = null): mixed
{
if (is_array($key)) {
$out = [];
$success = true;
foreach ($key as $k) {
$s = false;
$v = $this->fetch($k, $s);
if ($s) {
$out[$k] = $v;
}
}
return $out;
}
$success = false;
$found = false;
$raw = $this->_fetch($key, $found);
if (!$found || $raw === null) {
return false;
}
$expired = false;
$expiresAt = null;
$value = $this->unpackValue($raw, $expired, $expiresAt);
if ($expired) {
$this->_delete($key);
return false;
}
$success = true;
return $value;
}
/** apcu_add */
public function add(string|array $key, mixed $var = null, int $ttl = 0): array|bool
{
if (is_array($key)) {
$errors = [];
foreach ($key as $k => $v) {
if (!$this->add($k, $v, $ttl)) {
$errors[] = $k;
}
}
return $errors ?: true;
}
$expiresAt = $this->computeExpiresAt($ttl);
$payload = $this->packValue($var, $expiresAt);
$backendTtl = $this->backendTtl($expiresAt);
return $this->_add($key, $payload, $backendTtl);
}
/** apcu_store */
public function store(string|array $keys, mixed $var = null, int $ttl = 0): bool|array
{
if (is_array($keys)) {
$errors = [];
foreach ($keys as $k => $v) {
if (!$this->store($k, $v, $ttl)) {
$errors[] = $k;
}
}
return $errors ?: true;
}
$expiresAt = $this->computeExpiresAt($ttl);
$payload = $this->packValue($var, $expiresAt);
$backendTtl = $this->backendTtl($expiresAt);
$result = $this->_store($keys, $payload, $backendTtl);
// Probabilistic GC on writes
$this->maybeGarbageCollect();
return $result;
}
/** apcu_delete */
public function delete(mixed $key): mixed
{
if (is_array($key)) {
$failed = [];
foreach ($key as $k) {
if (!$this->_delete($k)) {
$failed[] = $k;
}
}
return $failed;
}
return $this->_delete($key);
}
/** apcu_exists */
public function exists(string|array $keys): array|bool
{
if (is_array($keys)) {
$out = [];
foreach ($keys as $k) {
$s = false;
$this->fetch($k, $s); // obeys TTL
if ($s) {
$out[$k] = true;
}
}
return $out;
}
$s = false;
$this->fetch($keys, $s);
return $s;
}
/** apcu_entry */
public function entry(string $key, callable $callback, int $ttl = 0): mixed
{
return $this->withLock($key, function () use ($key, $callback, $ttl) {
$s = false;
$val = $this->fetch($key, $s);
if ($s) {
return $val;
}
$val = $callback();
$expiresAt = $this->computeExpiresAt($ttl);
$payload = $this->packValue($val, $expiresAt);
$backendTtl = $this->backendTtl($expiresAt);
$this->_store($key, $payload, $backendTtl);
return $val;
});
}
/** apcu_cas */
public function cas(string $key, int $old, int $new): bool
{
return $this->withLock($key, function () use ($key, $old, $new) {
$found = false;
$raw = $this->_fetch($key, $found);
if (!$found || $raw === null) {
return false;
}
$expired = false;
$expiresAt = null;
$value = $this->unpackValue($raw, $expired, $expiresAt);
if ($expired) {
$this->_delete($key);
return false;
}
if (!is_int($value) || $value !== $old) {
return false;
}
// Preserve TTL (expiresAt) exactly
$payload = $this->packValue($new, $expiresAt);
$backendTtl = $this->backendTtl($expiresAt);
return $this->_store($key, $payload, $backendTtl);
});
}
/** apcu_inc */
public function inc(string $key, int $step = 1, bool &$success = null, int $ttl = 0): int|false
{
return $this->withLock($key, function () use ($key, $step, $ttl, &$success) {
$success = false;
$found = false;
$raw = $this->_fetch($key, $found);
if (!$found || $raw === null) {
// missing → create new with provided TTL
$value = $step;
$expiresAt = $this->computeExpiresAt($ttl);
$payload = $this->packValue($value, $expiresAt);
$backendTtl = $this->backendTtl($expiresAt);
$success = $this->_store($key, $payload, $backendTtl);
return $success ? $value : false;
}
$expired = false;
$expiresAt = null;
$current = $this->unpackValue($raw, $expired, $expiresAt);
if ($expired) {
$this->_delete($key);
// treat as missing and create with provided TTL
$value = $step;
$expiresAt = $this->computeExpiresAt($ttl);
$payload = $this->packValue($value, $expiresAt);
$backendTtl = $this->backendTtl($expiresAt);
$success = $this->_store($key, $payload, $backendTtl);
return $success ? $value : false;
}
if (!is_int($current)) {
return false;
}
$value = $current + $step;
// IMPORTANT: preserve existing expiresAt
$payload = $this->packValue($value, $expiresAt);
$backendTtl = $this->backendTtl($expiresAt);
$success = $this->_store($key, $payload, $backendTtl);
return $success ? $value : false;
});
}
/** apcu_dec */
public function dec(string $key, int $step = 1, bool &$success = null, int $ttl = 0): int|false
{
return $this->inc($key, -$step, $success, $ttl);
}
/** apcu_key_info (minimal) */
public function key_info(string $key): ?array
{
$found = false;
$raw = $this->_fetch($key, $found);
if (!$found || $raw === null) {
return null;
}
$expired = false;
$expiresAt = null;
$value = $this->unpackValue($raw, $expired, $expiresAt);
if ($expired) {
$this->_delete($key);
return null;
}
return [
'key' => $key,
'value_type' => gettype($value),
'ttl' => $expiresAt !== null ? max(0, $expiresAt - time()) : 0,
];
}
}