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

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

285 lines 10.9 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 * Keeps the taxonomy-term half of the n-gram cache in sync with the published
9 * categories and tags.
10 *
11 * Split from {@see ABJ_404_Solution_NGramCacheReconciler} because the term
12 * half reconciles against a different source through a different writer: the
13 * published-term set comes from the content repository (WP taxonomy) rather
14 * than the permalink cache, and rows are written by extracting n-grams and
15 * storing them through the cache repository rather than by handing page ids to
16 * the bulk rebuilder. It is also bounded differently -- the published term set
17 * is small, so it is processed in full rather than in batches, and there is no
18 * backlog to classify.
19 *
20 * Sibling of the term-side collaborators already in this layer
21 * (ABJ_404_Solution_TermCandidateSource,
22 * ABJ_404_Solution_TermNGramCoveragePolicy).
23 *
24 * Lock ownership stays with the orchestrator (DatabaseUpgradeNGram): the sync
25 * path runs under the shared 'ngram_rebuild' lock, the cleanup path without it
26 * (it only deletes by primary key on rows the source no longer references).
27 */
28 class ABJ_404_Solution_NGramTermCacheReconciler {
29
30 /** @var ABJ_404_Solution_DatabaseCore */
31 private $dbCore;
32
33 /** @var mixed */
34 private $extractor;
35
36 /** @var mixed */
37 private $repo;
38
39 /** @var mixed */
40 private $coveragePolicy;
41
42 /** @var ABJ_404_Solution_Functions */
43 private $f;
44
45 /** @var ABJ_404_Solution_Logging */
46 private $logger;
47
48 /**
49 * @param ABJ_404_Solution_DatabaseCore $dbCore
50 * @param mixed $extractor Object exposing extractNGrams().
51 * @param mixed $repo Object exposing storeNGrams().
52 * @param mixed $coveragePolicy Object exposing invalidateCoverageCaches().
53 * @param ABJ_404_Solution_Functions $f
54 * @param ABJ_404_Solution_Logging $logger
55 */
56 public function __construct($dbCore, $extractor, $repo, $coveragePolicy, $f, $logger) {
57 $this->dbCore = $dbCore;
58 $this->extractor = $extractor;
59 $this->repo = $repo;
60 $this->coveragePolicy = $coveragePolicy;
61 $this->f = $f;
62 $this->logger = $logger;
63 }
64
65 /**
66 * Add n-gram entries for published terms of one taxonomy type
67 * ('category' or 'tag') that are missing from the cache. Category and tag
68 * sync are identical apart from the type label and source rows, so they
69 * share this one implementation.
70 *
71 * @param string $ngramTable
72 * @param string $type 'category' or 'tag'.
73 * @param array<int, object> $terms Published terms of this type (term_id, url).
74 * @return array{added:int, failed:int}
75 */
76 public function syncMissingTerms(string $ngramTable, string $type, array $terms): array {
77 $stats = ['added' => 0, 'failed' => 0];
78
79 if (empty($terms)) {
80 return $stats;
81 }
82
83 $missing = [];
84 foreach ($terms as $term) {
85 /** @var object{term_id: int, url: string} $term */
86 $termId = (int)$term->term_id;
87 $exists = $this->dbCore->queryScalarInt(
88 "SELECT COUNT(*) AS c FROM {$ngramTable} WHERE id = %d AND type = %s",
89 ['query_params' => [$termId, $type]]
90 );
91 if ($exists == 0) {
92 $missing[] = $term;
93 }
94 }
95
96 if (empty($missing)) {
97 $this->logger->debugMessage("No missing {$type} ngram entries found. All {$type}s are synced.");
98 return $stats;
99 }
100
101 $this->logger->infoMessage("Found " . count($missing) . " {$type}s missing ngram entries. Adding...");
102
103 foreach ($missing as $term) {
104 try {
105 /** @var object{term_id: int, url: string} $term */
106 $termId = (int)$term->term_id;
107 $url = (string)$term->url;
108
109 if (empty($url) || $url === 'in code') {
110 $this->logger->debugMessage("Skipping {$type} {$termId} - no valid URL");
111 continue;
112 }
113
114 $urlNormalized = $this->f->strtolower(trim($url));
115 $ngrams = $this->extractNGrams($urlNormalized);
116 $success = $this->storeNGrams($termId, $url, $urlNormalized, $ngrams, $type);
117
118 if ($success) {
119 $stats['added']++;
120 } else {
121 $stats['failed']++;
122 }
123 } catch (Exception $e) {
124 $this->logger->errorMessage("Failed to add ngram for {$type} {$termId}: " . $e->getMessage());
125 $stats['failed']++;
126 }
127 }
128
129 return $stats;
130 }
131
132 /**
133 * Delete n-gram rows of one taxonomy type ('category' or 'tag') whose
134 * source term is no longer published. Category and tag cleanup are
135 * identical apart from the type label and source rows, so they share this
136 * one implementation.
137 *
138 * @param string $ngramTable
139 * @param string $type 'category' or 'tag'.
140 * @param array<int, object> $publishedTerms Currently-published terms of this type.
141 * @return array{deleted:int, errors:int}
142 */
143 public function cleanupOrphanedTerms(string $ngramTable, string $type, array $publishedTerms): array {
144 // Positive evidence is required before deleting anything. The published
145 // list arrives from ContentRepository::getPublishedCategories() /
146 // getPublishedTags(), which return an EMPTY ARRAY when their query
147 // fails (PublishedContentRepository logs the error and falls through to
148 // objectRows($result['rows'] ?? array())). An empty list is therefore
149 // indistinguishable from a failed read, and treating it as "nothing is
150 // published" made a single transient database error -- a Galera
151 // failover, a dropped connection -- delete every cached n-gram row of
152 // this type. Refusing to act costs at most some stale rows, which only
153 // affect suggestion ranking and are cleaned up on the next run that has
154 // real data; acting on it costs the whole cache.
155 if (empty($publishedTerms)) {
156 $this->logger->debugMessage("Skipping orphaned {$type} ngram cleanup: no published {$type} "
157 . "terms were supplied, which is indistinguishable from a failed lookup. "
158 . "Nothing is deleted without positive evidence of what is published.");
159 return ['deleted' => 0, 'errors' => 0];
160 }
161
162 $publishedIds = [];
163 foreach ($publishedTerms as $term) {
164 /** @var object{term_id: int, url: string} $term */
165 $publishedIds[] = (int)$term->term_id;
166 }
167
168 $entriesResult = $this->dbCore->queryAndGetResults(
169 "SELECT DISTINCT id FROM {$ngramTable} WHERE type = %s",
170 ['query_params' => [$type], 'result_type' => OBJECT]
171 );
172 $ngramEntries = isset($entriesResult['rows']) && is_array($entriesResult['rows']) ? $entriesResult['rows'] : [];
173
174 if (empty($ngramEntries)) {
175 return ['deleted' => 0, 'errors' => 0];
176 }
177
178 $orphaned = [];
179 foreach ($ngramEntries as $entry) {
180 if (!is_object($entry)) {
181 continue;
182 }
183 /** @var object{id: int} $entry */
184 $entId = (int)$entry->id;
185 if (!in_array($entId, $publishedIds)) {
186 $orphaned[] = $entId;
187 }
188 }
189
190 if (empty($orphaned)) {
191 $this->logger->debugMessage("No orphaned {$type} ngram entries found.");
192 return ['deleted' => 0, 'errors' => 0];
193 }
194
195 $this->logger->infoMessage("Found " . count($orphaned) . " orphaned {$type} ngram entries. Deleting...");
196
197 $deleted = 0;
198 $errors = 0;
199 foreach ($orphaned as $termId) {
200 $deleteResult = $this->dbCore->queryAndGetResults(
201 "DELETE FROM {$ngramTable} WHERE id = %d AND type = %s",
202 ['query_params' => [$termId, $type]]
203 );
204
205 $deleteError = isset($deleteResult['last_error']) && is_string($deleteResult['last_error']) ? $deleteResult['last_error'] : '';
206 if ($deleteError !== '') {
207 if (!$this->dbCore->errorClassifier()->classifyAndHandleInfrastructureError($deleteError)) {
208 $this->logger->errorMessage("Failed to delete orphaned {$type} ngram entry ID {$termId}: " . $deleteError);
209 }
210 $errors++;
211 } else {
212 $deleted++;
213 }
214 }
215
216 if ($deleted > 0) {
217 $this->invalidateCoverageCaches();
218 }
219
220 return ['deleted' => $deleted, 'errors' => $errors];
221 }
222
223 /**
224 * @param string $url
225 * @return array{bi: array<int, string>, tri: array<int, string>}
226 */
227 private function extractNGrams(string $url): array {
228 $extractor = $this->extractor;
229 if (!is_object($extractor) || !method_exists($extractor, 'extractNGrams')) {
230 throw new RuntimeException('NGramTermCacheReconciler requires an extractor with extractNGrams().');
231 }
232 $ngrams = $extractor->extractNGrams($url);
233 return $this->normalizeNGramPayload($ngrams);
234 }
235
236
237 /**
238 * @param mixed $ngrams
239 * @return array{bi: array<int, string>, tri: array<int, string>}
240 */
241 private function normalizeNGramPayload($ngrams): array {
242 $bi = [];
243 $tri = [];
244 if (is_array($ngrams)) {
245 $biRaw = isset($ngrams['bi']) && is_array($ngrams['bi']) ? $ngrams['bi'] : [];
246 foreach ($biRaw as $ngram) {
247 if (is_string($ngram)) {
248 $bi[] = $ngram;
249 }
250 }
251 $triRaw = isset($ngrams['tri']) && is_array($ngrams['tri']) ? $ngrams['tri'] : [];
252 foreach ($triRaw as $ngram) {
253 if (is_string($ngram)) {
254 $tri[] = $ngram;
255 }
256 }
257 }
258 return ['bi' => $bi, 'tri' => $tri];
259 }
260 /**
261 * @param int $pageId
262 * @param string $url
263 * @param string $urlNormalized
264 * @param array<string, mixed> $ngrams
265 * @param string $type
266 * @return bool
267 */
268 private function storeNGrams(int $pageId, string $url, string $urlNormalized, array $ngrams, string $type): bool {
269 $repo = $this->repo;
270 if (!is_object($repo) || !method_exists($repo, 'storeNGrams')) {
271 throw new RuntimeException('NGramTermCacheReconciler requires a repository with storeNGrams().');
272 }
273 return (bool)$repo->storeNGrams($pageId, $url, $urlNormalized, $ngrams, $type);
274 }
275
276 /** @return void */
277 private function invalidateCoverageCaches(): void {
278 $coveragePolicy = $this->coveragePolicy;
279 if (!is_object($coveragePolicy) || !method_exists($coveragePolicy, 'invalidateCoverageCaches')) {
280 throw new RuntimeException('NGramTermCacheReconciler requires a coverage policy with invalidateCoverageCaches().');
281 }
282 $coveragePolicy->invalidateCoverageCaches();
283 }
284 }
285