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

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

1,055 lines 48.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 trait ABJ_404_Solution_DatabaseUpgradesEtc_NGramTrait {
8
9 /** @return bool */
10 function scheduleNGramCacheRebuild() {
11 global $wpdb;
12
13 // MULTISITE: Acquire network-wide lock to prevent race conditions during scheduling
14 $lockKey = 'ngram_schedule';
15 $uniqueID = $this->syncUtils->synchronizerAcquireLockTry($lockKey);
16
17 if (empty($uniqueID)) {
18 $this->logger->debugMessage("N-gram rebuild scheduling: Another process holds the lock. Skipping.");
19 return true; // Another site is already handling scheduling
20 }
21
22 try {
23 // MULTISITE: Use network-aware option getter
24 $rawCurrentOffset = $this->getNetworkAwareOption('abj404_ngram_rebuild_offset', 0);
25 $currentOffset = is_scalar($rawCurrentOffset) ? (int)$rawCurrentOffset : 0;
26
27 // MULTISITE: Count pages across all sites if network-activated
28 $totalPages = $this->countTotalPagesForNGramRebuild();
29
30 // If offset is between 0 and total (exclusive), rebuild is in progress
31 if ($currentOffset > 0 && $currentOffset < $totalPages) {
32 $this->logger->debugMessage("N-gram cache rebuild already in progress at offset {$currentOffset} of {$totalPages}");
33 return true;
34 }
35
36 // Check if already scheduled
37 $nextScheduled = wp_next_scheduled('abj404_rebuild_ngram_cache_hook');
38 if ($nextScheduled) {
39 $this->logger->debugMessage("N-gram cache rebuild already scheduled for " . date('Y-m-d H:i:s', $nextScheduled));
40 return true;
41 }
42
43 // MULTISITE: Reset offset using network-aware setter
44 $this->updateNetworkAwareOption('abj404_ngram_rebuild_offset', 0);
45
46 // Schedule to run in 30 seconds (gives time for activation to complete)
47 $scheduleTime = time() + 30;
48 $hookName = 'abj404_rebuild_ngram_cache_hook';
49 $scheduled = wp_schedule_single_event($scheduleTime, $hookName);
50
51 if ($scheduled === false) {
52 // Quick check for DISABLE_WP_CRON as immediate diagnostic
53 if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
54 $this->logger->errorMessage(
55 "Cannot schedule N-gram cache rebuild: WP-Cron is disabled (DISABLE_WP_CRON=true). " .
56 "Consider enabling WP-Cron or using server-side cron with a fallback mechanism."
57 );
58 return false;
59 }
60
61 global $wpdb;
62
63 // Gather comprehensive diagnostic information for troubleshooting
64 $cronDisabled = defined('DISABLE_WP_CRON') && DISABLE_WP_CRON;
65 $alreadyScheduled = wp_next_scheduled($hookName);
66 $dbError = !empty($wpdb->last_error) ? $wpdb->last_error : 'none';
67 $rawRebuildOffset = $this->getNetworkAwareOption('abj404_ngram_rebuild_offset', 'not set');
68 $rebuildOffset = is_scalar($rawRebuildOffset) ? (string)$rawRebuildOffset : 'not set';
69 $rawCacheInit = $this->getNetworkAwareOption('abj404_ngram_cache_initialized', 'not set');
70 $cacheInitialized = is_scalar($rawCacheInit) ? (string)$rawCacheInit : 'not set';
71
72 $errorMsg = sprintf(
73 "Failed to schedule N-gram cache rebuild. Hook: %s, Schedule time: %d (current: %d), " .
74 "Already scheduled: %s, WP-Cron disabled: %s, DB error: %s, " .
75 "Rebuild offset: %s, Cache initialized: %s, Multisite: %s, Blog ID: %d",
76 $hookName,
77 $scheduleTime,
78 time(),
79 $alreadyScheduled ? date('Y-m-d H:i:s', $alreadyScheduled) : 'no',
80 $cronDisabled ? 'yes' : 'no',
81 $dbError,
82 $rebuildOffset,
83 $cacheInitialized,
84 is_multisite() ? 'yes' : 'no',
85 get_current_blog_id()
86 );
87
88 // Pattern 7 (defense-in-depth): if a concurrent infra-level
89 // DB error (disk full, read-only, crashed table) contributed
90 // to wp_schedule_single_event() failing, surface it as a
91 // plugin-page admin notice. The cron failure itself remains
92 // ERROR level — the user must act on a broken cron — but
93 // the underlying hosting issue is the actionable cause.
94 if (!empty($wpdb->last_error)) {
95 $this->dao->classifyAndHandleInfrastructureError($wpdb->last_error);
96 }
97
98 $this->logger->errorMessage($errorMsg);
99 return false;
100 }
101
102 $context = is_multisite() ? ' (network-wide)' : '';
103 $this->logger->infoMessage("N-gram cache rebuild scheduled to start in 30 seconds{$context}.");
104 return true;
105
106 } finally {
107 // Always release the lock
108 $this->syncUtils->synchronizerReleaseLock($uniqueID, $lockKey);
109 }
110 }
111
112 /**
113 * WP-Cron callback: Rebuild N-gram cache in batches (async).
114 *
115 * Lock Acquisition Flow (per-batch):
116 * 1. Create unique ID
117 * 2. Write unique ID to lock if empty
118 * 3. Sleep 30ms (allows race condition resolution)
119 * 4. Read lock back and verify ownership
120 * 5. Process batch only if lock belongs to this process
121 * 6. Release lock in finally block
122 *
123 * MULTISITE BEHAVIOR (FIXED):
124 * - Processes one site at a time completely before moving to the next site
125 * - Uses network options to track: pending sites, current site, and offset within current site
126 * - Switches to each site's blog context before processing its pages
127 * - Prevents the bug where only the first site got cache entries
128 * - Progress tracking shows per-site and network-wide completion status
129 *
130 * SINGLE SITE BEHAVIOR:
131 * - Uses simple offset tracking with network-aware options
132 * - Processes all pages in batches until complete
133 *
134 * @param int $offset Current batch offset (default: 0, overridden by network options)
135 * @return void
136 */
137 function rebuildNGramCacheAsync($offset = 0) {
138 global $wpdb;
139
140 // Acquire lock using SynchronizationUtils (per-batch lock)
141 $uniqueID = $this->syncUtils->synchronizerAcquireLockTry('ngram_rebuild');
142 if (empty($uniqueID)) {
143 $this->logger->debugMessage("N-gram async rebuild batch already processing (another process holds lock). Skipping.");
144 return;
145 }
146
147 try {
148 $batchSize = 50; // Smaller batches for async processing
149 $maxBatchesPerRun = 20; // Process up to 1000 pages per cron run
150
151 // MULTISITE: Process one site at a time to ensure all sites get cache entries
152 if ($this->isNetworkActivated()) {
153 // Get or initialize list of pending sites
154 $pendingSitesRaw = $this->getNetworkAwareOption('abj404_ngram_pending_sites', null);
155 /** @var array<int, int> $pendingSites */
156 $pendingSites = is_array($pendingSitesRaw) ? $pendingSitesRaw : [];
157
158 if ($pendingSitesRaw === null) {
159 // First run: Initialize site list and tracking
160 $sites = get_sites(array('fields' => 'ids', 'number' => 0));
161 $this->updateNetworkAwareOption('abj404_ngram_pending_sites', $sites);
162 $this->updateNetworkAwareOption('abj404_ngram_total_sites', count($sites));
163 $this->updateNetworkAwareOption('abj404_ngram_current_site_offset', 0);
164 $pendingSites = $sites;
165 }
166
167 if (empty($pendingSites)) {
168 // All sites processed!
169 $this->updateNetworkAwareOption('abj404_ngram_cache_initialized', '1');
170 $this->updateNetworkAwareOption('abj404_ngram_pending_sites', null);
171 $this->updateNetworkAwareOption('abj404_ngram_total_sites', null);
172 $this->updateNetworkAwareOption('abj404_ngram_current_site_offset', null);
173 $this->logger->infoMessage("N-gram cache rebuild complete for all sites in network!");
174 return;
175 }
176
177 // Get current site to process
178 $currentSiteId = (int)$pendingSites[0];
179 $rawOffset = $this->getNetworkAwareOption('abj404_ngram_current_site_offset', 0);
180 $offset = is_scalar($rawOffset) ? (int)$rawOffset : 0;
181 $rawTotalSites = $this->getNetworkAwareOption('abj404_ngram_total_sites', count($pendingSites));
182 $totalSites = is_scalar($rawTotalSites) ? (int)$rawTotalSites : count($pendingSites);
183 $completedSites = $totalSites - count($pendingSites);
184
185 // Switch to the site being processed
186 switch_to_blog($currentSiteId);
187
188 // Count pages for THIS site only
189 $permalinkCacheTable = $this->dao->getPrefixedTableName('abj404_permalink_cache');
190 $sitePages = $this->dao->queryScalarInt("SELECT COUNT(*) AS c FROM {$permalinkCacheTable}");
191
192 if ($sitePages == 0) {
193 // This site has no pages, move to next site
194 array_shift($pendingSites);
195 $this->updateNetworkAwareOption('abj404_ngram_pending_sites', $pendingSites);
196 $this->updateNetworkAwareOption('abj404_ngram_current_site_offset', 0);
197 restore_current_blog();
198
199 $this->logger->infoMessage(sprintf(
200 "Site %d has no pages. Moving to next site. Progress: %d/%d sites completed.",
201 $currentSiteId,
202 $completedSites + 1,
203 $totalSites
204 ));
205
206 // Reschedule immediately for next site
207 wp_schedule_single_event(time(), 'abj404_rebuild_ngram_cache_hook');
208 return;
209 }
210
211 $this->logger->infoMessage(sprintf(
212 "Processing N-gram cache for site %d (Site %d of %d): Offset %d of %d pages",
213 $currentSiteId,
214 $completedSites + 1,
215 $totalSites,
216 $offset,
217 $sitePages
218 ));
219
220 // Process batches for current site
221 $batchesProcessed = 0;
222 $totalStats = ['processed' => 0, 'success' => 0, 'failed' => 0];
223
224 while ($batchesProcessed < $maxBatchesPerRun && $offset < $sitePages) {
225 try {
226 // Process batch (already switched to correct blog)
227 $stats = $this->ngramFilter->rebuildCache($batchSize, $offset);
228
229 $totalStats['processed'] += $stats['processed'];
230 $totalStats['success'] += $stats['success'];
231 $totalStats['failed'] += $stats['failed'];
232
233 $offset += $batchSize;
234 $batchesProcessed++;
235
236 // Update offset for current site
237 $this->updateNetworkAwareOption('abj404_ngram_current_site_offset', $offset);
238
239 // Stop if we processed fewer pages than expected (end of site data)
240 if ($stats['processed'] < $batchSize) {
241 break;
242 }
243
244 } catch (Exception $e) {
245 $this->logger->errorMessage("Error during N-gram rebuild for site {$currentSiteId} at offset {$offset}: " . $e->getMessage());
246 $totalStats['failed'] += $batchSize;
247 $offset += $batchSize;
248 $batchesProcessed++;
249 $this->updateNetworkAwareOption('abj404_ngram_current_site_offset', $offset);
250 }
251 }
252
253 $progress = $sitePages > 0 ? min(100, round(($offset / $sitePages) * 100, 1)) : 100;
254
255 $this->logger->infoMessage(sprintf(
256 "Site %d progress: %d%% complete (%d/%d pages), %d success, %d failed",
257 $currentSiteId,
258 $progress,
259 $offset,
260 $sitePages,
261 $totalStats['success'],
262 $totalStats['failed']
263 ));
264
265 // Check if current site is complete
266 if ($offset >= $sitePages) {
267 // Site complete! Move to next site
268 array_shift($pendingSites);
269 $this->updateNetworkAwareOption('abj404_ngram_pending_sites', $pendingSites);
270 $this->updateNetworkAwareOption('abj404_ngram_current_site_offset', 0);
271
272 $this->logger->infoMessage(sprintf(
273 "Site %d complete! Progress: %d/%d sites completed.",
274 $currentSiteId,
275 $completedSites + 1,
276 $totalSites
277 ));
278 }
279
280 restore_current_blog();
281
282 // Reschedule for next batch or next site
283 wp_schedule_single_event(time() + 10, 'abj404_rebuild_ngram_cache_hook');
284
285 } else {
286 // SINGLE SITE: Use original simple logic
287 $rawSingleOffset = $this->getNetworkAwareOption('abj404_ngram_rebuild_offset', 0);
288 $offset = is_scalar($rawSingleOffset) ? (int)$rawSingleOffset : 0;
289 $permalinkCacheTable = $this->dao->getPrefixedTableName('abj404_permalink_cache');
290 $totalPages = $this->dao->queryScalarInt("SELECT COUNT(*) AS c FROM {$permalinkCacheTable}");
291
292 if ($totalPages == 0) {
293 $this->logger->debugMessage("No pages to process. Setting initialized flag.");
294 $this->updateNetworkAwareOption('abj404_ngram_cache_initialized', '1');
295 $this->updateNetworkAwareOption('abj404_ngram_rebuild_offset', 0);
296 return;
297 }
298
299 $this->logger->infoMessage(sprintf(
300 "Async N-gram rebuild: Processing batch at offset %d of %d total pages",
301 $offset,
302 $totalPages
303 ));
304
305 // Process batches
306 $batchesProcessed = 0;
307 $totalStats = ['processed' => 0, 'success' => 0, 'failed' => 0];
308
309 while ($batchesProcessed < $maxBatchesPerRun && $offset < $totalPages) {
310 try {
311 $stats = $this->ngramFilter->rebuildCache($batchSize, $offset);
312
313 $totalStats['processed'] += $stats['processed'];
314 $totalStats['success'] += $stats['success'];
315 $totalStats['failed'] += $stats['failed'];
316
317 $offset += $batchSize;
318 $batchesProcessed++;
319
320 $this->updateNetworkAwareOption('abj404_ngram_rebuild_offset', $offset);
321
322 if ($stats['processed'] < $batchSize) {
323 break;
324 }
325
326 } catch (Exception $e) {
327 $this->logger->errorMessage("Error during async N-gram cache rebuild at offset {$offset}: " . $e->getMessage());
328 $totalStats['failed'] += $batchSize;
329 $offset += $batchSize;
330 $batchesProcessed++;
331 $this->updateNetworkAwareOption('abj404_ngram_rebuild_offset', $offset);
332 }
333 }
334
335 $progress = $totalPages > 0 ? min(100, round(($offset / $totalPages) * 100, 1)) : 100;
336
337 $this->logger->infoMessage(sprintf(
338 "Async N-gram rebuild progress: %d%% complete (%d/%d pages), %d success, %d failed",
339 $progress,
340 $offset,
341 $totalPages,
342 $totalStats['success'],
343 $totalStats['failed']
344 ));
345
346 if ($offset < $totalPages) {
347 $scheduleTime = time() + 10;
348 $hookName = 'abj404_rebuild_ngram_cache_hook';
349 $scheduled = wp_schedule_single_event($scheduleTime, $hookName, [$offset]);
350
351 if ($scheduled === false) {
352 // Quick check for DISABLE_WP_CRON as immediate diagnostic
353 if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
354 $this->logger->errorMessage(
355 "Cannot schedule next N-gram rebuild batch at offset {$offset}: WP-Cron is disabled (DISABLE_WP_CRON=true). " .
356 "Consider enabling WP-Cron or using server-side cron with a fallback mechanism."
357 );
358 // Don't return - let the rebuild complete gracefully, just log the issue
359 } else {
360 global $wpdb;
361
362 // Gather comprehensive diagnostic information for troubleshooting
363 $cronDisabled = defined('DISABLE_WP_CRON') && DISABLE_WP_CRON;
364 $alreadyScheduled = wp_next_scheduled($hookName, [$offset]);
365 $dbError = !empty($wpdb->last_error) ? $wpdb->last_error : 'none';
366 $rawCacheInit2 = $this->getNetworkAwareOption('abj404_ngram_cache_initialized', 'not set');
367 $cacheInitialized = is_scalar($rawCacheInit2) ? (string)$rawCacheInit2 : 'not set';
368
369 $errorMsg = sprintf(
370 "Failed to schedule next N-gram rebuild batch at offset %d. Hook: %s, Schedule time: %d (current: %d), " .
371 "Already scheduled: %s, WP-Cron disabled: %s, DB error: %s, " .
372 "Cache initialized: %s, Progress: %.1f%%, Multisite: %s, Blog ID: %d",
373 $offset,
374 $hookName,
375 $scheduleTime,
376 time(),
377 $alreadyScheduled ? date('Y-m-d H:i:s', $alreadyScheduled) : 'no',
378 $cronDisabled ? 'yes' : 'no',
379 $dbError,
380 $cacheInitialized,
381 $progress,
382 is_multisite() ? 'yes' : 'no',
383 get_current_blog_id()
384 );
385
386 // Pattern 7 (defense-in-depth): if a concurrent
387 // infra-level DB error contributed to the cron
388 // failure, surface the hosting cause as a
389 // plugin-page admin notice.
390 if (!empty($wpdb->last_error)) {
391 $this->dao->classifyAndHandleInfrastructureError($wpdb->last_error);
392 }
393
394 $this->logger->errorMessage($errorMsg);
395 }
396 }
397 } else {
398 // All done!
399 $this->updateNetworkAwareOption('abj404_ngram_cache_initialized', '1');
400 $this->updateNetworkAwareOption('abj404_ngram_rebuild_offset', 0);
401 $this->logger->infoMessage("N-gram cache rebuild complete! Total: {$totalStats['processed']} processed, {$totalStats['success']} success, {$totalStats['failed']} failed.");
402 }
403 }
404
405 } finally {
406 // Always release lock, even if exception occurs
407 $this->syncUtils->synchronizerReleaseLock($uniqueID, 'ngram_rebuild');
408 }
409 }
410
411 /**
412 * Rebuild the N-gram cache for all pages (synchronous).
413 *
414 * WARNING: This method is synchronous and can take minutes on large sites.
415 * Use scheduleNGramCacheRebuild() instead for non-blocking background processing.
416 *
417 * This method is kept for manual rebuilds and testing purposes.
418 *
419 * @param int $batchSize Number of pages to process per batch (default: 100)
420 * @param bool $forceRebuild Force rebuild even if cache is already populated (default: false)
421 * @return array<string, mixed> Statistics: ['total_pages' => int, 'processed' => int, 'success' => int, 'failed' => int]
422 */
423 function rebuildNGramCache($batchSize = 100, $forceRebuild = false) {
424 global $wpdb;
425
426 // Use the same SynchronizationUtils lock as rebuildNGramCacheAsync() to
427 // prevent TRUNCATE TABLE from racing with async batch inserts.
428 $lockKey = 'ngram_rebuild';
429 $uniqueID = $this->syncUtils->synchronizerAcquireLockTry($lockKey);
430 if (empty($uniqueID)) {
431 $this->logger->infoMessage("N-gram rebuild already in progress (locked). Skipping.");
432 return [
433 'total_pages' => 0,
434 'processed' => 0,
435 'success' => 0,
436 'failed' => 0,
437 'locked' => true
438 ];
439 }
440
441 try {
442 $ngramTable = $this->dao->getPrefixedTableName('abj404_ngram_cache');
443 $permalinkCacheTable = $this->dao->getPrefixedTableName('abj404_permalink_cache');
444
445 // Check if cache is already populated (unless force rebuild)
446 if (!$forceRebuild) {
447 $existingCount = $this->dao->queryScalarInt("SELECT COUNT(*) AS c FROM {$ngramTable}");
448 if ($existingCount > 0) {
449 $this->logger->debugMessage("N-gram cache already contains {$existingCount} entries. Skipping rebuild (use forceRebuild=true to override).");
450 return [
451 'total_pages' => $existingCount,
452 'processed' => 0,
453 'success' => $existingCount,
454 'failed' => 0,
455 'skipped' => true
456 ];
457 }
458 }
459
460 $this->logger->debugMessage("Starting N-gram cache rebuild...");
461
462 // Clear existing N-gram cache (only if force rebuild or empty).
463 // skip_repair: TRUNCATE itself is the recovery path during rebuild;
464 // we must not recurse into the missing-table repairer here.
465 $truncateResult = $this->dao->queryAndGetResults(
466 "TRUNCATE TABLE {$ngramTable}",
467 ['skip_repair' => true]
468 );
469 $truncateError = isset($truncateResult['last_error']) && is_string($truncateResult['last_error']) ? $truncateResult['last_error'] : '';
470 if ($truncateError !== '') {
471 if (!$this->dao->classifyAndHandleInfrastructureError($truncateError)) {
472 $this->logger->errorMessage("Failed to truncate N-gram cache table: " . $truncateError);
473 }
474 return ['total_pages' => 0, 'processed' => 0, 'success' => 0, 'failed' => 1, 'error' => $truncateError];
475 }
476
477 // Invalidate coverage ratio caches immediately after truncate
478 // This prevents stale transient data from making SpellChecker believe
479 // the cache is populated when it's actually empty
480 $this->ngramFilter->invalidateCoverageCaches();
481
482 // Get total page count from permalink cache
483 $totalPagesResult = $this->dao->queryAndGetResults("SELECT COUNT(*) AS c FROM {$permalinkCacheTable}");
484 $totalPagesRows = isset($totalPagesResult['rows']) && is_array($totalPagesResult['rows']) ? $totalPagesResult['rows'] : [];
485 $totalPagesRow = $totalPagesRows[0] ?? null;
486
487 if (!is_array($totalPagesRow) || !isset($totalPagesRow['c'])) {
488 $countError = isset($totalPagesResult['last_error']) && is_string($totalPagesResult['last_error']) ? $totalPagesResult['last_error'] : '';
489 if (!$this->dao->classifyAndHandleInfrastructureError($countError)) {
490 $this->logger->errorMessage("Failed to query permalink cache table: " . $countError);
491 }
492 return ['total_pages' => 0, 'processed' => 0, 'success' => 0, 'failed' => 1, 'error' => $countError];
493 }
494 $totalPages = is_scalar($totalPagesRow['c']) ? (int)$totalPagesRow['c'] : 0;
495
496 if ($totalPages == 0) {
497 $this->logger->debugMessage("No pages in permalink cache. N-gram cache rebuild skipped (will rebuild when pages are added).");
498 return ['total_pages' => 0, 'processed' => 0, 'success' => 0, 'failed' => 0];
499 }
500
501 $this->logger->infoMessage("Rebuilding N-gram cache for {$totalPages} pages in batches of {$batchSize}...");
502
503 // Process in batches
504 $offset = 0;
505 $totalStats = ['processed' => 0, 'success' => 0, 'failed' => 0];
506
507 while ($offset < $totalPages) {
508 try {
509 $stats = $this->ngramFilter->rebuildCache($batchSize, $offset);
510
511 $totalStats['processed'] += $stats['processed'];
512 $totalStats['success'] += $stats['success'];
513 $totalStats['failed'] += $stats['failed'];
514
515 $offset += $batchSize;
516
517 // Stop if we processed fewer pages than expected (end of data)
518 if ($stats['processed'] < $batchSize) {
519 break;
520 }
521
522 } catch (Exception $e) {
523 $this->logger->errorMessage("Error during N-gram cache rebuild at offset {$offset}: " . $e->getMessage());
524 $totalStats['failed'] += $batchSize; // Mark batch as failed
525 $offset += $batchSize; // Continue to next batch
526 }
527 }
528
529 $totalStats['total_pages'] = $totalPages;
530
531 $successRate = $totalStats['processed'] > 0 ?
532 round(($totalStats['success'] / $totalStats['processed']) * 100, 1) : 0;
533
534 $this->logger->infoMessage(sprintf(
535 "N-gram cache rebuild complete: %d pages processed, %d success, %d failed (%.1f%% success rate)",
536 $totalStats['processed'],
537 $totalStats['success'],
538 $totalStats['failed'],
539 $successRate
540 ));
541
542 return $totalStats;
543
544 } finally {
545 // Always release the lock
546 $this->syncUtils->synchronizerReleaseLock($uniqueID, $lockKey);
547 }
548 }
549
550 /**
551 * Sync missing ngram entries for posts/pages and categories that don't have them yet.
552 * This runs as a background task to add entries for newly published content.
553 *
554 * Uses the same lock as rebuildNGramCache to prevent concurrent execution.
555 *
556 * @param int $batchSize Number of entries to process per batch (default: 50)
557 * @return array<string, mixed> Statistics: ['posts_added' => int, 'posts_failed' => int, 'categories_added' => int, 'categories_failed' => int]
558 */
559 function syncMissingNGrams($batchSize = 50) {
560 global $wpdb;
561
562 // Use the same SynchronizationUtils lock as rebuildNGramCacheAsync() and
563 // rebuildNGramCache() to prevent concurrent modification of the ngram table.
564 $lockKey = 'ngram_rebuild';
565 $uniqueID = $this->syncUtils->synchronizerAcquireLockTry($lockKey);
566 if (empty($uniqueID)) {
567 $this->logger->debugMessage("Ngram sync skipped - rebuild/sync already in progress.");
568 return ['posts_added' => 0, 'posts_failed' => 0, 'categories_added' => 0, 'categories_failed' => 0, 'locked' => true];
569 }
570
571 try {
572 $ngramTable = $this->dao->getPrefixedTableName('abj404_ngram_cache');
573 $permalinkCacheTable = $this->dao->getPrefixedTableName('abj404_permalink_cache');
574
575 $stats = ['posts_added' => 0, 'posts_failed' => 0, 'categories_added' => 0, 'categories_failed' => 0];
576
577 // ===== SYNC POSTS =====
578 // Find posts in permalink cache that don't have ngram entries
579 // Using LEFT JOIN to find missing entries
580 $missingResult = $this->dao->queryAndGetResults(
581 "SELECT pc.id
582 FROM {$permalinkCacheTable} pc
583 LEFT JOIN {$ngramTable} ng ON pc.id = ng.id AND ng.type = 'post'
584 WHERE ng.id IS NULL
585 LIMIT %d",
586 ['query_params' => [$batchSize]]
587 );
588
589 $missingError = isset($missingResult['last_error']) && is_string($missingResult['last_error']) ? $missingResult['last_error'] : '';
590 if ($missingError !== '') {
591 if (!$this->dao->classifyAndHandleInfrastructureError($missingError)) {
592 $this->logger->errorMessage("Failed to query for missing post ngram entries: " . $missingError);
593 }
594 return array_merge($stats, ['error' => $missingError]);
595 }
596 $missingRows = isset($missingResult['rows']) && is_array($missingResult['rows']) ? $missingResult['rows'] : [];
597 $missingIds = [];
598 foreach ($missingRows as $row) {
599 if (is_array($row) && isset($row['id'])) {
600 $missingIds[] = $row['id'];
601 }
602 }
603
604 if (!empty($missingIds)) {
605 $this->logger->infoMessage("Found " . count($missingIds) . " posts missing ngram entries. Adding...");
606
607 // Add ngrams for missing posts
608 $result = $this->ngramFilter->updateNGramsForPages($missingIds);
609
610 $stats['posts_added'] = $result['success'];
611 $stats['posts_failed'] = $result['failed'];
612 } else {
613 $this->logger->debugMessage("No missing post ngram entries found. All posts are synced.");
614 }
615
616 // ===== SYNC CATEGORIES =====
617 // Get all published categories
618 $categories = $this->dao->getPublishedCategories();
619
620 if (!empty($categories)) {
621 $missingCategories = [];
622
623 // Check which categories are missing from ngram cache
624 foreach ($categories as $category) {
625 /** @var object{term_id: int, url: string} $category */
626 $termId = (int)$category->term_id;
627
628 // Check if this category already has an ngram entry
629 $exists = $this->dao->queryScalarInt(
630 "SELECT COUNT(*) AS c FROM {$ngramTable} WHERE id = %d AND type = 'category'",
631 ['query_params' => [$termId]]
632 );
633
634 if ($exists == 0) {
635 $missingCategories[] = $category;
636 }
637 }
638
639 if (!empty($missingCategories)) {
640 $this->logger->infoMessage("Found " . count($missingCategories) . " categories missing ngram entries. Adding...");
641
642 // Add ngrams for missing categories
643 foreach ($missingCategories as $category) {
644 try {
645 /** @var object{term_id: int, url: string} $category */
646 $termId = (int)$category->term_id;
647 $url = (string)$category->url;
648
649 if (empty($url) || $url === 'in code') {
650 $this->logger->debugMessage("Skipping category {$termId} - no valid URL");
651 continue;
652 }
653
654 // Normalize URL
655 $urlNormalized = $this->f->strtolower(trim($url));
656
657 // Extract N-grams
658 $ngrams = $this->ngramFilter->extractNGrams($urlNormalized);
659
660 // Store with type='category'
661 $success = $this->ngramFilter->storeNGrams($termId, $url, $urlNormalized, $ngrams, 'category');
662
663 if ($success) {
664 $stats['categories_added']++;
665 } else {
666 $stats['categories_failed']++;
667 }
668 } catch (Exception $e) {
669 $this->logger->errorMessage("Failed to add ngram for category {$termId}: " . $e->getMessage());
670 $stats['categories_failed']++;
671 }
672 }
673 } else {
674 $this->logger->debugMessage("No missing category ngram entries found. All categories are synced.");
675 }
676 }
677
678 $this->logger->infoMessage("Ngram sync complete: {$stats['posts_added']} posts added, {$stats['posts_failed']} posts failed, {$stats['categories_added']} categories added, {$stats['categories_failed']} categories failed.");
679
680 return $stats;
681
682 } finally {
683 // Always release the lock
684 $this->syncUtils->synchronizerReleaseLock($uniqueID, $lockKey);
685 }
686 }
687
688 /**
689 * Cleanup orphaned ngram entries that don't have corresponding posts/pages or categories.
690 * This removes stale entries when posts are deleted or categories are removed.
691 *
692 * @return array<string, mixed> Statistics: ['posts_deleted' => int, 'categories_deleted' => int, 'errors' => int]
693 */
694 function cleanupOrphanedNGrams() {
695 $ngramTable = $this->dao->getPrefixedTableName('abj404_ngram_cache');
696 $permalinkCacheTable = $this->dao->getPrefixedTableName('abj404_permalink_cache');
697
698 $this->logger->debugMessage("Checking for orphaned ngram entries...");
699
700 $stats = ['posts_deleted' => 0, 'categories_deleted' => 0, 'errors' => 0];
701
702 // ===== CLEANUP ORPHANED POSTS =====
703 // Find ngram entries for posts that don't exist in permalink cache
704 // Using LEFT JOIN to find orphaned entries
705 $orphanedResult = $this->dao->queryAndGetResults(
706 "SELECT ng.id, ng.type
707 FROM {$ngramTable} ng
708 LEFT JOIN {$permalinkCacheTable} pc ON ng.id = pc.id AND ng.type = 'post'
709 WHERE ng.type = 'post' AND pc.id IS NULL",
710 ['result_type' => OBJECT]
711 );
712
713 $orphanedError = isset($orphanedResult['last_error']) && is_string($orphanedResult['last_error']) ? $orphanedResult['last_error'] : '';
714 if ($orphanedError !== '') {
715 if (!$this->dao->classifyAndHandleInfrastructureError($orphanedError)) {
716 $this->logger->errorMessage("Failed to query for orphaned post ngram entries: " . $orphanedError);
717 }
718 return array_merge($stats, ['error' => $orphanedError]);
719 }
720 $orphanedPosts = isset($orphanedResult['rows']) && is_array($orphanedResult['rows']) ? $orphanedResult['rows'] : [];
721
722 if (!empty($orphanedPosts)) {
723 $this->logger->infoMessage("Found " . count($orphanedPosts) . " orphaned post ngram entries. Deleting...");
724
725 // Delete each orphaned post entry
726 foreach ($orphanedPosts as $entry) {
727 if (!is_object($entry)) {
728 continue;
729 }
730 /** @var object{id: int, type: string} $entry */
731 $entryId = (int)$entry->id;
732 $entryType = (string)$entry->type;
733 $deleteResult = $this->dao->queryAndGetResults(
734 "DELETE FROM {$ngramTable} WHERE id = %d AND type = %s",
735 ['query_params' => [$entryId, $entryType]]
736 );
737
738 $deleteError = isset($deleteResult['last_error']) && is_string($deleteResult['last_error']) ? $deleteResult['last_error'] : '';
739 if ($deleteError !== '') {
740 if (!$this->dao->classifyAndHandleInfrastructureError($deleteError)) {
741 $this->logger->errorMessage("Failed to delete orphaned post ngram entry ID {$entryId}: " . $deleteError);
742 }
743 $stats['errors']++;
744 } else {
745 $stats['posts_deleted']++;
746 }
747 }
748 } else {
749 $this->logger->debugMessage("No orphaned post ngram entries found.");
750 }
751
752 // ===== CLEANUP ORPHANED CATEGORIES =====
753 // Get all published categories
754 $publishedCategories = $this->dao->getPublishedCategories();
755 $publishedCategoryIds = [];
756
757 if (!empty($publishedCategories)) {
758 foreach ($publishedCategories as $category) {
759 /** @var object{term_id: int, url: string} $category */
760 $publishedCategoryIds[] = (int)$category->term_id;
761 }
762 }
763
764 // Get all category ngram entries
765 $catEntriesResult = $this->dao->queryAndGetResults(
766 "SELECT DISTINCT id FROM {$ngramTable} WHERE type = 'category'",
767 ['result_type' => OBJECT]
768 );
769 $categoryNGramEntries = isset($catEntriesResult['rows']) && is_array($catEntriesResult['rows']) ? $catEntriesResult['rows'] : [];
770
771 if (!empty($categoryNGramEntries)) {
772 $orphanedCategories = [];
773
774 // Find category ngram entries that don't have corresponding published categories
775 foreach ($categoryNGramEntries as $entry) {
776 if (!is_object($entry)) {
777 continue;
778 }
779 /** @var object{id: int} $entry */
780 $entId = (int)$entry->id;
781 if (!in_array($entId, $publishedCategoryIds)) {
782 $orphanedCategories[] = $entId;
783 }
784 }
785
786 if (!empty($orphanedCategories)) {
787 $this->logger->infoMessage("Found " . count($orphanedCategories) . " orphaned category ngram entries. Deleting...");
788
789 // Delete orphaned category entries
790 foreach ($orphanedCategories as $categoryId) {
791 $catDeleteResult = $this->dao->queryAndGetResults(
792 "DELETE FROM {$ngramTable} WHERE id = %d AND type = %s",
793 ['query_params' => [$categoryId, 'category']]
794 );
795
796 $catDeleteError = isset($catDeleteResult['last_error']) && is_string($catDeleteResult['last_error']) ? $catDeleteResult['last_error'] : '';
797 if ($catDeleteError !== '') {
798 if (!$this->dao->classifyAndHandleInfrastructureError($catDeleteError)) {
799 $this->logger->errorMessage("Failed to delete orphaned category ngram entry ID {$categoryId}: " . $catDeleteError);
800 }
801 $stats['errors']++;
802 } else {
803 $stats['categories_deleted']++;
804 }
805 }
806 } else {
807 $this->logger->debugMessage("No orphaned category ngram entries found.");
808 }
809 }
810
811 $this->logger->infoMessage("Orphaned ngram cleanup complete: {$stats['posts_deleted']} posts deleted, {$stats['categories_deleted']} categories deleted, {$stats['errors']} errors.");
812
813 return $stats;
814 }
815
816 /**
817 * Build ngrams for all categories.
818 * Should be called during initial setup or manual rebuild.
819 *
820 * @param int $batchSize Number of categories to process per batch (default: 50)
821 * @return array<string, int> Statistics: ['processed' => int, 'success' => int, 'failed' => int]
822 */
823 function buildNGramsForCategories($batchSize = 50) {
824 $this->logger->debugMessage("Building N-grams for categories...");
825
826 $categories = $this->dao->getPublishedCategories();
827
828 if (empty($categories)) {
829 $this->logger->debugMessage("No published categories found.");
830 return ['processed' => 0, 'success' => 0, 'failed' => 0];
831 }
832
833 $stats = ['processed' => 0, 'success' => 0, 'failed' => 0];
834
835 foreach ($categories as $category) {
836 try {
837 /** @var object{term_id: int, url: string} $category */
838 $termId = (int)$category->term_id;
839 $url = (string)$category->url;
840
841 if (empty($url) || $url === 'in code') {
842 $this->logger->debugMessage("Skipping category {$termId} - no valid URL");
843 continue;
844 }
845
846 // Normalize URL
847 $urlNormalized = $this->f->strtolower(trim($url));
848
849 // Extract N-grams
850 $ngrams = $this->ngramFilter->extractNGrams($urlNormalized);
851
852 // Store with type='category'
853 $success = $this->ngramFilter->storeNGrams($termId, $url, $urlNormalized, $ngrams, 'category');
854
855 $stats['processed']++;
856 if ($success) {
857 $stats['success']++;
858 } else {
859 $stats['failed']++;
860 }
861 } catch (Exception $e) {
862 $this->logger->errorMessage("Failed to build ngram for category {$termId}: " . $e->getMessage());
863 $stats['processed']++;
864 $stats['failed']++;
865 }
866 }
867
868 $this->logger->infoMessage("Category N-grams built: {$stats['processed']} processed, {$stats['success']} success, {$stats['failed']} failed.");
869
870 return $stats;
871 }
872
873 /**
874 * Build ngrams for all tags.
875 * Should be called during initial setup or manual rebuild.
876 *
877 * @param int $batchSize Number of tags to process per batch (default: 50)
878 * @return array<string, int> Statistics: ['processed' => int, 'success' => int, 'failed' => int]
879 */
880 function buildNGramsForTags($batchSize = 50) {
881 $this->logger->debugMessage("Building N-grams for tags...");
882
883 $tags = $this->dao->getPublishedTags();
884
885 if (empty($tags)) {
886 $this->logger->debugMessage("No published tags found.");
887 return ['processed' => 0, 'success' => 0, 'failed' => 0];
888 }
889
890 $stats = ['processed' => 0, 'success' => 0, 'failed' => 0];
891
892 foreach ($tags as $tag) {
893 try {
894 /** @var object{term_id: int, url: string} $tag */
895 $termId = (int)$tag->term_id;
896 $url = (string)$tag->url;
897
898 if (empty($url) || $url === 'in code') {
899 $this->logger->debugMessage("Skipping tag {$termId} - no valid URL");
900 continue;
901 }
902
903 // Normalize URL
904 $urlNormalized = $this->f->strtolower(trim($url));
905
906 // Extract N-grams
907 $ngrams = $this->ngramFilter->extractNGrams($urlNormalized);
908
909 // Store with type='tag'
910 $success = $this->ngramFilter->storeNGrams($termId, $url, $urlNormalized, $ngrams, 'tag');
911
912 $stats['processed']++;
913 if ($success) {
914 $stats['success']++;
915 } else {
916 $stats['failed']++;
917 }
918 } catch (Exception $e) {
919 $this->logger->errorMessage("Failed to build ngram for tag {$termId}: " . $e->getMessage());
920 $stats['processed']++;
921 $stats['failed']++;
922 }
923 }
924
925 $this->logger->infoMessage("Tag N-grams built: {$stats['processed']} processed, {$stats['success']} success, {$stats['failed']} failed.");
926
927 return $stats;
928 }
929
930 /**
931 * Build ngrams for all content types (posts, pages, categories, tags).
932 * This is the comprehensive rebuild that should be called from the Tools page.
933 *
934 * @param int $batchSize Number of items to process per batch
935 * @return array<string, mixed> Combined statistics
936 */
937 function buildNGramsForAllContent($batchSize = 100) {
938 $this->logger->infoMessage("Starting comprehensive N-gram cache build for all content types...");
939
940 // Rebuild posts/pages (existing functionality)
941 $postsStats = $this->rebuildNGramCache($batchSize, true);
942
943 // Build categories
944 $categoriesStats = $this->buildNGramsForCategories($batchSize);
945
946 // Build tags
947 $tagsStats = $this->buildNGramsForTags($batchSize);
948
949 $totalStats = [
950 'posts' => $postsStats,
951 'categories' => $categoriesStats,
952 'tags' => $tagsStats,
953 'total_processed' => ($postsStats['processed'] ?? 0) + ($categoriesStats['processed'] ?? 0) + ($tagsStats['processed'] ?? 0),
954 'total_success' => ($postsStats['success'] ?? 0) + ($categoriesStats['success'] ?? 0) + ($tagsStats['success'] ?? 0),
955 'total_failed' => ($postsStats['failed'] ?? 0) + ($categoriesStats['failed'] ?? 0) + ($tagsStats['failed'] ?? 0)
956 ];
957
958 $this->logger->infoMessage("Comprehensive N-gram build complete: {$totalStats['total_processed']} total processed, {$totalStats['total_success']} success, {$totalStats['total_failed']} failed.");
959
960 return $totalStats;
961 }
962
963 /**
964 * Check if the plugin is network-activated in a multisite environment.
965 *
966 * @return bool True if network-activated, false otherwise
967 */
968 private function isNetworkActivated() {
969 if (!is_multisite()) {
970 return false;
971 }
972
973 if (!function_exists('is_plugin_active_for_network')) {
974 require_once ABSPATH . '/wp-admin/includes/plugin.php';
975 }
976
977 return is_plugin_active_for_network(plugin_basename(ABJ404_FILE));
978 }
979
980 /**
981 * Get an option value, using network-wide storage in multisite when network-activated.
982 *
983 * MULTISITE BEHAVIOR:
984 * - Network-activated: Uses get_site_option() for network-wide state
985 * - Single-site or per-site activation: Uses get_option() for site-specific state
986 *
987 * This ensures that N-gram rebuild state is shared across all sites in network-activated
988 * scenarios, preventing race conditions and duplicate work.
989 *
990 * @param string $option_name The option name
991 * @param mixed $default Default value if option doesn't exist
992 * @return mixed The option value
993 */
994 private function getNetworkAwareOption($option_name, $default = false) {
995 if ($this->isNetworkActivated()) {
996 return get_site_option($option_name, $default);
997 }
998 return get_option($option_name, $default);
999 }
1000
1001 /**
1002 * Update an option value, using network-wide storage in multisite when network-activated.
1003 *
1004 * MULTISITE BEHAVIOR:
1005 * - Network-activated: Uses update_site_option() for network-wide state
1006 * - Single-site or per-site activation: Uses update_option() for site-specific state
1007 *
1008 * This ensures that N-gram rebuild state is shared across all sites in network-activated
1009 * scenarios, preventing race conditions and duplicate work.
1010 *
1011 * @param string $option_name The option name
1012 * @param mixed $value The value to store
1013 * @return bool True if updated successfully
1014 */
1015 private function updateNetworkAwareOption($option_name, $value) {
1016 if ($this->isNetworkActivated()) {
1017 return update_site_option($option_name, $value);
1018 }
1019 return update_option($option_name, $value);
1020 }
1021
1022 /**
1023 * Count total pages for N-gram rebuild across all sites if network-activated.
1024 *
1025 * MULTISITE BEHAVIOR:
1026 * - Network-activated: Counts permalink cache entries across ALL sites in the network
1027 * - Single-site: Counts only current site's permalink cache entries
1028 *
1029 * This allows the rebuild process to accurately track progress when processing
1030 * pages from multiple sites.
1031 *
1032 * @return int Total number of pages to process
1033 */
1034 private function countTotalPagesForNGramRebuild() {
1035 if (!$this->isNetworkActivated()) {
1036 // Single site: count only current site's pages
1037 $permalinkCacheTable = $this->dao->getPrefixedTableName('abj404_permalink_cache');
1038 return $this->dao->queryScalarInt("SELECT COUNT(*) AS c FROM {$permalinkCacheTable}");
1039 }
1040
1041 // Multisite network-activated: count pages across all sites
1042 $sites = get_sites(array('fields' => 'ids', 'number' => 0));
1043 $totalPages = 0;
1044
1045 foreach ($sites as $blog_id) {
1046 switch_to_blog($blog_id);
1047 $permalinkCacheTable = $this->dao->getPrefixedTableName('abj404_permalink_cache');
1048 $totalPages += $this->dao->queryScalarInt("SELECT COUNT(*) AS c FROM {$permalinkCacheTable}");
1049 restore_current_blog();
1050 }
1051
1052 return $totalPages;
1053 }
1054 }
1055