logic = $logic; $this->logger = $logger; } /** * @param array $options Current options as returned by getOptions(true). * @return array Options after attempted recovery. */ function recoverIfStale(array $options): array { $cooldownKey = 'abj404_frontend_db_recovery_cooldown'; if (function_exists('get_transient') && get_transient($cooldownKey)) { return $options; } // Cooldown is set BEFORE attempting recovery so concurrent requests // bail immediately rather than piling onto the lock. if (function_exists('set_transient')) { set_transient($cooldownKey, '1', 5 * 60); } try { $upgraded = $this->logic->versionUpgrader()->upgradeIfNeeded($options); if (is_array($upgraded)) { $options = $upgraded; } } catch (\Throwable $e) { $failureCount = $this->recordFailedAttempt(); $this->logger->warn(sprintf( 'Frontend DB version recovery failed (consecutive failed attempts: %d): %s', $failureCount, $e->getMessage() )); $this->escalateIfStreakJustReachedTheThreshold($failureCount, 'The upgrade threw: ' . $e->getMessage()); return $options; } // upgradeIfNeeded ends in updateOptions() which clears the resolved- // options cache, so getOptions(true) returns fresh values from the DB. $fresh = $this->getOptions(); if (isset($fresh['DB_VERSION']) && defined('ABJ404_VERSION') && $fresh['DB_VERSION'] == ABJ404_VERSION) { $this->clearFailureStreak(); return $fresh; } $observed = (isset($fresh['DB_VERSION']) && is_scalar($fresh['DB_VERSION'])) ? (string)$fresh['DB_VERSION'] : '(missing)'; $expected = defined('ABJ404_VERSION') ? ABJ404_VERSION : '(unknown)'; $failureCount = $this->recordFailedAttempt(); $this->logger->warn(sprintf( 'Frontend DB_VERSION still stale after recovery attempt: have=%s expected=%s ' . '(consecutive failed attempts: %d)', $observed, $expected, $failureCount )); $this->escalateIfStreakJustReachedTheThreshold($failureCount, sprintf( 'DB_VERSION is stuck at %s while the running code expects %s.', $observed, $expected )); return $fresh; } /** * Record one more consecutive failure and return the new streak length. * * @return int */ private function recordFailedAttempt(): int { if (!function_exists('get_option') || !function_exists('update_option')) { return 1; } $stored = get_option(self::CONSECUTIVE_FAILURE_OPTION, 0); $failureCount = (is_scalar($stored) ? (int)$stored : 0) + 1; // @cache-write-audit: opt-out - stores a failure-streak counter, not a query result update_option(self::CONSECUTIVE_FAILURE_OPTION, $failureCount, false); return $failureCount; } /** @return void */ private function clearFailureStreak(): void { if (function_exists('delete_option')) { delete_option(self::CONSECUTIVE_FAILURE_OPTION); } } /** * Surface a persistent wedge exactly once per streak. * * Warnings are the right level for a single failed attempt: the plugin * degrades to a manual-redirect lookup and the next visitor retries. A * streak is different -- the site keeps running new code against an old * schema indefinitely, which is the "plugin cannot do its job" case that * the defensive-coding rules put at error level. Escalating on exact * equality (rather than >=) means the streak itself is the dedupe: one * report per wedge, not one per visitor, and no separate dedupe transient * that an object cache could drop. * * @param int $failureCount * @param string $detail * @return void */ private function escalateIfStreakJustReachedTheThreshold(int $failureCount, string $detail): void { if ($failureCount !== self::ESCALATE_AFTER_CONSECUTIVE_FAILURES) { return; } $this->logger->errorMessage(sprintf( 'Frontend database upgrade has now failed %d consecutive times, so this site is ' . 'serving degraded redirect lookups against an out-of-date schema. %s', $failureCount, $detail )); } /** * @return array */ private function getOptions(): array { $options = $this->logic->optionsResolver()->getOptions(true); return is_array($options) ? $options : array(); } }