PDOSqlite3ApcuDriver.php

PHP

Path: src/Mini/ApcuDrivers/PDOSqlite3ApcuDriver.php

<?php
namespace mini\Mini\ApcuDrivers;

use PDO;
use PDOException;

class PDOSqlite3ApcuDriver implements ApcuDriverInterface
{
    use ApcuDriverTrait;

    private PDO $pdo;

    /**
     * @param string $path Path to SQLite file. On Linux, /dev/shm/... gives
     *                     tmpfs-backed "in-memory" speed with persistence
     *                     across worker processes.
     */
    public function __construct(string $path)
    {
        $dsn = 'sqlite:' . $path;
        $this->pdo = new PDO($dsn, null, null, [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]);

        $this->initSchema();
        $this->configurePragmas();
    }

    private function initSchema(): void
    {
        $this->pdo->exec(
            'CREATE TABLE IF NOT EXISTS cache (
                 key     TEXT PRIMARY KEY,
                 payload BLOB NOT NULL
             )'
        );
        // Optional index is redundant with PRIMARY KEY
    }

    /**
     * Tuned for speed; tweak as needed.
     */
    private function configurePragmas(): void
    {
        $this->pdo->exec('PRAGMA journal_mode = WAL');
        $this->pdo->exec('PRAGMA synchronous = OFF');
        $this->pdo->exec('PRAGMA temp_store = MEMORY');
        $this->pdo->exec('PRAGMA locking_mode = NORMAL');
        $this->pdo->exec('PRAGMA busy_timeout = 5000');
    }

    /* --------------------------------------------------------------------
     * LOW-LEVEL BACKEND PRIMITIVES FOR ApcuDriverTrait
     * ------------------------------------------------------------------ */

    /**
     * _fetch(string $key, bool &$found = null): ?string
     */
    protected function _fetch(string $key, bool &$found = null): ?string
    {
        $stmt = $this->pdo->prepare('SELECT payload FROM cache WHERE key = :key');
        $stmt->execute([':key' => $key]);
        $row = $stmt->fetch();

        if ($row === false) {
            $found = false;
            return null;
        }

        $found = true;
        return $row['payload'];
    }

    /**
     * _add(string $key, string $payload, int $ttl): bool
     *
     * TTL is ignored here; trait stores logical expiry inside payload.
     */
    protected function _add(string $key, string $payload, int $ttl): bool
    {
        $stmt = $this->pdo->prepare(
            'INSERT OR IGNORE INTO cache (key, payload) VALUES (:key, :payload)'
        );

        $stmt->execute([
            ':key'     => $key,
            ':payload' => $payload,
        ]);

        // INSERT OR IGNORE: rowCount() == 1 means insert happened, 0 = existed
        return $stmt->rowCount() === 1;
    }

    /**
     * _store(string $key, string $payload, int $ttl): bool
     */
    protected function _store(string $key, string $payload, int $ttl): bool
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO cache (key, payload)
             VALUES (:key, :payload)
             ON CONFLICT(key) DO UPDATE SET payload = excluded.payload'
        );

        return $stmt->execute([
            ':key'     => $key,
            ':payload' => $payload,
        ]);
    }

    /**
     * _delete(string $key): bool
     */
    protected function _delete(string $key): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM cache WHERE key = :key');
        $stmt->execute([':key' => $key]);

        return $stmt->rowCount() > 0;
    }

    /* --------------------------------------------------------------------
     * GARBAGE COLLECTION
     * ------------------------------------------------------------------ */

    /**
     * Probabilistic GC: 1 in 10,000 chance to clean expired entries.
     *
     * SQLite has no native TTL, so we need to periodically scan for expired
     * entries based on the logical expiry stored in the payload.
     */
    protected function maybeGarbageCollect(): void
    {
        if (mt_rand(0, 9999) !== 0) {
            return;
        }

        // Scan all entries and delete expired ones
        // This is expensive but runs rarely (0.01% of writes)
        try {
            $stmt = $this->pdo->query('SELECT key, payload FROM cache');
            $toDelete = [];

            while ($row = $stmt->fetch()) {
                $expired = false;
                $expiresAt = null;
                $this->unpackValue($row['payload'], $expired, $expiresAt);

                if ($expired) {
                    $toDelete[] = $row['key'];
                }
            }

            if (!empty($toDelete)) {
                $placeholders = implode(',', array_fill(0, count($toDelete), '?'));
                $deleteStmt = $this->pdo->prepare("DELETE FROM cache WHERE key IN ($placeholders)");
                $deleteStmt->execute($toDelete);
            }
        } catch (\PDOException $e) {
            // GC failure is not critical - ignore
        }
    }

    /* --------------------------------------------------------------------
     * ApcuDriverInterface methods not provided by the trait
     * ------------------------------------------------------------------ */

    public function info(bool $limited = false): array|false
    {
        // Just return something minimal and cheap.
        $count = (int)$this->pdo
            ->query('SELECT COUNT(*) AS c FROM cache')
            ->fetch()['c'];

        return [
            'num_entries' => $count,
            'limited'     => $limited,
            'driver'      => 'sqlite',
        ];
    }

    public function sma_info(bool $limited = false): array|false
    {
        // SQLite doesn't expose allocator info in a useful way here.
        return [
            'available_memory' => null,
            'used_memory'      => null,
            'num_seg'          => 1,
            'seg_size'         => null,
            'limited'          => $limited,
            'driver'           => 'sqlite',
        ];
    }

    public function clear_cache(): bool
    {
        $this->pdo->exec('DELETE FROM cache');
        return true;
    }

    public function enabled(): bool
    {
        return extension_loaded('pdo_sqlite') || extension_loaded('sqlite3');
    }
}