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 / repositories / PermalinkCache.php

PermalinkCache.php in 404 Solution trunk, at includes/repositories/PermalinkCache.php

500 lines 20.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 require_once __DIR__ . '/PermalinkCacheScheduleLock.php';
9
10 /* Functions in this class should only be for plugging into WordPress listeners (filters, actions, etc). */
11
12 class ABJ_404_Solution_PermalinkCache {
13
14 /** The name of the hook to use in WordPress. */
15 const UPDATE_PERMALINK_CACHE_HOOK = 'abj404_updatePermalinkCacheAction';
16
17 /** The maximum number of times in a row to run the hook. */
18 const MAX_EXECUTIONS = 15;
19
20 /** @var self|null */
21 private static $instance = null;
22
23 /** @var ABJ_404_Solution_ContentRepository */
24 private $contentRepository;
25
26 /** @var mixed */
27 private $statsRepository;
28
29 /** @var ABJ_404_Solution_Logging */
30 private $logger;
31
32 /** @var ABJ_404_Solution_NGramFilter|null */
33 private $ngramFilter;
34
35 /** @var ABJ_404_Solution_OldPermalinkStructureStore */
36 private $oldPermalinkStructureStore;
37
38 /**
39 * Constructor with dependency injection.
40 *
41 * @param ABJ_404_Solution_ContentRepository|null $contentRepository Content repository
42 * @param ABJ_404_Solution_Logging|null $logging Logging service
43 * @param ABJ_404_Solution_StatsRepository|null $statsRepository Stats repository
44 * @param ABJ_404_Solution_NGramFilter|null $ngramFilter NGram filter (null = resolved lazily via abj_service)
45 * @param ABJ_404_Solution_OldPermalinkStructureStore|null $oldPermalinkStructureStore Previous structure store
46 */
47 public function __construct(
48 $contentRepository = null,
49 $logging = null,
50 $statsRepository = null,
51 $ngramFilter = null,
52 $oldPermalinkStructureStore = null
53 ) {
54 // Use injected dependencies or fall back to getInstance() for backward compatibility
55 $this->contentRepository = $contentRepository !== null ? $contentRepository : abj_service('content_repository');
56 $this->logger = $logging !== null ? $logging : abj_service('logging');
57 $this->statsRepository = $statsRepository !== null ? $statsRepository :
58 (is_object($contentRepository) && method_exists($contentRepository, 'getPostsNeedingContentKeywords')
59 ? $contentRepository
60 : call_user_func(array('ABJ_404_Solution_StatsRepositoryResolver', 'resolve'), __CLASS__));
61 $this->ngramFilter = $ngramFilter;
62 $this->oldPermalinkStructureStore = $oldPermalinkStructureStore !== null
63 ? $oldPermalinkStructureStore
64 : new ABJ_404_Solution_OldPermalinkStructureStore();
65 }
66
67 /** @return self */
68 public static function getInstance(): self {
69 if (self::$instance == null) {
70 self::$instance = new ABJ_404_Solution_PermalinkCache();
71 }
72
73 return self::$instance;
74 }
75
76 /**
77 * Install a singleton instance directly. The canonical seam for tests
78 * that need to swap in a test double, and for callers that have
79 * already constructed a fully configured instance. Pass `null` to
80 * clear the cached singleton.
81 *
82 * @param self|null $instance
83 * @return void
84 */
85 public static function setInstance($instance) {
86 self::$instance = $instance;
87 }
88
89 /** @return void */
90 static function init(): void {
91 $me = abj_service('permalink_cache');
92
93 add_action('updated_option', array($me, 'permalinkStructureChanged'), 10, 3);
94 }
95
96 /** If the permalink structure changes then truncate the cache table and update some values.
97 * @global type $abj404logging
98 * @param string $var1
99 * @param mixed $oldStructure
100 * @param mixed|null $newStructure
101 */
102 /**
103 * @param string $var1
104 * @param mixed $oldStructure
105 * @param mixed|null $newStructure
106 * @return void
107 */
108 function permalinkStructureChanged($var1, $oldStructure, $newStructure = null): void {
109 if ($var1 != 'permalink_structure') {
110 return;
111 }
112
113 $previousStructure = $newStructure === null ? '' : (is_scalar($oldStructure) ? (string)$oldStructure : '');
114 $currentStructure = $newStructure === null
115 ? (is_scalar($oldStructure) ? (string)$oldStructure : '')
116 : (is_scalar($newStructure) ? (string)$newStructure : '');
117 if ($previousStructure !== '' && $previousStructure !== $currentStructure) {
118 $this->oldPermalinkStructureStore->recordPreviousStructure($previousStructure);
119 }
120
121 // we need to truncate the permlink cache since the structure changed
122
123 $this->logger->debugMessage(__CLASS__ . "/" . __FUNCTION__ .
124 ": Truncating and updating permalink cache because the permalink structure changed to " .
125 $currentStructure);
126
127 $this->contentRepository->truncatePermalinkCacheTable();
128
129 // let's take this opportunity to update some of the values in the cache table.
130 $this->updatePermalinkCache(1);
131 }
132
133 /**
134 * Rebuild the permalink cache, bounded by a wall-clock budget.
135 *
136 * `$maxExecutionTime` is a real budget, not a hint. Callers on user-facing
137 * paths (post save/delete listeners, the settings-save handler, the
138 * permalink-structure change hook) pass a small one precisely because they
139 * are running inside somebody's request; the cron listener passes
140 * `max_execution_time - 5`. Before this was honoured, every one of those
141 * callers ran an unbounded full-corpus pass -- on a site with thousands of
142 * posts, that is seconds to minutes of PHP-side keyword extraction inside a
143 * foreground request.
144 *
145 * What the budget can and cannot bound:
146 * - The two set-based statements (the cache INSERT..SELECT and the
147 * parent-page pass) are single SQL statements. They cannot be split
148 * mid-flight, so they always run once; their cost is the database's,
149 * not PHP's.
150 * - The content-keyword population is a PHP loop over post bodies. That is
151 * the interruptible part, so it is what the deadline gates: batches run
152 * only while budget remains, and never at all once it is already spent.
153 *
154 * When the budget runs out with keyword work still pending, the run
155 * re-arms itself via scheduleToRunAgain() so a large corpus converges
156 * across successive cron ticks instead of being silently truncated at one
157 * batch forever. `$executionCount` caps that chain at MAX_EXECUTIONS.
158 *
159 * @param int $maxExecutionTime Wall-clock budget in seconds (minimum 1).
160 * @param int $executionCount 1-based position in a rescheduled chain.
161 * @return int Rows inserted into the permalink cache by this pass.
162 * @throws Exception
163 */
164 function updatePermalinkCache($maxExecutionTime, $executionCount = 1) {
165 $budgetSeconds = is_numeric($maxExecutionTime) ? max(1, (int)$maxExecutionTime) : 1;
166 $executionCount = is_numeric($executionCount) ? max(1, (int)$executionCount) : 1;
167 $deadline = abj_clock()->nowFloat() + $budgetSeconds;
168
169 // check to see if we need to upgrade the database.
170 // we must pass "true" here to avoid an infinite loop when updating the database.
171 abj_service('options_repository')->getOptions(true);
172
173 // insert the new rows.
174 $results = $this->contentRepository->updatePermalinkCache();
175 $rowsInserted = (is_array($results) && isset($results['rows_affected']) && is_int($results['rows_affected'])) ? $results['rows_affected'] : 0;
176
177 // Invalidate coverage ratio if rows were inserted (new permalinks may lack N-grams)
178 if ($rowsInserted > 0) {
179 $ngramFilter = $this->ngramFilter !== null ? $this->ngramFilter : abj_service('ngram_filter');
180 $ngramFilter->invalidateCoverageCaches();
181 }
182
183 // now we have to update the the pages that have parents to include the parent
184 // part of the URL.
185 // wherever the post_parent != 0, prepend the parent ID URL onto the current URL
186 // and update the post_parent to be the parent ID of the parent.
187 $this->contentRepository->updatePermalinkCacheParentPages();
188
189 $keywordWorkRemains = $this->populateContentKeywordsWithinBudget($deadline);
190
191 $this->checkPermalinkCacheStaleness();
192
193 if ($keywordWorkRemains && $executionCount < self::MAX_EXECUTIONS) {
194 $this->scheduleToRunAgain($executionCount + 1);
195 }
196
197 return $rowsInserted;
198 }
199
200 /**
201 * Maximum content-keyword batches a single run will process, independent of
202 * the wall-clock budget. Guarantees termination even when the budget clock
203 * does not advance (a frozen/injected clock) or when the keyword write is
204 * silently not sticking, so the same rows keep coming back forever. At
205 * populateContentKeywords()'s default batch size of 500 posts this is
206 * 10,000 posts per run, well above what any realistic budget affords.
207 */
208 const MAX_KEYWORD_BATCHES_PER_RUN = 20;
209
210 /**
211 * Populate content keywords in batches while wall-clock budget remains.
212 *
213 * Starts a batch only while budget remains. The set-based cache work runs
214 * before this method and can consume the entire caller budget by itself;
215 * starting a 500-post PHP batch after that point would make the deadline a
216 * decorative hint instead of a real request bound.
217 *
218 * Both early exits (budget spent, batch cap reached) mean "we stopped
219 * before finding out", which is NOT the same question as "is there more to
220 * do" -- so they ask, rather than assume. Assuming is production report 296
221 * (urbanseed.info): a frontend 404 calls this with a one-second budget, the
222 * set-based work above spends it before batch zero, and a site whose 73
223 * published posts all had keywords already re-armed the cron chain on every
224 * single 404. Fifty duplicate-event refusals a day for a pass with nothing
225 * in it.
226 *
227 * @param float $deadline Epoch seconds (microsecond precision) after which
228 * no further batch may start.
229 * @return bool True when keyword work is genuinely still pending, so the
230 * caller should reschedule. False when the corpus has converged.
231 */
232 private function populateContentKeywordsWithinBudget(float $deadline): bool {
233 $clock = abj_clock();
234 for ($batch = 0; $batch < self::MAX_KEYWORD_BATCHES_PER_RUN; $batch++) {
235 if ($clock->nowFloat() >= $deadline) {
236 return $this->keywordWorkRemains();
237 }
238 if ($this->populateContentKeywords() === 0) {
239 // Nothing left needing keywords: the corpus is converged, so
240 // there is nothing to reschedule.
241 return false;
242 }
243 }
244 return $this->keywordWorkRemains();
245 }
246
247 /**
248 * Whether any published post still lacks content keywords.
249 *
250 * Deliberately a one-row existence probe rather than a batch: it runs
251 * precisely when the caller's budget is already spent (or its batch
252 * allowance used up), so fetching 500 post bodies to answer a yes/no
253 * question would turn the deadline back into the decorative hint that
254 * bounding this pass exists to prevent.
255 *
256 * A repository that cannot answer -- the content_keywords column is still
257 * missing mid-migration, or the query failed -- returns no rows and is read
258 * as "nothing to do". That is the same answer the batch path already gives
259 * for the same condition (populateContentKeywords() returns 0), and it is
260 * the safe one: the alternative queues a cron pass that runs the identical
261 * failing query and re-arms itself forever.
262 */
263 private function keywordWorkRemains(): bool {
264 return $this->getPostsNeedingContentKeywords(1) !== array();
265 }
266
267 /** @return void */
268 private function checkPermalinkCacheStaleness(): void {
269 $cacheCount = $this->contentRepository->getPermalinkCacheCount();
270 if ($cacheCount > 0) {
271 return;
272 }
273 $postCount = function_exists('wp_count_posts') ? (int) (wp_count_posts('post')->publish ?? 0) : 0;
274 $pageCount = function_exists('wp_count_posts') ? (int) (wp_count_posts('page')->publish ?? 0) : 0;
275 if ($postCount + $pageCount === 0) {
276 return;
277 }
278 if (function_exists('set_transient')) {
279 // allow-cache-empty: notice payload is constructed locally and intentionally persisted as-is.
280 set_transient('abj404_plugin_db_notice', array(
281 'type' => 'stale_permalink_cache',
282 'message' => function_exists('__') ? __('Permalink cache appears empty after rebuild - suggestions may be degraded. Try rebuilding again or check available disk space.', '404-solution') : 'Permalink cache appears empty after rebuild - suggestions may be degraded. Try rebuilding again or check available disk space.',
283 'guidance' => function_exists('__') ? __('The permalink cache appears to be empty. Try rebuilding it from the Tools tab, or check that your site has enough disk space.', '404-solution') : 'The permalink cache appears to be empty. Try rebuilding it from the Tools tab, or check that your site has enough disk space.',
284 'timestamp' => abj_clock()->now(),
285 ), 86400);
286 }
287 }
288
289 /**
290 * Arm the next link of the deferred permalink-cache chain, unless the chain
291 * is already armed.
292 *
293 * The guard is the whole point of the method now. WordPress identifies a
294 * single event by hook AND arguments (md5(serialize($args))), and this
295 * chain's links carry `[$maxExecutionTime, $executionCount]` -- both of
296 * which move. So a link the previous cron tick queued as `[25, 7]` is NOT a
297 * duplicate of the `[55, 2]` a frontend 404 asks for, and without the probe
298 * WordPress dutifully queued a second pass beside the first. That is
299 * production report 295 (tv503.com, 7,568 posts + 81 pages): three `cron`
300 * option writes in two seconds, one per scanner 404, every one asking for
301 * `[55, 2]` because a foreground call always passes execution count 1. The
302 * chain was restarted from the front end rather than advanced, so
303 * MAX_EXECUTIONS never bounded anything, and the next cron spawn re-ran
304 * batches that were already queued to run.
305 *
306 * Asking the store rather than a known args tuple is deliberate: the budget
307 * is recomputed from ini_get() in whatever request arms the link, and a
308 * WP-Cron request routinely reports a different max_execution_time than a
309 * front-end one, so there is no tuple to probe for.
310 *
311 * Safe against wedging the chain, in both directions. WordPress removes a
312 * single event from the store BEFORE invoking its callback, so a pass
313 * re-arming its own successor sees nothing queued and advances normally;
314 * and if two links are somehow already queued, the first to run declines to
315 * add a third while the second is still there, which collapses the
316 * duplicates a pre-fix site accumulated instead of preserving them.
317 * The hook probe and WordPress's cron-option write are not atomic by
318 * themselves, so one expiring options-row claim encloses both. Otherwise
319 * two requests can inspect the same empty store and both add links whose
320 * different arguments bypass WordPress's exact-event de-duplication.
321 *
322 * @param int $executionCount
323 * @return void
324 */
325 function scheduleToRunAgain(int $executionCount): void {
326 $scheduleLock = new ABJ_404_Solution_PermalinkCacheScheduleLock();
327 $lockValue = $scheduleLock->acquire();
328 if ($lockValue === null) {
329 $this->logger->debugMessage(__CLASS__ . "/" . __FUNCTION__ .
330 ": another request is deciding whether to queue a permalink cache pass.");
331 return;
332 }
333
334 $scheduler = abj_cron_scheduler();
335 try {
336 $scheduler->refreshStoredEventReads();
337 if ($scheduler->hasAnyScheduledEvent(self::UPDATE_PERMALINK_CACHE_HOOK)) {
338 $this->logger->debugMessage(__CLASS__ . "/" . __FUNCTION__ .
339 ": a permalink cache pass is already queued; not queueing another.");
340 return;
341 }
342
343 $maxExecutionTime = (int)ini_get('max_execution_time') - 5;
344 $maxExecutionTime = max($maxExecutionTime, 25);
345
346 $scheduler->scheduleSingleAt(
347 ABJ_404_Solution_PermalinkCache::UPDATE_PERMALINK_CACHE_HOOK,
348 1,
349 array($maxExecutionTime, $executionCount)
350 );
351 } finally {
352 $scheduleLock->release($lockValue);
353 }
354 }
355
356 /** Maximum unique keywords to store per post. */
357 const MAX_CONTENT_KEYWORDS = 30;
358
359 /** Minimum word length to keep during keyword extraction. */
360 const MIN_KEYWORD_LENGTH = 3;
361
362 /**
363 * Populate content_keywords for permalink cache rows that have NULL.
364 *
365 * Reads post_content, strips HTML/shortcodes, filters stop words,
366 * keeps top keywords by frequency. Runs in batches to stay within
367 * PHP time limits.
368 *
369 * @param int $batchSize Maximum posts to process per call.
370 * @return int Number of rows updated.
371 */
372 function populateContentKeywords(int $batchSize = 500): int {
373 $rows = $this->getPostsNeedingContentKeywords($batchSize);
374
375 if (empty($rows)) {
376 return 0;
377 }
378
379 $idToKeywords = array();
380 foreach ($rows as $row) {
381 if (!is_object($row)) {
382 continue;
383 }
384 $id = isset($row->id) ? (int)$row->id : 0;
385 if ($id <= 0) {
386 continue;
387 }
388 $content = isset($row->post_content) && is_string($row->post_content) ? $row->post_content : '';
389 $idToKeywords[$id] = self::extractContentKeywords($content);
390 }
391
392 if (empty($idToKeywords)) {
393 return 0;
394 }
395
396 $this->bulkUpdateContentKeywords($idToKeywords);
397
398 return count($idToKeywords);
399 }
400
401 /**
402 * @param int $batchSize
403 * @return array<int, mixed>
404 */
405 private function getPostsNeedingContentKeywords(int $batchSize): array {
406 if (!is_object($this->statsRepository) || !method_exists($this->statsRepository, 'getPostsNeedingContentKeywords')) {
407 return array();
408 }
409 $rows = call_user_func(array($this->statsRepository, 'getPostsNeedingContentKeywords'), $batchSize);
410 return is_array($rows) ? $rows : array();
411 }
412
413 /**
414 * @param array<int, string> $idToKeywords
415 * @return void
416 */
417 private function bulkUpdateContentKeywords(array $idToKeywords): void {
418 if (!is_object($this->statsRepository) || !method_exists($this->statsRepository, 'bulkUpdateContentKeywords')) {
419 return;
420 }
421 call_user_func(array($this->statsRepository, 'bulkUpdateContentKeywords'), $idToKeywords);
422 }
423
424 /**
425 * Extract significant keywords from HTML post content.
426 *
427 * 1. Strip shortcodes ([shortcode attr=val]...[/shortcode] and [self-closing])
428 * 2. Strip HTML tags
429 * 3. Decode HTML entities
430 * 4. Split on whitespace, lowercase, strip non-alpha
431 * 5. Filter: length < MIN_KEYWORD_LENGTH, stop words
432 * 6. Count frequency, take top MAX_CONTENT_KEYWORDS unique words
433 * 7. Return space-joined string
434 *
435 * @param string $htmlContent Raw post_content (may contain HTML and shortcodes).
436 * @return string Space-separated lowercase keywords.
437 */
438 public static function extractContentKeywords(string $htmlContent): string {
439 if (trim($htmlContent) === '') {
440 return '';
441 }
442
443 // Strip shortcodes: [tag attr="val"]content[/tag] and [self-closing /]
444 $text = preg_replace('/\[\/?\w+[^\]]*\]/', '', $htmlContent);
445 if (!is_string($text)) {
446 $text = $htmlContent;
447 }
448
449 // Strip HTML tags
450 $text = strip_tags($text);
451
452 // Decode HTML entities
453 $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
454
455 // Lowercase
456 $text = strtolower($text);
457
458 // Replace non-alpha characters with spaces (keeps Unicode letters via \p{L})
459 $text = preg_replace('/[^\p{L}]+/u', ' ', $text);
460 if (!is_string($text)) {
461 return '';
462 }
463
464 // Split on whitespace
465 $words = preg_split('/\s+/', trim($text));
466 if (!is_array($words)) {
467 return '';
468 }
469
470 $stopLookup = array_flip(ABJ_404_Solution_StopWords::$common);
471 $freq = [];
472
473 foreach ($words as $word) {
474 if (!is_string($word) || strlen($word) < self::MIN_KEYWORD_LENGTH) {
475 continue;
476 }
477 if (isset($stopLookup[$word])) {
478 continue;
479 }
480 if (!isset($freq[$word])) {
481 $freq[$word] = 0;
482 }
483 $freq[$word]++;
484 }
485
486 if (empty($freq)) {
487 return '';
488 }
489
490 // Sort by frequency descending
491 arsort($freq);
492
493 // Take top N unique words
494 $top = array_slice(array_keys($freq), 0, self::MAX_CONTENT_KEYWORDS);
495
496 return implode(' ', $top);
497 }
498
499 }
500