PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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 / DataAccessTrait_ViewBuildHostFailurePolicy.php

DataAccessTrait_ViewBuildHostFailurePolicy.php in 404 Solution 4.1.19, at includes/DataAccessTrait_ViewBuildHostFailurePolicy.php

438 lines 18.6 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 * Host-failure policy + degraded-build state for the staged view-build pipeline.
9 *
10 * Triggered when a stage callback raises an error the classifier identifies
11 * as a permanent host-side environmental constraint (access denied, read-only,
12 * disk-full, quota) rather than a resumable kill. The orchestrator uses this
13 * trait to:
14 *
15 * 1. Mark optional stages permanently skipped (S3 indexes, S9 hits aggregate,
16 * S10 sort indexes) so subsequent cron ticks do NOT re-attempt the same
17 * denied DDL forever (gastroinovace.cz: 60 wasted attempts in 3 days).
18 *
19 * 2. Mark the build halted when a critical stage (S1/S2/S4-S8/S11) hits a
20 * permanent host failure, so the build does not loop on unrecoverable
21 * errors. The halt is dedup-windowed via transient (24h); a force
22 * rebuild explicitly clears it so admin can retry after fixing the
23 * host config.
24 *
25 * 3. Surface ONE admin notice per failure type per 24h on the plugin's
26 * own admin screen (`abj404_solution`). Per CLAUDE.md self-healing
27 * reliability rules: never email, never wp-admin-wide banner.
28 *
29 * 4. Track floor-kill streaks on batched stages: when a stage's batch
30 * is killed by the host while the adaptive shrink is already at
31 * VIEW_BUILD_MIN_BATCH_SIZE, the host cannot finish the plugin's
32 * smallest unit of work; halt rather than loop forever.
33 *
34 * Skip markers persist across normal invalidations (redirect edits) but
35 * are cleared by an explicit force rebuild and on plugin reactivation.
36 * Stored as standalone WP options outside the staged-build progress option
37 * registry: a redirect-edit watermark bump must NOT clear them or every
38 * redirect edit would re-arm the same denied DDL on the next cron tick.
39 */
40 trait ABJ_404_Solution_DataAccess_ViewBuildHostFailurePolicyTrait {
41
42 /**
43 * @param int $stageNumber
44 * @return string Site-prefixed option name for the stage skip marker.
45 */
46 private function stageSkipOptionName(int $stageNumber): string {
47 return $this->getLowercasePrefix() . 'abj404_view_build_s' . $stageNumber . '_skipped';
48 }
49
50 /**
51 * Read whether the named stage is permanently skipped on this site.
52 *
53 * @param int $stageNumber
54 * @return bool
55 */
56 private function isStageMarkedSkipped(int $stageNumber): bool {
57 if (!function_exists('get_option')) {
58 return false;
59 }
60 $value = get_option($this->stageSkipOptionName($stageNumber), 0);
61 return is_scalar($value) && intval($value) > 0;
62 }
63
64 /**
65 * Mark the named stage permanently skipped due to a host-side
66 * environmental constraint and surface a deduplicated admin notice.
67 * Idempotent: multiple calls with the same stage number write the
68 * same marker and reset the notice TTL.
69 *
70 * @param int $stageNumber
71 * @param string $errorText Original $wpdb->last_error / exception message.
72 * @return void
73 */
74 private function markStageSkippedForHostFailure(int $stageNumber, string $errorText): void {
75 if (function_exists('update_option')) {
76 update_option($this->stageSkipOptionName($stageNumber), $this->clock()->now(), false);
77 }
78 $this->setStagedBuildDegradedNotice($stageNumber, 'skipped', $errorText);
79 $this->logger->warn(sprintf(
80 '[staged] stage %d permanently skipped (host-side environmental '
81 . 'constraint, will not retry until force rebuild). Reason: %s',
82 $stageNumber,
83 substr($errorText, 0, 240)
84 ));
85 }
86
87 /**
88 * Mark the build halted at the named critical stage. The orchestrator
89 * checks isBuildHaltedForHostFailure() at entry and skips the run, so
90 * cron ticks during the dedup window are no-ops rather than retrying
91 * the same denied DDL forever.
92 *
93 * @param int $stageNumber
94 * @param string $errorText
95 * @return void
96 */
97 private function markBuildHaltedForHostFailure(int $stageNumber, string $errorText): void {
98 if (function_exists('set_transient')) {
99 set_transient(
100 $this->buildHaltTransientKey(),
101 array(
102 'stage' => $stageNumber,
103 'error' => $errorText,
104 'when' => $this->clock()->now(),
105 ),
106 ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_DEGRADED_NOTICE_TTL_SECONDS
107 );
108 }
109 $this->setStagedBuildDegradedNotice($stageNumber, 'halted', $errorText);
110 $this->logger->warn(sprintf(
111 '[staged] critical stage %d halted (host-side environmental '
112 . 'constraint, will not retry until force rebuild or 24h dedup '
113 . 'window expires). Reason: %s',
114 $stageNumber,
115 substr($errorText, 0, 240)
116 ));
117 }
118
119 /**
120 * @return string Transient key for the build-halted gate.
121 */
122 private function buildHaltTransientKey(): string {
123 return 'abj404_view_build_halted';
124 }
125
126 /**
127 * True if a prior tick halted the build for a permanent host failure
128 * and the dedup window has not yet expired. The advance entry point
129 * checks this so it does NOT re-run a build the host cannot finish,
130 * avoiding the same waste pattern that motivated the original fix
131 * (60 identical access-denied errors in 3 days at gastroinovace.cz).
132 *
133 * @return bool
134 */
135 private function isBuildHaltedForHostFailure(): bool {
136 if (!function_exists('get_transient')) {
137 return false;
138 }
139 $value = get_transient($this->buildHaltTransientKey());
140 return is_array($value);
141 }
142
143 /**
144 * Surface a deduplicated admin notice for a degraded-build event.
145 * Stored as a transient on the plugin's own notice channel so the
146 * admin Redirects screen can render it; falls back to a long-lived
147 * option when no transient API is available.
148 *
149 * Notice keys are descriptive on purpose so the matching test seam
150 * (StagedBuildPermanentFailureDegradesTest) can verify the right
151 * notice fired without coupling to internal hash details.
152 *
153 * @param int $stageNumber
154 * @param string $kind 'skipped' or 'halted'.
155 * @param string $errorText
156 * @return void
157 */
158 private function setStagedBuildDegradedNotice(int $stageNumber, string $kind, string $errorText): void {
159 $key = sprintf(
160 'abj404_view_build_s%d_%s_notice',
161 $stageNumber,
162 $kind === 'halted' ? 'halted' : 'skipped'
163 );
164 $payload = array(
165 'stage' => $stageNumber,
166 'kind' => $kind,
167 'error' => $errorText,
168 'message' => $this->describeDegradedNotice($stageNumber, $kind, $errorText),
169 'when' => $this->clock()->now(),
170 );
171 if (function_exists('set_transient')) {
172 set_transient(
173 $key,
174 $payload,
175 ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_DEGRADED_NOTICE_TTL_SECONDS
176 );
177 } elseif (function_exists('update_option')) {
178 update_option($key, $payload, false);
179 }
180 }
181
182 /**
183 * Set a halt notice for a non-stage failure (e.g. floor-kill streak
184 * detection). Same dedup TTL as setStagedBuildDegradedNotice() but
185 * with a descriptive scenario key so the admin can tell why the
186 * build halted.
187 *
188 * @param string $scenarioKey e.g. 's2_floor_kill_streak'.
189 * @param string $errorText
190 * @return void
191 */
192 private function setStagedBuildHaltNotice(string $scenarioKey, string $errorText): void {
193 $key = 'abj404_view_build_' . $scenarioKey . '_halt_notice';
194 $payload = array(
195 'scenario' => $scenarioKey,
196 'kind' => 'halted',
197 'error' => $errorText,
198 'when' => $this->clock()->now(),
199 );
200 if (function_exists('set_transient')) {
201 set_transient(
202 $key,
203 $payload,
204 ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_DEGRADED_NOTICE_TTL_SECONDS
205 );
206 } elseif (function_exists('update_option')) {
207 update_option($key, $payload, false);
208 }
209 }
210
211 /**
212 * Build a user-facing message describing the degraded build event
213 * and the host-side action the admin needs to take. Specificity
214 * matters: a vague "view build degraded" notice with no remediation
215 * path is exactly the silent-error pattern CLAUDE.md prohibits.
216 *
217 * @param int $stageNumber
218 * @param string $kind 'skipped' or 'halted'.
219 * @param string $errorText
220 * @return string
221 */
222 private function describeDegradedNotice(int $stageNumber, string $kind, string $errorText): string {
223 $errorSnippet = substr(trim($errorText), 0, 200);
224 $base = $kind === 'halted'
225 ? sprintf('The 404 Solution view-build pipeline halted at stage %d/11.', $stageNumber)
226 : sprintf('The 404 Solution view-build pipeline skipped optional stage %d/11.', $stageNumber);
227
228 $hint = '';
229 if (stripos($errorText, 'create temporary') !== false || stripos($errorText, "to database '") !== false
230 || ($stageNumber === 9 && stripos($errorText, 'access denied') !== false)) {
231 $hint = ' Ask your host to grant the CREATE TEMPORARY TABLES privilege to your WordPress database user '
232 . 'so the hits aggregate column can be populated.';
233 } elseif (stripos($errorText, 'alter command denied') !== false) {
234 $hint = ' Ask your host to grant the ALTER privilege to your WordPress database user.';
235 } elseif (stripos($errorText, 'rename') !== false || $stageNumber === 11) {
236 $hint = ' Ask your host to grant ALTER + DROP + CREATE on the database used by WordPress so the '
237 . 'view-build swap can complete.';
238 } elseif (stripos($errorText, 'access denied') !== false || stripos($errorText, 'command denied') !== false) {
239 $hint = ' Ask your host to review your WordPress database user privileges.';
240 }
241
242 return $base . $hint . ' Original error: ' . $errorSnippet;
243 }
244
245 /**
246 * Clear all skip markers + halt gate. Called from the explicit force
247 * rebuild path so an admin who has fixed their host configuration
248 * can retry the previously denied stages. NOT called from the
249 * source-mutation watermark bump path (regular redirect-edit
250 * invalidation): every redirect change would otherwise re-arm a
251 * denied DDL on the next cron tick, undoing the entire
252 * skip-persistence contract.
253 *
254 * @return void
255 */
256 public function clearStagedBuildDegradedState(): void {
257 if (function_exists('delete_option')) {
258 for ($s = 1; $s <= 11; $s++) {
259 delete_option($this->stageSkipOptionName($s));
260 }
261 }
262 if (function_exists('delete_transient')) {
263 delete_transient($this->buildHaltTransientKey());
264 }
265 // A force rebuild explicitly restarts the pipeline; the captured
266 // prefix is per-build, not per-host, so wipe it so the fresh S1
267 // re-captures from the (presumably correct) current $wpdb->prefix.
268 $this->clearPrefixAtStageOne();
269 }
270
271 /**
272 * Classify a stage exception and apply side effects (skip / halt /
273 * streak). Called from the catch block inside runTimedViewBuildStage()
274 * so the orchestrator stays focused on stage sequencing.
275 *
276 * Returns one of:
277 * - 'resumable_yield': caller should yield the stage (return false).
278 * - 'skipped' : caller should record stage as skipped.
279 * - 'halted' : caller should bail out of the build.
280 * - 'completed' : post-S11 reconciliation succeeded; treat
281 * as completion (return null).
282 * - 'rethrow' : programmer-class or unknown error; caller
283 * should rethrow so the dev mailbox carries
284 * actionable context.
285 *
286 * The 'resumable_yield' branch also bumps the per-stage no-progress
287 * streak; if the streak reaches
288 * VIEW_BUILD_FLOOR_KILL_STREAK_HALT_THRESHOLD it converts to 'halted'
289 * with a host_unfit notice. That is what stops the test pattern where
290 * a stage is killed every tick and the build never converges.
291 *
292 * @param int $stageNumber
293 * @param string $stageKey
294 * @param string $errMsg
295 * @param float $started
296 * @return string
297 */
298 private function classifyAndHandleStageFailure(int $stageNumber, string $stageKey, string $errMsg, float $started): string {
299 $classification = $this->classifyStageFailure($stageNumber, $errMsg);
300 if ($classification === 'resumable') {
301 $streak = $this->bumpStageNoProgressStreak($stageNumber);
302 if ($streak >= ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_FLOOR_KILL_STREAK_HALT_THRESHOLD) {
303 $this->setStagedBuildHaltNotice('floor_kill_streak', sprintf(
304 'stage %d: %d consecutive resumable kills with no progress (host_unfit). %s',
305 $stageNumber, $streak, substr($errMsg, 0, 200)
306 ));
307 $this->markBuildHaltedForHostFailure(
308 $stageNumber,
309 'floor_kill_streak (host_unfit): stage ' . $stageNumber
310 . ' killed ' . $streak . ' consecutive ticks: ' . substr($errMsg, 0, 200)
311 );
312 $this->logTimedViewBuildStage($stageNumber, $stageKey, 'halted_floor_kill_streak', $started);
313 return 'halted';
314 }
315 $this->logTimedViewBuildStage($stageNumber, $stageKey, 'killed_resumable', $started);
316 return 'resumable_yield';
317 }
318 if ($classification === 'skip') {
319 $this->markStageSkippedForHostFailure($stageNumber, $errMsg);
320 $this->logTimedViewBuildStage($stageNumber, $stageKey, 'skipped_host_failure', $started);
321 return 'skipped';
322 }
323 if ($classification === 'halt') {
324 if ($stageNumber === 11 && $this->reconcilePostStageElevenState()) {
325 // RENAME committed server-side, error was a connection
326 // artifact. Treat as success.
327 $this->logTimedViewBuildStage($stageNumber, $stageKey, 'completed_after_reconcile', $started);
328 return 'completed';
329 }
330 $this->markBuildHaltedForHostFailure($stageNumber, $errMsg);
331 $this->logTimedViewBuildStage($stageNumber, $stageKey, 'halted_host_failure', $started);
332 return 'halted';
333 }
334 return 'rethrow';
335 }
336
337 /**
338 * Increment the per-stage consecutive no-progress kill streak. Called
339 * when a resumable-kill error fires before any forward progress is
340 * observed in the tick. Returns the new streak value so the caller
341 * can decide whether the floor-kill halt threshold has been reached.
342 *
343 * Inlined option access (not via writeProgressOption) so the streak
344 * tracking does not require the helpers trait, which keeps the
345 * trait composable in test contexts that do not pull the full DAO.
346 *
347 * @param int $stageNumber 1..11
348 * @return int New streak value, or 0 when option API is unavailable.
349 */
350 private function bumpStageNoProgressStreak(int $stageNumber): int {
351 if (!function_exists('get_option') || !function_exists('update_option')) {
352 return 0;
353 }
354 $optName = $this->stageNoProgressStreakOptionName($stageNumber);
355 if ($optName === '') {
356 return 0;
357 }
358 $current = get_option($optName, 0);
359 $next = (is_scalar($current) ? max(0, intval($current)) : 0) + 1;
360 update_option($optName, $next, false);
361 return $next;
362 }
363
364 /**
365 * Reset the per-stage no-progress kill streak. Called whenever a
366 * stage tick finishes without a resumable-kill exception so
367 * legitimate slow stages do not eventually accumulate enough strikes
368 * to trip the halt.
369 *
370 * @param int $stageNumber
371 * @return void
372 */
373 private function resetStageNoProgressStreak(int $stageNumber): void {
374 if (!function_exists('update_option')) {
375 return;
376 }
377 $optName = $this->stageNoProgressStreakOptionName($stageNumber);
378 if ($optName !== '') {
379 update_option($optName, 0, false);
380 }
381 }
382
383 /**
384 * Build the site-prefixed option name for the per-stage no-progress
385 * streak counter. Falls back to a fixed prefix when getLowercasePrefix()
386 * is not composed.
387 *
388 * @param int $stageNumber
389 * @return string Empty when the stage is outside 1..11.
390 */
391 private function stageNoProgressStreakOptionName(int $stageNumber): string {
392 if ($stageNumber < 1 || $stageNumber > 11) {
393 return '';
394 }
395 $prefix = $this->getLowercasePrefix();
396 return $prefix . 'abj404_view_build_s' . $stageNumber . '_no_progress';
397 }
398
399 /**
400 * Reconcile post-S11 state when a RENAME swap raised an error AFTER
401 * the rename committed but the client lost the connection (Codex
402 * finding #3 in StagedBuildPermanentFailureDegradesTest). RENAME TABLE
403 * is atomic on the server; if view_done now exists with the buffer's
404 * row count and view_build is gone, the swap actually succeeded and
405 * the error was a connection-level artifact, not a real failure.
406 *
407 * Returns true when reconciliation finds the swap committed (caller
408 * treats as success: write freshness, clear progress, return ready).
409 * Returns false when view_done is genuinely missing or partial; the
410 * caller falls through to the regular failure handling.
411 *
412 * Intentionally tolerant on probe failures: when SHOW TABLES errors
413 * out, we cannot verify either way, so we conservatively report
414 * "not reconciled" and let the next tick retry.
415 *
416 * @return bool
417 */
418 public function reconcilePostStageElevenState(): bool {
419 $viewDoneTable = $this->viewDoneTableName();
420 $viewBuildTable = $this->viewBuildTableName();
421
422 if (!$this->stagedTableExists($viewDoneTable)) {
423 return false;
424 }
425 if ($this->stagedTableExists($viewBuildTable)) {
426 return false;
427 }
428 // RENAME swap committed: view_done exists, view_build was renamed
429 // away. Treat as success even though the request flow saw an error.
430 if (function_exists('update_option')) {
431 update_option($this->viewDoneFreshnessOptionName(), $this->clock()->now(), false);
432 }
433 $this->clearAllProgressOptions();
434 $this->invalidateViewDoneServeableCache();
435 return true;
436 }
437 }
438