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

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

653 lines 25.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 require_once __DIR__ . '/StatsRepositoryInterface.php';
8
9 /**
10 * Stats aggregation, dashboard snapshots, and digest data.
11 *
12 * Extracted from the DataAccess monolith (Phase 4 of the DataAccess refactor).
13 * Methods originate from DataAccessTrait_Stats after Phases 1 and 2 relocated
14 * redirect and permalink methods to their respective repositories.
15 *
16 * Receives DatabaseCore for query execution and LogsRepository for hits-table
17 * lifecycle checks.
18 */
19 class ABJ_404_Solution_StatsRepository implements ABJ_404_Solution_StatsRepositoryInterface {
20
21 /** @var int Max age for cached stats-periodic aggregates. */
22 const PERIODIC_STATS_CACHE_TTL_SECONDS = 300;
23 /** @var int Minimum interval before recalculating expensive stats aggregates. */
24 const PERIODIC_STATS_REFRESH_COOLDOWN_SECONDS = 30;
25 /** @var int Retention for dashboard stats snapshot payload. */
26 const STATS_DASHBOARD_CACHE_TTL_SECONDS = 86400;
27 /** @var int Minimum time between full stats snapshot recomputes. */
28 const STATS_DASHBOARD_REFRESH_COOLDOWN_SECONDS = 30;
29 /** @var int Cooldown for distributed refresh locks. */
30 const REFRESH_LOCK_COOLDOWN_SECONDS = 30;
31
32 /** @var ABJ_404_Solution_DatabaseCore */
33 private $dbCore;
34
35 /** @var ABJ_404_Solution_LogsRepository */
36 private $logsRepo;
37
38 /** @var ABJ_404_Solution_Functions */
39 private $f;
40
41 /** @var ABJ_404_Solution_Logging */
42 private $logger;
43
44 /**
45 * @param ABJ_404_Solution_DatabaseCore $dbCore
46 * @param ABJ_404_Solution_LogsRepository $logsRepo
47 * @param ABJ_404_Solution_Functions|null $functions
48 * @param ABJ_404_Solution_Logging|null $logging
49 */
50 public function __construct(
51 ABJ_404_Solution_DatabaseCore $dbCore,
52 ABJ_404_Solution_LogsRepository $logsRepo,
53 $functions = null,
54 $logging = null
55 ) {
56 $this->dbCore = $dbCore;
57 $this->logsRepo = $logsRepo;
58 $this->f = $functions !== null ? $functions : abj_service('functions');
59 $this->logger = $logging !== null ? $logging : abj_service('logging');
60 }
61
62 // =========================================================================
63 // Core stats queries
64 // =========================================================================
65
66 /** @inheritDoc */
67 function getStatsCount($query, array $valueParams) {
68 if ($query == '') {
69 return 0;
70 }
71
72 $result = $this->dbCore->queryAndGetResults($query, array('query_params' => $valueParams));
73
74 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
75 return 0;
76 }
77
78 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
79 if (empty($rows)) {
80 $this->logger->debugMessage("getStatsCount returned no results for query: " . esc_html($query));
81 return 0;
82 }
83
84 $first = $rows[0];
85 if (is_array($first)) {
86 $value = reset($first);
87 } else {
88 $value = $first;
89 }
90 return intval($value);
91 }
92
93 /** @inheritDoc */
94 function getPeriodicStatsSummary($sinceTimestamp, $notFoundDest = '404') {
95 $sinceTimestamp = absint($sinceTimestamp);
96 $notFoundDest = sanitize_text_field((string)$notFoundDest);
97 if ($notFoundDest === '') {
98 $notFoundDest = '404';
99 }
100
101 $zero = array(
102 'disp404' => 0,
103 'distinct404' => 0,
104 'visitors404' => 0,
105 'refer404' => 0,
106 'redirected' => 0,
107 'distinctredirected' => 0,
108 'distinctvisitors' => 0,
109 'distinctrefer' => 0,
110 );
111
112 $logsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_logsv2}');
113 $sql = "SELECT
114 COUNT(CASE WHEN dest_url = %s THEN 1 END) AS disp404,
115 COUNT(DISTINCT CASE WHEN dest_url = %s THEN requested_url END) AS distinct404,
116 COUNT(DISTINCT CASE WHEN dest_url = %s THEN user_ip END) AS visitors404,
117 COUNT(DISTINCT CASE WHEN dest_url = %s THEN referrer END) AS refer404,
118 COUNT(CASE WHEN dest_url <> %s THEN 1 END) AS redirected,
119 COUNT(DISTINCT CASE WHEN dest_url <> %s THEN requested_url END) AS distinctredirected,
120 COUNT(DISTINCT CASE WHEN dest_url <> %s THEN user_ip END) AS distinctvisitors,
121 COUNT(DISTINCT CASE WHEN dest_url <> %s THEN referrer END) AS distinctrefer
122 FROM {$logsTable}
123 WHERE timestamp >= %d";
124
125 $result = $this->dbCore->queryAndGetResults($sql, array(
126 'query_params' => array(
127 $notFoundDest, $notFoundDest, $notFoundDest, $notFoundDest,
128 $notFoundDest, $notFoundDest, $notFoundDest, $notFoundDest,
129 $sinceTimestamp,
130 ),
131 ));
132
133 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
134 return $zero;
135 }
136
137 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
138 if (empty($rows) || !is_array($rows[0] ?? null)) {
139 return $zero;
140 }
141 $row = $rows[0];
142
143 foreach ($zero as $key => $unused) {
144 $zero[$key] = isset($row[$key]) ? intval($row[$key]) : 0;
145 }
146
147 return $zero;
148 }
149
150 /** @inheritDoc */
151 function getPeriodicStatsSummariesCached($notFoundDest = '404') {
152 $today = mktime(0, 0, 0, abs(intval(date('m'))), abs(intval(date('d'))), abs(intval(date('Y'))));
153 $firstm = mktime(0, 0, 0, abs(intval(date('m'))), 1, abs(intval(date('Y'))));
154 $firsty = mktime(0, 0, 0, 1, 1, abs(intval(date('Y'))));
155
156 $thresholds = array(
157 'today' => intval($today),
158 'month' => intval($firstm),
159 'year' => intval($firsty),
160 'all' => 0,
161 );
162
163 $zero = array(
164 'disp404' => 0,
165 'distinct404' => 0,
166 'visitors404' => 0,
167 'refer404' => 0,
168 'redirected' => 0,
169 'distinctredirected' => 0,
170 'distinctvisitors' => 0,
171 'distinctrefer' => 0,
172 );
173 $emptyPayload = array(
174 'today' => $zero,
175 'month' => $zero,
176 'year' => $zero,
177 'all' => $zero,
178 );
179
180 $blogId = 1;
181 if (function_exists('get_current_blog_id')) {
182 $blogId = absint(get_current_blog_id());
183 if ($blogId <= 0) {
184 $blogId = 1;
185 }
186 }
187
188 $cacheKey = 'abj404_stats_periodic_v1_' . $blogId . '_' . md5(
189 $notFoundDest . '|' . $thresholds['today'] . '|' . $thresholds['month'] . '|' . $thresholds['year']
190 );
191 $cached = null;
192 if (function_exists('get_transient')) {
193 $cached = get_transient($cacheKey);
194 }
195
196 $isCachedValid = (is_array($cached) && isset($cached['periods']) && is_array($cached['periods']));
197 $currentMaxLogId = -1;
198 try {
199 $currentMaxLogId = intval($this->logsRepo->getMaxLogId());
200 } catch (Throwable $unused) { // allow-silent-catch: cache-key derivation; -1 means "no cached entry, recompute" which is the correct degraded behavior
201 $currentMaxLogId = -1;
202 }
203
204 if ($isCachedValid) {
205 $refreshedAt = intval($cached['refreshed_at'] ?? 0);
206 $ageSeconds = max(0, time() - $refreshedAt);
207 $cachedMaxLogId = intval($cached['max_log_id'] ?? -1);
208 if ($currentMaxLogId >= 0 && $cachedMaxLogId === $currentMaxLogId) {
209 /** @var array{today: array<string, int>, month: array<string, int>, year: array<string, int>, all: array<string, int>} */
210 $merged = array_merge($emptyPayload, $cached['periods']);
211 return $merged;
212 }
213 if ($ageSeconds < self::PERIODIC_STATS_REFRESH_COOLDOWN_SECONDS) {
214 /** @var array{today: array<string, int>, month: array<string, int>, year: array<string, int>, all: array<string, int>} */
215 $merged = array_merge($emptyPayload, $cached['periods']);
216 return $merged;
217 }
218 }
219
220 $lockKey = 'stats-periodic:' . $cacheKey;
221 $lockAcquired = $this->acquireRefreshLock($lockKey);
222 if (!$lockAcquired && $isCachedValid) {
223 /** @var array{today: array<string, int>, month: array<string, int>, year: array<string, int>, all: array<string, int>} */
224 $merged = array_merge($emptyPayload, $cached['periods']);
225 return $merged;
226 }
227
228 try {
229 $periods = array();
230 foreach ($thresholds as $key => $ts) {
231 $periods[$key] = $this->getPeriodicStatsSummary($ts, $notFoundDest);
232 }
233 /** @var array{today: array<string, int>, month: array<string, int>, year: array<string, int>, all: array<string, int>} */
234 $result = array_merge($emptyPayload, $periods);
235
236 if (function_exists('set_transient')) {
237 set_transient(
238 $cacheKey,
239 array(
240 'refreshed_at' => time(),
241 'max_log_id' => $currentMaxLogId,
242 'periods' => $result,
243 ),
244 self::PERIODIC_STATS_CACHE_TTL_SECONDS
245 );
246 }
247
248 return $result;
249 } finally {
250 if ($lockAcquired) {
251 $this->releaseRefreshLock($lockKey);
252 }
253 }
254 }
255
256 // =========================================================================
257 // Dashboard snapshot
258 // =========================================================================
259
260 /** @inheritDoc */
261 function getStatsDashboardSnapshot($allowStale = true) {
262 $cached = $this->getStatsDashboardSnapshotFromCache();
263 if (is_array($cached) && !empty($cached['data']) && $allowStale) {
264 /** @var array{refreshed_at: int, hash: string, data: array<string, mixed>} $cached */
265 return $cached;
266 }
267
268 return $this->refreshStatsDashboardSnapshot(false);
269 }
270
271 /** @inheritDoc */
272 function refreshStatsDashboardSnapshot($force = false) {
273 $cached = $this->getStatsDashboardSnapshotFromCache();
274 $hasCachedData = (is_array($cached) && !empty($cached['data']));
275 $cachedAge = $hasCachedData ? max(0, time() - (is_scalar($cached['refreshed_at'] ?? 0) ? intval($cached['refreshed_at'] ?? 0) : 0)) : PHP_INT_MAX;
276
277 if (!$force && $hasCachedData && $cachedAge < self::STATS_DASHBOARD_REFRESH_COOLDOWN_SECONDS) {
278 /** @var array{refreshed_at: int, hash: string, data: array<string, mixed>} $cached */
279 return $cached;
280 }
281
282 $lockKey = 'stats-dashboard:' . $this->getStatsDashboardSnapshotCacheKey();
283 $lockAcquired = $this->acquireRefreshLock($lockKey);
284 if (!$lockAcquired && $hasCachedData) {
285 /** @var array{refreshed_at: int, hash: string, data: array<string, mixed>} $cached */
286 return $cached;
287 }
288
289 try {
290 $data = $this->buildStatsDashboardSnapshotData();
291 $payload = array(
292 'refreshed_at' => time(),
293 'hash' => $this->hashStatsDashboardSnapshot($data),
294 'data' => $data,
295 );
296 if (function_exists('set_transient')) {
297 set_transient($this->getStatsDashboardSnapshotCacheKey(), $payload, self::STATS_DASHBOARD_CACHE_TTL_SECONDS);
298 }
299 return $payload;
300 } catch (Throwable $e) {
301 if ($hasCachedData) {
302 $this->logger->debugMessage(__FUNCTION__ . ' failed to recompute stats snapshot; returning cached snapshot. Error: ' . $e->getMessage());
303 /** @var array{refreshed_at: int, hash: string, data: array<string, mixed>} $cached */
304 return $cached;
305 }
306 throw $e;
307 } finally {
308 if ($lockAcquired) {
309 $this->releaseRefreshLock($lockKey);
310 }
311 }
312 }
313
314 /** @return array<string, mixed>|null */
315 private function getStatsDashboardSnapshotFromCache() {
316 if (!function_exists('get_transient')) {
317 return null;
318 }
319 $cached = get_transient($this->getStatsDashboardSnapshotCacheKey());
320 if (!is_array($cached)) {
321 return null;
322 }
323 if (!array_key_exists('data', $cached) || !is_array($cached['data'])) {
324 return null;
325 }
326 $cached['refreshed_at'] = intval($cached['refreshed_at'] ?? 0);
327 $cached['hash'] = is_string($cached['hash'] ?? null) ? $cached['hash'] : '';
328 return $cached;
329 }
330
331 /** @return string */
332 private function getStatsDashboardSnapshotCacheKey(): string {
333 $blogId = 1;
334 if (function_exists('get_current_blog_id')) {
335 $blogId = absint(get_current_blog_id());
336 if ($blogId <= 0) {
337 $blogId = 1;
338 }
339 }
340 return 'abj404_stats_dashboard_snapshot_v1_' . $blogId;
341 }
342
343 /**
344 * @param array<string, mixed> $data
345 * @return string
346 */
347 private function hashStatsDashboardSnapshot($data) {
348 $encoded = function_exists('wp_json_encode') ? wp_json_encode($data) : json_encode($data);
349 if (!is_string($encoded)) {
350 $encoded = '';
351 }
352 return md5($encoded);
353 }
354
355 /** @return array<string, mixed> */
356 private function buildStatsDashboardSnapshotData() {
357 $redirectsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_redirects}");
358
359 $auto301 = $this->getStatsCount(
360 "select count(id) from $redirectsTable where disabled = 0 and code = 301 and status = %d",
361 array(ABJ404_STATUS_AUTO)
362 );
363 $auto302 = $this->getStatsCount(
364 "select count(id) from $redirectsTable where disabled = 0 and code = 302 and status = %d",
365 array(ABJ404_STATUS_AUTO)
366 );
367 $manual301 = $this->getStatsCount(
368 "select count(id) from $redirectsTable where disabled = 0 and code = 301 and status = %d",
369 array(ABJ404_STATUS_MANUAL)
370 );
371 $manual302 = $this->getStatsCount(
372 "select count(id) from $redirectsTable where disabled = 0 and code = 302 and status = %d",
373 array(ABJ404_STATUS_MANUAL)
374 );
375 $trashedRedirects = $this->getStatsCount(
376 "select count(id) from $redirectsTable where disabled = 1 and (status = %d or status = %d)",
377 array(ABJ404_STATUS_AUTO, ABJ404_STATUS_MANUAL)
378 );
379
380 $captured = $this->getStatsCount(
381 "select count(id) from $redirectsTable where disabled = 0 and status = %d",
382 array(ABJ404_STATUS_CAPTURED)
383 );
384 $ignored = $this->getStatsCount(
385 "select count(id) from $redirectsTable where disabled = 0 and status in (%d, %d)",
386 array(ABJ404_STATUS_IGNORED, ABJ404_STATUS_LATER)
387 );
388 $trashedCaptured = $this->getStatsCount(
389 "select count(id) from $redirectsTable where disabled = 1 and (status in (%d, %d, %d) )",
390 array(ABJ404_STATUS_CAPTURED, ABJ404_STATUS_IGNORED, ABJ404_STATUS_LATER)
391 );
392
393 $thresholds = array(
394 'today' => (int)mktime(0, 0, 0, abs(intval(date('m'))), abs(intval(date('d'))), abs(intval(date('Y')))),
395 'month' => (int)mktime(0, 0, 0, abs(intval(date('m'))), 1, abs(intval(date('Y')))),
396 'year' => (int)mktime(0, 0, 0, 1, 1, abs(intval(date('Y')))),
397 'all' => 0,
398 );
399 $periods = array();
400 foreach ($thresholds as $periodKey => $ts) {
401 $periods[$periodKey] = $this->getPeriodicStatsSummary($ts, '404');
402 }
403
404 return array(
405 'redirects' => array(
406 'auto301' => intval($auto301),
407 'auto302' => intval($auto302),
408 'manual301' => intval($manual301),
409 'manual302' => intval($manual302),
410 'trashed' => intval($trashedRedirects),
411 ),
412 'captured' => array(
413 'captured' => intval($captured),
414 'ignored' => intval($ignored),
415 'trashed' => intval($trashedCaptured),
416 ),
417 'periods' => $periods,
418 );
419 }
420
421 // =========================================================================
422 // Log timestamp
423 // =========================================================================
424
425 /** @inheritDoc */
426 function getEarliestLogTimestamp() {
427 $query = 'SELECT min(timestamp) as timestamp FROM {wp_abj404_logsv2}';
428
429 $result = $this->dbCore->queryAndGetResults($query);
430
431 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
432 return -1;
433 }
434
435 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
436 if (empty($rows)) {
437 return -1;
438 }
439
440 $first = $rows[0];
441 $value = is_array($first) ? reset($first) : $first;
442 if ($value === null || $value === false || $value === '') {
443 return -1;
444 }
445 return intval($value);
446 }
447
448 // =========================================================================
449 // Email digest
450 // =========================================================================
451
452 /** @inheritDoc */
453 function getTopCapturedForDigest(int $limit): array {
454 $limit = max(1, $limit);
455
456 if (!$this->logsRepo->logsHitsTableExists()) {
457 $this->logger->warn('getTopCapturedForDigest: logs_hits rollup unavailable; '
458 . 'digest top-captured table will be empty until rebuild completes. '
459 . 'EmailDigest pre-checks via logsHitsTableExists() to render an "unavailable" message instead.');
460 $this->logsRepo->scheduleHitsTableRebuild();
461 return array();
462 }
463
464 $query = $this->buildTopCapturedForDigestQuery($limit);
465 $result = $this->dbCore->queryAndGetResults($query, array('timeout' => 60));
466
467 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
468 $errRaw = $result['last_error'] ?? '';
469 $errMsg = is_string($errRaw) ? $errRaw : '';
470 $timedOut = !empty($result['timed_out']);
471 $this->logger->warn('getTopCapturedForDigest: query failed against present rollup; '
472 . 'digest top-captured table will be empty. timed_out=' . ($timedOut ? '1' : '0')
473 . ', error=' . ($errMsg !== '' ? $errMsg : '(none)'));
474 return array();
475 }
476
477 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
478 return $rows;
479 }
480
481 /** @inheritDoc */
482 function buildTopCapturedForDigestQuery(int $limit): string {
483 $limit = max(1, $limit);
484 $query = "SELECT r.url, COALESCE(h.logshits, 0) AS logshits, r.timestamp AS created
485 FROM {wp_abj404_redirects} r
486 LEFT JOIN {wp_abj404_logs_hits} h
487 ON BINARY h.requested_url = BINARY
488 COALESCE(r.canonical_url, CONCAT('/', TRIM(BOTH '/' FROM r.url)))
489 WHERE r.status = " . ABJ404_STATUS_CAPTURED . " AND r.disabled = 0
490 ORDER BY logshits DESC, r.url ASC
491 LIMIT " . $limit;
492 return $this->dbCore->doTableNameReplacements($query);
493 }
494
495 /** @inheritDoc */
496 function getDigestSummaryStats(): array {
497 $zero = array(
498 'total_captured' => 0,
499 'total_manual' => 0,
500 'total_auto' => 0,
501 );
502
503 $redirectsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_redirects}');
504
505 try {
506 $total_captured = $this->getStatsCount(
507 "SELECT COUNT(id) FROM {$redirectsTable} WHERE status = %d AND disabled = 0",
508 array(ABJ404_STATUS_CAPTURED)
509 );
510 $total_manual = $this->getStatsCount(
511 "SELECT COUNT(id) FROM {$redirectsTable} WHERE status = %d AND disabled = 0",
512 array(ABJ404_STATUS_MANUAL)
513 );
514 $total_auto = $this->getStatsCount(
515 "SELECT COUNT(id) FROM {$redirectsTable} WHERE status = %d AND disabled = 0",
516 array(ABJ404_STATUS_AUTO)
517 );
518 } catch (Throwable $e) {
519 $this->logger->warn(
520 'getRedirectsBreakdownStats failed; returning zero counts: '
521 . $e->getMessage()
522 );
523 return $zero;
524 }
525
526 return array(
527 'total_captured' => intval($total_captured),
528 'total_manual' => intval($total_manual),
529 'total_auto' => intval($total_auto),
530 );
531 }
532
533 /** @inheritDoc */
534 function getCapturedCountForNotification(): int {
535 $viewRead = abj_service('view_read_service');
536 return $viewRead->getRecordCount(array(ABJ404_STATUS_CAPTURED));
537 }
538
539 // =========================================================================
540 // Content keywords (permalink cache)
541 // =========================================================================
542
543 /** @inheritDoc */
544 function getPostsNeedingContentKeywords(int $limit = 500): array {
545 $limitResults = " */\n limit " . absint($limit);
546
547 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getPostsNeedingContentKeywords.sql");
548 $query = $this->f->str_replace('{limit-results}', $limitResults, $query);
549
550 $result = $this->dbCore->queryAndGetResults($query, array(
551 'result_type' => OBJECT,
552 'log_errors' => false,
553 ));
554
555 $lastError = isset($result['last_error']) && is_string($result['last_error']) ? $result['last_error'] : '';
556 if ($lastError !== '') {
557 if (stripos($lastError, 'unknown column') !== false) {
558 $this->logger->warn("content_keywords column not yet available (DB migration pending): " . $lastError);
559 } else if (!$this->dbCore->classifyAndHandleInfrastructureError($lastError)) {
560 $this->logger->errorMessage("Error fetching posts for content keywords: " . $lastError);
561 }
562 return array();
563 }
564
565 $rows = isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : array();
566 return $rows;
567 }
568
569 /** @inheritDoc */
570 function bulkUpdateContentKeywords(array $idToKeywords): void {
571 if (empty($idToKeywords)) {
572 return;
573 }
574
575 $table = $this->dbCore->doTableNameReplacements('{wp_abj404_permalink_cache}');
576
577 $whenClauses = array();
578 $params = array();
579 $ids = array();
580 foreach ($idToKeywords as $id => $keywords) {
581 $intId = (int) $id;
582 $whenClauses[] = 'WHEN %d THEN %s';
583 $params[] = $intId;
584 $params[] = $keywords;
585 $ids[] = $intId;
586 }
587
588 $idPlaceholders = implode(',', array_fill(0, count($ids), '%d'));
589
590 $sql = "UPDATE `{$table}` SET content_keywords = CASE id\n "
591 . implode("\n ", $whenClauses)
592 . "\n END\n WHERE id IN ({$idPlaceholders})";
593
594 $allParams = array_merge($params, $ids);
595
596 $result = $this->dbCore->queryAndGetResults($sql, array('query_params' => $allParams));
597
598 $lastErrorRaw = $result['last_error'] ?? '';
599 $lastError = is_string($lastErrorRaw) ? $lastErrorRaw : '';
600 if ($lastError !== '') {
601 if (stripos($lastError, 'unknown column') !== false) {
602 $this->logger->warn("content_keywords column not yet available (DB migration pending): " . $lastError);
603 }
604 }
605 }
606
607 // =========================================================================
608 // Distributed refresh locks (self-contained, same pattern as ViewSnapshotCache)
609 // =========================================================================
610
611 /** @param string $cacheKey @return bool */
612 private function acquireRefreshLock(string $cacheKey): bool {
613 if (!function_exists('add_option')) {
614 return true;
615 }
616 if ($this->isRefreshLocked($cacheKey)) {
617 return false;
618 }
619 $lockKey = $this->getRefreshLockOptionName($cacheKey);
620 return (bool)add_option($lockKey, time(), '', false);
621 }
622
623 /** @param string $cacheKey @return void */
624 private function releaseRefreshLock(string $cacheKey): void {
625 if (function_exists('delete_option')) {
626 delete_option($this->getRefreshLockOptionName($cacheKey));
627 }
628 }
629
630 /** @param string $cacheKey @return bool */
631 private function isRefreshLocked(string $cacheKey): bool {
632 if (!function_exists('get_option')) {
633 return false;
634 }
635 $lockKey = $this->getRefreshLockOptionName($cacheKey);
636 $lockValue = get_option($lockKey, false);
637 if ($lockValue === false || $lockValue === '' || $lockValue === null) {
638 return false;
639 }
640 $lockTs = is_numeric($lockValue) ? (int)$lockValue : 0;
641 if ($lockTs > 0 && (time() - $lockTs) > self::REFRESH_LOCK_COOLDOWN_SECONDS) {
642 delete_option($lockKey);
643 return false;
644 }
645 return true;
646 }
647
648 /** @param string $cacheKey @return string */
649 private function getRefreshLockOptionName(string $cacheKey): string {
650 return $this->dbCore->getLowercasePrefix() . 'abj404_view_cache_lock_' . md5((string)$cacheKey);
651 }
652 }
653