PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.0
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 / RebuildHealthState.php

RebuildHealthState.php in 404 Solution 4.2.0, at includes/RebuildHealthState.php

323 lines 14.8 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 * Unified rebuild health state for view-build and logs-hits rebuild pipelines.
9 *
10 * Single wp_options row for health gate state (failure count, exponential
11 * backoff, trial tokens) and adaptive chunk sizing for hits rebuild.
12 *
13 * Created for task c632 (Bruno 507 rebuild loop).
14 */
15 class ABJ_404_Solution_RebuildHealthState {
16
17 const OPTION_NAME = 'abj404_rebuild_health';
18 const TRIAL_LOCK_OPTION = 'abj404_rebuild_health_trial_lock';
19 const MAX_COOLDOWN_SECONDS = 86400;
20 const TRIAL_TTL_SECONDS = 300;
21 const FAILURE_THRESHOLD = 3;
22 const INITIAL_COOLDOWN_SECONDS = 300;
23 const DISK_ERROR_COOLDOWN_SECONDS = 86400;
24 const DAILY_MAINTENANCE_RECOVERY_SECONDS = 86400;
25 const MAX_CHUNK_SIZE = 100000;
26 const MIN_CHUNK_SIZE = 100;
27 const CHUNK_GROWTH_FACTOR = 1.5;
28
29 /** @var ABJ_404_Solution_Clock */
30 private $clock;
31 /** @var ABJ_404_Solution_Logging|null */
32 private $logger;
33
34 /**
35 * @param ABJ_404_Solution_Clock $clock
36 * @param ABJ_404_Solution_Logging|null $logger
37 */
38 public function __construct(ABJ_404_Solution_Clock $clock, $logger = null) {
39 $this->clock = $clock;
40 $this->logger = $logger;
41 }
42
43 /** @return bool */
44 public function mayStartExpensiveRebuild(): bool {
45 $state = $this->readState();
46 if ($state === null) {
47 $this->log('warn', 'Rebuild health state is corrupt; refusing expensive rebuild.');
48 return false;
49 }
50 $gate = $state['gate'];
51 $trial = $state['trial'];
52 $now = $this->clock->now();
53 if ($this->dailyRecoveryIsActive($trial, $now)) {
54 return true;
55 }
56 $rawNext = $gate['next_allowed_at'] ?? 0;
57 $nextAllowed = is_numeric($rawNext) ? intval($rawNext) : 0;
58 if ($this->trialIsActive($trial, $now)) {
59 return !$this->gateHasOpenFailureWindow($gate);
60 }
61 return $nextAllowed <= $now;
62 }
63
64 /** @return bool */
65 public function beginExpensiveRebuildAttempt(): bool {
66 if (!$this->mayStartExpensiveRebuild()) {
67 return false;
68 }
69 $state = $this->readState();
70 if ($state === null) {
71 return false;
72 }
73 if ($this->dailyRecoveryIsActive($state['trial'], $this->clock->now())) {
74 return true;
75 }
76 $gate = $state['gate'];
77 if (!$this->gateHasOpenFailureWindow($gate)) {
78 return true;
79 }
80 return $this->acquireTrialToken() !== null;
81 }
82
83 /** @return bool */
84 public function beginDailyMaintenanceRebuildAttempt(): bool {
85 if ($this->beginExpensiveRebuildAttempt()) {
86 return true;
87 }
88 $state = $this->readState();
89 if ($state === null) {
90 return false;
91 }
92 $now = $this->clock->now();
93 $rawLastDaily = $state['gate']['last_daily_maintenance_attempt_ts'] ?? 0;
94 $lastDaily = is_numeric($rawLastDaily) ? intval($rawLastDaily) : 0;
95 if ($lastDaily > 0 && ($now - $lastDaily) < self::DAILY_MAINTENANCE_RECOVERY_SECONDS) {
96 return false;
97 }
98 if ($this->acquireTrialToken() === null) {
99 return false;
100 }
101 $this->mutateState(function (array $state) use ($now): array {
102 $state['gate']['last_daily_maintenance_attempt_ts'] = $now;
103 $state['trial']['daily_recovery_until'] = $now + self::TRIAL_TTL_SECONDS;
104 return $state;
105 });
106 return true;
107 }
108
109 /** @return string|null */
110 public function acquireTrialToken(): ?string {
111 if (!function_exists('add_option') || !function_exists('get_option')) { return null; }
112 $now = $this->clock->now();
113 $existing = get_option(self::TRIAL_LOCK_OPTION, 0);
114 $existingExpires = is_scalar($existing) ? intval($existing) : 0;
115 if ($existingExpires > 0 && $existingExpires <= $now && function_exists('delete_option')) { delete_option(self::TRIAL_LOCK_OPTION); }
116 $added = add_option(self::TRIAL_LOCK_OPTION, (string)($now + self::TRIAL_TTL_SECONDS), '', false);
117 if (!$added) { return null; }
118 try {
119 $token = bin2hex(random_bytes(8));
120 } catch (\Throwable $t) {
121 // allow-silent-catch: random_bytes unavailable on some hosts; fallback token is sufficient for trial lock dedup.
122 $token = (string)mt_rand() . '_' . (string)$now;
123 }
124 $this->mutateState(function (array $state) use ($token, $now): array { $state['trial'] = array('token' => $token, 'started_at' => $now, 'ttl' => self::TRIAL_TTL_SECONDS); return $state; });
125 return $token;
126 }
127
128 /**
129 * @param string $msg
130 * @param string $class
131 * @return void
132 */
133 public function recordFailure(string $msg, string $class = 'unknown'): void {
134 $now = $this->clock->now();
135 $this->mutateState(function (array $state) use ($msg, $class, $now): array {
136 $gate = $state['gate'];
137 $fc = is_numeric($gate['failure_count'] ?? 0) ? intval($gate['failure_count']) : 0;
138 $gate['failure_count'] = $fc + 1;
139 $gate['last_failure_ts'] = $now;
140 $gate['last_failure_msg'] = substr($msg, 0, 500);
141 $gate['last_failure_class'] = $class;
142 $cd = is_numeric($gate['cooldown_seconds'] ?? self::INITIAL_COOLDOWN_SECONDS) ? intval($gate['cooldown_seconds']) : self::INITIAL_COOLDOWN_SECONDS;
143 if ($class === 'disk') {
144 $gate['cooldown_seconds'] = self::DISK_ERROR_COOLDOWN_SECONDS;
145 $gate['next_allowed_at'] = $now + self::DISK_ERROR_COOLDOWN_SECONDS;
146 } elseif (($fc + 1) >= self::FAILURE_THRESHOLD) {
147 $gate['next_allowed_at'] = $now + $cd;
148 $gate['cooldown_seconds'] = min(self::MAX_COOLDOWN_SECONDS, $cd * 2);
149 }
150 $state['gate'] = $gate;
151 $state['trial'] = array('token' => '', 'started_at' => 0, 'ttl' => self::TRIAL_TTL_SECONDS, 'daily_recovery_until' => 0);
152 return $state;
153 });
154 if (function_exists('delete_option')) { delete_option(self::TRIAL_LOCK_OPTION); }
155 $this->log('warn', sprintf('Rebuild health: failure (class=%s). Message: %s', $class, substr($msg, 0, 200)));
156 }
157
158 /** @return void */
159 public function recordSuccess(): void {
160 $now = $this->clock->now();
161 $this->mutateState(function (array $state) use ($now): array {
162 $state['gate'] = array('failure_count' => 0, 'next_allowed_at' => 0, 'last_failure_ts' => 0, 'last_failure_msg' => '', 'last_failure_class' => '', 'cooldown_seconds' => self::INITIAL_COOLDOWN_SECONDS, 'last_success_ts' => $now);
163 $state['trial'] = array('token' => '', 'started_at' => 0, 'ttl' => self::TRIAL_TTL_SECONDS, 'daily_recovery_until' => 0);
164 return $state;
165 });
166 if (function_exists('delete_option')) { delete_option(self::TRIAL_LOCK_OPTION); }
167 }
168
169 /** @return void */
170 public function reset(): void {
171 if (function_exists('update_option')) { update_option(self::OPTION_NAME, $this->defaultState(), false); }
172 if (function_exists('delete_option')) { delete_option(self::TRIAL_LOCK_OPTION); }
173 }
174
175 /** @return array{failure_count: int, last_failure_msg: string, last_failure_class: string, cooldown_seconds: int, next_allowed_at: int}|null */
176 public function getNoticePayload(): ?array {
177 $state = $this->readState();
178 if ($state === null) { return array('failure_count' => 0, 'last_failure_msg' => 'Health state corrupt.', 'last_failure_class' => 'unknown', 'cooldown_seconds' => 0, 'next_allowed_at' => 0); }
179 $gate = $state['gate'];
180 $rawNext = $gate['next_allowed_at'] ?? 0;
181 $nextAllowed = is_numeric($rawNext) ? intval($rawNext) : 0;
182 if ($nextAllowed <= $this->clock->now()) { return null; }
183 $rawFc = $gate['failure_count'] ?? 0;
184 $rawMsg = $gate['last_failure_msg'] ?? '';
185 $rawCls = $gate['last_failure_class'] ?? '';
186 $rawCd = $gate['cooldown_seconds'] ?? 0;
187 return array('failure_count' => is_numeric($rawFc) ? intval($rawFc) : 0, 'last_failure_msg' => is_string($rawMsg) ? $rawMsg : '', 'last_failure_class' => is_string($rawCls) ? $rawCls : '', 'cooldown_seconds' => is_numeric($rawCd) ? intval($rawCd) : 0, 'next_allowed_at' => $nextAllowed);
188 }
189
190 /** @param string $errorMessage @return string */
191 public function classifyError(string $errorMessage): string {
192 $lower = strtolower($errorMessage);
193 foreach (array('507', 'incorrect key file', 'is full', 'table full', 'no space left', 'disk quota exceeded') as $p) { if (strpos($lower, $p) !== false) { return 'disk'; } }
194 foreach (array('max_statement_time exceeded', 'query execution was interrupted') as $p) { if (strpos($lower, $p) !== false) { return 'timeout'; } }
195 if (strpos($lower, 'illegal mix of collations') !== false) { return 'collation'; }
196 return 'unknown';
197 }
198
199 /** @param int $idRange @return int */
200 public function getHitsChunkSize(int $idRange): int {
201 $state = $this->readState();
202 $current = $state !== null ? ($state['hits_chunk_size']['current'] ?? null) : null;
203 if ($current !== null && is_numeric($current)) { return max(self::MIN_CHUNK_SIZE, min(self::MAX_CHUNK_SIZE, intval($current))); }
204 $estimated = $idRange <= 0 ? self::MAX_CHUNK_SIZE : max(self::MIN_CHUNK_SIZE, min(self::MAX_CHUNK_SIZE, intval($idRange / 10)));
205 $this->mutateState(function (array $state) use ($estimated): array { $state['hits_chunk_size']['current'] = $estimated; return $state; });
206 return $estimated;
207 }
208
209 /** @param int $size @return void */
210 public function recordHitsChunkSuccess(int $size): void {
211 $this->mutateState(function (array $state) use ($size): array { $state['hits_chunk_size']['last_successful'] = $size; if ($state['hits_chunk_size']['current'] === null) { $state['hits_chunk_size']['current'] = $size; } return $state; });
212 }
213
214 /** @return void */
215 public function recordHitsChunkFailure(): void {
216 $this->mutateState(function (array $state): array { $c = $state['hits_chunk_size']['current'] ?? self::MAX_CHUNK_SIZE; $state['hits_chunk_size']['current'] = intval(max(self::MIN_CHUNK_SIZE, intval(is_numeric($c) ? intval($c) : self::MAX_CHUNK_SIZE) / 2)); return $state; });
217 }
218
219 /** @param int $lastChunkSize @return void */
220 public function recordFullRebuildSuccess(int $lastChunkSize): void {
221 $this->mutateState(function (array $state) use ($lastChunkSize): array { $state['hits_chunk_size']['current'] = min(self::MAX_CHUNK_SIZE, intval($lastChunkSize * self::CHUNK_GROWTH_FACTOR)); $state['hits_chunk_size']['last_successful'] = $lastChunkSize; return $state; });
222 }
223
224 /**
225 * @return array<string, array<string, mixed>>|null
226 */
227 public function readState(): ?array {
228 if (!function_exists('get_option')) { return $this->defaultState(); }
229 $raw = get_option(self::OPTION_NAME, null);
230 if ($raw === null || $raw === false) { return $this->defaultState(); }
231 if (!is_array($raw) || !isset($raw['gate']) || !is_array($raw['gate'])) { return null; }
232 return $this->mergeDefaults($raw);
233 }
234
235 /**
236 * @param callable $mutator
237 * @return void
238 */
239 private function mutateState(callable $mutator): void {
240 $state = $this->readState();
241 if ($state === null) { $state = $this->defaultState(); }
242 $mutated = $mutator($state);
243 if (is_array($mutated)) {
244 $state = $mutated;
245 }
246 if (function_exists('update_option')) { update_option(self::OPTION_NAME, $state, false); }
247 }
248
249 /**
250 * @param array<int|string, mixed> $raw
251 * @return array<string, array<string, mixed>>
252 */
253 private function mergeDefaults(array $raw): array {
254 $d = $this->defaultState();
255 $gate = is_array($raw['gate'] ?? null) ? $raw['gate'] : array();
256 $trial = is_array($raw['trial'] ?? null) ? $raw['trial'] : array();
257 $chunk = is_array($raw['hits_chunk_size'] ?? null) ? $raw['hits_chunk_size'] : array();
258 return array('gate' => array_merge($d['gate'], $gate), 'trial' => array_merge($d['trial'], $trial), 'hits_chunk_size' => array_merge($d['hits_chunk_size'], $chunk));
259 }
260
261 /** @return array<string, array<string, mixed>> */
262 private function defaultState(): array {
263 return array(
264 'gate' => array('failure_count' => 0, 'next_allowed_at' => 0, 'last_failure_ts' => 0, 'last_failure_msg' => '', 'last_failure_class' => '', 'cooldown_seconds' => self::INITIAL_COOLDOWN_SECONDS, 'last_success_ts' => 0),
265 'trial' => array('token' => '', 'started_at' => 0, 'ttl' => self::TRIAL_TTL_SECONDS, 'daily_recovery_until' => 0),
266 'hits_chunk_size' => array('last_successful' => null, 'current' => null),
267 );
268 }
269
270 /**
271 * @param array<string, mixed> $gate
272 * @return bool
273 */
274 private function gateHasOpenFailureWindow(array $gate): bool {
275 $rawFailureCount = $gate['failure_count'] ?? 0;
276 $failureCount = is_numeric($rawFailureCount) ? intval($rawFailureCount) : 0;
277 $rawNext = $gate['next_allowed_at'] ?? 0;
278 $nextAllowed = is_numeric($rawNext) ? intval($rawNext) : 0;
279 $rawLastFailure = $gate['last_failure_ts'] ?? 0;
280 $lastFailure = is_numeric($rawLastFailure) ? intval($rawLastFailure) : 0;
281 $rawLastSuccess = $gate['last_success_ts'] ?? 0;
282 $lastSuccess = is_numeric($rawLastSuccess) ? intval($rawLastSuccess) : 0;
283 return $failureCount > 0 || $nextAllowed > 0 || $lastFailure > $lastSuccess;
284 }
285
286 /**
287 * @param array<string, mixed> $trial
288 * @param int $now
289 * @return bool
290 */
291 private function trialIsActive(array $trial, int $now): bool {
292 $rawToken = $trial['token'] ?? '';
293 $trialToken = is_string($rawToken) ? $rawToken : '';
294 $rawStarted = $trial['started_at'] ?? 0;
295 $trialStarted = is_numeric($rawStarted) ? intval($rawStarted) : 0;
296 $rawTtl = $trial['ttl'] ?? 0;
297 $trialTtl = is_numeric($rawTtl) ? intval($rawTtl) : 0;
298 return $trialToken !== '' && $trialStarted > 0 && ($now - $trialStarted) < $trialTtl;
299 }
300
301 /**
302 * @param array<string, mixed> $trial
303 * @param int $now
304 * @return bool
305 */
306 private function dailyRecoveryIsActive(array $trial, int $now): bool {
307 $rawUntil = $trial['daily_recovery_until'] ?? 0;
308 $until = is_numeric($rawUntil) ? intval($rawUntil) : 0;
309 return $until > $now;
310 }
311
312 /**
313 * @param string $level
314 * @param string $message
315 * @return void
316 */
317 private function log(string $level, string $message): void {
318 if ($this->logger === null) { return; }
319 if ($level === 'warn' && method_exists($this->logger, 'warn')) { $this->logger->warn($message); }
320 elseif (method_exists($this->logger, 'debugMessage')) { $this->logger->debugMessage($message); }
321 }
322 }
323