PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / frontend / FrontendDbVersionRecovery.php

FrontendDbVersionRecovery.php in 404 Solution trunk, at includes/frontend/FrontendDbVersionRecovery.php

171 lines 6.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Self-heals a stale DB_VERSION on the frontend so end users get redirects
9 * without needing an admin visit (task 233). Throttled by a transient so
10 * concurrent 404s don't all queue on the synchronizer lock inside
11 * PluginLogicVersionUpgrader::upgradeIfNeeded(). If recovery cannot close
12 * the gap (lock held, cooldown active, migration repeatedly throws), the
13 * caller falls through to a degraded redirect lookup (task 234) so manual
14 * redirects keep serving instead of every 404 falling to the theme 404 page.
15 */
16 class ABJ_404_Solution_FrontendDbVersionRecovery {
17
18 /** Number of consecutive recovery attempts that ended with DB_VERSION still
19 * stale. Stored as an option (not a transient) so an external object cache
20 * cannot silently drop the streak and hide a long-running wedge.
21 * @var string */
22 const CONSECUTIVE_FAILURE_OPTION = 'abj404_frontend_db_recovery_failures';
23
24 /** Escalate from warning to error once a streak reaches this length.
25 * With the 5-minute cooldown that is roughly 25 minutes of a site running
26 * new code against an old schema -- well past "transient hiccup" and into
27 * "something is stuck and a human needs to see it".
28 * @var int */
29 const ESCALATE_AFTER_CONSECUTIVE_FAILURES = 5;
30
31 /** @var ABJ_404_Solution_PluginLogic */
32 private $logic;
33
34 /** @var ABJ_404_Solution_Logging */
35 private $logger;
36
37 /**
38 * @param ABJ_404_Solution_PluginLogic $logic
39 * @param ABJ_404_Solution_Logging $logger
40 */
41 function __construct($logic, $logger) {
42 $this->logic = $logic;
43 $this->logger = $logger;
44 }
45
46 /**
47 * @param array<string, mixed> $options Current options as returned by getOptions(true).
48 * @return array<string, mixed> Options after attempted recovery.
49 */
50 function recoverIfStale(array $options): array {
51 $cooldownKey = 'abj404_frontend_db_recovery_cooldown';
52
53 if (function_exists('get_transient') && get_transient($cooldownKey)) {
54 return $options;
55 }
56
57 // Cooldown is set BEFORE attempting recovery so concurrent requests
58 // bail immediately rather than piling onto the lock.
59 if (function_exists('set_transient')) {
60 set_transient($cooldownKey, '1', 5 * 60);
61 }
62
63 try {
64 $upgraded = $this->logic->versionUpgrader()->upgradeIfNeeded($options);
65 if (is_array($upgraded)) {
66 $options = $upgraded;
67 }
68 } catch (\Throwable $e) {
69 $failureCount = $this->recordFailedAttempt();
70 $this->logger->warn(sprintf(
71 'Frontend DB version recovery failed (consecutive failed attempts: %d): %s',
72 $failureCount,
73 $e->getMessage()
74 ));
75 $this->escalateIfStreakJustReachedTheThreshold($failureCount,
76 'The upgrade threw: ' . $e->getMessage());
77 return $options;
78 }
79
80 // upgradeIfNeeded ends in updateOptions() which clears the resolved-
81 // options cache, so getOptions(true) returns fresh values from the DB.
82 $fresh = $this->getOptions();
83 if (isset($fresh['DB_VERSION']) && defined('ABJ404_VERSION') && $fresh['DB_VERSION'] == ABJ404_VERSION) {
84 $this->clearFailureStreak();
85 return $fresh;
86 }
87
88 $observed = (isset($fresh['DB_VERSION']) && is_scalar($fresh['DB_VERSION']))
89 ? (string)$fresh['DB_VERSION']
90 : '(missing)';
91 $expected = defined('ABJ404_VERSION') ? ABJ404_VERSION : '(unknown)';
92 $failureCount = $this->recordFailedAttempt();
93 $this->logger->warn(sprintf(
94 'Frontend DB_VERSION still stale after recovery attempt: have=%s expected=%s ' .
95 '(consecutive failed attempts: %d)',
96 $observed,
97 $expected,
98 $failureCount
99 ));
100 $this->escalateIfStreakJustReachedTheThreshold($failureCount, sprintf(
101 'DB_VERSION is stuck at %s while the running code expects %s.',
102 $observed,
103 $expected
104 ));
105 return $fresh;
106 }
107
108 /**
109 * Record one more consecutive failure and return the new streak length.
110 *
111 * @return int
112 */
113 private function recordFailedAttempt(): int {
114 if (!function_exists('get_option') || !function_exists('update_option')) {
115 return 1;
116 }
117
118 $stored = get_option(self::CONSECUTIVE_FAILURE_OPTION, 0);
119 $failureCount = (is_scalar($stored) ? (int)$stored : 0) + 1;
120
121 // @cache-write-audit: opt-out - stores a failure-streak counter, not a query result
122 update_option(self::CONSECUTIVE_FAILURE_OPTION, $failureCount, false);
123
124 return $failureCount;
125 }
126
127 /** @return void */
128 private function clearFailureStreak(): void {
129 if (function_exists('delete_option')) {
130 delete_option(self::CONSECUTIVE_FAILURE_OPTION);
131 }
132 }
133
134 /**
135 * Surface a persistent wedge exactly once per streak.
136 *
137 * Warnings are the right level for a single failed attempt: the plugin
138 * degrades to a manual-redirect lookup and the next visitor retries. A
139 * streak is different -- the site keeps running new code against an old
140 * schema indefinitely, which is the "plugin cannot do its job" case that
141 * the defensive-coding rules put at error level. Escalating on exact
142 * equality (rather than >=) means the streak itself is the dedupe: one
143 * report per wedge, not one per visitor, and no separate dedupe transient
144 * that an object cache could drop.
145 *
146 * @param int $failureCount
147 * @param string $detail
148 * @return void
149 */
150 private function escalateIfStreakJustReachedTheThreshold(int $failureCount, string $detail): void {
151 if ($failureCount !== self::ESCALATE_AFTER_CONSECUTIVE_FAILURES) {
152 return;
153 }
154
155 $this->logger->errorMessage(sprintf(
156 'Frontend database upgrade has now failed %d consecutive times, so this site is ' .
157 'serving degraded redirect lookups against an out-of-date schema. %s',
158 $failureCount,
159 $detail
160 ));
161 }
162
163 /**
164 * @return array<string, mixed>
165 */
166 private function getOptions(): array {
167 $options = $this->logic->optionsResolver()->getOptions(true);
168 return is_array($options) ? $options : array();
169 }
170 }
171