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_ViewBuildHelpers.php

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

1,288 lines 57.5 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 * Leaf-utility helpers for the staged view-build pipeline.
9 *
10 * Three responsibilities, all called from the orchestrator in the sibling
11 * trait ABJ_404_Solution_DataAccess_ViewQueriesStagedTrait:
12 *
13 * 1. Persisted progress tracking: per-stage option-name conventions plus
14 * get/set/clear helpers. Stage runners call readProgressOption /
15 * writeProgressOption to checkpoint resume state across PHP requests.
16 * Includes prefix-at-S1 capture and the SQL mode / max_allowed_packet
17 * session probe.
18 *
19 * 2. Staged SQL execution: load a SQL template from
20 * includes/sql/getRedirectsForViewStaged/, perform table-name and
21 * placeholder substitutions, route through queryAndGetResults, and
22 * raise a descriptive exception on failure. Plus a duplicate-key
23 * tolerant variant for re-runnable index DDL.
24 *
25 * 3. Build-side state probes: table existence (view_done, view_build,
26 * view_deleteme) and the view_done freshness / hard-stale-notice gate.
27 *
28 * Build-writer serialization (GET_LOCK / RELEASE_LOCK + option-row fallback)
29 * and the cron rebuild scheduler live on the sibling trait
30 * ABJ_404_Solution_DataAccess_ViewBuildLockAndCronTrait. All three traits
31 * are mixed into ABJ_404_Solution_DataAccess; properties declared here are
32 * visible to the staged-build trait inside the composing class.
33 */
34 trait ABJ_404_Solution_DataAccess_ViewBuildHelpersTrait {
35
36 /** @var int Per-stage timeout in seconds for staged queries; 0 means use queryAndGetResults default. */
37 private $stagedQueryTimeoutSeconds = 0;
38
39 /**
40 * Captured `$wpdb->prefix` snapshot taken at S1 entry. Compared at every
41 * subsequent stage entry to detect mid-build `switch_to_blog()` that
42 * would otherwise let S2-S11 run against a different blog's tables and
43 * silently corrupt the precomputed view (Codex finding #8).
44 *
45 * Authoritative for within-request detection: if a `switch_to_blog()`
46 * happens mid-request, `$wpdb->prefix` changes but this property does
47 * not (it lives on the singleton DAO). The companion option
48 * `abj404_view_build_prefix_at_s1` provides cross-request persistence
49 * (multisite options tables are per-blog, so the option naturally
50 * isolates per-blog: a resume on the same blog finds its capture; a
51 * resume after a between-request switch lands on a different options
52 * table where current_stage is also 0 and re-runs S1 cleanly).
53 *
54 * Empty when no build is active. Cleared on S11 completion.
55 *
56 * @var string
57 */
58 private $prefixAtStageOne = '';
59
60 /**
61 * Persisted progress tracker between requests. When a stage exits before
62 * completing all its batches (PHP timeout, per-stage budget reached), the
63 * next request resumes from the stored high-water id.
64 *
65 * Names are kept short to avoid WP's 191-char option_name index limit
66 * even with long table-prefix sites.
67 *
68 * @var array<string, string>
69 */
70 private static $viewBuildProgressOptionNames = array(
71 'started_at' => 'abj404_view_build_started_at',
72 'current_stage' => 'abj404_view_build_current_stage',
73 'last_started_stage' => 'abj404_view_build_last_started_stage',
74 'last_started_at' => 'abj404_view_build_last_started_at',
75 'last_completed_stage' => 'abj404_view_build_last_completed_stage',
76 'last_completed_at' => 'abj404_view_build_last_completed_at',
77 's2_high_water' => 'abj404_view_build_s2_high_water',
78 's4_high_water' => 'abj404_view_build_s4_high_water',
79 's5_high_water' => 'abj404_view_build_s5_high_water',
80 // Per-stage adaptive batch sizes. When a host kills a batch query at
81 // its full per-query limit (genuine batch-too-big), the runtime
82 // halves the corresponding entry and persists it so the next tick
83 // resumes at the smaller size. Reset to absent on a fresh build via
84 // clearAllProgressOptions; preserved across resumes.
85 's2_batch_size' => 'abj404_view_build_s2_batch_size',
86 's4_batch_size' => 'abj404_view_build_s4_batch_size',
87 's5_batch_size' => 'abj404_view_build_s5_batch_size',
88 // Per-stage consecutive kill counter for non-batched stages
89 // (S3 / S9 / S10). Incremented when a stage's single SQL
90 // statement is killed by the host (max_statement_time, gone-away,
91 // lock-wait); reset to 0 when the stage completes. When > 0 the
92 // next attempt for that stage uses an extended SET STATEMENT
93 // timeout that overrides the host's session limit -- the
94 // non-batched analog of adaptive batch shrink.
95 's3_kill_streak' => 'abj404_view_build_s3_kill_streak',
96 's9_kill_streak' => 'abj404_view_build_s9_kill_streak',
97 's10_kill_streak' => 'abj404_view_build_s10_kill_streak',
98 // Per-stage no-progress resumable-kill streak. Counts consecutive
99 // ticks where the stage callback raised a resumable-kill error
100 // (host kill, lock wait, gone-away) without making any forward
101 // progress. After VIEW_BUILD_FLOOR_KILL_STREAK_HALT_THRESHOLD
102 // strikes the build halts: the host cannot complete this stage's
103 // smallest unit of work so further retries only loop. Reset to
104 // 0 on any successful completion or wall-clock yield with
105 // progress. Distinct from s{N}_kill_streak: that one extends the
106 // per-query timeout for non-batched stages; this one detects
107 // genuine "host can never finish" and halts.
108 's1_no_progress_streak' => 'abj404_view_build_s1_no_progress',
109 's2_no_progress_streak' => 'abj404_view_build_s2_no_progress',
110 's3_no_progress_streak' => 'abj404_view_build_s3_no_progress',
111 's4_no_progress_streak' => 'abj404_view_build_s4_no_progress',
112 's5_no_progress_streak' => 'abj404_view_build_s5_no_progress',
113 's6_no_progress_streak' => 'abj404_view_build_s6_no_progress',
114 's7_no_progress_streak' => 'abj404_view_build_s7_no_progress',
115 's8_no_progress_streak' => 'abj404_view_build_s8_no_progress',
116 's9_no_progress_streak' => 'abj404_view_build_s9_no_progress',
117 's10_no_progress_streak' => 'abj404_view_build_s10_no_progress',
118 's11_no_progress_streak' => 'abj404_view_build_s11_no_progress',
119 );
120
121 /**
122 * @param string $shortName One of self::$viewBuildProgressOptionNames keys.
123 * @return string Site-prefixed option name.
124 */
125 private function progressOptionName(string $shortName): string {
126 if (!isset(self::$viewBuildProgressOptionNames[$shortName])) {
127 return '';
128 }
129 return $this->getLowercasePrefix() . self::$viewBuildProgressOptionNames[$shortName];
130 }
131
132 /**
133 * @param string $shortName Progress key.
134 * @param int $default
135 * @return int
136 */
137 private function readProgressOption(string $shortName, int $default = 0): int {
138 if (!function_exists('get_option')) {
139 return $default;
140 }
141 $name = $this->progressOptionName($shortName);
142 if ($name === '') {
143 return $default;
144 }
145 // Broken-cache bypass for high-stakes reads (current_stage,
146 // s2/s4/s5_high_water). A prior verifyOptionWriteCoherent() set the
147 // abj404_option_cache_incoherent transient because wp_cache_delete +
148 // retry could not get a fresh value. Without this bypass the next
149 // read of current_stage returns the stale cached 0 and every
150 // advanceViewBuildOnce re-enters S1.
151 if (in_array($shortName, self::$viewBuildProgressHighStakesShortNames, true)
152 && function_exists('get_transient')
153 && get_transient('abj404_option_cache_incoherent') !== false
154 && function_exists('wp_cache_delete')) {
155 wp_cache_delete($name, 'options');
156 wp_cache_delete('alloptions', 'options');
157 }
158 $value = get_option($name, $default);
159 return is_scalar($value) ? max(0, intval($value)) : $default;
160 }
161
162 /**
163 * Subset of {@see $viewBuildProgressOptionNames} keys whose writes route
164 * through {@see verifyOptionWriteCoherent} instead of bare update_option.
165 *
166 * The helper costs an extra get_option per write (a wp_cache_get on
167 * coherent hosts; one extra DB round-trip on hosts that fail the
168 * verification). That cost is justified for state where a stale read
169 * could cause a destructive stage to re-run or a batch high-water mark
170 * to rewind, but not for kill-streak counters or started_at where a
171 * single tick of stale data is harmless.
172 *
173 * @var array<int,string>
174 */
175 private static $viewBuildProgressHighStakesShortNames = array(
176 'current_stage',
177 's2_high_water',
178 's4_high_water',
179 's5_high_water',
180 );
181
182 /**
183 * @param string $shortName Progress key.
184 * @param int $value
185 * @return void
186 */
187 private function writeProgressOption(string $shortName, int $value): void {
188 if (!function_exists('update_option')) {
189 return;
190 }
191 $name = $this->progressOptionName($shortName);
192 if ($name === '') {
193 return;
194 }
195 $intValue = max(0, intval($value));
196 // High-stakes view_build_state writes route through the cache-coherent
197 // helper (read-back + wp_cache_delete + retry) so a persistent object
198 // cache returning a stale value cannot let a parallel worker rewind
199 // current_stage or a batch high-water and re-run a destructive stage.
200 // Lower-stakes writes use the bare update_option path -- the read-back
201 // cost is non-trivial and a single tick of stale streak data is
202 // harmless.
203 if (in_array($shortName, self::$viewBuildProgressHighStakesShortNames, true)) {
204 $writeOk = $this->verifyOptionWriteCoherent($name, $intValue);
205 $readBack = function_exists('get_option') ? get_option($name, null) : null;
206 $this->logViewBuildProgressOptionWrite($shortName, $name, $intValue, $writeOk, $readBack, 'coherent');
207 return;
208 }
209 // autoload=false so progress writes (potentially many per request)
210 // don't bloat the alloptions cache that loads on every WP page.
211 $writeOk = update_option($name, $intValue, false);
212 $readBack = function_exists('get_option') ? get_option($name, null) : null;
213 $this->logViewBuildProgressOptionWrite($shortName, $name, $intValue, $writeOk, $readBack, 'direct');
214 }
215
216 /**
217 * Log only the stage-resume metadata writes that are needed to diagnose
218 * S1 success-vs-progress-write failures without flooding logs for every
219 * batched high-water update.
220 *
221 * @param string $shortName
222 * @param string $optionName
223 * @param int $expected
224 * @param mixed $updateReturn
225 * @param mixed $readBack
226 * @param string $path
227 * @return void
228 */
229 private function logViewBuildProgressOptionWrite(
230 string $shortName,
231 string $optionName,
232 int $expected,
233 $updateReturn,
234 $readBack,
235 string $path
236 ): void {
237 if (!in_array($shortName, array(
238 'started_at',
239 'current_stage',
240 'last_started_stage',
241 'last_started_at',
242 'last_completed_stage',
243 'last_completed_at',
244 ), true)) {
245 return;
246 }
247 if (!is_object($this->logger) || !method_exists($this->logger, 'debugMessage')) {
248 return;
249 }
250
251 $readBackForLog = is_scalar($readBack) ? (string)$readBack : gettype($readBack);
252 $this->logger->debugMessage(sprintf(
253 '[staged] view build progress option write: key=%s option=%s expected=%d path=%s update_option_return=%s read_back=%s',
254 $shortName,
255 $optionName,
256 $expected,
257 $path,
258 $updateReturn ? 'true' : 'false',
259 substr($readBackForLog, 0, 240)
260 ));
261 }
262
263 /**
264 * Cache-coherent option write. Persistent object caches (Redis,
265 * Memcached, mu-cluster split routing) can serve a stale `get_option`
266 * value for one tick after `update_option` writes the row. For
267 * high-stakes options (staged-build current_stage, batch high-water
268 * marks) that single tick is enough to let a parallel worker rewind to
269 * a just-completed stage and re-run destructive work.
270 *
271 * Procedure:
272 * 1. update_option($name, $expected, autoload=false).
273 * 2. get_option($name) and strict-compare to $expected.
274 * 3. On mismatch: wp_cache_delete($name, 'options') and the
275 * 'alloptions' bucket (covers both keying strategies WP uses), then
276 * update_option + get_option once more.
277 * 4. On persistent mismatch: set a 24h transient
278 * 'abj404_option_cache_incoherent' carrying name + observed value
279 * so other code can short-circuit cache-coherence-sensitive logic,
280 * log a warning, return false.
281 * 5. On success (first or retry): return true.
282 *
283 * Idempotent and safe to call repeatedly. Loose-equal comparison is
284 * intentional: option values round-trip through serialization and
285 * scalar coercion, so an int 4 may come back as the string "4".
286 *
287 * @param string $optionName WordPress option name (already fully prefixed).
288 * @param mixed $expected Value just written -- compared against the read-back.
289 * @return bool True on coherent write (first try or retry); false when the
290 * cache layer fails to invalidate even after wp_cache_delete.
291 */
292 public function verifyOptionWriteCoherent(string $optionName, $expected): bool {
293 if (!function_exists('update_option') || !function_exists('get_option')) {
294 return false;
295 }
296 // Capture the prior persisted value so a first-read-back-fail WARN
297 // (below, for current_stage only) can carry prior + new + observed,
298 // letting support see whether the cache returned the previous value
299 // or some unrelated state from a parallel request.
300 $prior = get_option($optionName, null);
301 update_option($optionName, $expected, false);
302 $actual = get_option($optionName, null);
303 if ($this->optionReadBackMatches($actual, $expected)) {
304 return true;
305 }
306 // First read disagrees with the just-written value. Surface a WARN
307 // for current_stage specifically (the most diagnostically valuable
308 // stage-progress key) so the signal survives a site with DEBUG off.
309 // Other high-stakes keys (s2/s4/s5_high_water) stay silent on the
310 // first miss to avoid log volume; they still hit the persistent-
311 // mismatch WARN further down if the retry also fails.
312 if ($this->isCurrentStageOptionName($optionName) && is_object($this->logger)
313 && method_exists($this->logger, 'warn')) {
314 $this->logger->warn(sprintf(
315 '[staged] option write incoherent (first read-back) on %s: prior=%s new=%s observed=%s; flushing cache and retrying',
316 $optionName,
317 is_scalar($prior) ? (string)$prior : '<non-scalar>',
318 is_scalar($expected) ? (string)$expected : '<non-scalar>',
319 is_scalar($actual) ? (string)$actual : '<non-scalar>'
320 ));
321 }
322 // Flush both candidate cache keys and retry. Use a typeof-guarded
323 // call because wp_cache_delete is part of WP core but not loaded
324 // in unit-test bootstraps that don't pull in cache.php.
325 if (function_exists('wp_cache_delete')) {
326 wp_cache_delete($optionName, 'options');
327 // alloptions is the bundled bucket WP loads on every page; even
328 // for autoload=false writes some object-cache backends miss the
329 // per-key invalidation and need the bucket flushed.
330 wp_cache_delete('alloptions', 'options');
331 }
332 update_option($optionName, $expected, false);
333 $retry = get_option($optionName, null);
334 if ($this->optionReadBackMatches($retry, $expected)) {
335 return true;
336 }
337
338 // Persistent mismatch: surface to other code via a deduplicated
339 // transient and log a warning. Don't email -- this is a host-config
340 // problem, not a plugin defect.
341 if (function_exists('set_transient')) {
342 // allow-cache-empty: payload is diagnostic state; empty observed/error fields are still actionable.
343 set_transient(
344 'abj404_option_cache_incoherent',
345 array(
346 'option' => $optionName,
347 'expected' => is_scalar($expected) ? (string)$expected : 'non-scalar',
348 'observed' => is_scalar($retry) ? (string)$retry : 'non-scalar',
349 'when' => time(),
350 ),
351 86400
352 );
353 }
354 if (is_object($this->logger)) {
355 $message = sprintf(
356 '[staged] option write incoherent on this host: %s expected=%s observed=%s '
357 . '(persistent object cache likely returning stale values; '
358 . 'wp_cache_delete + retry did not invalidate).',
359 $optionName,
360 is_scalar($expected) ? (string)$expected : '<non-scalar>',
361 is_scalar($retry) ? (string)$retry : '<non-scalar>'
362 );
363 if (method_exists($this->logger, 'warn')) {
364 $this->logger->warn($message);
365 } elseif (method_exists($this->logger, 'debugMessage')) {
366 $this->logger->debugMessage($message);
367 }
368 }
369 return false;
370 }
371
372 /**
373 * Whether the fully-prefixed option name refers to the view-build
374 * `current_stage` key (the prefix component varies by site). Used by
375 * verifyOptionWriteCoherent() to scope its first-read-back-fail WARN to
376 * the most diagnostically valuable stage-progress key.
377 *
378 * @param string $optionName
379 * @return bool
380 */
381 private function isCurrentStageOptionName(string $optionName): bool {
382 $suffix = self::$viewBuildProgressOptionNames['current_stage'] ?? '';
383 if ($suffix === '') {
384 return false;
385 }
386 $len = strlen($suffix);
387 return $len > 0 && substr($optionName, -$len) === $suffix;
388 }
389
390 /**
391 * Loose-equal read-back comparison. WP option values round-trip through
392 * serialize() and may come back as a different scalar type than written
393 * (int 4 -> string "4"). The semantic question is "did the persisted
394 * value reflect the write," so we compare via string casts when both
395 * sides are scalar; otherwise fall back to ==.
396 *
397 * Null asymmetry is treated as a mismatch. PHP's loose-equal would
398 * otherwise have `null == 0`, `null == ''`, `null == false` all return
399 * true, so a cache layer that served null ("option not found") for a
400 * value-of-zero write (s2/s4/s5_high_water reset on a fresh build) would
401 * have spuriously passed verification. An unwritten cache slot is not
402 * the same value as a written falsy value.
403 *
404 * @param mixed $actual
405 * @param mixed $expected
406 * @return bool
407 */
408 private function optionReadBackMatches($actual, $expected): bool {
409 if (($actual === null) !== ($expected === null)) {
410 return false;
411 }
412 if (is_scalar($actual) && is_scalar($expected)) {
413 return (string)$actual === (string)$expected;
414 }
415 return $actual == $expected;
416 }
417
418 /** @return void */
419 private function clearAllProgressOptions(): void {
420 if (!function_exists('delete_option')) {
421 return;
422 }
423 foreach (self::$viewBuildProgressOptionNames as $optName) {
424 delete_option($this->getLowercasePrefix() . $optName);
425 }
426 // The S1 prefix capture lives outside $viewBuildProgressOptionNames
427 // because its option name is intentionally not prefix-bound (so a
428 // mid-build switch_to_blog cannot make get_option silently miss it).
429 // It belongs to the same fresh-start lifecycle, so clear it alongside.
430 $this->clearPrefixAtStageOne();
431 // Same lifecycle: a fresh build must re-probe the live session so a
432 // hosting move that changed sql_mode (or a schema swap that changed
433 // max_allowed_packet) is picked up at the next S1 entry. The PHP
434 // environment probe (set_time_limit / memory_limit) is reset for the
435 // same reason: an ini change between builds must take effect.
436 $this->clearSqlModeProbeCache();
437 $this->clearPhpEnvironmentProbeCache();
438 }
439
440 /**
441 * Per-build watermark stamp machinery -- option-name helpers, raw
442 * read/write, the stage-boundary advance gate, and the
443 * stamp/clear/publish methods -- lives on
444 * {@see ABJ_404_Solution_DataAccess_ViewBuildStartedWatermarkTrait}.
445 * The orchestrator and abort/fresh-start methods below call into it
446 * via `$this->` (both traits compose into ABJ_404_Solution_DataAccess).
447 */
448
449 /**
450 * One-call cleanup for the fresh-start branch of runStagedBuildOnce:
451 * scrap progress options (registry + Phase-2 active stamp), drop any
452 * leftover buffer tables. Pulled out of the orchestrator so the
453 * body line count stays within the per-function cap.
454 *
455 * last_build_started_watermark is intentionally NOT cleared here: it
456 * is diagnostic-only and survives across fresh-start boundaries (and
457 * gets overwritten on the next S1 entry stamp).
458 *
459 * @return void
460 */
461 private function performFreshStartCleanup(): void {
462 $this->clearAllProgressOptions();
463 $this->clearActiveBuildStartedWatermark();
464 $this->dropTransientStagedTables();
465 }
466
467 /**
468 * Single-call boundary gate. Returns true (and runs the abort
469 * cleanup) when the live mutation watermark has advanced past the
470 * S1-entry stamp; the orchestrator pairs this with `return false`
471 * to drop out of runStagedBuildOnce. Keeping the gate to one line
472 * per stage in the orchestrator (rather than four) is what holds
473 * runStagedBuildOnce within the project's per-function line cap.
474 *
475 * @param int $aboutToRunStage Stage about to fire (2..11).
476 * @return bool True when an abort was triggered.
477 */
478 private function gateAbortIfMutationWatermarkAdvanced(int $aboutToRunStage): bool {
479 if (!$this->mutationWatermarkAdvancedSinceBuildStart()) {
480 return false;
481 }
482 $this->abortStagedBuildForMutationWatermarkAdvance($aboutToRunStage);
483 return true;
484 }
485
486 /**
487 * S11 swap that fires the CLAUDE.md R6 pre-RENAME action hook
488 * (`abj404_view_build_before_rename_swap`) and re-checks the
489 * mutation watermark immediately after. The re-check closes the
490 * race between the S10/S11 boundary gate and the actual RENAME
491 * TABLE statement: a mutation that lands inside that window must
492 * not see the buffer get promoted to view_done. Returning false
493 * from the closure marks the stage 'yielded' (NOT 'completed'),
494 * preventing markViewBuildStageCompleted from firing and the
495 * orchestrator from publishing built_watermark for a build that
496 * never swapped.
497 *
498 * @return bool True when the swap completed cleanly (orchestrator
499 * should publish built_watermark); false when the
500 * stage aborted / halted / yielded (orchestrator
501 * should return false from runStagedBuildOnce). On
502 * abort, this method runs the abort cleanup itself.
503 */
504 private function runS11SwapWithPreRenameWatermarkRecheck(): bool {
505 $aborted = false;
506 $result = $this->runTimedViewBuildStage(11, 'staged_build_s11_swap', function () use (&$aborted) {
507 if (function_exists('do_action')) {
508 do_action('abj404_view_build_before_rename_swap');
509 }
510 if ($this->mutationWatermarkAdvancedSinceBuildStart()) {
511 $aborted = true;
512 return false;
513 }
514 $this->stageRenameSwap();
515 });
516 if ($aborted) {
517 $this->abortStagedBuildForMutationWatermarkAdvance(11);
518 return false;
519 }
520 return $result !== false && $result !== 'halted';
521 }
522
523 /**
524 * Abort the in-flight build cleanly because an external mutation
525 * bumped the watermark. Runner owns the buffer and the progress
526 * markers, so it owns the cleanup: drop the buffer, wipe progress
527 * (including active_build_started_watermark so the next tick
528 * re-stamps from scratch). built_watermark is intentionally left
529 * alone -- it records the LAST SUCCESSFUL build's coverage, not the
530 * aborted run. last_build_started_watermark is also intentionally
531 * left alone -- it is diagnostic-only and its purpose is to survive
532 * abort so an operator can see "the most recent build attempt
533 * stamped against watermark X, then aborted".
534 *
535 * The build lock is released by the try/finally in advanceViewBuildOnce
536 * once runStagedBuildOnce returns false; this method does not touch it.
537 * scheduleViewDoneRebuild is likewise the caller's responsibility
538 * (advanceViewBuildOnce already calls it on a non-complete tick).
539 *
540 * @param int $aboutToRunStage Stage the gate fired before (2..11).
541 * @return void
542 */
543 private function abortStagedBuildForMutationWatermarkAdvance(int $aboutToRunStage): void {
544 // Read the active stamp BEFORE clearing it so the diagnostic log
545 // line below carries the value we aborted against. Reading post-
546 // clear would always show -1 and erase the most useful field for
547 // debugging "why did this build abort?" tickets.
548 $startedForLog = $this->readActiveBuildStartedWatermark();
549 $this->dropTransientBuffersIfPresent();
550 $this->clearAllProgressOptions();
551 // active_build_started_watermark lives outside the progress
552 // registry so the happy path (S11 completion) leaves it observable
553 // to the next tick's pre-image read. The abort path explicitly
554 // clears it so the abort-then-fresh-restart loop re-stamps from
555 // the live current() rather than reusing the aborted run's
556 // pre-image.
557 $this->clearActiveBuildStartedWatermark();
558 if (is_object($this->logger) && method_exists($this->logger, 'infoMessage')) {
559 $current = class_exists('ABJ_404_Solution_MutationWatermark')
560 ? ABJ_404_Solution_MutationWatermark::current() : -1;
561 $this->logger->infoMessage(sprintf(
562 '[staged] runStagedBuildOnce: mutation watermark advanced '
563 . '(started=%d, current=%d); aborting before stage %d. '
564 . 'Buffer dropped, progress cleared; next tick rebuilds from S0.',
565 $startedForLog, $current, $aboutToRunStage
566 ));
567 }
568 }
569
570 /**
571 * Option name used to persist the `$wpdb->prefix` captured at S1. Kept
572 * deliberately NOT site-prefixed so that within a single request we can
573 * still tell when `switch_to_blog()` has flipped `$wpdb->prefix` out
574 * from under us: the option-key the get_option call computes does not
575 * itself depend on the current prefix. (WP's options table itself is
576 * per-blog in multisite, which gives the cross-blog isolation we want
577 * for the cross-request resume case for free.)
578 *
579 * @return string
580 */
581 private function prefixAtStageOneOptionName(): string {
582 return 'abj404_view_build_prefix_at_s1';
583 }
584
585 /**
586 * Snapshot the current `$wpdb->prefix` so subsequent stage entries can
587 * detect a mid-build `switch_to_blog()`. Called from runStagedBuildOnce
588 * at S1 entry. Idempotent on repeated S1 runs (fresh start clears via
589 * clearPrefixAtStageOne first, then captures the live prefix here).
590 *
591 * @return void
592 */
593 public function capturePrefixAtBuildStart(): void {
594 global $wpdb;
595 $prefix = (isset($wpdb->prefix) && is_string($wpdb->prefix)) ? $wpdb->prefix : '';
596 $this->prefixAtStageOne = $prefix;
597 if (function_exists('update_option')) {
598 update_option($this->prefixAtStageOneOptionName(), $prefix, false);
599 }
600 }
601
602 /**
603 * Compare the live `$wpdb->prefix` against the snapshot taken at S1.
604 * Returns true when they match (or no snapshot exists -- fresh blog or
605 * pre-S1). Returns false when a mismatch is detected, which is the
606 * orchestrator's signal to halt the rebuild before S2-S11 writes
607 * against a different blog's tables.
608 *
609 * Logic:
610 * - In-memory `$this->prefixAtStageOne` is authoritative when set.
611 * `switch_to_blog()` cannot flip an instance property, so any
612 * change in `$wpdb->prefix` after capture is a real mismatch.
613 * - Falls back to the persisted option for cross-request resumes
614 * (the in-memory capture starts empty on each request).
615 * - Empty captured value means S1 has never run on this blog (in
616 * multisite, options are per-blog: a fresh blog has no record
617 * of any past build) -- treat as "nothing to verify".
618 *
619 * @return bool False on mismatch (caller should halt the build).
620 */
621 public function verifyPrefixUnchangedSinceStageOne(): bool {
622 global $wpdb;
623 $current = (isset($wpdb->prefix) && is_string($wpdb->prefix)) ? $wpdb->prefix : '';
624
625 if ($this->prefixAtStageOne !== '') {
626 return $this->prefixAtStageOne === $current;
627 }
628
629 if (!function_exists('get_option')) {
630 return true;
631 }
632 $captured = get_option($this->prefixAtStageOneOptionName(), '');
633 if (!is_string($captured) || $captured === '') {
634 return true;
635 }
636 return $captured === $current;
637 }
638
639 /**
640 * Clear the captured S1 prefix so the next rebuild starts fresh.
641 * Called after a successful S11 swap and from the explicit force
642 * rebuild path in clearStagedBuildDegradedState().
643 *
644 * @return void
645 */
646 public function clearPrefixAtStageOne(): void {
647 $this->prefixAtStageOne = '';
648 if (function_exists('delete_option')) {
649 delete_option($this->prefixAtStageOneOptionName());
650 }
651 }
652
653 /**
654 * Read-only accessor for diagnostic logging. Returns the in-memory
655 * capture if present, otherwise the persisted option, otherwise ''.
656 *
657 * @return string
658 */
659 private function capturedPrefixForLog(): string {
660 if ($this->prefixAtStageOne !== '') {
661 return $this->prefixAtStageOne;
662 }
663 if (!function_exists('get_option')) {
664 return '';
665 }
666 $captured = get_option($this->prefixAtStageOneOptionName(), '');
667 return is_string($captured) ? $captured : '';
668 }
669
670 /**
671 * Cached probe result for the current build run: SESSION sql_mode and
672 * max_allowed_packet. Populated on first call to probeSqlModeForBuild()
673 * within a request. Returned shape:
674 * array{
675 * sql_mode: string, // raw flags, e.g. "STRICT_TRANS_TABLES,ONLY_FULL_GROUP_BY"
676 * strict_mode_active: bool, // true if STRICT_TRANS_TABLES or STRICT_ALL_TABLES present
677 * only_full_group_by_active: bool, // true if ONLY_FULL_GROUP_BY present
678 * no_zero_date_active: bool, // true if NO_ZERO_DATE / NO_ZERO_IN_DATE present
679 * max_allowed_packet: int, // bytes (0 if unknown)
680 * adjusted: bool, // true if we successfully relaxed sql_mode for the build connection
681 * adjustment_denied: bool, // true if the relax attempt was rejected (privilege)
682 * truncate_url_to: int // 2048 default, smaller when packet is constrained
683 * }
684 *
685 * @var array<string,mixed>|null
686 */
687 private $sqlModeProbeCache = null;
688
689 /**
690 * Option name used to persist the most recent probe result so a
691 * post-mortem on a stuck build can see the exact session config the
692 * runner saw at S1 entry. Lives outside the per-request cache so the
693 * dashboard can read it across requests.
694 *
695 * @return string
696 */
697 private function sqlModeProbeOptionName(): string {
698 return 'abj404_view_build_session_probe';
699 }
700
701 /**
702 * Probe the live MySQL session for sql_mode and max_allowed_packet at
703 * staged-build entry, before S1 runs. Persists the result in
704 * `view_build_state` for diagnostic purposes and tries to relax
705 * STRICT_TRANS_TABLES / ONLY_FULL_GROUP_BY for THIS connection only via
706 * `SET SESSION sql_mode = ''`. The S2 INSERT already uses a strict-safe
707 * REGEXP-guarded CAST so it survives strict mode regardless; the relax
708 * is belt-and-suspenders for any future query the build may issue.
709 *
710 * Idempotent within a request -- repeat calls return the cached result
711 * without re-querying. Cleared by clearSqlModeProbeCache() on a fresh
712 * build (alongside clearAllProgressOptions).
713 *
714 * Public so the orchestrator and tests can call it. The contract test
715 * `StagedBuildHostQuirksTest::testStrictSqlModeIsDetectedAndAdjustedOrSurfaced`
716 * asserts the method exists; without this, a strict host fails S2 with
717 * an unhelpful CAST error.
718 *
719 * @return array<string,mixed> See sqlModeProbeCache docblock.
720 */
721 public function probeSqlModeForBuild(): array {
722 if (is_array($this->sqlModeProbeCache)) {
723 return $this->sqlModeProbeCache;
724 }
725
726 $result = array(
727 'sql_mode' => '',
728 'strict_mode_active' => false,
729 'only_full_group_by_active' => false,
730 'no_zero_date_active' => false,
731 'max_allowed_packet' => 0,
732 'adjusted' => false,
733 'adjustment_denied' => false,
734 'truncate_url_to' => 2048,
735 );
736
737 global $wpdb;
738 if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'get_row')) {
739 $this->sqlModeProbeCache = $result;
740 return $result;
741 }
742 /** @var \wpdb $wpdb */
743
744 // Round-trip both probes in one query: avoids two protocol hops on
745 // slow shared hosts. Suppress wpdb's own error display because some
746 // hosts revoke @@SESSION reads (rare but real on certain ProxySQL
747 // routings) and we want to fail soft.
748 $prevSuppress = method_exists($wpdb, 'suppress_errors') ? $wpdb->suppress_errors(true) : false;
749 try {
750 // DAO-bypass-approved: probe live @@SESSION on this connection.
751 $row = $wpdb->get_row(
752 "SELECT @@SESSION.sql_mode AS sql_mode, @@SESSION.max_allowed_packet AS max_allowed_packet",
753 ARRAY_A
754 );
755 } catch (\Throwable $e) {
756 $row = null;
757 }
758 if (method_exists($wpdb, 'suppress_errors')) {
759 $wpdb->suppress_errors($prevSuppress);
760 }
761
762 if (is_array($row)) {
763 // Case-insensitive key lookup (some MySQL drivers normalize column case).
764 foreach ($row as $k => $v) {
765 $klow = strtolower((string)$k);
766 if ($klow === 'sql_mode' && is_scalar($v)) {
767 $result['sql_mode'] = (string)$v;
768 } elseif ($klow === 'max_allowed_packet' && is_scalar($v)) {
769 $result['max_allowed_packet'] = (int)$v;
770 }
771 }
772 }
773
774 $modeUpper = strtoupper($result['sql_mode']);
775 $result['strict_mode_active'] = (
776 strpos($modeUpper, 'STRICT_TRANS_TABLES') !== false ||
777 strpos($modeUpper, 'STRICT_ALL_TABLES') !== false
778 );
779 $result['only_full_group_by_active'] = (strpos($modeUpper, 'ONLY_FULL_GROUP_BY') !== false);
780 $result['no_zero_date_active'] = (
781 strpos($modeUpper, 'NO_ZERO_DATE') !== false ||
782 strpos($modeUpper, 'NO_ZERO_IN_DATE') !== false
783 );
784
785 // If max_allowed_packet < 1MB, leave headroom for SQL framing
786 // (column names, escapes, repeated values) by truncating URL inputs
787 // to floor(packet * 0.4). Leaves >50% packet room for the rest of
788 // the row payload. Above 1MB we keep the schema's 2048-char ceiling.
789 $packet = (int)$result['max_allowed_packet'];
790 if ($packet > 0 && $packet < 1048576) {
791 $result['truncate_url_to'] = max(255, (int)floor($packet * 0.4));
792 $this->logger->warn(sprintf(
793 '[staged] max_allowed_packet=%d (<1MB); URL inputs will be truncated to %d chars to leave room for SQL framing.',
794 $packet, $result['truncate_url_to']
795 ));
796 }
797
798 // If strict mode or ONLY_FULL_GROUP_BY is active, attempt to relax
799 // it for THIS connection only (no global change, no other clients
800 // affected). This is best-effort: managed hosts may deny the SET.
801 // The S2 INSERT already uses a strict-safe CAST so the build
802 // survives a denied relax; the warn below makes it diagnosable.
803 if ($result['strict_mode_active'] || $result['only_full_group_by_active']) {
804 $relaxed = $this->attemptRelaxSqlModeForBuildConnection($result['sql_mode']);
805 $result['adjusted'] = $relaxed === true;
806 $result['adjustment_denied'] = $relaxed === false;
807 if ($result['adjustment_denied']) {
808 $this->logger->warn(sprintf(
809 '[staged] sql_mode contains STRICT_TRANS_TABLES / ONLY_FULL_GROUP_BY (%s) and the relax attempt was denied. The S2 INSERT is strict-safe via REGEXP-guarded CAST; build will proceed.',
810 $result['sql_mode']
811 ));
812 } elseif ($result['adjusted']) {
813 $this->logger->infoMessage(sprintf(
814 '[staged] Relaxed sql_mode for build connection (was: %s).',
815 $result['sql_mode']
816 ));
817 }
818 }
819
820 // @cache-write-audit: opt-out - $result is a captured snapshot of
821 // session-variable state used for diagnostics, not a cached query
822 // result. A failed SHOW VARIABLES populates defaults that are still
823 // safe to persist (the dashboard reader treats sql_mode=='' as a
824 // probe failure and skips its row).
825 if (function_exists('update_option')) {
826 update_option($this->sqlModeProbeOptionName(), $result, false);
827 }
828
829 $this->sqlModeProbeCache = $result;
830 return $result;
831 }
832
833 /**
834 * Alias kept for the alternative contract phrasing in
835 * StagedBuildHostQuirksTest. Returns the same probe result.
836 *
837 * @return array<string,mixed>
838 */
839 public function detectAndAdjustSqlMode(): array {
840 return $this->probeSqlModeForBuild();
841 }
842
843 /**
844 * Strip STRICT_TRANS_TABLES / STRICT_ALL_TABLES / ONLY_FULL_GROUP_BY /
845 * NO_ZERO_DATE / NO_ZERO_IN_DATE from the supplied sql_mode string and
846 * issue `SET SESSION sql_mode = '<remaining>'`. Returns true on success,
847 * false on denial, null when wpdb is unavailable.
848 *
849 * @param string $currentSqlMode
850 * @return bool|null
851 */
852 private function attemptRelaxSqlModeForBuildConnection(string $currentSqlMode): ?bool {
853 global $wpdb;
854 if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'query')) {
855 return null;
856 }
857 /** @var \wpdb $wpdb */
858 $flags = array_filter(array_map('trim', explode(',', $currentSqlMode)));
859 $strip = array(
860 'STRICT_TRANS_TABLES',
861 'STRICT_ALL_TABLES',
862 'ONLY_FULL_GROUP_BY',
863 'NO_ZERO_DATE',
864 'NO_ZERO_IN_DATE',
865 'TRADITIONAL', // umbrella that re-enables strict
866 );
867 $relaxed = array();
868 foreach ($flags as $flag) {
869 $upper = strtoupper($flag);
870 if (in_array($upper, $strip, true)) {
871 continue;
872 }
873 $relaxed[] = $flag;
874 }
875 $newMode = implode(',', $relaxed);
876 // @utf8-audit: opt-out - sql_mode flags are server-controlled
877 // uppercase ASCII identifiers (STRICT_TRANS_TABLES, ONLY_FULL_GROUP_BY,
878 // etc.); $newMode is built from filtered $flags whose source is
879 // SHOW SESSION VARIABLES output, never user input.
880 $escaped = function_exists('esc_sql') ? esc_sql($newMode) : str_replace("'", "''", $newMode);
881 $escapedStr = is_array($escaped) ? '' : (string)$escaped;
882 $prevSuppress = method_exists($wpdb, 'suppress_errors') ? $wpdb->suppress_errors(true) : false;
883 try {
884 // DAO-bypass-approved: SET SESSION must run on the live wpdb connection.
885 $ok = $wpdb->query("SET SESSION sql_mode = '" . $escapedStr . "'");
886 } catch (\Throwable $e) {
887 $ok = false;
888 }
889 if (method_exists($wpdb, 'suppress_errors')) {
890 $wpdb->suppress_errors($prevSuppress);
891 }
892 $err = $wpdb->last_error;
893 if ($ok === false || $err !== '') {
894 return false;
895 }
896 return true;
897 }
898
899 /** @return void */
900 private function clearSqlModeProbeCache(): void {
901 $this->sqlModeProbeCache = null;
902 if (function_exists('delete_option')) {
903 delete_option($this->sqlModeProbeOptionName());
904 }
905 // The session-variables probe (operational + DDL-safety MySQL vars)
906 // shares the same lifecycle as the sql_mode probe: a fresh build must
907 // re-evaluate session config in case the host was tuned between runs.
908 // Trait method lives on ABJ_404_Solution_DataAccess_ViewBuildSessionEnvProbeTrait.
909 $this->clearSessionVariablesProbeCache();
910 }
911
912 // PHP-runtime environment probe (set_time_limit / memory_limit) lives
913 // on the sibling ABJ_404_Solution_DataAccess_ViewBuildPhpEnvProbeTrait.
914 // The MySQL-session operational + DDL-safety probe lives on the sibling
915 // ABJ_404_Solution_DataAccess_ViewBuildSessionEnvProbeTrait.
916 // clearAllProgressOptions() above calls clearPhpEnvironmentProbeCache()
917 // and (via clearSqlModeProbeCache) clearSessionVariablesProbeCache so a
918 // fresh build re-evaluates the host on next entry.
919
920 /**
921 * Sanitize a URL string at the build/log boundary so a NULL byte or a
922 * pathological length cannot reach the SQL layer. Behavior:
923 * - Strip ASCII NULL (\x00) bytes and other low control bytes (\x01-\x08,
924 * \x0B, \x0C, \x0E-\x1F, \x7F) that wpdb->prepare() would otherwise
925 * reject with "could not execute query, contains invalid data".
926 * - Truncate to the cap returned by the most recent
927 * probeSqlModeForBuild() (default 2048 == varchar(2048) ceiling on
928 * the redirects table; smaller when max_allowed_packet < 1MB).
929 *
930 * Public so the 404-listener boundary and the staged-build entry both
931 * route through one sanitizer (same input rules everywhere). The
932 * contract tests `testNullByteInUrlRejectedAtBoundaryNotInSqlLayer` and
933 * `testUrlLongerThan2048CharsTruncatedOrRejectedAtBoundary` assert the
934 * method exists; the implementation is what makes the gastroinovace.cz
935 * 2,800x error-mailbox flood (mid-2024) stay fixed.
936 *
937 * @param string $url Raw URL captured from $_SERVER['REQUEST_URI'] or wpdb input.
938 * @param int $maxLength Optional override; 0 means "use the probe-derived cap".
939 * @return string
940 */
941 public function sanitizeUrlBeforeInsert(string $url, int $maxLength = 0): string {
942 if ($url === '') {
943 return '';
944 }
945 // Strip NULL bytes and control bytes BEFORE truncation so a
946 // multi-byte sequence at the cap doesn't get split mid-byte and
947 // become a partial NULL.
948 $clean = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $url);
949 if (!is_string($clean)) {
950 $clean = $url;
951 }
952 if ($maxLength <= 0) {
953 $probe = is_array($this->sqlModeProbeCache) ? $this->sqlModeProbeCache : null;
954 $maxLength = ($probe !== null && isset($probe['truncate_url_to']))
955 ? max(255, (int)$probe['truncate_url_to'])
956 : 2048;
957 }
958 if (function_exists('mb_strlen') && function_exists('mb_substr')) {
959 if (mb_strlen($clean) > $maxLength) {
960 $clean = mb_substr($clean, 0, $maxLength);
961 }
962 } elseif (strlen($clean) > $maxLength) {
963 $clean = substr($clean, 0, $maxLength);
964 }
965 return $clean;
966 }
967
968 /**
969 * Execute a staged SQL file with placeholder substitution and the
970 * standard error-handling pipeline.
971 *
972 * On failure, the error message is prefixed with the file name and any
973 * batch bounds present in $extraTranslations so the GUI's "stage N
974 * failed" notice carries actionable context. The current sub-stage
975 * label set by markBuildStage() remains in place so the AJAX shutdown
976 * handler renders the correct stageNumber/queryLabel.
977 *
978 * @param string $relativePath
979 * @param array<string, string> $extraTranslations
980 * @return void
981 */
982 private function runStagedSqlFile(string $relativePath, array $extraTranslations): void {
983 $path = __DIR__ . '/sql/getRedirectsForViewStaged/' . $relativePath;
984 $template = ABJ_404_Solution_Functions::readFileContents($path);
985 if (!is_string($template) || trim($template) === '') {
986 throw new \Exception("Staged SQL template missing or empty: $relativePath");
987 }
988 $sql = $this->doTableNameReplacements($template);
989 // extraTranslations (status_for_view / type_for_view labels, batch
990 // bounds) must run BEFORE doNormalReplacements: doNormalReplacements
991 // falls back to __() for any {key} it does not know, which strips
992 // the braces and prevents the str_replace below from matching.
993 if (!empty($extraTranslations)) {
994 $sql = $this->f->str_replace(array_keys($extraTranslations), array_values($extraTranslations), $sql);
995 }
996 $sql = $this->f->doNormalReplacements($sql);
997 $result = $this->queryAndGetResults($sql, $this->stagedQueryOptions());
998 $err = isset($result['last_error']) && is_string($result['last_error']) ? trim($result['last_error']) : '';
999 if ($err !== '') {
1000 $context = $this->describeStagedSqlFailure($relativePath, $extraTranslations);
1001 throw new \Exception('Staged SQL ' . $context . ' failed: ' . $err);
1002 }
1003 }
1004
1005 /**
1006 * Same as runStagedSqlFile but silently tolerates "Duplicate key name"
1007 * errors so an interrupted ALTER TABLE ADD INDEX can be safely re-run
1008 * on a request that resumes a prior partially-completed build. All
1009 * other errors are raised as usual.
1010 *
1011 * @param string $relativePath
1012 * @param array<string, string> $extraTranslations
1013 * @return void
1014 */
1015 private function runStagedSqlFileTolerantOfDuplicateKey(string $relativePath, array $extraTranslations): void {
1016 try {
1017 $this->runStagedSqlFile($relativePath, $extraTranslations);
1018 } catch (\Throwable $e) {
1019 $msg = $e->getMessage();
1020 if (stripos($msg, 'Duplicate key name') !== false
1021 || stripos($msg, 'errno: 1061') !== false) {
1022 // The index already exists from a prior partial run; the
1023 // expected resume-time state, not a failure. Log at debug so
1024 // a "why did this stage take 0ms" question has an answer.
1025 $this->logger->debugMessage(sprintf(
1026 '[staged] %s: index already exists, tolerated as resume.',
1027 $relativePath
1028 ));
1029 return;
1030 }
1031 throw $e;
1032 }
1033 }
1034
1035 /**
1036 * Render a short human-readable description of which file + which batch
1037 * bounds were running when an error fired. Used to enrich error
1038 * messages so the GUI notice lists the exact failing slice.
1039 *
1040 * @param string $relativePath
1041 * @param array<string, string> $extraTranslations
1042 * @return string
1043 */
1044 private function describeStagedSqlFailure(string $relativePath, array $extraTranslations): string {
1045 $parts = array($relativePath);
1046 if (isset($extraTranslations['{LO_BOUND}'])) {
1047 $parts[] = 'lo=' . $extraTranslations['{LO_BOUND}'];
1048 }
1049 if (isset($extraTranslations['{HI_BOUND}'])) {
1050 $parts[] = 'hi=' . $extraTranslations['{HI_BOUND}'];
1051 }
1052 if (isset($extraTranslations['{BATCH_SIZE}'])) {
1053 $parts[] = 'limit=' . $extraTranslations['{BATCH_SIZE}'];
1054 }
1055 return implode(' ', $parts);
1056 }
1057
1058 /**
1059 * Render a short human-readable summary of how far a resumable build has
1060 * progressed. Used in the admin notice and the throw message when a
1061 * request can't yet serve view_done because the build is still running
1062 * across requests.
1063 *
1064 * @return string e.g. "stage 2/11, 3000/12000 rows" or "not yet started".
1065 */
1066 private function describeBuildProgressForNotice(): string {
1067 $stage = $this->readProgressOption('current_stage', 0);
1068 if ($stage <= 0) {
1069 return 'not yet started';
1070 }
1071 $parts = array('stage ' . $stage . '/11');
1072 if ($stage < 2) {
1073 // S2 is the heaviest; surface buffer/redirect counts.
1074 $copied = $this->countViewBuildRows();
1075 $total = $this->countLiveRedirects();
1076 if ($total > 0) {
1077 $parts[] = $copied . '/' . $total . ' rows';
1078 }
1079 }
1080 return implode(', ', $parts);
1081 }
1082
1083 /**
1084 * @return array<string, mixed> Options for queryAndGetResults that
1085 * inherit the warmup pipeline's per-stage timeout when set.
1086 */
1087 private function stagedQueryOptions(): array {
1088 if ($this->stagedQueryTimeoutSeconds > 0) {
1089 return array('timeout' => $this->stagedQueryTimeoutSeconds);
1090 }
1091 return array();
1092 }
1093
1094 /** @return bool */
1095 private function viewDoneTableExists(): bool {
1096 return $this->stagedTableExists($this->viewDoneTableName());
1097 }
1098
1099 /**
1100 * Cheap "does view_done have at least one row" probe used by
1101 * viewDoneIsServeable() to make the post-invalidate stale-but-present
1102 * decision honest. Without this, viewDoneIsServeable() might report a
1103 * just-promoted-but-empty buffer as serveable; the admin would render
1104 * an empty redirects screen indefinitely with no rebuild scheduled.
1105 *
1106 * SELECT 1 ... LIMIT 1 is the cheapest existence query MySQL can do;
1107 * within a request the result is memoized inside viewDoneIsServeable()
1108 * so the probe fires once even on hot AJAX paths.
1109 *
1110 * @return bool
1111 */
1112 private function viewDoneHasRows(): bool {
1113 if (!$this->viewDoneTableExists()) {
1114 return false;
1115 }
1116 $sql = 'SELECT 1 FROM `' . $this->viewDoneTableName() . '` LIMIT 1';
1117 $result = $this->queryAndGetResults($sql, array('log_errors' => false));
1118 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
1119 return !empty($rows);
1120 }
1121
1122 /**
1123 * Option name for the floor timestamp on the data currently stored in
1124 * the view_done table. Distinct from viewDoneFreshnessOptionName():
1125 *
1126 * - viewDoneFreshnessOptionName() (built_at): cleared on invalidate.
1127 * "Last build that has not been invalidated." Drives the freshness
1128 * TTL gate that decides whether to schedule a background rebuild.
1129 *
1130 * - viewDoneDataBuiltAtOptionName() (data_built_at): preserved across
1131 * invalidate. "When was the snapshot currently on disk produced."
1132 * Drives the hard-stale notice and lets us answer "how old is the
1133 * data the admin is looking at" honestly even after invalidation.
1134 *
1135 * @return string
1136 */
1137 private function viewDoneDataBuiltAtOptionName(): string {
1138 return $this->getLowercasePrefix() . 'abj404_view_done_data_built_at';
1139 }
1140
1141 /**
1142 * Unix timestamp when the data currently in the view_done table was
1143 * produced. Survives every freshness-signal clear (admin mutation,
1144 * cron-fired rebuild, force-restart) so the read path can compute an
1145 * honest "data on disk is N hours old" age regardless of whether the
1146 * built_at marker has been reset.
1147 *
1148 * @return int
1149 */
1150 private function viewDoneDataBuiltAt(): int {
1151 if (!function_exists('get_option')) {
1152 return 0;
1153 }
1154 $built = get_option($this->viewDoneDataBuiltAtOptionName(), 0);
1155 return is_scalar($built) ? max(0, intval($built)) : 0;
1156 }
1157
1158 /**
1159 * Set a deduplicated admin notice when the data in view_done is older
1160 * than VIEW_DONE_HARD_STALE_NOTICE_AGE_SECONDS. Surfaced on the plugin's
1161 * own admin screen by abj404_show_view_build_cron_notices in
1162 * 404-solution.php; never sent via email or shown wp-admin-wide.
1163 *
1164 * Same 24h dedup TTL as the other view-build notices so the three
1165 * notice families share a consistent lifecycle.
1166 *
1167 * @param int $ageSeconds Current age of data on disk.
1168 * @return void
1169 */
1170 private function setViewDoneHardStaleNotice(int $ageSeconds): void {
1171 if (!function_exists('set_transient')) {
1172 return;
1173 }
1174 $key = 'abj404_view_done_hard_stale';
1175 if (function_exists('get_transient') && get_transient($key) !== false) {
1176 return; // dedup window still active
1177 }
1178 $hours = max(1, intval(floor($ageSeconds / 3600)));
1179 $template = $this->localizeOrDefaultViewBuildNotice(
1180 'The 404 Solution redirects table data is more than %d hours old. '
1181 . 'A background rebuild is scheduled but has not completed; the '
1182 . 'redirects screen is showing the most recent successful snapshot. '
1183 . 'Check WordPress cron health and the staged-build progress.'
1184 );
1185 $payload = array(
1186 'type' => 'view_done_hard_stale',
1187 'message' => sprintf($template, $hours),
1188 'timestamp' => time(),
1189 'error_string' => '',
1190 'age_hours' => $hours,
1191 );
1192 // allow-cache-empty: notice payload is intentional; error_string is empty by definition for stale-data state.
1193 set_transient($key, $payload, ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_DEGRADED_NOTICE_TTL_SECONDS);
1194 }
1195
1196 /**
1197 * Self-heal: clear the hard-stale notice when a successful build
1198 * completes and the data on disk is no longer stale. Called from
1199 * markViewDoneBuildCompleted() so the notice does not linger for the
1200 * full 24h dedup TTL after the build catches up.
1201 *
1202 * @return void
1203 */
1204 private function clearViewDoneHardStaleNotice(): void {
1205 if (function_exists('delete_transient')) {
1206 delete_transient('abj404_view_done_hard_stale');
1207 }
1208 }
1209
1210 /**
1211 * Read-path hook: when serving stale data from view_done, surface the
1212 * hard-stale notice if the data is older than the configured threshold.
1213 *
1214 * No-ops when data_built_at is missing (legacy installs that pre-date
1215 * the data-built-at signal) so a one-time migration does not generate
1216 * spurious 24h notices on the first read after upgrade. The next
1217 * successful build sets the signal and from then on the staleness
1218 * check is honest.
1219 *
1220 * @return void
1221 */
1222 private function maybeRaiseViewDoneHardStaleNotice(): void {
1223 $built = $this->viewDoneDataBuiltAt();
1224 if ($built <= 0) {
1225 return;
1226 }
1227 $age = time() - $built;
1228 if ($age >= ABJ_404_Solution_ViewBuildConfig::VIEW_DONE_HARD_STALE_NOTICE_AGE_SECONDS) {
1229 $this->setViewDoneHardStaleNotice($age);
1230 }
1231 }
1232
1233 /** @param string $tableName @return bool */
1234 private function stagedTableExists(string $tableName): bool {
1235 global $wpdb;
1236 if (!isset($wpdb) || !method_exists($wpdb, 'prepare')) {
1237 return false;
1238 }
1239 /** @var \wpdb $wpdb */
1240 // DAO-bypass-approved: prepare only; execution still routes through queryAndGetResults().
1241 $sql = $wpdb->prepare('SHOW TABLES LIKE %s', $tableName);
1242 if (!is_string($sql) || $sql === '') {
1243 return false;
1244 }
1245 $result = $this->queryAndGetResults($sql, array('log_errors' => false));
1246 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
1247 if (empty($rows)) {
1248 return false;
1249 }
1250 $first = $rows[0];
1251 $first = is_array($first) ? $first : array();
1252 $value = reset($first);
1253 $valueStr = is_scalar($value) ? (string)$value : '';
1254 return ($valueStr === $tableName);
1255 }
1256
1257 /** @return bool */
1258 private function viewDoneIsFresh(): bool {
1259 if (!function_exists('get_option')) {
1260 return false;
1261 }
1262 $built = get_option($this->viewDoneFreshnessOptionName(), 0);
1263 $builtAt = is_scalar($built) ? intval($built) : 0;
1264 if ($builtAt <= 0) {
1265 return false;
1266 }
1267 return (time() - $builtAt) < ABJ_404_Solution_ViewBuildConfig::VIEW_DONE_FRESHNESS_TTL_SECONDS;
1268 }
1269
1270 /**
1271 * Tiny helper so the staged-build notices read the same way as the
1272 * existing setPluginDbNotice() copy: call __() when WordPress is loaded,
1273 * otherwise return the raw English. Kept local to the helpers trait
1274 * (rather than DataAccess.php's private localizeOrDefault()) so the
1275 * sibling lock-and-cron trait can reach it via $this-> on the composing
1276 * class without exposing the private DataAccess method.
1277 *
1278 * @param string $text
1279 * @return string
1280 */
1281 private function localizeOrDefaultViewBuildNotice(string $text): string {
1282 if (function_exists('__')) {
1283 return __($text, '404-solution');
1284 }
1285 return $text;
1286 }
1287 }
1288