PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 / ngram / NGramCacheSyncRebuilder.php

NGramCacheSyncRebuilder.php in 404 Solution trunk, at includes/ngram/NGramCacheSyncRebuilder.php

228 lines 9.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 /**
8 * Synchronous (blocking) full rebuild of the N-gram cache from
9 * permalink_cache. Used by manual rebuilds and by the all-content
10 * orchestrator's first phase.
11 *
12 * WARNING: this path is synchronous and can take minutes on large
13 * sites. The cron-driven async rebuild
14 * (NGramCacheRebuildScheduler::runAsyncBatch) is the standard path;
15 * this collaborator is for manual tools and tests.
16 *
17 * Lock ownership: the orchestrator (DatabaseUpgradeNGram) acquires
18 * the shared 'ngram_rebuild' SyncUtils lock before calling. This
19 * collaborator assumes the lock is held and is therefore safe to
20 * TRUNCATE the cache table.
21 */
22 class ABJ_404_Solution_NGramCacheSyncRebuilder {
23
24 /** @var ABJ_404_Solution_DatabaseCore */
25 private $dbCore;
26
27 /** @var mixed */
28 private $rebuilder;
29
30 /** @var mixed */
31 private $coveragePolicy;
32
33 /** @var ABJ_404_Solution_Logging */
34 private $logger;
35
36 /**
37 * @param ABJ_404_Solution_DatabaseCore $dbCore
38 * @param mixed $rebuilder Object exposing rebuildCache().
39 * @param mixed $coveragePolicy Object exposing invalidateCoverageCaches().
40 * @param ABJ_404_Solution_Logging $logger
41 */
42 public function __construct($dbCore, $rebuilder, $coveragePolicy, $logger) {
43 $this->dbCore = $dbCore;
44 $this->rebuilder = $rebuilder;
45 $this->coveragePolicy = $coveragePolicy;
46 $this->logger = $logger;
47 }
48
49 /**
50 * TRUNCATE the cache table and rebuild it from permalink_cache in
51 * batches of $batchSize. Skips when the cache is already populated
52 * unless $forceRebuild is true.
53 *
54 * @param int $batchSize
55 * @param bool $forceRebuild
56 * @return array<string, mixed> ['total_pages' => int, 'processed' => int, 'success' => int, 'failed' => int]
57 */
58 public function rebuild(int $batchSize = 100, bool $forceRebuild = false): array {
59 $ngramTable = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_ngram_cache');
60 $permalinkCacheTable = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_permalink_cache');
61
62 $alreadyPopulated = $this->checkAlreadyPopulated($ngramTable, $forceRebuild);
63 if ($alreadyPopulated !== null) {
64 return $alreadyPopulated;
65 }
66
67 $this->logger->debugMessage("Starting N-gram cache rebuild...");
68
69 $truncateError = $this->truncateAndInvalidate($ngramTable);
70 if ($truncateError !== null) {
71 return $truncateError;
72 }
73
74 $totalPagesOrError = $this->countTotalPages($permalinkCacheTable);
75 if (is_array($totalPagesOrError)) {
76 return $totalPagesOrError;
77 }
78 $totalPages = $totalPagesOrError;
79
80 if ($totalPages == 0) {
81 $this->logger->debugMessage("No pages in permalink cache. N-gram cache rebuild skipped (will rebuild when pages are added).");
82 return ['total_pages' => 0, 'processed' => 0, 'success' => 0, 'failed' => 0];
83 }
84
85 $this->logger->infoMessage("Rebuilding N-gram cache for {$totalPages} pages in batches of {$batchSize}...");
86
87 $totalStats = $this->runBatchedRebuild($batchSize, $totalPages);
88 $totalStats['total_pages'] = $totalPages;
89
90 $successRate = $totalStats['processed'] > 0 ?
91 round(($totalStats['success'] / $totalStats['processed']) * 100, 1) : 0;
92
93 $this->logger->infoMessage(sprintf(
94 "N-gram cache rebuild complete: %d pages processed, %d success, %d failed (%.1f%% success rate)",
95 $totalStats['processed'],
96 $totalStats['success'],
97 $totalStats['failed'],
98 $successRate
99 ));
100
101 return $totalStats;
102 }
103
104 /**
105 * @return array<string, mixed>|null populated-skip payload, or null when rebuild should proceed
106 */
107 private function checkAlreadyPopulated(string $ngramTable, bool $forceRebuild): ?array {
108 if ($forceRebuild) {
109 return null;
110 }
111 $existingCount = $this->dbCore->queryScalarInt("SELECT COUNT(*) AS c FROM {$ngramTable}");
112 if ($existingCount <= 0) {
113 return null;
114 }
115 $this->logger->debugMessage("N-gram cache already contains {$existingCount} entries. Skipping rebuild (use forceRebuild=true to override).");
116 return [
117 'total_pages' => $existingCount,
118 'processed' => 0,
119 'success' => $existingCount,
120 'failed' => 0,
121 'skipped' => true,
122 ];
123 }
124
125 /**
126 * TRUNCATE the cache table and invalidate coverage caches.
127 *
128 * @return array<string, mixed>|null error-result payload when truncate fails, or null on success
129 */
130 private function truncateAndInvalidate(string $ngramTable): ?array {
131 // skip_repair: TRUNCATE itself is the recovery path during
132 // rebuild; we must not recurse into the missing-table
133 // repairer here.
134 $truncateResult = $this->dbCore->queryAndGetResults(
135 "TRUNCATE TABLE {$ngramTable}",
136 ['skip_repair' => true]
137 );
138 $truncateError = isset($truncateResult['last_error']) && is_string($truncateResult['last_error']) ? $truncateResult['last_error'] : '';
139 if ($truncateError !== '') {
140 if (!$this->dbCore->errorClassifier()->classifyAndHandleInfrastructureError($truncateError)) {
141 $this->logger->errorMessage("Failed to truncate N-gram cache table: " . $truncateError);
142 }
143 return ['total_pages' => 0, 'processed' => 0, 'success' => 0, 'failed' => 1, 'error' => $truncateError];
144 }
145
146 // Invalidate coverage ratio caches immediately after truncate
147 // so SpellChecker does not see stale transient data while the
148 // cache is empty.
149 $this->invalidateCoverageCaches();
150 return null;
151 }
152
153 /**
154 * @return int|array<string, mixed> int on success, error-result payload on failure
155 */
156 private function countTotalPages(string $permalinkCacheTable) {
157 $totalPagesResult = $this->dbCore->queryAndGetResults("SELECT COUNT(*) AS c FROM {$permalinkCacheTable}");
158 $totalPagesRows = isset($totalPagesResult['rows']) && is_array($totalPagesResult['rows']) ? $totalPagesResult['rows'] : [];
159 $totalPagesRow = $totalPagesRows[0] ?? null;
160
161 if (!is_array($totalPagesRow) || !isset($totalPagesRow['c'])) {
162 $countError = isset($totalPagesResult['last_error']) && is_string($totalPagesResult['last_error']) ? $totalPagesResult['last_error'] : '';
163 if (!$this->dbCore->errorClassifier()->classifyAndHandleInfrastructureError($countError)) {
164 $this->logger->errorMessage("Failed to query permalink cache table: " . $countError);
165 }
166 return ['total_pages' => 0, 'processed' => 0, 'success' => 0, 'failed' => 1, 'error' => $countError];
167 }
168 return is_scalar($totalPagesRow['c']) ? (int)$totalPagesRow['c'] : 0;
169 }
170
171 /**
172 * @return array{processed:int, success:int, failed:int}
173 */
174 private function runBatchedRebuild(int $batchSize, int $totalPages): array {
175 $offset = 0;
176 $totalStats = ['processed' => 0, 'success' => 0, 'failed' => 0];
177
178 while ($offset < $totalPages) {
179 try {
180 $stats = $this->runRebuildBatch($batchSize, $offset);
181
182 $totalStats['processed'] += $stats['processed'];
183 $totalStats['success'] += $stats['success'];
184 $totalStats['failed'] += $stats['failed'];
185
186 $offset += $batchSize;
187
188 if ($stats['processed'] < $batchSize) {
189 break;
190 }
191 } catch (Exception $e) {
192 $this->logger->errorMessage("Error during N-gram cache rebuild at offset {$offset}: " . $e->getMessage());
193 $totalStats['failed'] += $batchSize;
194 $offset += $batchSize;
195 }
196 }
197
198 return $totalStats;
199 }
200
201 /** @return void */
202 private function invalidateCoverageCaches(): void {
203 $coveragePolicy = $this->coveragePolicy;
204 if (!is_object($coveragePolicy) || !method_exists($coveragePolicy, 'invalidateCoverageCaches')) {
205 throw new RuntimeException('NGramCacheSyncRebuilder requires a coverage policy with invalidateCoverageCaches().');
206 }
207 $coveragePolicy->invalidateCoverageCaches();
208 }
209
210 /**
211 * @param int $batchSize
212 * @param int $offset
213 * @return array{processed: int, success: int, failed: int}
214 */
215 private function runRebuildBatch(int $batchSize, int $offset): array {
216 $rebuilder = $this->rebuilder;
217 if (!is_object($rebuilder) || !method_exists($rebuilder, 'rebuildCache')) {
218 throw new RuntimeException('NGramCacheSyncRebuilder requires a rebuilder with rebuildCache().');
219 }
220 $stats = $rebuilder->rebuildCache($batchSize, $offset);
221 return [
222 'processed' => is_array($stats) && isset($stats['processed']) && is_numeric($stats['processed']) ? (int)$stats['processed'] : 0,
223 'success' => is_array($stats) && isset($stats['success']) && is_numeric($stats['success']) ? (int)$stats['success'] : 0,
224 'failed' => is_array($stats) && isset($stats['failed']) && is_numeric($stats['failed']) ? (int)$stats['failed'] : 0,
225 ];
226 }
227 }
228