diff --git a/dbmemorycache.php b/dbmemorycache.php index f1f9724..4ef857f 100644 --- a/dbmemorycache.php +++ b/dbmemorycache.php @@ -2,7 +2,7 @@ /** * DB Memory Cache Module for PrestaShop - * Robust edition: Handles Reboots, Table Full errors, and Long Keys. + * Robust edition: High performance, dynamic sizing, auto-recovery. */ declare(strict_types=1); @@ -15,18 +15,15 @@ class DbMemoryCache extends Module { private string $tableName; - // Static flag to ensure we only check table existence once per page load - private static bool $isInitialized = false; - - // Max chars for value (approx 16KB safety limit for utf8mb4) - const MAX_CACHE_SIZE_CHARS = 16000; + // Statically cache the max size so we don't query the `configuration` table on every set() + private static ?int $maxCacheSizeChars = null; public function __construct() { $this->name = 'dbmemorycache'; $this->tab = 'administration'; - $this->version = '1.2.0'; - $this->author = 'YourName'; + $this->version = '1.3.0'; + $this->author = 'Panariga'; $this->need_instance = 0; $this->ps_versions_compliancy = ['min' => '8.0.0', 'max' => _PS_VERSION_]; $this->bootstrap = true; @@ -34,52 +31,177 @@ class DbMemoryCache extends Module parent::__construct(); $this->displayName = $this->trans('MySQL Memory Cache Pro', [], 'Modules.Dbmemorycache.Admin'); - $this->description = $this->trans('High-speed RAM cache. Auto-recovers after reboot.', [], 'Modules.Dbmemorycache.Admin'); + $this->description = $this->trans('High-speed RAM cache. Auto-recovers table and handles dynamic sizes.', [], 'Modules.Dbmemorycache.Admin'); $this->tableName = _DB_PREFIX_ . 'custom_mem_cache'; } public function install(): bool { - // We create it here just to be sure, but the runtime logic handles it too. - return parent::install() && $this->createCacheTable(); + return parent::install() && $this->calculateAndSaveLimits() && $this->createCacheTable(); } public function uninstall(): bool { - return $this->dropCacheTable() && parent::uninstall(); + return $this->dropCacheTable() + && Configuration::deleteByName('DB_MEM_CACHE_MAX_SIZE') + && parent::uninstall(); } - /** - * Lazy Initialization. - * Checks/Creates table only when actually needed, not on module instantiation. + * Load the configuration page in the PrestaShop Back Office. */ - private function initCache(): void + public function getContent(): string { - if (self::$isInitialized) { - return; + $output = ''; + + // Handle form submissions for cache management + if (Tools::isSubmit('submitFlushCache')) { + if ($this->flush()) { + $output .= $this->displayConfirmation($this->trans('Cache flushed successfully.', [], 'Modules.Dbmemorycache.Admin')); + } else { + $output .= $this->displayError($this->trans('Failed to flush cache. Table might be missing.', [], 'Modules.Dbmemorycache.Admin')); + } + } elseif (Tools::isSubmit('submitPruneCache')) { + if ($this->prune()) { + $output .= $this->displayConfirmation($this->trans('Expired keys pruned successfully.', [], 'Modules.Dbmemorycache.Admin')); + } else { + $output .= $this->displayError($this->trans('Failed to prune cache.', [], 'Modules.Dbmemorycache.Admin')); + } } - $this->createCacheTable(); - self::$isInitialized = true; + // Fetch current table statistics + $stats = $this->getTableStats(); + + // Build the HTML view using standard PrestaShop Bootstrap UI + $currentIndex = $this->context->link->getAdminLink('AdminModules', false) + . '&configure=' . $this->name + . '&tab_module=' . $this->tab + . '&module_name=' . $this->name + . '&token=' . Tools::getAdminTokenLite('AdminModules'); + + $statusLabel = $stats['exists'] + ? 'Online (MEMORY Engine)' + : 'Offline / Missing'; + + $this->context->smarty->assign([ + 'statusLabel' => $statusLabel, + 'stats' => $stats, + 'dataMemoryUsed' => $this->formatBytes($stats['data_size']), + 'indexMemoryUsed' => $this->formatBytes($stats['index_size']), + 'currentIndex' => $currentIndex, + ]); + $output .= $this->context->smarty->fetch($this->local_path . 'views/templates/admin/configure.tpl'); + + return $output; } /** - * Creates the MEMORY table if it doesn't exist. - * Uses VARCHAR(128) for keys to allow "prefix_SHA256". + * Queries MySQL for the specific MEMORY table statistics. */ + private function getTableStats(): array + { + $db = Db::getInstance(); + $stats = [ + 'exists' => false, + 'rows' => 0, + 'expired_rows' => 0, + 'data_size' => 0, + 'index_size' => 0, + 'max_size' => $this->getMaxCacheSize(), + ]; + + // Escape the prefix+tablename for the LIKE clause + $safeTableName = pSQL($this->tableName); + $sql = "SHOW TABLE STATUS LIKE '$safeTableName'"; + $stats['sql'] = $sql; + + try { + $tableStatus = $db->executeS($sql, true); + if ($tableStatus) { + $stats['exists'] = true; + $stats['rows'] = (int)($tableStatus['0']['Rows'] ?? 0); + $stats['data_size'] = (int)($tableStatus['0']['Data_length'] ?? 0); + $stats['index_size'] = (int)($tableStatus['0']['Index_length'] ?? 0); + + // Count how many rows are technically expired but haven't been overwritten/pruned yet + $now = time(); + $expiredSql = "SELECT COUNT(*) FROM `{$this->tableName}` WHERE `expiry` <= $now"; + $stats['expired_rows'] = (int)$db->getValue($expiredSql); + } + } catch (\Exception $e) { + // Table likely doesn't exist (reboot/crash) + $stats['exists'] = false; + } + + return $stats; + } + + /** + * Helper to convert bytes to human-readable format. + */ + private function formatBytes(int $bytes, int $precision = 2): string + { + if ($bytes <= 0) { + return '0 B'; + } + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + + $bytes /= (1 << (10 * $pow)); + + return round($bytes, $precision) . ' ' . $units[$pow]; + } + /** + * Calculates the maximum safe VARCHAR size for the MEMORY engine + * based on MySQL's 65,535 byte row limit. + */ + private function calculateAndSaveLimits(): bool + { + // Max row size: 65535 bytes + // Key: 128 chars * 4 bytes (utf8mb4) = 512 bytes + // Expiry: INT = 4 bytes + // Available bytes for value: 65535 - 512 - 4 = 65019 bytes + // Max characters (utf8mb4): floor(65019 / 4) = 16254 + + // We set 16000 as a safe upper boundary, but you could query DB version here if needed. + $safeCharLimit = 16000; + + return Configuration::updateValue('DB_MEM_CACHE_MAX_SIZE', $safeCharLimit); + } + + /** + * Fetch max size from config (cached statically for performance) + */ + private function getMaxCacheSize(): int + { + if (self::$maxCacheSizeChars === null) { + self::$maxCacheSizeChars = (int) Configuration::get('DB_MEM_CACHE_MAX_SIZE', null, null, null, 16000); + } + return self::$maxCacheSizeChars; + } + private function createCacheTable(): bool { - // MAX_ROWS is a hint to MySQL to allocate memory efficiently + $maxChars = $this->getMaxCacheSize(); + + // We use utf8mb4_bin for the Key (case-sensitive) + // We use ascii for the Value (1 byte per char) to save 75% RAM! $sql = "CREATE TABLE IF NOT EXISTS `{$this->tableName}` ( - `cache_key` VARCHAR(128) NOT NULL, - `cache_value` VARCHAR(" . self::MAX_CACHE_SIZE_CHARS . ") NOT NULL, + `cache_key` VARCHAR(128) COLLATE utf8mb4_bin NOT NULL, + `cache_value` VARCHAR(" . $maxChars . ") CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, `expiry` INT(11) UNSIGNED NOT NULL, PRIMARY KEY (`cache_key`), INDEX `idx_expiry` (`expiry`) - ) ENGINE=MEMORY DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci MAX_ROWS=10000;"; + ) ENGINE=MEMORY DEFAULT CHARSET=utf8mb4 MAX_ROWS=10000;"; - return Db::getInstance()->execute($sql); + try { + return Db::getInstance()->execute($sql); + } catch (\Exception $e) { + PrestaShopLogger::addLog("DbMemoryCache Installation Error: " . $e->getMessage(), 3); + return false; + } } private function dropCacheTable(): bool @@ -87,21 +209,14 @@ class DbMemoryCache extends Module return Db::getInstance()->execute("DROP TABLE IF EXISTS `{$this->tableName}`"); } - /** - * Set a value in cache with "Table Full" protection - */ public function setValue(string $key, $value, int $ttlSeconds = 3600): bool { - $this->initCache(); + // We use serialize instead of JSON. JSON converts Objects to Arrays, + // which breaks cache expectations for other PrestaShop modules. + $encodedValue = serialize($value); - // 1. Serialize - $encodedValue = json_encode($value, JSON_UNESCAPED_UNICODE); - if ($encodedValue === false) { - return false; - } - - // 2. Size Guard - if (mb_strlen($encodedValue, 'UTF-8') > self::MAX_CACHE_SIZE_CHARS) { + // Size Guard + if (mb_strlen($encodedValue, 'UTF-8') > $this->getMaxCacheSize()) { return false; } @@ -109,76 +224,63 @@ class DbMemoryCache extends Module $db = Db::getInstance(); $safeKey = pSQL($key); $safeValue = pSQL($encodedValue, true); - - // 3. Delete existing key (clean state) - // We suppress errors here just in case table is weird state - try { - $db->execute("DELETE FROM `{$this->tableName}` WHERE `cache_key` = '$safeKey'"); - } catch (Exception $e) { - // If delete fails, table might be gone (manual drop?), try re-init - $this->createCacheTable(); - } - - // 4. Insert with Retry Logic for "Table Full" - try { - $sql = "INSERT IGNORE INTO `{$this->tableName}` (`cache_key`, `cache_value`, `expiry`) + // REPLACE INTO is faster and cleaner than DELETE + INSERT IGNORE + $sql = "REPLACE INTO `{$this->tableName}` (`cache_key`, `cache_value`, `expiry`) VALUES ('$safeKey', '$safeValue', $expiry)"; + try { + return $db->execute($sql); - } catch (Exception $e) { - // MySQL Error 1114 = The table is full - // If the message contains "is full" or error code matches - if (strpos($e->getMessage(), 'is full') !== false || $e->getCode() == 1114) { + } catch (\PrestaShopDatabaseException $e) { + $errorMsg = $e->getMessage(); - // EMERGENCY MEASURE: Prune expired keys - $this->prune(); - - // Retry Insert once - try { - return $db->execute($sql); - } catch (Exception $e2) { - // Still full? The cache is saturated with valid data. - // Option: Flush everything (Aggressive) OR Just return false (Conservative) - // Let's return false to preserve existing valid cache. - return false; + // Error 1146: Table doesn't exist (Manual drop or DB crash) + if (strpos($errorMsg, '1146') !== false) { + if ($this->createCacheTable()) { + return $db->execute($sql); // Retry once + } + } + + // Error 1114: The table is full + if (strpos($errorMsg, 'is full') !== false || $e->getCode() == 1114) { + $this->prune(); // Emergency prune + try { + return $db->execute($sql); // Retry once + } catch (\Exception $e2) { + return false; // Still full, fail gracefully } } - // Log other errors - PrestaShopLogger::addLog("DbMemoryCache SET Error: " . $e->getMessage(), 3); return false; } } public function getValue(string $key) { - $this->initCache(); - - $db = Db::getInstance(); $safeKey = pSQL($key); $now = time(); try { $sql = "SELECT `cache_value` FROM `{$this->tableName}` WHERE `cache_key` = '$safeKey' AND `expiry` > $now"; - - $result = $db->getValue($sql); - } catch (Exception $e) { - // If table is missing (reboot happened during this request?), recreate and return null - $this->createCacheTable(); + $result = Db::getInstance()->getValue($sql); + } catch (\PrestaShopDatabaseException $e) { + // Recreate table quietly if it went missing, return null + if (strpos($e->getMessage(), '1146') !== false) { + $this->createCacheTable(); + } return null; } - if (!$result) { + if ($result === false || $result === null) { return null; } - return json_decode($result, true); + // Supress unserialize notices if data became corrupted + return @unserialize($result); } public function existsValue(string $key): bool { - $this->initCache(); - $safeKey = pSQL($key); $now = time(); @@ -186,29 +288,49 @@ class DbMemoryCache extends Module $sql = "SELECT 1 FROM `{$this->tableName}` WHERE `cache_key` = '$safeKey' AND `expiry` > $now"; return (bool) Db::getInstance()->getValue($sql); - } catch (Exception $e) { + } catch (\Exception $e) { + if (strpos($e->getMessage(), '1146') !== false) { + $this->createCacheTable(); + } return false; } } public function deleteValue(string $key): bool { - $this->initCache(); $safeKey = pSQL($key); - return Db::getInstance()->execute("DELETE FROM `{$this->tableName}` WHERE `cache_key` = '$safeKey'"); + try { + return Db::getInstance()->execute("DELETE FROM `{$this->tableName}` WHERE `cache_key` = '$safeKey'"); + } catch (\Exception $e) { + if (strpos($e->getMessage(), '1146') !== false) { + $this->createCacheTable(); + } + return false; + } } public function prune(): bool { - $this->initCache(); $now = time(); - // Delete rows where expiry is in the past - return Db::getInstance()->execute("DELETE FROM `{$this->tableName}` WHERE `expiry` <= $now"); + try { + return Db::getInstance()->execute("DELETE FROM `{$this->tableName}` WHERE `expiry` <= $now"); + } catch (\Exception $e) { + if (strpos($e->getMessage(), '1146') !== false) { + $this->createCacheTable(); + } + return false; + } } public function flush(): bool { - $this->initCache(); - return Db::getInstance()->execute("TRUNCATE TABLE `{$this->tableName}`"); + try { + return Db::getInstance()->execute("TRUNCATE TABLE `{$this->tableName}`"); + } catch (\Exception $e) { + if (strpos($e->getMessage(), '1146') !== false) { + $this->createCacheTable(); + } + return false; + } } } diff --git a/views/templates/admin/configure.tpl b/views/templates/admin/configure.tpl new file mode 100644 index 0000000..0b88a5e --- /dev/null +++ b/views/templates/admin/configure.tpl @@ -0,0 +1,56 @@ +
| Status | +{$statusLabel} | +
| Max Allowed Value Size | +{$stats['max_size']} characters | +
| Total Keys in Cache | +{$stats['rows']} | +
| Expired Keys (Awaiting Prune) | +{$stats['expired_rows']} | +
| Data Memory Used | +{$dataMemoryUsed} | +
| Index Memory Used | +{$indexMemoryUsed} | +
MEMORY engine.
+ Data is stored entirely in RAM. If your database server reboots, the data is automatically cleared,
+ but the module will auto-recover and recreate the table schema if necessary.
+