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

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

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