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

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

379 lines 17.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 require_once __DIR__ . '/NGramTermCacheReconciler.php';
8
9 /**
10 * Incremental maintenance of the n-gram cache: keep it in sync with
11 * the canonical content sources (permalink_cache for posts/pages,
12 * published categories for category archives) without rebuilding
13 * everything.
14 *
15 * Two paired operations:
16 *
17 * - syncMissing(): find ids that exist in the source but lack
18 * n-gram entries, and add them.
19 * - cleanupOrphaned(): find n-gram rows whose source row no longer
20 * exists, and delete them.
21 *
22 * Owns the POST side of both operations directly (permalink_cache as the
23 * source, the bulk rebuilder as the writer, and the drift-vs-backlog
24 * measurement that decides whether an incremental pass is even the right
25 * tool). The taxonomy-term side reconciles a different source through a
26 * different writer and lives in
27 * {@see ABJ_404_Solution_NGramTermCacheReconciler}, which this class composes
28 * so both public operations stay one call for the orchestrator.
29 *
30 * Lock ownership: the orchestrator (DatabaseUpgradeNGram) acquires
31 * the shared 'ngram_rebuild' SyncUtils lock before calling
32 * syncMissing() so its INSERTs do not race with a TRUNCATE from the
33 * sync rebuilder. cleanupOrphaned() runs without the rebuild lock
34 * because it only deletes by primary key on rows the source no
35 * longer references.
36 */
37 class ABJ_404_Solution_NGramCacheReconciler {
38
39 /** @var ABJ_404_Solution_DatabaseCore */
40 private $dbCore;
41
42 /** @var mixed */
43 private $rebuilder;
44
45 /** @var mixed */
46 private $coveragePolicy;
47
48 /** @var ABJ_404_Solution_ContentRepositoryInterface */
49 private $contentRepo;
50
51 /** @var ABJ_404_Solution_Logging */
52 private $logger;
53
54 /** @var ABJ_404_Solution_NGramTermCacheReconciler */
55 private $termReconciler;
56
57 /**
58 * @param ABJ_404_Solution_DatabaseCore $dbCore
59 * @param mixed $rebuilder Object exposing updateNGramsForPages().
60 * @param mixed $extractor Object exposing extractNGrams().
61 * @param mixed $repo Object exposing storeNGrams().
62 * @param mixed $coveragePolicy Object exposing invalidateCoverageCaches().
63 * @param ABJ_404_Solution_ContentRepositoryInterface $contentRepo
64 * @param ABJ_404_Solution_Functions $f
65 * @param ABJ_404_Solution_Logging $logger
66 */
67 public function __construct($dbCore, $rebuilder, $extractor, $repo, $coveragePolicy, $contentRepo, $f, $logger) {
68 $this->dbCore = $dbCore;
69 $this->rebuilder = $rebuilder;
70 $this->coveragePolicy = $coveragePolicy;
71 $this->contentRepo = $contentRepo;
72 $this->logger = $logger;
73 $this->termReconciler = new ABJ_404_Solution_NGramTermCacheReconciler(
74 $dbCore, $extractor, $repo, $coveragePolicy, $f, $logger);
75 }
76
77 /**
78 * Find post and category ids that exist in the source but are
79 * missing from the n-gram cache, and add entries for them.
80 *
81 * Sized for DRIFT: the handful of rows a day that slipped past the
82 * real-time post-save hooks. A gap this call cannot finish by its next
83 * run is a BACKLOG that belongs to the bulk rebuild path instead, and is
84 * reported as such through the 'posts_backlogged' key so the caller can
85 * hand it off (see ABJ_404_Solution_DatabaseUpgradeNGram::syncMissingNGrams).
86 *
87 * @param int $batchSize maximum number of missing posts to add
88 * per call (categories are processed in full
89 * because the published category set is
90 * small).
91 * @return array<string, mixed> ['posts_added' => int, 'posts_failed' => int, 'posts_missing_total' => int|null, 'posts_remaining' => int|null, 'posts_backlogged' => bool, 'categories_added' => int, 'categories_failed' => int, 'tags_added' => int, 'tags_failed' => int]
92 */
93 public function syncMissing($batchSize = 50) {
94 $ngramTable = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_ngram_cache');
95 $permalinkCacheTable = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_permalink_cache');
96
97 $stats = ['posts_added' => 0, 'posts_failed' => 0, 'posts_missing_total' => 0, 'posts_remaining' => 0,
98 'posts_backlogged' => false, 'categories_added' => 0, 'categories_failed' => 0, 'tags_added' => 0, 'tags_failed' => 0];
99
100 $postsResult = $this->syncMissingPosts($ngramTable, $permalinkCacheTable, $batchSize);
101 if (isset($postsResult['error'])) {
102 return array_merge($stats, ['error' => $postsResult['error']]);
103 }
104 $stats['posts_added'] = $postsResult['added'];
105 $stats['posts_failed'] = $postsResult['failed'];
106 $stats['posts_missing_total'] = $postsResult['missing_total'];
107 $stats['posts_remaining'] = $postsResult['remaining'];
108 $stats['posts_backlogged'] = $postsResult['backlogged'];
109
110 $categoriesResult = $this->termReconciler->syncMissingTerms($ngramTable, 'category', $this->contentRepo->getPublishedCategories());
111 $stats['categories_added'] = $categoriesResult['added'];
112 $stats['categories_failed'] = $categoriesResult['failed'];
113
114 $tagsResult = $this->termReconciler->syncMissingTerms($ngramTable, 'tag', $this->contentRepo->getPublishedTags());
115 $stats['tags_added'] = $tagsResult['added'];
116 $stats['tags_failed'] = $tagsResult['failed'];
117
118 $remainingText = $stats['posts_remaining'] === null ? 'unknown' : (string)$stats['posts_remaining'];
119 $this->logger->infoMessage("Ngram sync complete: {$stats['posts_added']} posts added, {$stats['posts_failed']} posts failed, "
120 . "{$remainingText} posts still missing after this run, "
121 . "{$stats['categories_added']} categories added, {$stats['categories_failed']} categories failed, "
122 . "{$stats['tags_added']} tags added, {$stats['tags_failed']} tags failed.");
123
124 return $stats;
125 }
126
127 /**
128 * Delete n-gram rows whose source no longer exists.
129 *
130 * @return array{posts_deleted:int, categories_deleted:int, tags_deleted:int, errors:int}|array<string, mixed>
131 */
132 public function cleanupOrphaned() {
133 $ngramTable = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_ngram_cache');
134 $permalinkCacheTable = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_permalink_cache');
135
136 $this->logger->debugMessage("Checking for orphaned ngram entries...");
137
138 $stats = ['posts_deleted' => 0, 'categories_deleted' => 0, 'tags_deleted' => 0, 'errors' => 0];
139
140 $postsResult = $this->cleanupOrphanedPosts($ngramTable, $permalinkCacheTable);
141 if (isset($postsResult['error'])) {
142 return array_merge($stats, ['error' => $postsResult['error']]);
143 }
144 $stats['posts_deleted'] = $postsResult['deleted'];
145 $stats['errors'] += $postsResult['errors'];
146
147 $categoriesResult = $this->termReconciler->cleanupOrphanedTerms($ngramTable, 'category', $this->contentRepo->getPublishedCategories());
148 $stats['categories_deleted'] = $categoriesResult['deleted'];
149 $stats['errors'] += $categoriesResult['errors'];
150
151 $tagsResult = $this->termReconciler->cleanupOrphanedTerms($ngramTable, 'tag', $this->contentRepo->getPublishedTags());
152 $stats['tags_deleted'] = $tagsResult['deleted'];
153 $stats['errors'] += $tagsResult['errors'];
154
155 $this->logger->infoMessage("Orphaned ngram cleanup complete: {$stats['posts_deleted']} posts deleted, {$stats['categories_deleted']} categories deleted, {$stats['tags_deleted']} tags deleted, {$stats['errors']} errors.");
156
157 return $stats;
158 }
159
160 /**
161 * @return array{added:int, failed:int, missing_total:int|null, remaining:int|null, backlogged:bool}|array{error:string, added:int, failed:int, missing_total:null, remaining:null, backlogged:bool}
162 */
163 private function syncMissingPosts(string $ngramTable, string $permalinkCacheTable, int $batchSize): array {
164 // Measure the WHOLE gap before taking a batch out of it. Counting the
165 // rows that came back from a LIMITed SELECT can only ever report the
166 // batch size, so a 12,000-row backlog and a 50-row drift logged the
167 // same line ("Found 50 posts missing ngram entries") and neither the
168 // user nor we could tell them apart -- which is how a months-long
169 // drain read as a stuck 50-row loop.
170 $missingTotal = $this->countMissingPosts($ngramTable, $permalinkCacheTable);
171
172 $missingResult = $this->dbCore->queryAndGetResults(
173 "SELECT pc.id
174 FROM {$permalinkCacheTable} pc
175 LEFT JOIN {$ngramTable} ng ON pc.id = ng.id AND ng.type = 'post'
176 WHERE ng.id IS NULL
177 LIMIT %d",
178 ['query_params' => [$batchSize]]
179 );
180
181 $missingError = isset($missingResult['last_error']) && is_string($missingResult['last_error']) ? $missingResult['last_error'] : '';
182 if ($missingError !== '') {
183 if (!$this->dbCore->errorClassifier()->classifyAndHandleInfrastructureError($missingError)) {
184 $this->logger->errorMessage("Failed to query for missing post ngram entries: " . $missingError);
185 }
186 return ['error' => $missingError, 'added' => 0, 'failed' => 0,
187 'missing_total' => null, 'remaining' => null, 'backlogged' => false];
188 }
189
190 $missingRows = isset($missingResult['rows']) && is_array($missingResult['rows']) ? $missingResult['rows'] : [];
191 $missingIds = [];
192 foreach ($missingRows as $row) {
193 if (is_array($row) && isset($row['id']) && is_numeric($row['id'])) {
194 $missingIds[] = (int)$row['id'];
195 }
196 }
197
198 if (empty($missingIds)) {
199 $this->logger->debugMessage("No missing post ngram entries found. All posts are synced.");
200 return ['added' => 0, 'failed' => 0, 'missing_total' => 0, 'remaining' => 0, 'backlogged' => false];
201 }
202
203 $batchCount = count($missingIds);
204 $this->logger->infoMessage(sprintf(
205 "Found %s posts missing ngram entries. Adding %d of them in this batch...",
206 $missingTotal === null ? 'an unknown number of' : (string)$missingTotal,
207 $batchCount
208 ));
209
210 $result = $this->updateNGramsForPages($missingIds);
211
212 $remaining = $missingTotal === null ? null : max(0, $missingTotal - $result['success']);
213 $backlogged = $this->isBacklog($remaining, $batchCount, $batchSize);
214
215 $this->logger->infoMessage(sprintf(
216 "Ngram post sync: closed %d of %s missing entries this run; %s still missing.",
217 $result['success'],
218 $missingTotal === null ? 'an unknown number of' : (string)$missingTotal,
219 $remaining === null ? 'an unknown number' : (string)$remaining
220 ));
221
222 return ['added' => $result['success'], 'failed' => $result['failed'],
223 'missing_total' => $missingTotal, 'remaining' => $remaining, 'backlogged' => $backlogged];
224 }
225
226 /**
227 * Count every permalink-cache row with no post-type n-gram entry.
228 *
229 * Deliberately NOT routed through queryScalarInt(): that helper returns 0
230 * both for "nothing is missing" and for "the query failed", and those two
231 * answers drive opposite recovery decisions here. Null means "unknown".
232 *
233 * @return int|null
234 */
235 private function countMissingPosts(string $ngramTable, string $permalinkCacheTable): ?int {
236 $countResult = $this->dbCore->queryAndGetResults(
237 "SELECT COUNT(*) AS c
238 FROM {$permalinkCacheTable} pc
239 LEFT JOIN {$ngramTable} ng ON pc.id = ng.id AND ng.type = 'post'
240 WHERE ng.id IS NULL"
241 );
242
243 $countError = isset($countResult['last_error']) && is_string($countResult['last_error']) ? $countResult['last_error'] : '';
244 if ($countError !== '') {
245 if (!$this->dbCore->errorClassifier()->classifyAndHandleInfrastructureError($countError)) {
246 $this->logger->warn("Could not measure the missing post ngram backlog: " . $countError);
247 }
248 return null;
249 }
250
251 $rows = isset($countResult['rows']) && is_array($countResult['rows']) ? $countResult['rows'] : [];
252 if (empty($rows) || !is_array($rows[0])) {
253 return null;
254 }
255 $first = reset($rows[0]);
256 return is_scalar($first) ? (int)$first : null;
257 }
258
259 /**
260 * DRIFT or BACKLOG: is what is left over more than this incremental path
261 * can finish on its next run?
262 *
263 * The discriminator is the exact remaining row count, not the N-gram
264 * coverage ratio. The ratio is ngram_cache rows over permalink_cache rows,
265 * but ngram_cache also holds category and tag rows while permalink_cache
266 * holds only posts and pages -- so a site with enough terms reports a
267 * healthy ratio (11,000 posts + 1,100 tags over 12,028 permalinks = 1.006)
268 * while a thousand posts are missing. The ratio is the harm signal that
269 * gates the spell prefilter; it is structurally incapable of measuring
270 * this backlog.
271 *
272 * A leftover of at most one batch converges on the next daily run, so it
273 * stays here. Anything larger would take days-to-months at this batch size
274 * and belongs to the bulk rebuild (1,000 rows per cron run, self
275 * rescheduling). When the backlog could not be measured, a saturated batch
276 * is the fallback signal: it proves this run could not see the end of the
277 * gap.
278 *
279 * @param int|null $remaining Rows still missing after this run, null when unmeasurable.
280 * @param int $batchCount Rows this run took out of the gap.
281 * @param int $batchSize The per-run cap.
282 * @return bool
283 */
284 private function isBacklog(?int $remaining, int $batchCount, int $batchSize): bool {
285 if ($remaining === null) {
286 return $batchCount >= $batchSize;
287 }
288 return $remaining > $batchSize;
289 }
290
291 /**
292 * @return array{deleted:int, errors:int}|array{error:string, deleted:int, errors:int}
293 */
294 private function cleanupOrphanedPosts(string $ngramTable, string $permalinkCacheTable): array {
295 $orphanedResult = $this->dbCore->queryAndGetResults(
296 "SELECT ng.id, ng.type
297 FROM {$ngramTable} ng
298 LEFT JOIN {$permalinkCacheTable} pc ON ng.id = pc.id AND ng.type = 'post'
299 WHERE ng.type = 'post' AND pc.id IS NULL",
300 ['result_type' => OBJECT]
301 );
302
303 $orphanedError = isset($orphanedResult['last_error']) && is_string($orphanedResult['last_error']) ? $orphanedResult['last_error'] : '';
304 if ($orphanedError !== '') {
305 if (!$this->dbCore->errorClassifier()->classifyAndHandleInfrastructureError($orphanedError)) {
306 $this->logger->errorMessage("Failed to query for orphaned post ngram entries: " . $orphanedError);
307 }
308 return ['error' => $orphanedError, 'deleted' => 0, 'errors' => 0];
309 }
310
311 $orphanedPosts = isset($orphanedResult['rows']) && is_array($orphanedResult['rows']) ? $orphanedResult['rows'] : [];
312
313 if (empty($orphanedPosts)) {
314 $this->logger->debugMessage("No orphaned post ngram entries found.");
315 return ['deleted' => 0, 'errors' => 0];
316 }
317
318 $this->logger->infoMessage("Found " . count($orphanedPosts) . " orphaned post ngram entries. Deleting...");
319
320 $deleted = 0;
321 $errors = 0;
322 foreach ($orphanedPosts as $entry) {
323 if (!is_object($entry)) {
324 continue;
325 }
326 /** @var object{id: int, type: string} $entry */
327 $entryId = (int)$entry->id;
328 $entryType = (string)$entry->type;
329 $deleteResult = $this->dbCore->queryAndGetResults(
330 "DELETE FROM {$ngramTable} WHERE id = %d AND type = %s",
331 ['query_params' => [$entryId, $entryType]]
332 );
333
334 $deleteError = isset($deleteResult['last_error']) && is_string($deleteResult['last_error']) ? $deleteResult['last_error'] : '';
335 if ($deleteError !== '') {
336 if (!$this->dbCore->errorClassifier()->classifyAndHandleInfrastructureError($deleteError)) {
337 $this->logger->errorMessage("Failed to delete orphaned post ngram entry ID {$entryId}: " . $deleteError);
338 }
339 $errors++;
340 } else {
341 $deleted++;
342 }
343 }
344
345 if ($deleted > 0) {
346 $this->invalidateCoverageCaches();
347 }
348
349 return ['deleted' => $deleted, 'errors' => $errors];
350 }
351
352 /**
353 * @param array<int, int> $pageIds
354 * @return array{processed: int, success: int, failed: int}
355 */
356 private function updateNGramsForPages(array $pageIds): array {
357 $rebuilder = $this->rebuilder;
358 if (!is_object($rebuilder) || !method_exists($rebuilder, 'updateNGramsForPages')) {
359 throw new RuntimeException('NGramCacheReconciler requires a rebuilder with updateNGramsForPages().');
360 }
361 $stats = $rebuilder->updateNGramsForPages($pageIds);
362 return [
363 'processed' => is_array($stats) && isset($stats['processed']) && is_numeric($stats['processed']) ? (int)$stats['processed'] : 0,
364 'success' => is_array($stats) && isset($stats['success']) && is_numeric($stats['success']) ? (int)$stats['success'] : 0,
365 'failed' => is_array($stats) && isset($stats['failed']) && is_numeric($stats['failed']) ? (int)$stats['failed'] : 0,
366 ];
367 }
368
369 /** @return void */
370 private function invalidateCoverageCaches(): void {
371 $coveragePolicy = $this->coveragePolicy;
372 if (!is_object($coveragePolicy) || !method_exists($coveragePolicy, 'invalidateCoverageCaches')) {
373 throw new RuntimeException('NGramCacheReconciler requires a coverage policy with invalidateCoverageCaches().');
374 }
375 $coveragePolicy->invalidateCoverageCaches();
376 }
377
378 }
379