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 / database / upgrades / DatabaseUpgradeNGram.php

DatabaseUpgradeNGram.php in 404 Solution trunk, at includes/database/upgrades/DatabaseUpgradeNGram.php

485 lines 18.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 require_once __DIR__ . '/../../ngram/NGramNetworkOptionStore.php';
8 require_once __DIR__ . '/../../ngram/NGramCacheRebuildScheduler.php';
9 require_once __DIR__ . '/../../ngram/NGramCacheRebuildBatchRunner.php';
10 require_once __DIR__ . '/../../ngram/NGramCacheSyncRebuilder.php';
11 require_once __DIR__ . '/../../ngram/NGramCacheReconciler.php';
12 require_once __DIR__ . '/../../ngram/NGramLastUpdatedEpochMigration.php';
13
14 /**
15 * DatabaseUpgradesEtc delegate that owns the n-gram cache lifecycle.
16 *
17 * Acts as a thin orchestrator around five single-responsibility
18 * collaborators:
19 *
20 * - NGramNetworkOptionStore: network-aware option storage + multisite
21 * detection (also reached from DatabaseUpgradeBootstrap via the
22 * cross-component dispatcher).
23 * - NGramCacheRebuildScheduler: decides whether the WP-Cron rebuild
24 * chain needs arming (fresh start or resume of a stalled walk).
25 * - NGramCacheRebuildBatchRunner: the cron callback's batch loop that
26 * drains the content set and reschedules the chain.
27 * - NGramCacheSyncRebuilder: synchronous TRUNCATE+rebuild used by
28 * manual rebuild tools and the all-content composer.
29 * - NGramCacheReconciler: incremental sync-missing + cleanup-orphaned
30 * (posts, categories, and tags).
31 *
32 * Lock ownership lives here on the public entry points: the three
33 * write paths (rebuildNGramCache, rebuildNGramCacheAsync,
34 * syncMissingNGrams) acquire the 'ngram_rebuild' SyncUtils lock and
35 * scheduleNGramCacheRebuild acquires 'ngram_schedule', then delegate
36 * the actual work. This keeps the lock contract on the public
37 * surface while the collaborators stay pure.
38 */
39 class ABJ_404_Solution_DatabaseUpgradeNGram extends ABJ_404_Solution_DatabaseUpgradeComponent {
40
41 /**
42 * Documented cron hook for the n-gram rebuild loop. Defined here
43 * so the literal appears in this file for the Pattern 8
44 * coordination-key audit (AsyncWorkerCoordinationAuditTest).
45 * The scheduler collaborator owns the runtime use.
46 */
47 const REBUILD_CRON_HOOK = 'abj404_rebuild_ngram_cache_hook';
48
49 /**
50 * Convert legacy n-gram cache `last_updated` datetime storage to bigint
51 * Unix epoch seconds before the generic schema diff runs.
52 *
53 * Cross-component contract: DatabaseUpgradeBootstrap reaches this via the
54 * upgrade dispatcher. The conversion itself lives in
55 * {@see ABJ_404_Solution_NGramLastUpdatedEpochMigration}.
56 *
57 * @param string $tableName Physical n-gram cache table name.
58 * @return bool True when the table is already safe for generic schema
59 * verification, or after conversion succeeds.
60 */
61 function ensureLastUpdatedEpochColumn($tableName): bool {
62 return $this->newLastUpdatedEpochMigration()->ensureEpochColumn($tableName);
63 }
64
65 private function newLastUpdatedEpochMigration(): ABJ_404_Solution_NGramLastUpdatedEpochMigration {
66 return new ABJ_404_Solution_NGramLastUpdatedEpochMigration($this->dbCore, $this->logger);
67 }
68
69 /**
70 * Acquire the 'ngram_schedule' SyncUtils lock and delegate to the
71 * scheduler. Multiple admin clicks during a click storm collapse
72 * into one scheduled cron event.
73 *
74 * @return bool
75 */
76 function scheduleNGramCacheRebuild() {
77 $lockKey = 'ngram_schedule';
78 $uniqueID = $this->syncUtils->synchronizerAcquireLockTry($lockKey);
79
80 if (empty($uniqueID)) {
81 $this->logger->debugMessage("N-gram rebuild scheduling: Another process holds the lock. Skipping.");
82 return true;
83 }
84
85 try {
86 return $this->newScheduler()->scheduleRebuild();
87 } finally {
88 $this->syncUtils->synchronizerReleaseLock($uniqueID, $lockKey);
89 }
90 }
91
92 /**
93 * Whether the rebuild cron chain currently has an event queued.
94 *
95 * Exposed so callers outside the n-gram package (the Tools-tab rebuild
96 * button) can ask the question instead of probing WP-Cron themselves: the
97 * chain is identified by hook AND args, and only
98 * {@see ABJ_404_Solution_NGramCacheRebuildScheduler::armedRebuildTimestamp()}
99 * knows which args a given moment's chain carries.
100 *
101 * @return bool
102 */
103 function nGramRebuildIsArmed(): bool {
104 return $this->newScheduler()->armedRebuildTimestamp() !== false;
105 }
106
107 /**
108 * WP-Cron callback. Acquires the shared 'ngram_rebuild' lock so
109 * its INSERTs cannot race with a concurrent TRUNCATE from the
110 * sync rebuilder, then delegates to the scheduler's batch driver.
111 *
112 * @param int $offset legacy parameter retained for cron payload
113 * compatibility; the scheduler reads the
114 * authoritative offset from the network option
115 * store.
116 * @return void
117 */
118 function rebuildNGramCacheAsync($offset = 0) {
119 $lockKey = 'ngram_rebuild';
120 $uniqueID = $this->syncUtils->synchronizerAcquireLockTry($lockKey);
121 if (empty($uniqueID)) {
122 $this->logger->debugMessage("N-gram async rebuild batch already processing (another process holds lock). Skipping.");
123 return;
124 }
125
126 try {
127 $this->newBatchRunner()->runAsyncBatch();
128 } finally {
129 $this->syncUtils->synchronizerReleaseLock($uniqueID, $lockKey);
130 }
131 }
132
133 /**
134 * Synchronous rebuild entry point. Same lock as the async path so
135 * its TRUNCATE cannot race batch INSERTs.
136 *
137 * @param int $batchSize
138 * @param bool $forceRebuild
139 * @return array<string, mixed>
140 */
141 function rebuildNGramCache($batchSize = 100, $forceRebuild = false) {
142 $lockKey = 'ngram_rebuild';
143 $uniqueID = $this->syncUtils->synchronizerAcquireLockTry($lockKey);
144 if (empty($uniqueID)) {
145 $this->logger->infoMessage("N-gram rebuild already in progress (locked). Skipping.");
146 return [
147 'total_pages' => 0,
148 'processed' => 0,
149 'success' => 0,
150 'failed' => 0,
151 'locked' => true,
152 ];
153 }
154
155 try {
156 return $this->newSyncRebuilder()->rebuild($batchSize, $forceRebuild);
157 } finally {
158 $this->syncUtils->synchronizerReleaseLock($uniqueID, $lockKey);
159 }
160 }
161
162 /**
163 * Transient key for the backlog-escalation cooldown. Mirrors the
164 * self-healing cooldown pattern used by the logsv2 auto-trim.
165 */
166 const BACKLOG_REBUILD_COOLDOWN_KEY = 'abj404_ngram_backlog_rebuild_cooldown';
167
168 /**
169 * Sync entries that exist in the source but are missing from the
170 * cache. Same lock as rebuild to keep mutations serialized.
171 *
172 * The incremental path owns drift only. When the reconciler reports a
173 * backlog it cannot finish, the bulk rebuild is re-armed here so the gap
174 * closes in cron runs instead of draining at one batch per daily tick.
175 *
176 * @param int $batchSize
177 * @return array<string, mixed>
178 */
179 function syncMissingNGrams($batchSize = 50) {
180 $lockKey = 'ngram_rebuild';
181 $uniqueID = $this->syncUtils->synchronizerAcquireLockTry($lockKey);
182 if (empty($uniqueID)) {
183 $this->logger->debugMessage("Ngram sync skipped - rebuild/sync already in progress.");
184 return ['posts_added' => 0, 'posts_failed' => 0, 'categories_added' => 0, 'categories_failed' => 0, 'locked' => true];
185 }
186
187 try {
188 $stats = $this->newReconciler()->syncMissing($batchSize);
189 } finally {
190 $this->syncUtils->synchronizerReleaseLock($uniqueID, $lockKey);
191 }
192
193 // Outside the rebuild lock on purpose: scheduling takes the
194 // 'ngram_schedule' lock and the batch driver this arms takes
195 // 'ngram_rebuild' itself.
196 if (is_array($stats) && !empty($stats['posts_backlogged'])) {
197 $this->escalateBacklogToBulkRebuild($stats);
198 }
199
200 return $stats;
201 }
202
203 /**
204 * Hand a backlog the incremental reconciler cannot close to the bulk
205 * rebuild path, which processes 1,000 rows per cron run and reschedules
206 * itself until the whole content set is covered.
207 *
208 * Idempotent by construction -- scheduleNGramCacheRebuild() no-ops while a
209 * rebuild chain is already armed -- and additionally rate limited by a
210 * one-hour transient cooldown so a rebuild that cannot make progress
211 * cannot be re-armed in a hot loop by repeated sync calls.
212 *
213 * @param array<string, mixed> $stats Reconciler stats for this run.
214 * @return void
215 */
216 private function escalateBacklogToBulkRebuild(array $stats): void {
217 $cooldownKey = self::BACKLOG_REBUILD_COOLDOWN_KEY;
218 if (function_exists('get_transient') && get_transient($cooldownKey)) {
219 $this->logger->debugMessage(
220 "N-gram backlog rebuild already armed within the cooldown window. Skipping.");
221 return;
222 }
223
224 $remaining = isset($stats['posts_remaining']) && is_numeric($stats['posts_remaining'])
225 ? (string)(int)$stats['posts_remaining']
226 : 'an unknown number of';
227
228 $this->logger->infoMessage(
229 "N-gram cache backlog of {$remaining} posts is beyond incremental sync capacity. "
230 . "Handing off to the bulk rebuild.");
231
232 $scheduled = $this->scheduleNGramCacheRebuild();
233
234 if (function_exists('set_transient')) {
235 $ttl = defined('HOUR_IN_SECONDS') ? (int) HOUR_IN_SECONDS : 3600;
236 // @cache-write-audit: opt-out - escalation cooldown marker, not query result data.
237 // allow-cache-empty: fixed rate-limit marker, not a cached query payload.
238 set_transient($cooldownKey, 1, $ttl);
239 }
240
241 if (!$scheduled) {
242 $this->logger->warn(
243 "N-gram cache backlog rebuild could not be scheduled; the incremental sync "
244 . "keeps draining it until the next attempt.");
245 }
246 }
247
248 /**
249 * Delete cache rows whose source no longer exists. Runs without
250 * the rebuild lock; it only deletes by primary key.
251 *
252 * @return array<string, mixed>
253 */
254 function cleanupOrphanedNGrams() {
255 return $this->newReconciler()->cleanupOrphaned();
256 }
257
258 /**
259 * Cross-component contract: DatabaseUpgradeBootstrap and others
260 * reach this via the upgrade dispatcher to learn whether the
261 * plugin is network-activated.
262 *
263 * @return bool
264 */
265 function isNetworkActivated() {
266 return $this->newOptionStore()->isNetworkActivated();
267 }
268
269 /**
270 * Cross-component contract: network-aware option getter.
271 *
272 * @param string $option_name
273 * @param mixed $default
274 * @return mixed
275 */
276 function getNetworkAwareOption($option_name, $default = false) {
277 return $this->newOptionStore()->getOption($option_name, $default);
278 }
279
280 /**
281 * Cross-component contract: network-aware option setter.
282 *
283 * @param string $option_name
284 * @param mixed $value
285 * @return bool
286 */
287 function updateNetworkAwareOption($option_name, $value) {
288 return $this->newOptionStore()->updateOption($option_name, $value);
289 }
290
291 /**
292 * Exposed for the multisite race-condition test (calls through
293 * the upgrade dispatcher) and as part of the schedule
294 * pre-condition. Sums permalink_cache rows across every site when
295 * network-activated, otherwise returns the current site count.
296 *
297 * @return int
298 */
299 function countTotalPagesForNGramRebuild() {
300 return $this->newScheduler()->countTotalPagesForRebuild();
301 }
302
303 private function newOptionStore(): ABJ_404_Solution_NGramNetworkOptionStore {
304 return new ABJ_404_Solution_NGramNetworkOptionStore();
305 }
306
307 private function newScheduler(): ABJ_404_Solution_NGramCacheRebuildScheduler {
308 return new ABJ_404_Solution_NGramCacheRebuildScheduler(
309 $this->dbCore,
310 $this->logger,
311 $this->newOptionStore(),
312 $this->cronScheduler instanceof ABJ_404_Solution_CronScheduler ? $this->cronScheduler : null
313 );
314 }
315
316 /**
317 * The platform services a rebuild tick runs against.
318 *
319 * The null-vs-instance decision about the cron scheduler is made once, in
320 * the runtime's constructor, instead of once here and again in each
321 * receiving constructor.
322 *
323 * @return ABJ_404_Solution_NGramRebuildRuntime
324 */
325 private function newRebuildRuntime(): ABJ_404_Solution_NGramRebuildRuntime {
326 return new ABJ_404_Solution_NGramRebuildRuntime(
327 $this->dbCore,
328 $this->logger,
329 $this->cronScheduler instanceof ABJ_404_Solution_CronScheduler ? $this->cronScheduler : null
330 );
331 }
332
333 private function newBatchRunner(): ABJ_404_Solution_NGramCacheRebuildBatchRunner {
334 return new ABJ_404_Solution_NGramCacheRebuildBatchRunner(
335 $this->newRebuildRuntime(),
336 $this->resolveNGramRebuilder(),
337 $this->newOptionStore()
338 );
339 }
340
341 private function newSyncRebuilder(): ABJ_404_Solution_NGramCacheSyncRebuilder {
342 return new ABJ_404_Solution_NGramCacheSyncRebuilder(
343 $this->dbCore,
344 $this->resolveNGramRebuilder(),
345 $this->resolveNGramCoveragePolicy(),
346 $this->logger
347 );
348 }
349
350 private function newReconciler(): ABJ_404_Solution_NGramCacheReconciler {
351 return new ABJ_404_Solution_NGramCacheReconciler(
352 $this->dbCore,
353 $this->resolveNGramRebuilder(),
354 $this->resolveNGramExtractor(),
355 $this->resolveNGramCacheRepository(),
356 $this->resolveNGramCoveragePolicy(),
357 $this->contentRepo,
358 $this->f,
359 $this->logger
360 );
361 }
362
363 /** @return object */
364 private function resolveNGramExtractor() {
365 if ($this->ngramExtractor instanceof ABJ_404_Solution_NGramExtractor) {
366 return $this->ngramExtractor;
367 }
368 if (is_object($this->ngramExtractor) && method_exists($this->ngramExtractor, 'extractNGrams')) {
369 return $this->ngramExtractor;
370 }
371 $legacy = $this->legacyNGramFacade('extractNGrams');
372 if ($legacy !== null) {
373 return $legacy;
374 }
375 return new ABJ_404_Solution_NGramExtractor($this->f, $this->logger);
376 }
377
378 /** @return object */
379 private function resolveNGramCacheRepository() {
380 if ($this->ngramCacheRepository instanceof ABJ_404_Solution_NGramCacheRepository) {
381 return $this->ngramCacheRepository;
382 }
383 if (is_object($this->ngramCacheRepository) && method_exists($this->ngramCacheRepository, 'storeNGrams')) {
384 return $this->ngramCacheRepository;
385 }
386 $legacy = $this->legacyNGramFacade('storeNGrams');
387 if ($legacy !== null) {
388 return $legacy;
389 }
390 return new ABJ_404_Solution_NGramCacheRepository(
391 $this->typedDbCoreOrNull(),
392 $this->logger,
393 new ABJ_404_Solution_NGramSimilarity(),
394 function() {
395 return $this->resolveConcreteNGramCoveragePolicy();
396 }
397 );
398 }
399
400 /** @return object */
401 private function resolveNGramCoveragePolicy() {
402 if ($this->ngramCoveragePolicy instanceof ABJ_404_Solution_NGramCoveragePolicy) {
403 return $this->ngramCoveragePolicy;
404 }
405 if (is_object($this->ngramCoveragePolicy) && method_exists($this->ngramCoveragePolicy, 'invalidateCoverageCaches')) {
406 return $this->ngramCoveragePolicy;
407 }
408 $legacy = $this->legacyNGramFacade('invalidateCoverageCaches');
409 if ($legacy !== null) {
410 return $legacy;
411 }
412 return new ABJ_404_Solution_NGramCoveragePolicy($this->typedDbCoreOrNull());
413 }
414
415 /** @return object */
416 private function resolveNGramRebuilder() {
417 if ($this->ngramRebuilder instanceof ABJ_404_Solution_NGramRebuilder) {
418 return $this->ngramRebuilder;
419 }
420 if (is_object($this->ngramRebuilder) && method_exists($this->ngramRebuilder, 'rebuildCache')) {
421 return $this->ngramRebuilder;
422 }
423 $legacy = $this->legacyNGramFacade('rebuildCache');
424 if ($legacy !== null) {
425 return $legacy;
426 }
427 return new ABJ_404_Solution_NGramRebuilder(
428 new ABJ_404_Solution_NGramRebuilderDependencies(
429 $this->typedDbCoreOrNull(),
430 $this->logger,
431 $this->f,
432 $this->resolveConcreteNGramExtractor(),
433 $this->resolveConcreteNGramCacheRepository(),
434 $this->resolveConcreteNGramCoveragePolicy()
435 )
436 );
437 }
438
439 /**
440 * @param string $requiredMethod
441 * @return object|null
442 */
443 private function legacyNGramFacade(string $requiredMethod) {
444 return is_object($this->ngramFilter) && method_exists($this->ngramFilter, $requiredMethod)
445 ? $this->ngramFilter
446 : null;
447 }
448
449 /** @return ABJ_404_Solution_DatabaseCore */
450 private function typedDbCoreOrNull() {
451 return $this->dbCore;
452 }
453
454 /** @return ABJ_404_Solution_NGramExtractor */
455 private function resolveConcreteNGramExtractor() {
456 if ($this->ngramExtractor instanceof ABJ_404_Solution_NGramExtractor) {
457 return $this->ngramExtractor;
458 }
459 return new ABJ_404_Solution_NGramExtractor($this->f, $this->logger);
460 }
461
462 /** @return ABJ_404_Solution_NGramCacheRepository */
463 private function resolveConcreteNGramCacheRepository() {
464 if ($this->ngramCacheRepository instanceof ABJ_404_Solution_NGramCacheRepository) {
465 return $this->ngramCacheRepository;
466 }
467 return new ABJ_404_Solution_NGramCacheRepository(
468 $this->typedDbCoreOrNull(),
469 $this->logger,
470 new ABJ_404_Solution_NGramSimilarity(),
471 function() {
472 return $this->resolveConcreteNGramCoveragePolicy();
473 }
474 );
475 }
476
477 /** @return ABJ_404_Solution_NGramCoveragePolicy */
478 private function resolveConcreteNGramCoveragePolicy() {
479 if ($this->ngramCoveragePolicy instanceof ABJ_404_Solution_NGramCoveragePolicy) {
480 return $this->ngramCoveragePolicy;
481 }
482 return new ABJ_404_Solution_NGramCoveragePolicy($this->typedDbCoreOrNull());
483 }
484 }
485