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

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

803 lines 36.2 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 interface ABJ_404_Solution_ViewSnapshotCacheHostInterface {
8 /** @param string $sub @param array<string, mixed> $tableOptions @return bool */
9 public function viewTableSnapshotAvailable($sub, array $tableOptions): bool;
10 /** @param string $sub @param array<string, mixed> $tableOptions @return bool */
11 public function viewRowsSnapshotAvailable($sub, array $tableOptions): bool;
12 /** @param string $sub @param array<string, mixed> $tableOptions @return array<int|string, mixed> */
13 public function getRedirectsForView($sub, $tableOptions);
14 /** @param string $sub @param array<string, mixed> $tableOptions @return int */
15 public function getRedirectsForViewCount(string $sub, array $tableOptions): int;
16 }
17
18 class ABJ_404_Solution_ViewSnapshotCache {
19
20 /** @var ABJ_404_Solution_DatabaseCore */
21 private $dbCore;
22
23 /** @var ABJ_404_Solution_Logging */
24 private $logger;
25
26 /** @var ABJ_404_Solution_ViewSnapshotCacheHostInterface|null */
27 private $host;
28
29 /** @var ABJ_404_Solution_ViewBuildOrchestratorInterface|null */
30 private $viewBuildOrchestrator;
31
32 /** @var bool */
33 private static $viewSnapshotTableEnsured = false;
34
35 /**
36 * @param ABJ_404_Solution_DatabaseCore $dbCore
37 * @param ABJ_404_Solution_Logging $logger
38 */
39 public function __construct(
40 ABJ_404_Solution_DatabaseCore $dbCore,
41 $logger
42 ) {
43 $this->dbCore = $dbCore;
44 $this->logger = $logger;
45 }
46
47 /**
48 * @param ABJ_404_Solution_ViewSnapshotCacheHostInterface $host
49 * @return void
50 */
51 public function setHost(ABJ_404_Solution_ViewSnapshotCacheHostInterface $host): void {
52 $this->host = $host;
53 }
54
55 /**
56 * @param ABJ_404_Solution_ViewBuildOrchestratorInterface $viewBuildOrchestrator
57 * @return void
58 */
59 public function setViewBuildOrchestrator(ABJ_404_Solution_ViewBuildOrchestratorInterface $viewBuildOrchestrator): void {
60 $this->viewBuildOrchestrator = $viewBuildOrchestrator;
61 }
62
63 /** @return ABJ_404_Solution_ViewBuildOrchestratorInterface */
64 private function requireViewBuildOrchestrator(): ABJ_404_Solution_ViewBuildOrchestratorInterface {
65 if ($this->viewBuildOrchestrator === null) {
66 throw new \RuntimeException('ViewSnapshotCache requires ViewBuildOrchestrator (call setViewBuildOrchestrator first)'); // allow-raw-error: assertion, should never reach user
67 }
68 return $this->viewBuildOrchestrator;
69 }
70
71 /** @param bool $value @return void */
72 public static function setViewSnapshotTableEnsured(bool $value): void {
73 self::$viewSnapshotTableEnsured = $value;
74 }
75
76 /**
77 * @param string $prefix
78 * @param string $sub
79 * @param array<string, mixed> $tableOptions
80 * @return string
81 */
82 public function getViewSnapshotCacheKey($prefix, $sub, $tableOptions) {
83 // Cache key intentionally omits the mutation watermark. Including it
84 // produced a race on busy sites. getRedirectsForView read watermark
85 // value A, wrote the snapshot under key-with-A, then an incoming 404
86 // capture bumped the watermark to B before viewRowsSnapshotAvailable
87 // ran. The availability check read with key-with-B and missed,
88 // throwing "Warmup rows stage completed but the row snapshot was not
89 // available afterward." The cache TTL of 120 seconds bounds staleness
90 // to two minutes after any mutation, which is acceptable. The next
91 // admin page load picks up fresh data automatically.
92 $cacheShape = array(
93 'sub' => (string)$sub,
94 'filter' => is_scalar($tableOptions['filter'] ?? 0) ? (int)($tableOptions['filter'] ?? 0) : 0,
95 'orderby' => is_scalar($tableOptions['orderby'] ?? 'url') ? (string)($tableOptions['orderby'] ?? 'url') : 'url',
96 'order' => is_scalar($tableOptions['order'] ?? 'ASC') ? (string)($tableOptions['order'] ?? 'ASC') : 'ASC',
97 'paged' => is_scalar($tableOptions['paged'] ?? 1) ? (int)($tableOptions['paged'] ?? 1) : 1,
98 'perpage' => is_scalar($tableOptions['perpage'] ?? ABJ404_OPTION_DEFAULT_PERPAGE) ? (int)($tableOptions['perpage'] ?? ABJ404_OPTION_DEFAULT_PERPAGE) : ABJ404_OPTION_DEFAULT_PERPAGE,
99 'filterText' => is_scalar($tableOptions['filterText'] ?? '') ? (string)($tableOptions['filterText'] ?? '') : '',
100 'score_range' => (function ($v) { return is_string($v) ? $v : 'all'; })($tableOptions['score_range'] ?? 'all'),
101 'blog' => function_exists('get_current_blog_id') ? (int)get_current_blog_id() : 1,
102 );
103 $encoded = function_exists('wp_json_encode') ? wp_json_encode($cacheShape) : json_encode($cacheShape);
104 return $prefix . '_' . md5((string)$encoded);
105 }
106
107 /** @return void */
108 private function ensureViewSnapshotTableExists(): void {
109 if (self::$viewSnapshotTableEnsured) {
110 return;
111 }
112 self::$viewSnapshotTableEnsured = true;
113 $sqlFile = __DIR__ . '/sql/createViewCacheTable.sql';
114 $create = ABJ_404_Solution_Functions::readFileContents($sqlFile);
115 if (is_string($create) && trim($create) !== '') {
116 $this->dbCore->queryAndGetResults($create, array('log_errors' => false));
117 }
118 }
119
120 /** @param string $cacheKey @return string */
121 private function getViewSnapshotLockOptionName(string $cacheKey): string {
122 return $this->dbCore->getLowercasePrefix() . 'abj404_view_cache_lock_' . md5((string)$cacheKey);
123 }
124
125 /** @return string */
126 private function getViewSnapshotWarmupGlobalLockKey(): string {
127 return 'abj404_view_table_warmup_global';
128 }
129
130 /** @return bool */
131 public function acquireViewSnapshotWarmupGlobalLock(): bool {
132 return $this->acquireViewSnapshotRefreshLock($this->getViewSnapshotWarmupGlobalLockKey());
133 }
134
135 /** @return void */
136 public function releaseViewSnapshotWarmupGlobalLock(): void {
137 $this->releaseViewSnapshotRefreshLock($this->getViewSnapshotWarmupGlobalLockKey());
138 }
139
140 /** @param string $cacheKey @return string */
141 private function getViewWarmupStateOptionName(string $cacheKey): string {
142 return $this->dbCore->getLowercasePrefix() . 'abj404_view_warmup_' . md5((string)$cacheKey);
143 }
144
145 /**
146 * @param array<string, mixed> $tableOptions
147 * @return bool
148 */
149 public function canUseViewTableSnapshotCache(array $tableOptions): bool {
150 if (!empty($tableOptions['_abj404_force_view_rebuild'])) {
151 return false;
152 }
153 $rawOrderBy = $tableOptions['orderby'] ?? '';
154 $orderBy = strtolower(is_string($rawOrderBy) ? $rawOrderBy : '');
155 $isLogsMaintenanceSort = ($orderBy === 'logshits' || $orderBy === 'last_used');
156 $rawPerpage = $tableOptions['perpage'] ?? 0;
157 return absint(is_scalar($rawPerpage) ? $rawPerpage : 0) <= 200 && !$isLogsMaintenanceSort;
158 }
159
160 /**
161 * @param string $sub
162 * @param array<string, mixed> $tableOptions
163 * @return string
164 */
165 private function getViewTableWarmupShapeKey(string $sub, array $tableOptions): string {
166 return $this->getViewSnapshotCacheKey('abj404_view_table', $sub, $tableOptions);
167 }
168
169 /**
170 * @param mixed $state
171 * @return array<string, mixed>
172 */
173 private function normalizeViewWarmupState($state): array {
174 $default = array(
175 'status' => 'idle',
176 'stage' => 'rows',
177 'stage_started_at' => 0,
178 'stage_completed_at' => 0,
179 'attempts_by_stage' => array('rows' => 0, 'count' => 0),
180 'timings_by_stage' => array(
181 'rows' => array('last_ms' => 0, 'max_ms' => 0, 'last_completed_at' => 0, 'last_error' => ''),
182 'count' => array('last_ms' => 0, 'max_ms' => 0, 'last_completed_at' => 0, 'last_error' => ''),
183 ),
184 'query_label' => 'getRedirectsForView',
185 'last_error' => '',
186 'logged_stale_by_stage' => array(),
187 'build_progress_at_stage_start' => array(),
188 );
189 if (!is_array($state)) {
190 return $default;
191 }
192 /** @var array<string, mixed> $out */
193 $out = array_merge($default, $state);
194 $status = is_string($out['status'] ?? null) ? $out['status'] : 'idle';
195 if (!in_array($status, array('idle', 'running', 'ready', 'blocked', 'error'), true)) {
196 $out['status'] = 'idle';
197 } else {
198 $out['status'] = $status;
199 }
200 $stage = is_string($out['stage'] ?? null) ? $out['stage'] : 'rows';
201 if (!in_array($stage, array('rows', 'count'), true)) {
202 $out['stage'] = 'rows';
203 } else {
204 $out['stage'] = $stage;
205 }
206 $stageStartedAt = $out['stage_started_at'] ?? 0;
207 $stageCompletedAt = $out['stage_completed_at'] ?? 0;
208 $out['stage_started_at'] = is_scalar($stageStartedAt) ? intval($stageStartedAt) : 0;
209 $out['stage_completed_at'] = is_scalar($stageCompletedAt) ? intval($stageCompletedAt) : 0;
210
211 $attempts = is_array($out['attempts_by_stage']) ? $out['attempts_by_stage'] : array();
212 $attemptsRows = $attempts['rows'] ?? 0;
213 $attemptsCount = $attempts['count'] ?? 0;
214 $out['attempts_by_stage'] = array(
215 'rows' => is_scalar($attemptsRows) ? intval($attemptsRows) : 0,
216 'count' => is_scalar($attemptsCount) ? intval($attemptsCount) : 0,
217 );
218
219 $timings = is_array($out['timings_by_stage']) ? $out['timings_by_stage'] : array();
220 $out['timings_by_stage'] = array(
221 'rows' => $this->normalizeStageTiming($timings['rows'] ?? null),
222 'count' => $this->normalizeStageTiming($timings['count'] ?? null),
223 );
224
225 $out['query_label'] = is_string($out['query_label'] ?? null) ? $out['query_label'] : $this->getViewWarmupStageQueryLabel((string)$out['stage']);
226 $out['last_error'] = is_string($out['last_error'] ?? null) ? $out['last_error'] : '';
227 $out['logged_stale_by_stage'] = is_array($out['logged_stale_by_stage']) ? $out['logged_stale_by_stage'] : array();
228 $out['build_progress_at_stage_start'] = is_array($out['build_progress_at_stage_start'] ?? null)
229 ? $out['build_progress_at_stage_start'] : array();
230 return $out;
231 }
232
233 /**
234 * @param mixed $timing
235 * @return array<string, mixed>
236 */
237 private function normalizeStageTiming($timing): array {
238 $default = array('last_ms' => 0, 'max_ms' => 0, 'last_completed_at' => 0, 'last_error' => '');
239 if (!is_array($timing)) {
240 return $default;
241 }
242 $lastMs = $timing['last_ms'] ?? 0;
243 $maxMs = $timing['max_ms'] ?? 0;
244 $lastCompletedAt = $timing['last_completed_at'] ?? 0;
245 $lastError = $timing['last_error'] ?? '';
246 return array(
247 'last_ms' => is_scalar($lastMs) ? intval($lastMs) : 0,
248 'max_ms' => is_scalar($maxMs) ? intval($maxMs) : 0,
249 'last_completed_at' => is_scalar($lastCompletedAt) ? intval($lastCompletedAt) : 0,
250 'last_error' => is_string($lastError) ? $lastError : '',
251 );
252 }
253
254 /** @param string $stage @return string */
255 private function getViewWarmupStageQueryLabel(string $stage): string {
256 return $stage === 'count' ? 'getRedirectsForViewCount' : 'getRedirectsForView';
257 }
258
259 /** @param string $stage @return int */
260 private function getViewWarmupStageNumber(string $stage): int {
261 return $stage === 'count' ? 2 : 1;
262 }
263
264 /** @return array<string, int> */
265 public function getViewBuildProgressFingerprint(): array {
266 return array(
267 'started_at' => $this->requireViewBuildOrchestrator()->readBuildProgressOption('started_at', 0),
268 'current_stage' => $this->requireViewBuildOrchestrator()->readBuildProgressOption('current_stage', 0),
269 'last_started_stage' => $this->requireViewBuildOrchestrator()->readBuildProgressOption('last_started_stage', 0),
270 'last_completed_stage' => $this->requireViewBuildOrchestrator()->readBuildProgressOption('last_completed_stage', 0),
271 's2_high_water' => $this->requireViewBuildOrchestrator()->readBuildProgressOption('s2_high_water', 0),
272 's4_high_water' => $this->requireViewBuildOrchestrator()->readBuildProgressOption('s4_high_water', 0),
273 's5_high_water' => $this->requireViewBuildOrchestrator()->readBuildProgressOption('s5_high_water', 0),
274 );
275 }
276
277 /**
278 * @param mixed $baseline
279 * @param array<string, int>|null $current
280 * @return bool
281 */
282 private function viewBuildProgressAdvancedSince($baseline, ?array $current = null): bool {
283 if (!is_array($baseline) || empty($baseline)) {
284 return false;
285 }
286 $current = $current ?? $this->getViewBuildProgressFingerprint();
287 foreach (array('current_stage', 's2_high_water', 's4_high_water', 's5_high_water') as $key) {
288 $before = is_scalar($baseline[$key] ?? null) ? intval($baseline[$key]) : 0;
289 $after = is_scalar($current[$key] ?? null) ? intval($current[$key]) : 0;
290 if ($after > $before) {
291 return true;
292 }
293 }
294 return false;
295 }
296
297 /**
298 * @param array<string, mixed> $state
299 * @param string $stage
300 * @param array<string, int>|null $currentProgress
301 * @return bool
302 */
303 private function forgiveWarmupAttemptIfBuildProgressed(array &$state, string $stage, ?array $currentProgress = null): bool {
304 if (!$this->viewBuildProgressAdvancedSince($state['build_progress_at_stage_start'] ?? array(), $currentProgress)) {
305 return false;
306 }
307 $attempts = is_array($state['attempts_by_stage'] ?? null) ? $state['attempts_by_stage'] : array('rows' => 0, 'count' => 0);
308 $rawAttempt = $attempts[$stage] ?? 0;
309 $attempts[$stage] = max(0, (is_scalar($rawAttempt) ? intval($rawAttempt) : 0) - 1);
310 $state['attempts_by_stage'] = $attempts;
311 $state['build_progress_at_stage_start'] = is_array($currentProgress)
312 ? $currentProgress : $this->getViewBuildProgressFingerprint();
313 return true;
314 }
315
316 /**
317 * @param string $optionName
318 * @return array<string, mixed>
319 */
320 private function getViewWarmupState(string $optionName): array {
321 if (!function_exists('get_option')) {
322 return $this->normalizeViewWarmupState(null);
323 }
324 return $this->normalizeViewWarmupState(get_option($optionName, array()));
325 }
326
327 /**
328 * @param string $optionName
329 * @param array<string, mixed> $state
330 * @return void
331 */
332 private function setViewWarmupState(string $optionName, array $state): void {
333 if (function_exists('update_option')) {
334 update_option($optionName, $state, false);
335 } else if (function_exists('add_option')) {
336 add_option($optionName, $state, '', false);
337 }
338 }
339
340 /**
341 * @param string $sub
342 * @param array<string, mixed> $tableOptions
343 * @return array<string, mixed>
344 */
345 public function warmViewTableSnapshotStage(string $sub, array $tableOptions): array {
346 if (!$this->canUseViewTableSnapshotCache($tableOptions)) {
347 return array(
348 'status' => 'ready',
349 'ready' => true,
350 'uncached' => true,
351 'stage' => 'rows',
352 'stageNumber' => 1,
353 'queryLabel' => 'getRedirectsForView',
354 'message' => 'This table shape is not snapshot-cacheable.',
355 );
356 }
357
358 $host = $this->host;
359 if ($host === null) {
360 throw new \RuntimeException('ViewSnapshotCache requires host (call setHost first)'); // allow-raw-error: assertion, should never reach user
361 }
362
363 $shapeKey = $this->getViewTableWarmupShapeKey($sub, $tableOptions);
364 $optionName = $this->getViewWarmupStateOptionName($shapeKey);
365 $state = $this->getViewWarmupState($optionName);
366 $now = time();
367
368 if ($host->viewTableSnapshotAvailable($sub, $tableOptions)) {
369 $state['status'] = 'ready';
370 $state['stage'] = 'count';
371 $state['query_label'] = 'getRedirectsForViewCount';
372 $state['stage_completed_at'] = $now;
373 $state['last_error'] = '';
374 $this->setViewWarmupState($optionName, $state);
375 return $this->formatViewWarmupResponse($state, true);
376 }
377
378 if ($host->viewRowsSnapshotAvailable($sub, $tableOptions)) {
379 $state['stage'] = 'count';
380 $state['query_label'] = 'getRedirectsForViewCount';
381 } else {
382 $state['stage'] = 'rows';
383 $state['query_label'] = 'getRedirectsForView';
384 }
385
386 $stage = (string)$state['stage'];
387 $attempts = is_array($state['attempts_by_stage']) ? $state['attempts_by_stage'] : array('rows' => 0, 'count' => 0);
388 $attemptCountRaw = $attempts[$stage] ?? 0;
389 $attemptCount = is_scalar($attemptCountRaw) ? intval($attemptCountRaw) : 0;
390
391 if ($state['status'] === 'running') {
392 $stageStartedAt = $state['stage_started_at'] ?? 0;
393 $elapsed = $now - (is_scalar($stageStartedAt) ? intval($stageStartedAt) : 0);
394 if ($elapsed <= ABJ_404_Solution_ViewReadRuntimeState::VIEW_SNAPSHOT_WARMUP_STALE_SECONDS) {
395 return $this->formatViewWarmupResponse($state, false);
396 }
397 $currentBuildProgress = $this->getViewBuildProgressFingerprint();
398 if ($this->forgiveWarmupAttemptIfBuildProgressed($state, $stage, $currentBuildProgress)) {
399 $attempts = is_array($state['attempts_by_stage']) ? $state['attempts_by_stage'] : array('rows' => 0, 'count' => 0);
400 $attemptCountRaw = $attempts[$stage] ?? 0;
401 $attemptCount = is_scalar($attemptCountRaw) ? intval($attemptCountRaw) : 0;
402 }
403 if ($attemptCount >= ABJ_404_Solution_ViewReadRuntimeState::VIEW_SNAPSHOT_WARMUP_MAX_ATTEMPTS) {
404 $state['status'] = 'blocked';
405 $previousLastError = $state['last_error'] ?? '';
406 $previousError = is_string($previousLastError) ? trim($previousLastError) : '';
407 $state['last_error'] = 'Previous warmup stage was killed or stalled too many times.'
408 . ($this->isViewWarmupErrorDiagnostic($previousError) ? ' Previous error: ' . $previousError : '');
409 $this->logViewWarmupFailure($sub, $tableOptions, $state);
410 $this->setViewWarmupState($optionName, $state);
411 return $this->formatViewWarmupResponse($state, false);
412 }
413 $loggedKey = $stage . ':' . (is_scalar($stageStartedAt) ? intval($stageStartedAt) : 0);
414 $loggedStaleByStage = is_array($state['logged_stale_by_stage'] ?? null) ? $state['logged_stale_by_stage'] : array();
415 if (empty($loggedStaleByStage[$loggedKey])) {
416 $this->logStaleViewWarmupStage($sub, $tableOptions, $state, $elapsed, $attemptCount);
417 $loggedStaleByStage[$loggedKey] = 1;
418 $state['logged_stale_by_stage'] = $loggedStaleByStage;
419 }
420 }
421
422 if ($attemptCount >= ABJ_404_Solution_ViewReadRuntimeState::VIEW_SNAPSHOT_WARMUP_MAX_ATTEMPTS) {
423 $previousLastError = $state['last_error'] ?? '';
424 $previousError = is_string($previousLastError) ? trim($previousLastError) : '';
425 if (!$this->isViewWarmupErrorDiagnostic($previousError)) {
426 $attempts[$stage] = ABJ_404_Solution_ViewReadRuntimeState::VIEW_SNAPSHOT_WARMUP_MAX_ATTEMPTS - 1;
427 $state['attempts_by_stage'] = $attempts;
428 $attemptCount = is_scalar($attempts[$stage] ?? 0) ? intval($attempts[$stage]) : 0;
429 }
430 }
431
432 if ($attemptCount >= ABJ_404_Solution_ViewReadRuntimeState::VIEW_SNAPSHOT_WARMUP_MAX_ATTEMPTS) {
433 $state['status'] = 'blocked';
434 $state['last_error'] = 'Warmup stage reached the retry limit.'
435 . ($this->isViewWarmupErrorDiagnostic($previousError) ? ' Previous error: ' . $previousError : '');
436 $this->logViewWarmupFailure($sub, $tableOptions, $state);
437 $this->setViewWarmupState($optionName, $state);
438 return $this->formatViewWarmupResponse($state, false);
439 }
440
441 if (!$this->acquireViewSnapshotWarmupGlobalLock()) {
442 $state['status'] = 'running';
443 $state['last_error'] = 'Another table cache warmup is already running for this site.';
444 return $this->formatViewWarmupResponse($state, false, array(
445 'locked' => true,
446 'lockScope' => 'site',
447 'retryAfterMs' => 2500,
448 ));
449 }
450
451 $attempts[$stage] = $attemptCount + 1;
452 $state['status'] = 'running';
453 $state['stage_started_at'] = $now;
454 $state['stage_completed_at'] = 0;
455 $state['attempts_by_stage'] = $attempts;
456 $state['query_label'] = $this->getViewWarmupStageQueryLabel($stage);
457 $state['last_error'] = '';
458 $state['build_progress_at_stage_start'] = $this->getViewBuildProgressFingerprint();
459 $this->setViewWarmupState($optionName, $state);
460
461 $stageOptions = $tableOptions;
462 $stageOptions['_abj404_query_timeout'] = ABJ_404_Solution_ViewReadRuntimeState::VIEW_SNAPSHOT_WARMUP_STAGE_TIMEOUT_SECONDS;
463 $stageOptions['_abj404_throw_on_view_query_error'] = true;
464
465 $startMs = microtime(true);
466 try {
467 if ($stage === 'rows') {
468 $host->getRedirectsForView($sub, $stageOptions);
469 if (!$host->viewRowsSnapshotAvailable($sub, $tableOptions)) {
470 throw new \Exception('Warmup rows stage completed but the row snapshot was not available afterward.'); // allow-raw-error: pre-existing warmup assertion moved from ViewReadService.php
471 }
472 $state['status'] = 'idle';
473 $state['stage'] = 'count';
474 $state['query_label'] = 'getRedirectsForViewCount';
475 } else {
476 $host->getRedirectsForViewCount($sub, $stageOptions);
477 if (!$host->viewTableSnapshotAvailable($sub, $tableOptions)) {
478 throw new \Exception('Warmup count stage completed but the full table snapshot was not available afterward.'); // allow-raw-error: pre-existing warmup assertion moved from ViewReadService.php
479 }
480 $state['status'] = 'ready';
481 $state['stage'] = 'count';
482 $state['query_label'] = 'getRedirectsForViewCount';
483 }
484 $elapsedMs = (int)round((microtime(true) - $startMs) * 1000);
485 $state['stage_completed_at'] = time();
486 $state['last_error'] = '';
487
488 $timingsByStage = is_array($state['timings_by_stage'] ?? null) ? $state['timings_by_stage'] : array();
489 $timings = $this->normalizeStageTiming($timingsByStage[$stage] ?? null);
490 $timings['last_ms'] = $elapsedMs;
491 $timings['max_ms'] = max($timings['max_ms'], $elapsedMs);
492 $timings['last_completed_at'] = $state['stage_completed_at'];
493 $timings['last_error'] = '';
494 $timingsByStage[$stage] = $timings;
495 $state['timings_by_stage'] = $timingsByStage;
496
497 $this->logger->debugMessage(sprintf(
498 "[warmup] shape=%s stage=%s ms=%d attempts=%d error=",
499 substr($shapeKey, 0, 8),
500 $stage,
501 $elapsedMs,
502 $attemptCount + 1
503 ));
504
505 $this->setViewWarmupState($optionName, $state);
506 return $this->formatViewWarmupResponse($state, $state['status'] === 'ready');
507 } catch (Throwable $e) {
508 $elapsedMs = (int)round((microtime(true) - $startMs) * 1000);
509 $errorMessage = $e->getMessage();
510 $state['last_error'] = $errorMessage;
511 $state['stage_completed_at'] = time();
512 $currentAttempts = $attempts[$stage] ?? 0;
513 if ($this->forgiveWarmupAttemptIfBuildProgressed($state, $stage)) {
514 $attempts = is_array($state['attempts_by_stage']) ? $state['attempts_by_stage'] : $attempts;
515 $rawAttemptCount = $attempts[$stage] ?? 0;
516 $currentAttempts = is_scalar($rawAttemptCount) ? intval($rawAttemptCount) : 0;
517 }
518 $state['status'] = ($currentAttempts >= ABJ_404_Solution_ViewReadRuntimeState::VIEW_SNAPSHOT_WARMUP_MAX_ATTEMPTS) ? 'blocked' : 'idle';
519
520 $timingsByStage = is_array($state['timings_by_stage'] ?? null) ? $state['timings_by_stage'] : array();
521 $timings = $this->normalizeStageTiming($timingsByStage[$stage] ?? null);
522 $timings['last_error'] = $errorMessage;
523 $timingsByStage[$stage] = $timings;
524 $state['timings_by_stage'] = $timingsByStage;
525
526 $this->logger->debugMessage(sprintf(
527 "[warmup] shape=%s stage=%s ms=%d attempts=%d error=%s",
528 substr($shapeKey, 0, 8),
529 $stage,
530 $elapsedMs,
531 $attemptCount + 1,
532 $errorMessage
533 ));
534
535 $this->logViewWarmupFailure($sub, $tableOptions, $state);
536 $this->setViewWarmupState($optionName, $state);
537 return $this->formatViewWarmupResponse($state, false);
538 } finally {
539 $this->releaseViewSnapshotWarmupGlobalLock();
540 }
541 }
542
543 /**
544 * @param array<string, mixed> $state
545 * @param bool $ready
546 * @param array<string, mixed> $extra
547 * @return array<string, mixed>
548 */
549 private function formatViewWarmupResponse(array $state, bool $ready, array $extra = array()): array {
550 $stageValue = $state['stage'] ?? 'rows';
551 $stage = is_string($stageValue) ? $stageValue : 'rows';
552 $statusValue = $state['status'] ?? 'idle';
553 $status = is_string($statusValue) ? $statusValue : 'idle';
554 $stageStartedAt = $state['stage_started_at'] ?? 0;
555 $stageCompletedAt = $state['stage_completed_at'] ?? 0;
556 $lastError = $state['last_error'] ?? '';
557 $response = array(
558 'status' => $status,
559 'ready' => $ready || $status === 'ready',
560 'stage' => $stage,
561 'stageNumber' => $this->getViewWarmupStageNumber($stage),
562 'queryLabel' => $this->getViewWarmupStageQueryLabel($stage),
563 'stageStartedAt' => is_scalar($stageStartedAt) ? intval($stageStartedAt) : 0,
564 'stageCompletedAt' => is_scalar($stageCompletedAt) ? intval($stageCompletedAt) : 0,
565 'attemptsByStage' => is_array($state['attempts_by_stage'] ?? null) ? $state['attempts_by_stage'] : array(),
566 'timingsByStage' => is_array($state['timings_by_stage'] ?? null) ? $state['timings_by_stage'] : array(),
567 'lastError' => is_string($lastError) ? $lastError : '',
568 );
569 foreach ($extra as $key => $value) {
570 if (is_string($key)) {
571 $response[$key] = $value;
572 }
573 }
574 return $response;
575 }
576
577 /**
578 * @param string $sub
579 * @param array<string, mixed> $tableOptions
580 * @param array<string, mixed> $state
581 * @param int $elapsed
582 * @param int $attemptCount
583 * @return void
584 */
585 private function logStaleViewWarmupStage(string $sub, array $tableOptions, array $state, int $elapsed, int $attemptCount): void {
586 $details = array(
587 'stage' => is_string($state['stage'] ?? null) ? $state['stage'] : '',
588 'query_label' => is_string($state['query_label'] ?? null) ? $state['query_label'] : '',
589 'elapsed_seconds' => $elapsed,
590 'subpage' => $sub,
591 'attempt_count' => $attemptCount,
592 'table_shape' => array(
593 'filter' => $tableOptions['filter'] ?? null,
594 'orderby' => $tableOptions['orderby'] ?? null,
595 'order' => $tableOptions['order'] ?? null,
596 'paged' => $tableOptions['paged'] ?? null,
597 'perpage' => $tableOptions['perpage'] ?? null,
598 'filterText_length' => is_string($tableOptions['filterText'] ?? null) ? strlen($tableOptions['filterText']) : 0,
599 'score_range' => $tableOptions['score_range'] ?? null,
600 ),
601 );
602 $message = 'Table cache warmup stage appears stalled: ' . json_encode($details);
603 $this->logger->warn($message);
604 }
605
606 /**
607 * @param string $sub
608 * @param array<string, mixed> $tableOptions
609 * @param array<string, mixed> $state
610 * @return void
611 */
612 private function logViewWarmupFailure(string $sub, array $tableOptions, array $state): void {
613 $details = array(
614 'status' => is_string($state['status'] ?? null) ? $state['status'] : '',
615 'stage' => is_string($state['stage'] ?? null) ? $state['stage'] : '',
616 'stage_number' => $this->getViewWarmupStageNumber(is_string($state['stage'] ?? null) ? $state['stage'] : 'rows'),
617 'query_label' => is_string($state['query_label'] ?? null) ? $state['query_label'] : '',
618 'last_error' => is_string($state['last_error'] ?? null) ? $state['last_error'] : '',
619 'subpage' => $sub,
620 'attempts_by_stage' => is_array($state['attempts_by_stage'] ?? null) ? $state['attempts_by_stage'] : array(),
621 'table_shape' => array(
622 'filter' => $tableOptions['filter'] ?? null,
623 'orderby' => $tableOptions['orderby'] ?? null,
624 'order' => $tableOptions['order'] ?? null,
625 'paged' => $tableOptions['paged'] ?? null,
626 'perpage' => $tableOptions['perpage'] ?? null,
627 'filterText_length' => is_string($tableOptions['filterText'] ?? null) ? strlen($tableOptions['filterText']) : 0,
628 'score_range' => $tableOptions['score_range'] ?? null,
629 ),
630 );
631 $message = 'Table cache warmup failed: ' . json_encode($details);
632 $this->logger->errorMessage($message);
633 }
634
635 /** @param string $lastError @return bool */
636 private function isViewWarmupErrorDiagnostic(string $lastError): bool {
637 if ($lastError === '') {
638 return false;
639 }
640 return $lastError !== 'Warmup stage reached the retry limit.'
641 && $lastError !== 'Previous warmup stage was killed or stalled too many times.';
642 }
643
644 /** @param string $cacheKey @return bool */
645 private function isViewSnapshotRefreshLocked(string $cacheKey): bool {
646 if (!function_exists('get_option')) {
647 return false;
648 }
649 $lockKey = $this->getViewSnapshotLockOptionName($cacheKey);
650 $lockValue = get_option($lockKey, false);
651 if ($lockValue === false || $lockValue === '' || $lockValue === null) {
652 return false;
653 }
654 $lockTs = is_numeric($lockValue) ? (int)$lockValue : 0;
655 if ($lockTs > 0 && (time() - $lockTs) > ABJ_404_Solution_ViewReadRuntimeState::VIEW_SNAPSHOT_REFRESH_COOLDOWN_SECONDS) {
656 if (function_exists('delete_option')) {
657 delete_option($lockKey);
658 }
659 return false;
660 }
661 return true;
662 }
663
664 /** @param string $cacheKey @return bool */
665 private function acquireViewSnapshotRefreshLock(string $cacheKey): bool {
666 if (!function_exists('add_option')) {
667 return true;
668 }
669 if ($this->isViewSnapshotRefreshLocked($cacheKey)) {
670 return false;
671 }
672 $lockKey = $this->getViewSnapshotLockOptionName($cacheKey);
673 return (bool)add_option($lockKey, time(), '', false);
674 }
675
676 /** @param string $cacheKey @return void */
677 private function releaseViewSnapshotRefreshLock(string $cacheKey): void {
678 if (function_exists('delete_option')) {
679 delete_option($this->getViewSnapshotLockOptionName($cacheKey));
680 }
681 }
682
683 /**
684 * @param mixed $payload
685 * @return array<string, mixed>|null
686 */
687 private function decodeSnapshotPayload($payload) {
688 if (!is_string($payload) || $payload === '') {
689 return null;
690 }
691 $decoded = json_decode($payload, true);
692 return is_array($decoded) ? $decoded : null;
693 }
694
695 /**
696 * @param string $cacheKey
697 * @param bool $allowExpired
698 * @param bool $respectCooldown
699 * @return array<string, mixed>|null
700 */
701 public function getViewRowsSnapshotFromTable(string $cacheKey, bool $allowExpired = false, bool $respectCooldown = false) {
702 $this->ensureViewSnapshotTableExists();
703 $query = "SELECT payload, refreshed_at, expires_at
704 FROM {wp_abj404_view_cache}
705 WHERE cache_key = %s LIMIT 1";
706 $result = $this->dbCore->queryAndGetResults($query, array('query_params' => array($cacheKey), 'log_errors' => true));
707 $resultRows = $result['rows'] ?? array();
708 if (!is_array($resultRows) || empty($resultRows) || !is_array($resultRows[0])) {
709 return null;
710 }
711 $row = $resultRows[0];
712 $expiresAtRaw = $row['expires_at'] ?? 0;
713 $refreshedAtRaw = $row['refreshed_at'] ?? 0;
714 $expiresAt = is_scalar($expiresAtRaw) ? intval($expiresAtRaw) : 0;
715 $refreshedAt = is_scalar($refreshedAtRaw) ? intval($refreshedAtRaw) : 0;
716 $now = time();
717 $isFresh = ($expiresAt > $now);
718 $recentEnough = ($refreshedAt > 0 && ($now - $refreshedAt) <= ABJ_404_Solution_ViewReadRuntimeState::VIEW_SNAPSHOT_REFRESH_COOLDOWN_SECONDS);
719 if (!$allowExpired && !$isFresh) {
720 return null;
721 }
722 if ($respectCooldown && !$isFresh && !$recentEnough) {
723 return null;
724 }
725 $payload = $row['payload'] ?? '';
726 return $this->decodeSnapshotPayload(is_scalar($payload) ? (string)$payload : '');
727 }
728
729 /**
730 * @param string $cacheKey
731 * @param string $sub
732 * @param mixed $rows
733 * @param int $ttlSeconds
734 * @return void
735 */
736 public function setViewRowsSnapshotToTable(string $cacheKey, string $sub, $rows, int $ttlSeconds): void {
737 if (!is_array($rows)) {
738 return;
739 }
740 $this->ensureViewSnapshotTableExists();
741 $encoded = function_exists('wp_json_encode') ? wp_json_encode($rows) : json_encode($rows);
742 if (!is_string($encoded)) {
743 return;
744 }
745 $bytes = strlen($encoded);
746 if ($bytes > ABJ_404_Solution_ViewReadRuntimeState::VIEW_SNAPSHOT_MAX_PAYLOAD_BYTES) {
747 return;
748 }
749 $now = time();
750 $expiresAt = $now + max(1, intval($ttlSeconds));
751 $query = "INSERT INTO {wp_abj404_view_cache}
752 (cache_key, subpage, payload, payload_bytes, refreshed_at, expires_at, updated_at)
753 VALUES (%s, %s, %s, %d, %d, %d, %d)
754 ON DUPLICATE KEY UPDATE
755 subpage = VALUES(subpage),
756 payload = VALUES(payload),
757 payload_bytes = VALUES(payload_bytes),
758 refreshed_at = VALUES(refreshed_at),
759 expires_at = VALUES(expires_at),
760 updated_at = VALUES(updated_at)";
761 $this->dbCore->queryAndGetResults($query, array(
762 'query_params' => array($cacheKey, (string)$sub, $encoded, $bytes, $now, $expiresAt, $now),
763 'log_errors' => false,
764 ));
765 $this->cleanupExpiredViewSnapshotRowsIfNeeded();
766 }
767
768 /**
769 * @param string $cacheKey
770 * @param int $timeoutMs
771 * @return array<string, mixed>|null
772 */
773 public function waitForViewRowsSnapshotFromTable(string $cacheKey, int $timeoutMs = 4000) {
774 $deadline = microtime(true) + (max(100, intval($timeoutMs)) / 1000);
775 while (microtime(true) < $deadline) {
776 $rows = $this->getViewRowsSnapshotFromTable($cacheKey, false, false);
777 if (is_array($rows)) {
778 return $rows;
779 }
780 usleep(100000);
781 }
782 return null;
783 }
784
785 /** @return void */
786 private function cleanupExpiredViewSnapshotRowsIfNeeded(): void {
787 if (!function_exists('get_transient') || !function_exists('set_transient')) {
788 return;
789 }
790 $marker = get_transient('abj404_view_cache_cleanup_marker');
791 if ($marker !== false) {
792 return;
793 }
794 set_transient('abj404_view_cache_cleanup_marker', time(), 1800);
795 $query = "DELETE FROM {wp_abj404_view_cache} WHERE expires_at < %d";
796 $this->dbCore->queryAndGetResults($query, array(
797 'query_params' => array(time() - ABJ_404_Solution_ViewReadRuntimeState::VIEW_SNAPSHOT_REFRESH_COOLDOWN_SECONDS),
798 'log_errors' => false,
799 ));
800 }
801
802 }
803