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

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

789 lines 35.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Admin-list view snapshot caching: a `wp_abj404_view_cache` table holds JSON
9 * payloads for the admin list/count views so a fast first paint is possible
10 * without waiting on the heavy aggregate queries. This trait owns the
11 * lifecycle: table DDL, refresh-lock dance (option-based, cooldown-bounded),
12 * snapshot read/write/wait, and stale-row cleanup. Constants
13 * (`VIEW_SNAPSHOT_*`) and the request-local "DDL ensured" flag remain on the
14 * using class because tests reset them via the public static accessor.
15 */
16 trait ABJ_404_Solution_DataAccess_ViewSnapshotCacheTrait {
17
18 /**
19 * Build a stable cache key for admin list data/count snapshots.
20 *
21 * The key embeds the current per-blog mutation watermark, so any source-
22 * data mutation (which already bumps the watermark via the centralized
23 * invalidateViewSnapshotCache / bumpMutationWatermark seam) implicitly
24 * invalidates every prior cache entry. Readers post-mutation generate a
25 * new key, miss the cache, and rebuild from the fresh view_done snapshot.
26 * Old entries become orphans and are reaped by the expires_at cleanup.
27 *
28 * Why this matters. The pre-watermark invalidation path manually issued
29 * `DELETE FROM wp_options WHERE option_name LIKE '_transient_abj404_view_%'`
30 * to drop the WP-transient mirror written alongside the table-backed
31 * cache. That DELETE assumes transients live in wp_options; integration
32 * tests that stub `set_transient` to a `$GLOBALS['test_transients']`
33 * registry diverge silently, the transient survives the invalidate, and
34 * the next read returns the pre-mutation snapshot. Version-keying makes
35 * the manual delete optional (it now only matters for disk-space
36 * reclamation, not correctness) and closes the test/production gap by
37 * construction.
38 *
39 * @param string $prefix
40 * @param string $sub
41 * @param array<string, mixed> $tableOptions
42 * @return string
43 */
44 private function getViewSnapshotCacheKey($prefix, $sub, $tableOptions) {
45 $cacheShape = array(
46 'sub' => (string)$sub,
47 'filter' => is_scalar($tableOptions['filter'] ?? 0) ? (int)($tableOptions['filter'] ?? 0) : 0,
48 'orderby' => is_scalar($tableOptions['orderby'] ?? 'url') ? (string)($tableOptions['orderby'] ?? 'url') : 'url',
49 'order' => is_scalar($tableOptions['order'] ?? 'ASC') ? (string)($tableOptions['order'] ?? 'ASC') : 'ASC',
50 'paged' => is_scalar($tableOptions['paged'] ?? 1) ? (int)($tableOptions['paged'] ?? 1) : 1,
51 'perpage' => is_scalar($tableOptions['perpage'] ?? ABJ404_OPTION_DEFAULT_PERPAGE) ? (int)($tableOptions['perpage'] ?? ABJ404_OPTION_DEFAULT_PERPAGE) : ABJ404_OPTION_DEFAULT_PERPAGE,
52 'filterText' => is_scalar($tableOptions['filterText'] ?? '') ? (string)($tableOptions['filterText'] ?? '') : '',
53 'score_range' => (function ($v) { return is_string($v) ? $v : 'all'; })($tableOptions['score_range'] ?? 'all'),
54 'blog' => function_exists('get_current_blog_id') ? (int)get_current_blog_id() : 1,
55 'mw' => $this->readMutationWatermarkForCacheKey(),
56 );
57 $encoded = function_exists('wp_json_encode') ? wp_json_encode($cacheShape) : json_encode($cacheShape);
58 return $prefix . '_' . md5((string)$encoded);
59 }
60
61 /**
62 * Read the current mutation watermark for the per-blog cache key, with
63 * a defensive fallback of 0 when the watermark primitive is unavailable
64 * for any reason: the class is not yet loaded (cold-bootstrap path
65 * before the autoloader resolved it), the global `$wpdb` does not yet
66 * expose the query / prepare / get_var triple the primitive needs
67 * (legacy unit-test mocks that only expose `query`), or the underlying
68 * MariaDB connection is mid-failure. The fallback collapses to a
69 * single shared "version 0" bucket; correctness still holds because:
70 *
71 * - In a healthy install, the first real mutation produces a
72 * non-zero watermark for every subsequent read, so cache keys
73 * diverge by version as designed.
74 * - In a degraded environment where the watermark can't be read, no
75 * watermark-bumping mutation can succeed either (the same wpdb is
76 * in use), so the cache is implicitly version-stable.
77 *
78 * Throwable catch is intentional: this primitive sits on the read hot
79 * path and must never propagate a watermark read failure as a hard
80 * fault into `getViewSnapshotCacheKey()`. A swallowed read produces
81 * a less-precise cache key, not a broken read.
82 *
83 * @return int
84 */
85 private function readMutationWatermarkForCacheKey(): int {
86 if (!class_exists('ABJ_404_Solution_MutationWatermark')) {
87 return 0;
88 }
89 try {
90 return ABJ_404_Solution_MutationWatermark::current();
91 // allow-silent-catch: degraded wpdb (e.g. unit-test mocks lacking prepare()) falls back to "version 0" cache bucket; never propagate a watermark-read fault into the read hot path
92 } catch (\Throwable $e) {
93 return 0;
94 }
95 }
96
97 /** @return void */
98 private function ensureViewSnapshotTableExists(): void {
99 if (self::$viewSnapshotTableEnsured) {
100 return;
101 }
102 self::$viewSnapshotTableEnsured = true;
103 $sqlFile = __DIR__ . '/sql/createViewCacheTable.sql';
104 $create = ABJ_404_Solution_Functions::readFileContents($sqlFile);
105 if (is_string($create) && trim($create) !== '') {
106 $this->queryAndGetResults($create, array('log_errors' => false));
107 }
108 }
109
110 /** @param string $cacheKey @return string */
111 private function getViewSnapshotLockOptionName(string $cacheKey): string {
112 return $this->getLowercasePrefix() . 'abj404_view_cache_lock_' . md5((string)$cacheKey);
113 }
114
115 /** @return string */
116 private function getViewSnapshotWarmupGlobalLockKey(): string {
117 return 'abj404_view_table_warmup_global';
118 }
119
120 /** @return bool */
121 protected function acquireViewSnapshotWarmupGlobalLock(): bool {
122 return $this->acquireViewSnapshotRefreshLock($this->getViewSnapshotWarmupGlobalLockKey());
123 }
124
125 /** @return void */
126 protected function releaseViewSnapshotWarmupGlobalLock(): void {
127 $this->releaseViewSnapshotRefreshLock($this->getViewSnapshotWarmupGlobalLockKey());
128 }
129
130 /** @param string $cacheKey @return string */
131 private function getViewWarmupStateOptionName(string $cacheKey): string {
132 return $this->getLowercasePrefix() . 'abj404_view_warmup_' . md5((string)$cacheKey);
133 }
134
135 /**
136 * @param array<string, mixed> $tableOptions
137 * @return bool
138 */
139 private function canUseViewTableSnapshotCache(array $tableOptions): bool {
140 if (!empty($tableOptions['_abj404_force_view_rebuild'])) {
141 return false;
142 }
143 $rawOrderBy = $tableOptions['orderby'] ?? '';
144 $orderBy = strtolower(is_string($rawOrderBy) ? $rawOrderBy : '');
145 $isLogsMaintenanceSort = ($orderBy === 'logshits' || $orderBy === 'last_used');
146 $rawPerpage = $tableOptions['perpage'] ?? 0;
147 return absint(is_scalar($rawPerpage) ? $rawPerpage : 0) <= 200 && !$isLogsMaintenanceSort;
148 }
149
150 /**
151 * @param string $sub
152 * @param array<string, mixed> $tableOptions
153 * @return string
154 */
155 private function getViewTableWarmupShapeKey(string $sub, array $tableOptions): string {
156 return $this->getViewSnapshotCacheKey('abj404_view_table', $sub, $tableOptions);
157 }
158
159 /**
160 * @param mixed $state
161 * @return array<string, mixed>
162 */
163 private function normalizeViewWarmupState($state): array {
164 $default = array(
165 'status' => 'idle',
166 'stage' => 'rows',
167 'stage_started_at' => 0,
168 'stage_completed_at' => 0,
169 'attempts_by_stage' => array('rows' => 0, 'count' => 0),
170 'timings_by_stage' => array(
171 'rows' => array('last_ms' => 0, 'max_ms' => 0, 'last_completed_at' => 0, 'last_error' => ''),
172 'count' => array('last_ms' => 0, 'max_ms' => 0, 'last_completed_at' => 0, 'last_error' => ''),
173 ),
174 'query_label' => 'getRedirectsForView',
175 'last_error' => '',
176 'logged_stale_by_stage' => array(),
177 'build_progress_at_stage_start' => array(),
178 );
179 if (!is_array($state)) {
180 return $default;
181 }
182 /** @var array<string, mixed> $out */
183 $out = array_merge($default, $state);
184 $status = is_string($out['status'] ?? null) ? $out['status'] : 'idle';
185 if (!in_array($status, array('idle', 'running', 'ready', 'blocked', 'error'), true)) {
186 $out['status'] = 'idle';
187 } else {
188 $out['status'] = $status;
189 }
190 $stage = is_string($out['stage'] ?? null) ? $out['stage'] : 'rows';
191 if (!in_array($stage, array('rows', 'count'), true)) {
192 $out['stage'] = 'rows';
193 } else {
194 $out['stage'] = $stage;
195 }
196 $stageStartedAt = $out['stage_started_at'] ?? 0;
197 $stageCompletedAt = $out['stage_completed_at'] ?? 0;
198 $out['stage_started_at'] = is_scalar($stageStartedAt) ? intval($stageStartedAt) : 0;
199 $out['stage_completed_at'] = is_scalar($stageCompletedAt) ? intval($stageCompletedAt) : 0;
200
201 $attempts = is_array($out['attempts_by_stage']) ? $out['attempts_by_stage'] : array();
202 $attemptsRows = $attempts['rows'] ?? 0;
203 $attemptsCount = $attempts['count'] ?? 0;
204 $out['attempts_by_stage'] = array(
205 'rows' => is_scalar($attemptsRows) ? intval($attemptsRows) : 0,
206 'count' => is_scalar($attemptsCount) ? intval($attemptsCount) : 0,
207 );
208
209 $timings = is_array($out['timings_by_stage']) ? $out['timings_by_stage'] : array();
210 $out['timings_by_stage'] = array(
211 'rows' => $this->normalizeStageTiming($timings['rows'] ?? null),
212 'count' => $this->normalizeStageTiming($timings['count'] ?? null),
213 );
214
215 $out['query_label'] = is_string($out['query_label'] ?? null) ? $out['query_label'] : $this->getViewWarmupStageQueryLabel((string)$out['stage']);
216 $out['last_error'] = is_string($out['last_error'] ?? null) ? $out['last_error'] : '';
217 $out['logged_stale_by_stage'] = is_array($out['logged_stale_by_stage']) ? $out['logged_stale_by_stage'] : array();
218 $out['build_progress_at_stage_start'] = is_array($out['build_progress_at_stage_start'] ?? null)
219 ? $out['build_progress_at_stage_start'] : array();
220 return $out;
221 }
222
223 /**
224 * @param mixed $timing
225 * @return array<string, mixed>
226 */
227 private function normalizeStageTiming($timing): array {
228 $default = array('last_ms' => 0, 'max_ms' => 0, 'last_completed_at' => 0, 'last_error' => '');
229 if (!is_array($timing)) {
230 return $default;
231 }
232 $lastMs = $timing['last_ms'] ?? 0;
233 $maxMs = $timing['max_ms'] ?? 0;
234 $lastCompletedAt = $timing['last_completed_at'] ?? 0;
235 $lastError = $timing['last_error'] ?? '';
236 return array(
237 'last_ms' => is_scalar($lastMs) ? intval($lastMs) : 0,
238 'max_ms' => is_scalar($maxMs) ? intval($maxMs) : 0,
239 'last_completed_at' => is_scalar($lastCompletedAt) ? intval($lastCompletedAt) : 0,
240 'last_error' => is_string($lastError) ? $lastError : '',
241 );
242 }
243
244 /** @param string $stage @return string */
245 private function getViewWarmupStageQueryLabel(string $stage): string {
246 return $stage === 'count' ? 'getRedirectsForViewCount' : 'getRedirectsForView';
247 }
248
249 /** @param string $stage @return int */
250 private function getViewWarmupStageNumber(string $stage): int {
251 return $stage === 'count' ? 2 : 1;
252 }
253
254 /** @return array<string, int> */
255 private function getViewBuildProgressFingerprint(): array {
256 return array(
257 'started_at' => $this->readProgressOption('started_at', 0),
258 'current_stage' => $this->readProgressOption('current_stage', 0),
259 'last_started_stage' => $this->readProgressOption('last_started_stage', 0),
260 'last_completed_stage' => $this->readProgressOption('last_completed_stage', 0),
261 's2_high_water' => $this->readProgressOption('s2_high_water', 0),
262 's4_high_water' => $this->readProgressOption('s4_high_water', 0),
263 's5_high_water' => $this->readProgressOption('s5_high_water', 0),
264 );
265 }
266
267 /**
268 * @param mixed $baseline
269 * @param array<string, int>|null $current
270 * @return bool
271 */
272 private function viewBuildProgressAdvancedSince($baseline, ?array $current = null): bool {
273 if (!is_array($baseline) || empty($baseline)) {
274 return false;
275 }
276 $current = $current ?? $this->getViewBuildProgressFingerprint();
277 foreach (array('current_stage', 's2_high_water', 's4_high_water', 's5_high_water') as $key) {
278 $before = is_scalar($baseline[$key] ?? null) ? intval($baseline[$key]) : 0;
279 $after = is_scalar($current[$key] ?? null) ? intval($current[$key]) : 0;
280 if ($after > $before) {
281 return true;
282 }
283 }
284 return false;
285 }
286
287 /**
288 * @param array<string, mixed> $state
289 * @param string $stage
290 * @param array<string, int>|null $currentProgress
291 * @return bool
292 */
293 private function forgiveWarmupAttemptIfBuildProgressed(array &$state, string $stage, ?array $currentProgress = null): bool {
294 if (!$this->viewBuildProgressAdvancedSince($state['build_progress_at_stage_start'] ?? array(), $currentProgress)) {
295 return false;
296 }
297 $attempts = is_array($state['attempts_by_stage'] ?? null) ? $state['attempts_by_stage'] : array('rows' => 0, 'count' => 0);
298 $rawAttempt = $attempts[$stage] ?? 0;
299 $attempts[$stage] = max(0, (is_scalar($rawAttempt) ? intval($rawAttempt) : 0) - 1);
300 $state['attempts_by_stage'] = $attempts;
301 $state['build_progress_at_stage_start'] = is_array($currentProgress)
302 ? $currentProgress : $this->getViewBuildProgressFingerprint();
303 return true;
304 }
305
306 /**
307 * @param string $optionName
308 * @return array<string, mixed>
309 */
310 private function getViewWarmupState(string $optionName): array {
311 if (!function_exists('get_option')) {
312 return $this->normalizeViewWarmupState(null);
313 }
314 return $this->normalizeViewWarmupState(get_option($optionName, array()));
315 }
316
317 /**
318 * @param string $optionName
319 * @param array<string, mixed> $state
320 * @return void
321 */
322 private function setViewWarmupState(string $optionName, array $state): void {
323 if (function_exists('update_option')) {
324 update_option($optionName, $state, false);
325 } else if (function_exists('add_option')) {
326 add_option($optionName, $state, '', false);
327 }
328 }
329
330 /**
331 * Warm exactly one admin table snapshot stage, then return progress.
332 *
333 * @param string $sub
334 * @param array<string, mixed> $tableOptions
335 * @return array<string, mixed>
336 */
337 function warmViewTableSnapshotStage(string $sub, array $tableOptions): array {
338 if (!$this->canUseViewTableSnapshotCache($tableOptions)) {
339 return array(
340 'status' => 'ready',
341 'ready' => true,
342 'uncached' => true,
343 'stage' => 'rows',
344 'stageNumber' => 1,
345 'queryLabel' => 'getRedirectsForView',
346 'message' => 'This table shape is not snapshot-cacheable.',
347 );
348 }
349
350 $shapeKey = $this->getViewTableWarmupShapeKey($sub, $tableOptions);
351 $optionName = $this->getViewWarmupStateOptionName($shapeKey);
352 $state = $this->getViewWarmupState($optionName);
353 $now = time();
354
355 if ($this->viewTableSnapshotAvailable($sub, $tableOptions)) {
356 $state['status'] = 'ready';
357 $state['stage'] = 'count';
358 $state['query_label'] = 'getRedirectsForViewCount';
359 $state['stage_completed_at'] = $now;
360 $state['last_error'] = '';
361 $this->setViewWarmupState($optionName, $state);
362 return $this->formatViewWarmupResponse($state, true);
363 }
364
365 if ($this->viewRowsSnapshotAvailable($sub, $tableOptions)) {
366 $state['stage'] = 'count';
367 $state['query_label'] = 'getRedirectsForViewCount';
368 } else {
369 $state['stage'] = 'rows';
370 $state['query_label'] = 'getRedirectsForView';
371 }
372
373 $stage = (string)$state['stage'];
374 $attempts = is_array($state['attempts_by_stage']) ? $state['attempts_by_stage'] : array('rows' => 0, 'count' => 0);
375 $attemptCountRaw = $attempts[$stage] ?? 0;
376 $attemptCount = is_scalar($attemptCountRaw) ? intval($attemptCountRaw) : 0;
377
378 if ($state['status'] === 'running') {
379 $stageStartedAt = $state['stage_started_at'] ?? 0;
380 $elapsed = $now - (is_scalar($stageStartedAt) ? intval($stageStartedAt) : 0);
381 if ($elapsed <= self::VIEW_SNAPSHOT_WARMUP_STALE_SECONDS) {
382 return $this->formatViewWarmupResponse($state, false);
383 }
384 $currentBuildProgress = $this->getViewBuildProgressFingerprint();
385 if ($this->forgiveWarmupAttemptIfBuildProgressed($state, $stage, $currentBuildProgress)) {
386 $attempts = is_array($state['attempts_by_stage']) ? $state['attempts_by_stage'] : array('rows' => 0, 'count' => 0);
387 $attemptCountRaw = $attempts[$stage] ?? 0;
388 $attemptCount = is_scalar($attemptCountRaw) ? intval($attemptCountRaw) : 0;
389 }
390 if ($attemptCount >= self::VIEW_SNAPSHOT_WARMUP_MAX_ATTEMPTS) {
391 $state['status'] = 'blocked';
392 $previousLastError = $state['last_error'] ?? '';
393 $previousError = is_string($previousLastError) ? trim($previousLastError) : '';
394 $state['last_error'] = 'Previous warmup stage was killed or stalled too many times.'
395 . ($this->isViewWarmupErrorDiagnostic($previousError) ? ' Previous error: ' . $previousError : '');
396 $this->logViewWarmupFailure($sub, $tableOptions, $state);
397 $this->setViewWarmupState($optionName, $state);
398 return $this->formatViewWarmupResponse($state, false);
399 }
400 $loggedKey = $stage . ':' . (is_scalar($stageStartedAt) ? intval($stageStartedAt) : 0);
401 $loggedStaleByStage = is_array($state['logged_stale_by_stage'] ?? null) ? $state['logged_stale_by_stage'] : array();
402 if (empty($loggedStaleByStage[$loggedKey])) {
403 $this->logStaleViewWarmupStage($sub, $tableOptions, $state, $elapsed, $attemptCount);
404 $loggedStaleByStage[$loggedKey] = 1;
405 $state['logged_stale_by_stage'] = $loggedStaleByStage;
406 }
407 }
408
409 if ($attemptCount >= self::VIEW_SNAPSHOT_WARMUP_MAX_ATTEMPTS) {
410 $previousLastError = $state['last_error'] ?? '';
411 $previousError = is_string($previousLastError) ? trim($previousLastError) : '';
412 if (!$this->isViewWarmupErrorDiagnostic($previousError)) {
413 $attempts[$stage] = self::VIEW_SNAPSHOT_WARMUP_MAX_ATTEMPTS - 1;
414 $state['attempts_by_stage'] = $attempts;
415 $attemptCount = is_scalar($attempts[$stage] ?? 0) ? intval($attempts[$stage]) : 0;
416 }
417 }
418
419 if ($attemptCount >= self::VIEW_SNAPSHOT_WARMUP_MAX_ATTEMPTS) {
420 $state['status'] = 'blocked';
421 $state['last_error'] = 'Warmup stage reached the retry limit.'
422 . ($this->isViewWarmupErrorDiagnostic($previousError) ? ' Previous error: ' . $previousError : '');
423 $this->logViewWarmupFailure($sub, $tableOptions, $state);
424 $this->setViewWarmupState($optionName, $state);
425 return $this->formatViewWarmupResponse($state, false);
426 }
427
428 if (!$this->acquireViewSnapshotWarmupGlobalLock()) {
429 $state['status'] = 'running';
430 $state['last_error'] = 'Another table cache warmup is already running for this site.';
431 return $this->formatViewWarmupResponse($state, false, array(
432 'locked' => true,
433 'lockScope' => 'site',
434 'retryAfterMs' => 2500,
435 ));
436 }
437
438 $attempts[$stage] = $attemptCount + 1;
439 $state['status'] = 'running';
440 $state['stage_started_at'] = $now;
441 $state['stage_completed_at'] = 0;
442 $state['attempts_by_stage'] = $attempts;
443 $state['query_label'] = $this->getViewWarmupStageQueryLabel($stage);
444 $state['last_error'] = '';
445 $state['build_progress_at_stage_start'] = $this->getViewBuildProgressFingerprint();
446 $this->setViewWarmupState($optionName, $state);
447
448 $stageOptions = $tableOptions;
449 $stageOptions['_abj404_query_timeout'] = self::VIEW_SNAPSHOT_WARMUP_STAGE_TIMEOUT_SECONDS;
450 $stageOptions['_abj404_throw_on_view_query_error'] = true;
451
452 $startMs = microtime(true);
453 try {
454 if ($stage === 'rows') {
455 $this->getRedirectsForView($sub, $stageOptions);
456 if (!$this->viewRowsSnapshotAvailable($sub, $tableOptions)) {
457 throw new \Exception('Warmup rows stage completed but the row snapshot was not available afterward.');
458 }
459 $state['status'] = 'idle';
460 $state['stage'] = 'count';
461 $state['query_label'] = 'getRedirectsForViewCount';
462 } else {
463 $this->getRedirectsForViewCount($sub, $stageOptions);
464 if (!$this->viewTableSnapshotAvailable($sub, $tableOptions)) {
465 throw new \Exception('Warmup count stage completed but the full table snapshot was not available afterward.');
466 }
467 $state['status'] = 'ready';
468 $state['stage'] = 'count';
469 $state['query_label'] = 'getRedirectsForViewCount';
470 }
471 $elapsedMs = (int)round((microtime(true) - $startMs) * 1000);
472 $state['stage_completed_at'] = time();
473 $state['last_error'] = '';
474
475 $timingsByStage = is_array($state['timings_by_stage'] ?? null) ? $state['timings_by_stage'] : array();
476 $timings = $this->normalizeStageTiming($timingsByStage[$stage] ?? null);
477 $timings['last_ms'] = $elapsedMs;
478 $timings['max_ms'] = max($timings['max_ms'], $elapsedMs);
479 $timings['last_completed_at'] = $state['stage_completed_at'];
480 $timings['last_error'] = '';
481 $timingsByStage[$stage] = $timings;
482 $state['timings_by_stage'] = $timingsByStage;
483
484 $this->logger->debugMessage(sprintf(
485 "[warmup] shape=%s stage=%s ms=%d attempts=%d error=",
486 substr($shapeKey, 0, 8),
487 $stage,
488 $elapsedMs,
489 $attemptCount + 1
490 ));
491
492 $this->setViewWarmupState($optionName, $state);
493 return $this->formatViewWarmupResponse($state, $state['status'] === 'ready');
494 } catch (Throwable $e) {
495 $elapsedMs = (int)round((microtime(true) - $startMs) * 1000);
496 $errorMessage = $e->getMessage();
497 $state['last_error'] = $errorMessage;
498 $state['stage_completed_at'] = time();
499 $currentAttempts = $attempts[$stage] ?? 0;
500 if ($this->forgiveWarmupAttemptIfBuildProgressed($state, $stage)) {
501 $attempts = is_array($state['attempts_by_stage']) ? $state['attempts_by_stage'] : $attempts;
502 $rawAttemptCount = $attempts[$stage] ?? 0;
503 $currentAttempts = is_scalar($rawAttemptCount) ? intval($rawAttemptCount) : 0;
504 }
505 $state['status'] = ($currentAttempts >= self::VIEW_SNAPSHOT_WARMUP_MAX_ATTEMPTS) ? 'blocked' : 'idle';
506
507 $timingsByStage = is_array($state['timings_by_stage'] ?? null) ? $state['timings_by_stage'] : array();
508 $timings = $this->normalizeStageTiming($timingsByStage[$stage] ?? null);
509 $timings['last_error'] = $errorMessage;
510 $timingsByStage[$stage] = $timings;
511 $state['timings_by_stage'] = $timingsByStage;
512
513 $this->logger->debugMessage(sprintf(
514 "[warmup] shape=%s stage=%s ms=%d attempts=%d error=%s",
515 substr($shapeKey, 0, 8),
516 $stage,
517 $elapsedMs,
518 $attemptCount + 1,
519 $errorMessage
520 ));
521
522 $this->logViewWarmupFailure($sub, $tableOptions, $state);
523 $this->setViewWarmupState($optionName, $state);
524 return $this->formatViewWarmupResponse($state, false);
525 } finally {
526 $this->releaseViewSnapshotWarmupGlobalLock();
527 }
528 }
529
530 /**
531 * @param array<string, mixed> $state
532 * @param bool $ready
533 * @param array<string, mixed> $extra
534 * @return array<string, mixed>
535 */
536 private function formatViewWarmupResponse(array $state, bool $ready, array $extra = array()): array {
537 $stageValue = $state['stage'] ?? 'rows';
538 $stage = is_string($stageValue) ? $stageValue : 'rows';
539 $statusValue = $state['status'] ?? 'idle';
540 $status = is_string($statusValue) ? $statusValue : 'idle';
541 $stageStartedAt = $state['stage_started_at'] ?? 0;
542 $stageCompletedAt = $state['stage_completed_at'] ?? 0;
543 $lastError = $state['last_error'] ?? '';
544 $response = array(
545 'status' => $status,
546 'ready' => $ready || $status === 'ready',
547 'stage' => $stage,
548 'stageNumber' => $this->getViewWarmupStageNumber($stage),
549 'queryLabel' => $this->getViewWarmupStageQueryLabel($stage),
550 'stageStartedAt' => is_scalar($stageStartedAt) ? intval($stageStartedAt) : 0,
551 'stageCompletedAt' => is_scalar($stageCompletedAt) ? intval($stageCompletedAt) : 0,
552 'attemptsByStage' => is_array($state['attempts_by_stage'] ?? null) ? $state['attempts_by_stage'] : array(),
553 'timingsByStage' => is_array($state['timings_by_stage'] ?? null) ? $state['timings_by_stage'] : array(),
554 'lastError' => is_string($lastError) ? $lastError : '',
555 );
556 foreach ($extra as $key => $value) {
557 if (is_string($key)) {
558 $response[$key] = $value;
559 }
560 }
561 return $response;
562 }
563
564 /**
565 * @param string $sub
566 * @param array<string, mixed> $tableOptions
567 * @param array<string, mixed> $state
568 * @param int $elapsed
569 * @param int $attemptCount
570 * @return void
571 */
572 private function logStaleViewWarmupStage(string $sub, array $tableOptions, array $state, int $elapsed, int $attemptCount): void {
573 $details = array(
574 'stage' => is_string($state['stage'] ?? null) ? $state['stage'] : '',
575 'query_label' => is_string($state['query_label'] ?? null) ? $state['query_label'] : '',
576 'elapsed_seconds' => $elapsed,
577 'subpage' => $sub,
578 'attempt_count' => $attemptCount,
579 'table_shape' => array(
580 'filter' => $tableOptions['filter'] ?? null,
581 'orderby' => $tableOptions['orderby'] ?? null,
582 'order' => $tableOptions['order'] ?? null,
583 'paged' => $tableOptions['paged'] ?? null,
584 'perpage' => $tableOptions['perpage'] ?? null,
585 'filterText_length' => is_string($tableOptions['filterText'] ?? null) ? strlen($tableOptions['filterText']) : 0,
586 'score_range' => $tableOptions['score_range'] ?? null,
587 ),
588 );
589 $message = 'Table cache warmup stage appears stalled: ' . json_encode($details);
590 $this->logger->warn($message);
591 }
592
593 /**
594 * @param string $sub
595 * @param array<string, mixed> $tableOptions
596 * @param array<string, mixed> $state
597 * @return void
598 */
599 private function logViewWarmupFailure(string $sub, array $tableOptions, array $state): void {
600 $details = array(
601 'status' => is_string($state['status'] ?? null) ? $state['status'] : '',
602 'stage' => is_string($state['stage'] ?? null) ? $state['stage'] : '',
603 'stage_number' => $this->getViewWarmupStageNumber(is_string($state['stage'] ?? null) ? $state['stage'] : 'rows'),
604 'query_label' => is_string($state['query_label'] ?? null) ? $state['query_label'] : '',
605 'last_error' => is_string($state['last_error'] ?? null) ? $state['last_error'] : '',
606 'subpage' => $sub,
607 'attempts_by_stage' => is_array($state['attempts_by_stage'] ?? null) ? $state['attempts_by_stage'] : array(),
608 'table_shape' => array(
609 'filter' => $tableOptions['filter'] ?? null,
610 'orderby' => $tableOptions['orderby'] ?? null,
611 'order' => $tableOptions['order'] ?? null,
612 'paged' => $tableOptions['paged'] ?? null,
613 'perpage' => $tableOptions['perpage'] ?? null,
614 'filterText_length' => is_string($tableOptions['filterText'] ?? null) ? strlen($tableOptions['filterText']) : 0,
615 'score_range' => $tableOptions['score_range'] ?? null,
616 ),
617 );
618 $message = 'Table cache warmup failed: ' . json_encode($details);
619 $this->logger->errorMessage($message);
620 }
621
622 /** @param string $lastError @return bool */
623 private function isViewWarmupErrorDiagnostic(string $lastError): bool {
624 if ($lastError === '') {
625 return false;
626 }
627 return $lastError !== 'Warmup stage reached the retry limit.'
628 && $lastError !== 'Previous warmup stage was killed or stalled too many times.';
629 }
630
631 /** @param string $cacheKey @return bool */
632 private function isViewSnapshotRefreshLocked(string $cacheKey): bool {
633 if (!function_exists('get_option')) {
634 return false;
635 }
636 $lockKey = $this->getViewSnapshotLockOptionName($cacheKey);
637 $lockValue = get_option($lockKey, false);
638 if ($lockValue === false || $lockValue === '' || $lockValue === null) {
639 return false;
640 }
641 $lockTs = is_numeric($lockValue) ? (int)$lockValue : 0;
642 if ($lockTs > 0 && (time() - $lockTs) > self::VIEW_SNAPSHOT_REFRESH_COOLDOWN_SECONDS) {
643 if (function_exists('delete_option')) {
644 delete_option($lockKey);
645 }
646 return false;
647 }
648 return true;
649 }
650
651 /** @param string $cacheKey @return bool */
652 private function acquireViewSnapshotRefreshLock(string $cacheKey): bool {
653 if (!function_exists('add_option')) {
654 return true;
655 }
656 if ($this->isViewSnapshotRefreshLocked($cacheKey)) {
657 return false;
658 }
659 $lockKey = $this->getViewSnapshotLockOptionName($cacheKey);
660 return (bool)add_option($lockKey, time(), '', false);
661 }
662
663 /** @param string $cacheKey @return void */
664 private function releaseViewSnapshotRefreshLock(string $cacheKey): void {
665 if (function_exists('delete_option')) {
666 delete_option($this->getViewSnapshotLockOptionName($cacheKey));
667 }
668 }
669
670 /**
671 * @param mixed $payload
672 * @return array<string, mixed>|null
673 */
674 private function decodeSnapshotPayload($payload) {
675 if (!is_string($payload) || $payload === '') {
676 return null;
677 }
678 $decoded = json_decode($payload, true);
679 return is_array($decoded) ? $decoded : null;
680 }
681
682 /**
683 * @param string $cacheKey
684 * @param bool $allowExpired
685 * @param bool $respectCooldown
686 * @return array<string, mixed>|null
687 */
688 private function getViewRowsSnapshotFromTable(string $cacheKey, bool $allowExpired = false, bool $respectCooldown = false) {
689 $this->ensureViewSnapshotTableExists();
690 $query = "SELECT payload, refreshed_at, expires_at
691 FROM {wp_abj404_view_cache}
692 WHERE cache_key = %s LIMIT 1";
693 $result = $this->queryAndGetResults($query, array('query_params' => array($cacheKey), 'log_errors' => true));
694 $resultRows = $result['rows'] ?? array();
695 if (!is_array($resultRows) || empty($resultRows) || !is_array($resultRows[0])) {
696 return null;
697 }
698 $row = $resultRows[0];
699 $expiresAtRaw = $row['expires_at'] ?? 0;
700 $refreshedAtRaw = $row['refreshed_at'] ?? 0;
701 $expiresAt = is_scalar($expiresAtRaw) ? intval($expiresAtRaw) : 0;
702 $refreshedAt = is_scalar($refreshedAtRaw) ? intval($refreshedAtRaw) : 0;
703 $now = time();
704 $isFresh = ($expiresAt > $now);
705 $recentEnough = ($refreshedAt > 0 && ($now - $refreshedAt) <= self::VIEW_SNAPSHOT_REFRESH_COOLDOWN_SECONDS);
706 if (!$allowExpired && !$isFresh) {
707 return null;
708 }
709 if ($respectCooldown && !$isFresh && !$recentEnough) {
710 return null;
711 }
712 $payload = $row['payload'] ?? '';
713 return $this->decodeSnapshotPayload(is_scalar($payload) ? (string)$payload : '');
714 }
715
716 /**
717 * @param string $cacheKey
718 * @param string $sub
719 * @param mixed $rows
720 * @param int $ttlSeconds
721 * @return void
722 */
723 private function setViewRowsSnapshotToTable(string $cacheKey, string $sub, $rows, int $ttlSeconds): void {
724 if (!is_array($rows)) {
725 return;
726 }
727 $this->ensureViewSnapshotTableExists();
728 $encoded = function_exists('wp_json_encode') ? wp_json_encode($rows) : json_encode($rows);
729 if (!is_string($encoded)) {
730 return;
731 }
732 $bytes = strlen($encoded);
733 if ($bytes > self::VIEW_SNAPSHOT_MAX_PAYLOAD_BYTES) {
734 return;
735 }
736 $now = time();
737 $expiresAt = $now + max(1, intval($ttlSeconds));
738 $query = "INSERT INTO {wp_abj404_view_cache}
739 (cache_key, subpage, payload, payload_bytes, refreshed_at, expires_at, updated_at)
740 VALUES (%s, %s, %s, %d, %d, %d, %d)
741 ON DUPLICATE KEY UPDATE
742 subpage = VALUES(subpage),
743 payload = VALUES(payload),
744 payload_bytes = VALUES(payload_bytes),
745 refreshed_at = VALUES(refreshed_at),
746 expires_at = VALUES(expires_at),
747 updated_at = VALUES(updated_at)";
748 $this->queryAndGetResults($query, array(
749 'query_params' => array($cacheKey, (string)$sub, $encoded, $bytes, $now, $expiresAt, $now),
750 'log_errors' => false,
751 ));
752 $this->cleanupExpiredViewSnapshotRowsIfNeeded();
753 }
754
755 /**
756 * @param string $cacheKey
757 * @param int $timeoutMs
758 * @return array<string, mixed>|null
759 */
760 private function waitForViewRowsSnapshotFromTable(string $cacheKey, int $timeoutMs = 4000) {
761 $deadline = microtime(true) + (max(100, intval($timeoutMs)) / 1000);
762 while (microtime(true) < $deadline) {
763 $rows = $this->getViewRowsSnapshotFromTable($cacheKey, false, false);
764 if (is_array($rows)) {
765 return $rows;
766 }
767 usleep(100000);
768 }
769 return null;
770 }
771
772 /** @return void */
773 private function cleanupExpiredViewSnapshotRowsIfNeeded(): void {
774 if (!function_exists('get_transient') || !function_exists('set_transient')) {
775 return;
776 }
777 $marker = get_transient('abj404_view_cache_cleanup_marker');
778 if ($marker !== false) {
779 return;
780 }
781 set_transient('abj404_view_cache_cleanup_marker', time(), 1800);
782 $query = "DELETE FROM {wp_abj404_view_cache} WHERE expires_at < %d";
783 $this->queryAndGetResults($query, array(
784 'query_params' => array(time() - self::VIEW_SNAPSHOT_REFRESH_COOLDOWN_SECONDS),
785 'log_errors' => false,
786 ));
787 }
788 }
789