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 / ViewBuildHostFailurePolicy.php

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

647 lines 30.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 * 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 * @property ABJ_404_Solution_DatabaseCore $dbCore
41 * @property ABJ_404_Solution_Functions $f
42 * @property ABJ_404_Solution_Logging $logger
43 * @property ABJ_404_Solution_ViewReadService|null $viewReadService
44 * @property ABJ_404_Solution_LogsRepository|null $logsRepo
45 * @property int $stagedQueryTimeoutSeconds
46 * @property string $lastBatchProgressDetail
47 * @property bool $viewBuildStageOpenForShutdown
48 * @property int $viewBuildShutdownStageNumber
49 * @property string $viewBuildShutdownStageKey
50 * @property bool|null $namedLockSupportedThisRequest
51 * @property bool $fallbackLockLoggedThisRequest
52 * @property bool $usingTransientFallbackLock
53 * @property string $lastNamedLockUnsupportedReason
54 * @property string $lastNamedLockUnsupportedError
55 * @method void abortStagedBuildForMutationWatermarkAdvance(...$arguments)
56 * @method bool acquireTransientFallbackLock(...$arguments)
57 * @method bool acquireViewBuildLock(...$arguments)
58 * @method string activeBuildStartedWatermarkOptionName(...$arguments)
59 * @method bool adminMutationGateBlocks(...$arguments)
60 * @method array<mixed> advanceViewBuildOnce(...$arguments)
61 * @method void assertBuildBufferExistsOrHalt(...$arguments)
62 * @method ?bool attemptRelaxSqlModeForBuildConnection(...$arguments)
63 * @method bool bufferIntegrityPassesForPromote(...$arguments)
64 * @method string buildHaltTransientKey(...$arguments)
65 * @method string buildViewDoneCountQuery(...$arguments)
66 * @method string builtWatermarkOptionName(...$arguments)
67 * @method int bumpMutationWatermark(...$arguments)
68 * @method int bumpStageNoProgressStreak(...$arguments)
69 * @method string capturedPrefixForLog(...$arguments)
70 * @method void capturePrefixAtBuildStart(...$arguments)
71 * @method void claimForegroundViewBuildLease(...$arguments)
72 * @method string classifyAndHandleStageFailure(...$arguments)
73 * @method array<mixed> classifySessionVariableWarnings(...$arguments)
74 * @method string classifyStageFailure(...$arguments)
75 * @method void clearActiveBuildStartedWatermark(...$arguments)
76 * @method void clearAdminMutationGateOptions(...$arguments)
77 * @method void clearAllProgressOptions(...$arguments)
78 * @method void clearPhpEnvironmentProbeCache(...$arguments)
79 * @method void clearPrefixAtStageOne(...$arguments)
80 * @method void clearSessionVariablesProbeCache(...$arguments)
81 * @method void clearSqlModeProbeCache(...$arguments)
82 * @method void clearStagedBuildDegradedState(...$arguments)
83 * @method void clearViewBuildOpenStageForShutdown(...$arguments)
84 * @method void clearViewDoneHardStaleNotice(...$arguments)
85 * @method ABJ_404_Solution_Clock clock(...$arguments)
86 * @method int countLiveRedirects(...$arguments)
87 * @method int countViewBuildRows(...$arguments)
88 * @method string describeBuildProgressForNotice(...$arguments)
89 * @method string describeDegradedNotice(...$arguments)
90 * @method string describeStagedSqlFailure(...$arguments)
91 * @method array<mixed> detectAndAdjustSqlMode(...$arguments)
92 * @method float detectHostStagedQueryLimitSeconds(...$arguments)
93 * @method string doTableNameReplacements(...$arguments)
94 * @method void dropDeletemeTable(...$arguments)
95 * @method void dropTransientBuffersIfPresent(...$arguments)
96 * @method void dropTransientStagedTables(...$arguments)
97 * @method void ensureConnection(...$arguments)
98 * @method void ensureFallbackLockNoticeAndLog(...$arguments)
99 * @method int extendedTimeoutForKilledNonBatchedStage(...$arguments)
100 * @method array<mixed> fetchSessionVariablesRowOrEmpty(...$arguments)
101 * @method string filesystemEnvironmentProbeOptionName(...$arguments)
102 * @method bool forceRestartViewBuild(...$arguments)
103 * @method bool foregroundViewBuildLeaseActive(...$arguments)
104 * @method string formatPhpMemoryBytesHuman(...$arguments)
105 * @method bool gateAbortIfMutationWatermarkAdvanced(...$arguments)
106 * @method string getColumnCollationString(...$arguments)
107 * @method int getCronStuckHours(...$arguments)
108 * @method string getLowercasePrefix(...$arguments)
109 * @method array<string, mixed> getViewBuildProgress(...$arguments)
110 * @method array<mixed> getViewBuildProgressFingerprint(...$arguments)
111 * @method int getViewDoneBuiltAtTimestamp(...$arguments)
112 * @method bool haltIfPrefixChangedSinceStageOne(...$arguments)
113 * @method string humanBatchProgress(...$arguments)
114 * @method float intelligentStagedQueryTimeoutSeconds(...$arguments)
115 * @method void invalidateViewDoneServeableCache(...$arguments)
116 * @method bool isBuildHaltedForHostFailure(...$arguments)
117 * @method bool isCurrentStageOptionName(...$arguments)
118 * @method bool isNamedLockUnsupportedError(...$arguments)
119 * @method bool isResumableStagedKill(...$arguments)
120 * @method bool isStageMarkedSkipped(...$arguments)
121 * @method bool isTransientConnectionError(...$arguments)
122 * @method string lastBuildStartedWatermarkOptionName(...$arguments)
123 * @method string legacyStartedWatermarkOptionName(...$arguments)
124 * @method string localizeOrDefaultViewBuildNotice(...$arguments)
125 * @method bool logsHitsTableExists(...$arguments)
126 * @method void logTimedViewBuildStage(...$arguments)
127 * @method void logViewBuildProgressOptionWrite(...$arguments)
128 * @method void logViewBuildShutdownDiagnostics(...$arguments)
129 * @method void markBuildHaltedForHostFailure(...$arguments)
130 * @method void markBuildStage(...$arguments)
131 * @method void markStageSkippedForHostFailure(...$arguments)
132 * @method void markViewBuildStageCompleted(...$arguments)
133 * @method void markViewBuildStageStarted(...$arguments)
134 * @method void markViewDoneBuildCompleted(...$arguments)
135 * @method void markViewDoneInvalidatedByAdminMutation(...$arguments)
136 * @method int maxBuildBufferId(...$arguments)
137 * @method void maybeRaiseViewDoneHardStaleNotice(...$arguments)
138 * @method bool mutationWatermarkAdvancedSinceBuildStart(...$arguments)
139 * @method int mutationWatermarkObservedByAdminAction(...$arguments)
140 * @method int mutationWatermarkObservedByAdminActionAt(...$arguments)
141 * @method string mutationWatermarkObservedByAdminActionAtOptionName(...$arguments)
142 * @method string mutationWatermarkObservedByAdminActionOptionName(...$arguments)
143 * @method string normalizePathPrefix(...$arguments)
144 * @method bool optionReadBackMatches(...$arguments)
145 * @method int parsePhpMemoryLimitToBytes(...$arguments)
146 * @method bool pathFallsWithinAny(...$arguments)
147 * @method void performFreshStartCleanup(...$arguments)
148 * @method array<mixed> phpDisabledFunctionsList(...$arguments)
149 * @method string phpEnvironmentProbeOptionName(...$arguments)
150 * @method float phpTimeRemainingSeconds(...$arguments)
151 * @method string prefixAtStageOneOptionName(...$arguments)
152 * @method array<mixed> probeFilesystemEnvironmentForBuild(...$arguments)
153 * @method float probeFloatFromValues(...$arguments)
154 * @method int probeIntFromValues(...$arguments)
155 * @method int probeMemoryLimitForS9(...$arguments)
156 * @method array<mixed> probePhpEnvironmentForBuild(...$arguments)
157 * @method array<mixed> probeSessionVariablesAtS1Entry(...$arguments)
158 * @method bool probeSetTimeLimitAvailability(...$arguments)
159 * @method array<mixed> probeSqlModeForBuild(...$arguments)
160 * @method string probeStringFromValues(...$arguments)
161 * @method string progressOptionName(...$arguments)
162 * @method void publishBuiltWatermarkFromActiveBuildStartedWatermark(...$arguments)
163 * @method array<mixed> queryAndGetResults(...$arguments)
164 * @method int readActiveBuildStartedWatermark(...$arguments)
165 * @method array<int, array<string, mixed>> readFromViewDone(...$arguments)
166 * @method int readProgressOption(...$arguments)
167 * @method int readWatermarkOption(...$arguments)
168 * @method void rebuildViewDoneInBackground(...$arguments)
169 * @method bool reconcilePostStageElevenState(...$arguments)
170 * @method string reconcileStagedTablesAtRunnerStartup(...$arguments)
171 * @method int recordStageBatchKilled(...$arguments)
172 * @method void registerViewBuildShutdownDiagnostics(...$arguments)
173 * @method bool releaseAndReacquireBetweenStages(...$arguments)
174 * @method void releaseViewBuildLock(...$arguments)
175 * @method void resetStageNoProgressStreak(...$arguments)
176 * @method string resolveColumnCollationForStagedBuild(...$arguments)
177 * @method void runForceRestartCleanupInsideLock(...$arguments)
178 * @method bool runIdRangeBatchedUpdate(...$arguments)
179 * @method int runInsertBatch(...$arguments)
180 * @method mixed runNonBatchedStageWithKillStreakEscape(...$arguments)
181 * @method array{ran: bool, reason: string, progress: array<string, mixed>} runPageLoadFallbackAdvance(...$arguments)
182 * @method int runRedirectsForViewCountStaged(...$arguments)
183 * @method array<int, array<string, mixed>> runRedirectsForViewStaged(...$arguments)
184 * @method bool runS11SwapWithPreRenameWatermarkRecheck(...$arguments)
185 * @method bool runStagedBuildOnce(...$arguments)
186 * @method bool runStagedBuildStages6Through11(...$arguments)
187 * @method void runStagedSqlFile(...$arguments)
188 * @method void runStagedSqlFileTolerantOfDuplicateKey(...$arguments)
189 * @method mixed runTimedViewBuildStage(...$arguments)
190 * @method int safeCurrentMutationWatermark(...$arguments)
191 * @method string sanitizeUrlBeforeInsert(...$arguments)
192 * @method void scheduleViewDoneRebuild(...$arguments)
193 * @method string sessionVariablesProbeOptionName(...$arguments)
194 * @method void setFilesystemEnvAdminNotice(...$arguments)
195 * @method void setLowMemoryLimitAdminNotice(...$arguments)
196 * @method void setSessionEnvAdminNotice(...$arguments)
197 * @method void setStagedBuildDegradedNotice(...$arguments)
198 * @method void setStagedBuildHaltNotice(...$arguments)
199 * @method void setViewBuildCronStuckNotice(...$arguments)
200 * @method void setViewBuildScheduleFailedNotice(...$arguments)
201 * @method void setViewDoneHardStaleNotice(...$arguments)
202 * @method array<mixed> splitOpenBasedirPaths(...$arguments)
203 * @method string sqlModeProbeOptionName(...$arguments)
204 * @method void stageAddPreJoinIndexes(...$arguments)
205 * @method void stageAddSortIndexes(...$arguments)
206 * @method void stageCreateBuildTable(...$arguments)
207 * @method array<string, mixed> stagedQueryOptions(...$arguments)
208 * @method bool stagedTableExists(...$arguments)
209 * @method bool stageInsertRedirectsBatched(...$arguments)
210 * @method string stageNoProgressStreakOptionName(...$arguments)
211 * @method void stageRenameSwap(...$arguments)
212 * @method string stageSkipOptionName(...$arguments)
213 * @method void stageUpdateExternal(...$arguments)
214 * @method void stageUpdateHits(...$arguments)
215 * @method void stageUpdateHome(...$arguments)
216 * @method bool stageUpdatePostsBatched(...$arguments)
217 * @method void stageUpdateSpecial(...$arguments)
218 * @method bool stageUpdateTermsBatched(...$arguments)
219 * @method void stampStartedWatermarksAtS1Entry(...$arguments)
220 * @method void sweepStaleRebuildTransients(...$arguments)
221 * @method string transientFallbackLockOptionName(...$arguments)
222 * @method bool verifyBuildLockSerializesWriter(...$arguments)
223 * @method bool verifyOptionWriteCoherent(...$arguments)
224 * @method bool verifyPrefixUnchangedSinceStageOne(...$arguments)
225 * @method int viewBuildBatchSize(...$arguments)
226 * @method int viewBuildBatchSizeForStage(...$arguments)
227 * @method array<mixed> viewBuildOnlyTranslations(...$arguments)
228 * @method float viewBuildPerStageBudgetSeconds(...$arguments)
229 * @method string viewBuildTableName(...$arguments)
230 * @method string viewDeletemeTableName(...$arguments)
231 * @method int viewDoneBuiltAt(...$arguments)
232 * @method int viewDoneBuiltWatermark(...$arguments)
233 * @method int viewDoneDataBuiltAt(...$arguments)
234 * @method string viewDoneDataBuiltAtOptionName(...$arguments)
235 * @method string viewDoneFreshnessOptionName(...$arguments)
236 * @method bool viewDoneHasRows(...$arguments)
237 * @method bool viewDoneIsFresh(...$arguments)
238 * @method bool viewDoneIsServeable(...$arguments)
239 * @method int viewDoneMutationInvalidatedAt(...$arguments)
240 * @method string viewDoneMutationInvalidatedAtOptionName(...$arguments)
241 * @method bool viewDoneTableExists(...$arguments)
242 * @method string viewDoneTableName(...$arguments)
243 * @method void writeProgressOption(...$arguments)
244 * @method void writeWatermarkOption(...$arguments)
245 */
246 class ABJ_404_Solution_ViewBuildHostFailurePolicy extends ABJ_404_Solution_ViewBuildCollaborator {
247
248 /**
249 * @param int $stageNumber
250 * @return string Site-prefixed option name for the stage skip marker.
251 */
252 public function stageSkipOptionName(int $stageNumber): string {
253 return $this->getLowercasePrefix() . 'abj404_view_build_s' . $stageNumber . '_skipped';
254 }
255
256 /**
257 * Read whether the named stage is permanently skipped on this site.
258 *
259 * @param int $stageNumber
260 * @return bool
261 */
262 public function isStageMarkedSkipped(int $stageNumber): bool {
263 if (!function_exists('get_option')) {
264 return false;
265 }
266 $value = get_option($this->stageSkipOptionName($stageNumber), 0);
267 return is_scalar($value) && intval($value) > 0;
268 }
269
270 /**
271 * Mark the named stage permanently skipped due to a host-side
272 * environmental constraint and surface a deduplicated admin notice.
273 * Idempotent: multiple calls with the same stage number write the
274 * same marker and reset the notice TTL.
275 *
276 * @param int $stageNumber
277 * @param string $errorText Original $wpdb->last_error / exception message.
278 * @return void
279 */
280 public function markStageSkippedForHostFailure(int $stageNumber, string $errorText): void {
281 if (function_exists('update_option')) {
282 update_option($this->stageSkipOptionName($stageNumber), $this->clock()->now(), false);
283 }
284 $this->setStagedBuildDegradedNotice($stageNumber, 'skipped', $errorText);
285 $this->logger->warn(sprintf(
286 '[staged] stage %d permanently skipped (host-side environmental '
287 . 'constraint, will not retry until force rebuild). Reason: %s',
288 $stageNumber,
289 substr($errorText, 0, 240)
290 ));
291 }
292
293 /**
294 * Mark the build halted at the named critical stage. The orchestrator
295 * checks isBuildHaltedForHostFailure() at entry and skips the run, so
296 * cron ticks during the dedup window are no-ops rather than retrying
297 * the same denied DDL forever.
298 *
299 * @param int $stageNumber
300 * @param string $errorText
301 * @return void
302 */
303 public function markBuildHaltedForHostFailure(int $stageNumber, string $errorText): void {
304 if (function_exists('set_transient')) {
305 // allow-cache-empty: host-failure halt marker intentionally stores error context, not query data.
306 set_transient(
307 $this->buildHaltTransientKey(),
308 array(
309 'stage' => $stageNumber,
310 'error' => $errorText,
311 'when' => $this->clock()->now(),
312 ),
313 ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_DEGRADED_NOTICE_TTL_SECONDS
314 );
315 }
316 $this->setStagedBuildDegradedNotice($stageNumber, 'halted', $errorText);
317 $this->logger->warn(sprintf(
318 '[staged] critical stage %d halted (host-side environmental '
319 . 'constraint, will not retry until force rebuild or 24h dedup '
320 . 'window expires). Reason: %s',
321 $stageNumber,
322 substr($errorText, 0, 240)
323 ));
324 }
325
326 /**
327 * @return string Transient key for the build-halted gate.
328 */
329 public function buildHaltTransientKey(): string {
330 return 'abj404_view_build_halted';
331 }
332
333 /**
334 * True if a prior tick halted the build for a permanent host failure
335 * and the dedup window has not yet expired. The advance entry point
336 * checks this so it does NOT re-run a build the host cannot finish,
337 * avoiding the same waste pattern that motivated the original fix
338 * (60 identical access-denied errors in 3 days at gastroinovace.cz).
339 *
340 * @return bool
341 */
342 public function isBuildHaltedForHostFailure(): bool {
343 if (!function_exists('get_transient')) {
344 return false;
345 }
346 $value = get_transient($this->buildHaltTransientKey());
347 return is_array($value);
348 }
349
350 /**
351 * Surface a deduplicated admin notice for a degraded-build event.
352 * Stored as a transient on the plugin's own notice channel so the
353 * admin Redirects screen can render it; falls back to a long-lived
354 * option when no transient API is available.
355 *
356 * Notice keys are descriptive on purpose so the matching test seam
357 * (StagedBuildPermanentFailureDegradesTest) can verify the right
358 * notice fired without coupling to internal hash details.
359 *
360 * @param int $stageNumber
361 * @param string $kind 'skipped' or 'halted'.
362 * @param string $errorText
363 * @return void
364 */
365 public function setStagedBuildDegradedNotice(int $stageNumber, string $kind, string $errorText): void {
366 $key = sprintf(
367 'abj404_view_build_s%d_%s_notice',
368 $stageNumber,
369 $kind === 'halted' ? 'halted' : 'skipped'
370 );
371 $payload = array(
372 'stage' => $stageNumber,
373 'kind' => $kind,
374 'error' => $errorText,
375 'message' => $this->describeDegradedNotice($stageNumber, $kind, $errorText),
376 'when' => $this->clock()->now(),
377 );
378 if (function_exists('set_transient')) {
379 // allow-cache-empty: degraded-state notice marker intentionally stores diagnostics, not query data.
380 set_transient(
381 $key,
382 $payload,
383 ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_DEGRADED_NOTICE_TTL_SECONDS
384 );
385 } elseif (function_exists('update_option')) {
386 update_option($key, $payload, false);
387 }
388 }
389
390 /**
391 * Set a halt notice for a non-stage failure (e.g. floor-kill streak
392 * detection). Same dedup TTL as setStagedBuildDegradedNotice() but
393 * with a descriptive scenario key so the admin can tell why the
394 * build halted.
395 *
396 * @param string $scenarioKey e.g. 's2_floor_kill_streak'.
397 * @param string $errorText
398 * @return void
399 */
400 public function setStagedBuildHaltNotice(string $scenarioKey, string $errorText): void {
401 $key = 'abj404_view_build_' . $scenarioKey . '_halt_notice';
402 $payload = array(
403 'scenario' => $scenarioKey,
404 'kind' => 'halted',
405 'error' => $errorText,
406 'when' => $this->clock()->now(),
407 );
408 if (function_exists('set_transient')) {
409 // allow-cache-empty: scenario halt marker intentionally stores diagnostics, not query data.
410 set_transient(
411 $key,
412 $payload,
413 ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_DEGRADED_NOTICE_TTL_SECONDS
414 );
415 } elseif (function_exists('update_option')) {
416 update_option($key, $payload, false);
417 }
418 }
419
420 /**
421 * Build a user-facing message describing the degraded build event
422 * and the host-side action the admin needs to take. Specificity
423 * matters: a vague "view build degraded" notice with no remediation
424 * path is exactly the silent-error pattern CLAUDE.md prohibits.
425 *
426 * @param int $stageNumber
427 * @param string $kind 'skipped' or 'halted'.
428 * @param string $errorText
429 * @return string
430 */
431 public function describeDegradedNotice(int $stageNumber, string $kind, string $errorText): string {
432 $errorSnippet = substr(trim($errorText), 0, 200);
433 $base = $kind === 'halted'
434 ? sprintf('The 404 Solution view-build pipeline halted at stage %d/11.', $stageNumber)
435 : sprintf('The 404 Solution view-build pipeline skipped optional stage %d/11.', $stageNumber);
436
437 $hint = '';
438 if (stripos($errorText, 'create temporary') !== false || stripos($errorText, "to database '") !== false
439 || ($stageNumber === 9 && stripos($errorText, 'access denied') !== false)) {
440 $hint = ' Ask your host to grant the CREATE TEMPORARY TABLES privilege to your WordPress database user '
441 . 'so the hits aggregate column can be populated.';
442 } elseif (stripos($errorText, 'alter command denied') !== false) {
443 $hint = ' Ask your host to grant the ALTER privilege to your WordPress database user.';
444 } elseif (stripos($errorText, 'rename') !== false || $stageNumber === 11) {
445 $hint = ' Ask your host to grant ALTER + DROP + CREATE on the database used by WordPress so the '
446 . 'view-build swap can complete.';
447 } elseif (stripos($errorText, 'access denied') !== false || stripos($errorText, 'command denied') !== false) {
448 $hint = ' Ask your host to review your WordPress database user privileges.';
449 }
450
451 return $base . $hint . ' Original error: ' . $errorSnippet;
452 }
453
454 /**
455 * Clear all skip markers + halt gate. Called from the explicit force
456 * rebuild path so an admin who has fixed their host configuration
457 * can retry the previously denied stages. NOT called from the
458 * source-mutation watermark bump path (regular redirect-edit
459 * invalidation): every redirect change would otherwise re-arm a
460 * denied DDL on the next cron tick, undoing the entire
461 * skip-persistence contract.
462 *
463 * @return void
464 */
465 public function clearStagedBuildDegradedState(): void {
466 if (function_exists('delete_option')) {
467 for ($s = 1; $s <= 11; $s++) {
468 delete_option($this->stageSkipOptionName($s));
469 }
470 }
471 if (function_exists('delete_transient')) {
472 delete_transient($this->buildHaltTransientKey());
473 }
474 // A force rebuild explicitly restarts the pipeline; the captured
475 // prefix is per-build, not per-host, so wipe it so the fresh S1
476 // re-captures from the (presumably correct) current $wpdb->prefix.
477 $this->clearPrefixAtStageOne();
478 }
479
480 /**
481 * Classify a stage exception and apply side effects (skip / halt /
482 * streak). Called from the catch block inside runTimedViewBuildStage()
483 * so the orchestrator stays focused on stage sequencing.
484 *
485 * Returns one of:
486 * - 'resumable_yield': caller should yield the stage (return false).
487 * - 'skipped' : caller should record stage as skipped.
488 * - 'halted' : caller should bail out of the build.
489 * - 'completed' : post-S11 reconciliation succeeded; treat
490 * as completion (return null).
491 * - 'rethrow' : programmer-class or unknown error; caller
492 * should rethrow so the dev mailbox carries
493 * actionable context.
494 *
495 * The 'resumable_yield' branch also bumps the per-stage no-progress
496 * streak; if the streak reaches
497 * VIEW_BUILD_FLOOR_KILL_STREAK_HALT_THRESHOLD it converts to 'halted'
498 * with a host_unfit notice. That is what stops the test pattern where
499 * a stage is killed every tick and the build never converges.
500 *
501 * @param int $stageNumber
502 * @param string $stageKey
503 * @param string $errMsg
504 * @param float $started
505 * @return string
506 */
507 public function classifyAndHandleStageFailure(int $stageNumber, string $stageKey, string $errMsg, float $started): string {
508 $classification = $this->classifyStageFailure($stageNumber, $errMsg);
509 if ($classification === 'resumable') {
510 $streak = $this->bumpStageNoProgressStreak($stageNumber);
511 if ($streak >= ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_FLOOR_KILL_STREAK_HALT_THRESHOLD) {
512 $this->setStagedBuildHaltNotice('floor_kill_streak', sprintf(
513 'stage %d: %d consecutive resumable kills with no progress (host_unfit). %s',
514 $stageNumber, $streak, substr($errMsg, 0, 200)
515 ));
516 $this->markBuildHaltedForHostFailure(
517 $stageNumber,
518 'floor_kill_streak (host_unfit): stage ' . $stageNumber
519 . ' killed ' . $streak . ' consecutive ticks: ' . substr($errMsg, 0, 200)
520 );
521 $this->logTimedViewBuildStage($stageNumber, $stageKey, 'halted_floor_kill_streak', $started);
522 return 'halted';
523 }
524 $this->logTimedViewBuildStage($stageNumber, $stageKey, 'killed_resumable', $started);
525 return 'resumable_yield';
526 }
527 if ($classification === 'skip') {
528 $this->markStageSkippedForHostFailure($stageNumber, $errMsg);
529 $this->logTimedViewBuildStage($stageNumber, $stageKey, 'skipped_host_failure', $started);
530 return 'skipped';
531 }
532 if ($classification === 'halt') {
533 if ($stageNumber === 11 && $this->reconcilePostStageElevenState()) {
534 // RENAME committed server-side, error was a connection
535 // artifact. Treat as success.
536 $this->logTimedViewBuildStage($stageNumber, $stageKey, 'completed_after_reconcile', $started);
537 return 'completed';
538 }
539 $this->markBuildHaltedForHostFailure($stageNumber, $errMsg);
540 $this->logTimedViewBuildStage($stageNumber, $stageKey, 'halted_host_failure', $started);
541 return 'halted';
542 }
543 return 'rethrow';
544 }
545
546 /**
547 * Increment the per-stage consecutive no-progress kill streak. Called
548 * when a resumable-kill error fires before any forward progress is
549 * observed in the tick. Returns the new streak value so the caller
550 * can decide whether the floor-kill halt threshold has been reached.
551 *
552 * Inlined option access (not via writeProgressOption) so the streak
553 * tracking does not require the helpers trait, which keeps the
554 * trait composable in test contexts that do not pull the full DAO.
555 *
556 * @param int $stageNumber 1..11
557 * @return int New streak value, or 0 when option API is unavailable.
558 */
559 public function bumpStageNoProgressStreak(int $stageNumber): int {
560 if (!function_exists('get_option') || !function_exists('update_option')) {
561 return 0;
562 }
563 $optName = $this->stageNoProgressStreakOptionName($stageNumber);
564 if ($optName === '') {
565 return 0;
566 }
567 $current = get_option($optName, 0);
568 $next = (is_scalar($current) ? max(0, intval($current)) : 0) + 1;
569 update_option($optName, $next, false);
570 return $next;
571 }
572
573 /**
574 * Reset the per-stage no-progress kill streak. Called whenever a
575 * stage tick finishes without a resumable-kill exception so
576 * legitimate slow stages do not eventually accumulate enough strikes
577 * to trip the halt.
578 *
579 * @param int $stageNumber
580 * @return void
581 */
582 public function resetStageNoProgressStreak(int $stageNumber): void {
583 if (!function_exists('update_option')) {
584 return;
585 }
586 $optName = $this->stageNoProgressStreakOptionName($stageNumber);
587 if ($optName !== '') {
588 update_option($optName, 0, false);
589 }
590 }
591
592 /**
593 * Build the site-prefixed option name for the per-stage no-progress
594 * streak counter. Falls back to a fixed prefix when getLowercasePrefix()
595 * is not composed.
596 *
597 * @param int $stageNumber
598 * @return string Empty when the stage is outside 1..11.
599 */
600 public function stageNoProgressStreakOptionName(int $stageNumber): string {
601 if ($stageNumber < 1 || $stageNumber > 11) {
602 return '';
603 }
604 $prefix = $this->getLowercasePrefix();
605 return $prefix . 'abj404_view_build_s' . $stageNumber . '_no_progress';
606 }
607
608 /**
609 * Reconcile post-S11 state when a RENAME swap raised an error AFTER
610 * the rename committed but the client lost the connection (Codex
611 * finding #3 in StagedBuildPermanentFailureDegradesTest). RENAME TABLE
612 * is atomic on the server; if view_done now exists with the buffer's
613 * row count and view_build is gone, the swap actually succeeded and
614 * the error was a connection-level artifact, not a real failure.
615 *
616 * Returns true when reconciliation finds the swap committed (caller
617 * treats as success: write freshness, clear progress, return ready).
618 * Returns false when view_done is genuinely missing or partial; the
619 * caller falls through to the regular failure handling.
620 *
621 * Intentionally tolerant on probe failures: when SHOW TABLES errors
622 * out, we cannot verify either way, so we conservatively report
623 * "not reconciled" and let the next tick retry.
624 *
625 * @return bool
626 */
627 public function reconcilePostStageElevenState(): bool {
628 $viewDoneTable = $this->viewDoneTableName();
629 $viewBuildTable = $this->viewBuildTableName();
630
631 if (!$this->stagedTableExists($viewDoneTable)) {
632 return false;
633 }
634 if ($this->stagedTableExists($viewBuildTable)) {
635 return false;
636 }
637 // RENAME swap committed: view_done exists, view_build was renamed
638 // away. Treat as success even though the request flow saw an error.
639 if (function_exists('update_option')) {
640 update_option($this->viewDoneFreshnessOptionName(), $this->clock()->now(), false);
641 }
642 $this->clearAllProgressOptions();
643 $this->invalidateViewDoneServeableCache();
644 return true;
645 }
646 }
647