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

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

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