diff --git a/config_uk.xml b/config_uk.xml index 9939b73..51d0752 100644 --- a/config_uk.xml +++ b/config_uk.xml @@ -2,10 +2,10 @@ dbmemorycache - - - + + + - 0 + 1 0 \ No newline at end of file diff --git a/dbmemorycache.php b/dbmemorycache.php index 4ef857f..f742458 100644 --- a/dbmemorycache.php +++ b/dbmemorycache.php @@ -18,11 +18,14 @@ class DbMemoryCache extends Module // Statically cache the max size so we don't query the `configuration` table on every set() private static ?int $maxCacheSizeChars = null; + // Prevents recursive cleanup loops + private static bool $cleanupInProgress = false; + public function __construct() { $this->name = 'dbmemorycache'; $this->tab = 'administration'; - $this->version = '1.3.0'; + $this->version = '1.4.0'; $this->author = 'Panariga'; $this->need_instance = 0; $this->ps_versions_compliancy = ['min' => '8.0.0', 'max' => _PS_VERSION_]; @@ -159,13 +162,7 @@ class DbMemoryCache extends Module */ 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); @@ -228,29 +225,9 @@ class DbMemoryCache extends Module $sql = "REPLACE INTO `{$this->tableName}` (`cache_key`, `cache_value`, `expiry`) VALUES ('$safeKey', '$safeValue', $expiry)"; try { - return $db->execute($sql); } catch (\PrestaShopDatabaseException $e) { - $errorMsg = $e->getMessage(); - - // 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 - } - } - - return false; + return $this->handleWriteError($e, $sql); } } @@ -333,4 +310,195 @@ class DbMemoryCache extends Module return false; } } + + /** + * Unified handler for write errors (1114 table full, 1146 table missing). + * Attempts recovery and retries the SQL once. + */ + private function handleWriteError(\PrestaShopDatabaseException $e, string $retrySql): bool + { + $errorMsg = $e->getMessage(); + $db = Db::getInstance(); + + // Error 1146: Table doesn't exist (manual drop or DB crash/reboot) + if (strpos($errorMsg, '1146') !== false) { + if ($this->createCacheTable()) { + try { + return $db->execute($retrySql); + } catch (\Exception $e2) { + return false; + } + } + return false; + } + + // Error 1114: The table is full + if (strpos($errorMsg, '1114') !== false || strpos($errorMsg, 'is full') !== false) { + if ($this->emergencyCleanup()) { + try { + return $db->execute($retrySql); + } catch (\PrestaShopDatabaseException $e2) { + // Still full after cleanup — escalate one more time + $errorMsg2 = $e2->getMessage(); + if (strpos($errorMsg2, '1114') !== false || strpos($errorMsg2, 'is full') !== false) { + $this->emergencyCleanup(true); // aggressive mode + try { + return $db->execute($retrySql); + } catch (\Exception $e3) { + PrestaShopLogger::addLog( + 'DbMemoryCache: Table still full after aggressive cleanup. Key dropped.', + 3, null, 'DbMemoryCache' + ); + return false; + } + } + return false; + } + } + return false; + } + + // Unknown write error — log and fail + PrestaShopLogger::addLog( + 'DbMemoryCache: Unhandled write error: ' . $errorMsg, + 3, null, 'DbMemoryCache' + ); + return false; + } + + /** + * Multi-level emergency cleanup for "table is full" errors. + * + * Strategy (escalating): + * 1. Prune all expired rows + * 2. Evict oldest 25% of rows by expiry (soonest to expire = least valuable) + * 3. (aggressive) Evict oldest 50% + flush if nothing else works + * + * @param bool $aggressive If true, skip to the most aggressive cleanup level. + * @return bool True if some rows were freed. + */ + private function emergencyCleanup(bool $aggressive = false): bool + { + // Prevent recursive cleanup if setValue is called during cleanup + if (self::$cleanupInProgress) { + return false; + } + self::$cleanupInProgress = true; + + try { + $db = Db::getInstance(); + + // Level 1: Prune expired rows + if (!$aggressive) { + $now = time(); + try { + $db->execute("DELETE FROM `{$this->tableName}` WHERE `expiry` <= $now"); + } catch (\Exception $e) { + // table might be missing, nothing to clean + } + + $freedRows = (int) $db->getNumberOfRows(); + if ($freedRows > 0) { + PrestaShopLogger::addLog( + "DbMemoryCache: Emergency prune freed $freedRows expired rows.", + 2, null, 'DbMemoryCache' + ); + return true; + } + + // Level 2: Evict the oldest 25% by nearest expiry (least time remaining) + if ($this->evictOldest(25)) { + return true; + } + } + + // Level 3 (aggressive): Evict 50% + if ($this->evictOldest(50)) { + PrestaShopLogger::addLog( + 'DbMemoryCache: Aggressive cleanup — evicted 50% of rows.', + 2, null, 'DbMemoryCache' + ); + return true; + } + + // Level 4: Full flush as last resort + PrestaShopLogger::addLog( + 'DbMemoryCache: Full flush triggered — all other cleanup levels failed.', + 3, null, 'DbMemoryCache' + ); + try { + return $db->execute("TRUNCATE TABLE `{$this->tableName}`"); + } catch (\Exception $e) { + // TRUNCATE can't fail on MEMORY tables unless table is missing + $this->createCacheTable(); + return true; + } + } finally { + self::$cleanupInProgress = false; + } + } + + /** + * Evict the oldest N% of rows (by soonest expiry = least remaining TTL). + * For a MEMORY table, this is the most efficient LRU-like eviction. + * + * @param int $percent Percentage of rows to evict (1-100). + * @return bool True if any rows were deleted. + */ + private function evictOldest(int $percent): bool + { + $db = Db::getInstance(); + + try { + $totalRows = (int) $db->getValue("SELECT COUNT(*) FROM `{$this->tableName}`"); + if ($totalRows === 0) { + return false; + } + + $rowsToEvict = max(1, (int) ceil($totalRows * $percent / 100)); + + // Find the expiry threshold: the Nth-lowest expiry value + $threshold = $db->getValue( + "SELECT `expiry` FROM `{$this->tableName}` ORDER BY `expiry` ASC LIMIT $rowsToEvict, 1" + ); + + if ($threshold !== false && $threshold !== null) { + // Delete all rows with expiry below the threshold + $db->execute( + "DELETE FROM `{$this->tableName}` WHERE `expiry` < " . (int) $threshold + ); + } else { + // Fewer rows than the limit — delete the bottom chunk directly + // For MEMORY engine, sub-selects in DELETE are not supported, + // so we use a two-step approach + $keysToDelete = $db->executeS( + "SELECT `cache_key` FROM `{$this->tableName}` ORDER BY `expiry` ASC LIMIT $rowsToEvict" + ); + if ($keysToDelete && count($keysToDelete) > 0) { + $keyList = implode("','", array_map(function ($row) { + return pSQL($row['cache_key']); + }, $keysToDelete)); + $db->execute( + "DELETE FROM `{$this->tableName}` WHERE `cache_key` IN ('$keyList')" + ); + } + } + + $deletedCount = max(0, $totalRows - (int) $db->getValue("SELECT COUNT(*) FROM `{$this->tableName}`")); + if ($deletedCount > 0) { + PrestaShopLogger::addLog( + "DbMemoryCache: Evicted $deletedCount rows ({$percent}% target of $totalRows).", + 2, null, 'DbMemoryCache' + ); + return true; + } + } catch (\Exception $e) { + PrestaShopLogger::addLog( + 'DbMemoryCache: evictOldest failed: ' . $e->getMessage(), + 3, null, 'DbMemoryCache' + ); + } + + return false; + } }