| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Decides whether the asynchronous N-gram cache rebuild needs to be started |
| 9 |
* or resumed, and arms the WP-Cron chain when it does. |
| 10 |
* |
| 11 |
* Arming is a decision with many callers -- the 404 request path on an empty |
| 12 |
* cache, the daily reconciler when its backlog is beyond incremental repair, |
| 13 |
* the admin rebuild button, the activation/upgrade initializer -- and it is |
| 14 |
* the half of the rebuild lifecycle where the "is a rebuild already running?" |
| 15 |
* question has to be answered correctly. Running the batches once armed is a |
| 16 |
* separate concern with exactly one caller (the cron callback) and lives in |
| 17 |
* {@see ABJ_404_Solution_NGramCacheRebuildBatchRunner}. |
| 18 |
* |
| 19 |
* Lock acquisition is owned by the orchestrator |
| 20 |
* (DatabaseUpgradeNGram). This collaborator assumes the appropriate |
| 21 |
* SyncUtils lock is already held when its methods are called. |
| 22 |
* |
| 23 |
* Cross-component caller note: `countTotalPagesForRebuild()` is |
| 24 |
* reachable from outside (a multisite race-condition test invokes it |
| 25 |
* through the upgrade dispatcher) so it must remain a stable public |
| 26 |
* surface on this collaborator. |
| 27 |
*/ |
| 28 |
class ABJ_404_Solution_NGramCacheRebuildScheduler { |
| 29 |
|
| 30 |
/** WP-Cron hook this scheduler enqueues. */ |
| 31 |
const REBUILD_CRON_HOOK = 'abj404_rebuild_ngram_cache_hook'; |
| 32 |
|
| 33 |
/** |
| 34 |
* How far out the first tick of a chain is queued. Named rather than |
| 35 |
* repeated as a literal because it is quoted back to the admin and used in |
| 36 |
* the failure diagnostic, and three copies of a number that has to agree is |
| 37 |
* three chances for them not to. |
| 38 |
*/ |
| 39 |
const START_DELAY_SECONDS = 30; |
| 40 |
|
| 41 |
/** @var ABJ_404_Solution_DatabaseCore */ |
| 42 |
private $dbCore; |
| 43 |
|
| 44 |
/** @var ABJ_404_Solution_Logging */ |
| 45 |
private $logger; |
| 46 |
|
| 47 |
/** @var ABJ_404_Solution_NGramNetworkOptionStore */ |
| 48 |
private $optionStore; |
| 49 |
|
| 50 |
/** @var ABJ_404_Solution_CronScheduler */ |
| 51 |
private $cronScheduler; |
| 52 |
|
| 53 |
/** |
| 54 |
* @param ABJ_404_Solution_DatabaseCore $dbCore |
| 55 |
* @param ABJ_404_Solution_Logging $logger |
| 56 |
* @param ABJ_404_Solution_NGramNetworkOptionStore $optionStore |
| 57 |
* @param ABJ_404_Solution_CronScheduler|null $cronScheduler |
| 58 |
*/ |
| 59 |
public function __construct($dbCore, $logger, $optionStore, ?ABJ_404_Solution_CronScheduler $cronScheduler = null) { |
| 60 |
$this->dbCore = $dbCore; |
| 61 |
$this->logger = $logger; |
| 62 |
$this->optionStore = $optionStore; |
| 63 |
$this->cronScheduler = $cronScheduler instanceof ABJ_404_Solution_CronScheduler |
| 64 |
? $cronScheduler |
| 65 |
: abj_cron_scheduler(); |
| 66 |
} |
| 67 |
|
| 68 |
/** |
| 69 |
* Enqueue a single cron-driven rebuild if one is not already |
| 70 |
* pending or in progress. |
| 71 |
* |
| 72 |
* "In progress" is decided by whether the cron chain is still armed, NOT |
| 73 |
* by whether the offset is non-zero. A partially-advanced offset with no |
| 74 |
* queued event is a rebuild whose chain DIED (cron refused, request |
| 75 |
* killed, plugin update mid-walk); reading that as "already in progress" |
| 76 |
* is what left wedged rebuilds unrecoverable for months, because every |
| 77 |
* caller that asked for a rebuild -- the 404 request path, the daily |
| 78 |
* reconciler, the activation initializer -- was told one was already |
| 79 |
* running. A stalled chain is resumed from its own offset rather than |
| 80 |
* restarted from zero, so no completed work is repeated. |
| 81 |
* |
| 82 |
* @return bool true when scheduling succeeded or was a no-op |
| 83 |
* because a rebuild is already pending/in progress; |
| 84 |
* false when WP-Cron rejected the schedule call. |
| 85 |
*/ |
| 86 |
public function scheduleRebuild() { |
| 87 |
$currentOffset = $this->currentRebuildOffset(); |
| 88 |
|
| 89 |
$hookName = self::REBUILD_CRON_HOOK; |
| 90 |
$armedAt = $this->armedRebuildTimestamp(); |
| 91 |
if ($armedAt !== false) { |
| 92 |
// Site-local, not date(): date() renders in whatever timezone the |
| 93 |
// host process happens to default to, so the same event reads |
| 94 |
// differently on two hosts and the line cannot be compared against |
| 95 |
// anything else in the log. |
| 96 |
$this->logger->debugMessage( |
| 97 |
"N-gram cache rebuild already scheduled for " |
| 98 |
. ABJ_404_Solution_SiteLocalTimestamp::format('Y-m-d H:i:s T', $armedAt)); |
| 99 |
return true; |
| 100 |
} |
| 101 |
|
| 102 |
$totalPages = $this->countTotalPagesForRebuild(); |
| 103 |
|
| 104 |
// A positive offset with nothing queued is a stalled walk. Resume it |
| 105 |
// where it stopped. If total-page counting is unavailable we cannot |
| 106 |
// tell "stalled mid-walk" from "finished", so treat in-flight state as |
| 107 |
// resumable rather than discarding it. |
| 108 |
$resuming = $currentOffset > 0 && ($totalPages <= 0 || $currentOffset < $totalPages); |
| 109 |
|
| 110 |
if ($resuming) { |
| 111 |
$this->logger->infoMessage( |
| 112 |
"N-gram cache rebuild stalled at offset {$currentOffset} of {$totalPages} with no queued event. Resuming."); |
| 113 |
} else { |
| 114 |
$this->optionStore->updateOption('abj404_ngram_rebuild_offset', 0); |
| 115 |
} |
| 116 |
|
| 117 |
// Resolved to an absolute second ONCE, then used both to ask and to |
| 118 |
// report. Computing it twice against the clock let the reported |
| 119 |
// schedule time differ from the requested one whenever the two reads |
| 120 |
// straddled a second boundary. |
| 121 |
$scheduleTime = $this->cronScheduler->timestampAfter(self::START_DELAY_SECONDS); |
| 122 |
// Resumed events carry the offset as their cron argument, exactly like |
| 123 |
// the chain's own reschedules, so armedRebuildTimestamp() recognizes |
| 124 |
// them and a second caller cannot start a parallel chain. |
| 125 |
$scheduled = $this->cronScheduler->scheduleSingleAt( |
| 126 |
$hookName, $scheduleTime, $resuming ? [$currentOffset] : []); |
| 127 |
|
| 128 |
if ($scheduled === false) { |
| 129 |
$this->reportScheduleFailure($hookName, $scheduleTime); |
| 130 |
return false; |
| 131 |
} |
| 132 |
|
| 133 |
$context = is_multisite() ? ' (network-wide)' : ''; |
| 134 |
$this->logger->infoMessage( |
| 135 |
"N-gram cache rebuild scheduled to start in " . self::START_DELAY_SECONDS . " seconds{$context}."); |
| 136 |
return true; |
| 137 |
} |
| 138 |
|
| 139 |
/** |
| 140 |
* Timestamp of the queued rebuild event, or false when the chain is not |
| 141 |
* armed. |
| 142 |
* |
| 143 |
* Two probes are needed because WP-Cron identifies an event by hook AND |
| 144 |
* arguments: the first tick of a chain (and every multisite reschedule) is |
| 145 |
* enqueued with no arguments, while a single-site chain in flight |
| 146 |
* reschedules itself as scheduleSingle($hook, <retry delay>, [$offset]) -- |
| 147 |
* the delay varies with the chain's backoff, the arguments do not. A no-args |
| 148 |
* probe alone cannot see an in-flight chain, so it would report every |
| 149 |
* healthy mid-walk rebuild as unarmed and spawn a second chain beside it. |
| 150 |
* |
| 151 |
* Public, and reading the offset itself rather than taking it as a |
| 152 |
* parameter, because it is the ONE place that knows how this chain is |
| 153 |
* identified in the cron store. Every other "is a rebuild queued?" question |
| 154 |
* -- the failure diagnostic below, the Tools-tab rebuild button -- routes |
| 155 |
* through here instead of issuing its own probe. Each private copy of that |
| 156 |
* question was a single no-args probe, and each therefore answered "not |
| 157 |
* queued" for a healthy in-flight single-site chain. |
| 158 |
* |
| 159 |
* @return int|false |
| 160 |
*/ |
| 161 |
public function armedRebuildTimestamp() { |
| 162 |
$hookName = self::REBUILD_CRON_HOOK; |
| 163 |
$nextScheduled = $this->cronScheduler->nextScheduled($hookName); |
| 164 |
if ($nextScheduled !== false) { |
| 165 |
return $nextScheduled; |
| 166 |
} |
| 167 |
$currentOffset = $this->currentRebuildOffset(); |
| 168 |
if ($currentOffset > 0) { |
| 169 |
$nextForOffset = $this->cronScheduler->nextScheduled($hookName, [$currentOffset]); |
| 170 |
if ($nextForOffset !== false) { |
| 171 |
return $nextForOffset; |
| 172 |
} |
| 173 |
} |
| 174 |
return false; |
| 175 |
} |
| 176 |
|
| 177 |
/** |
| 178 |
* The rebuild cursor as stored, coerced to an offset. |
| 179 |
* |
| 180 |
* @return int |
| 181 |
*/ |
| 182 |
private function currentRebuildOffset(): int { |
| 183 |
$raw = $this->optionStore->getOption('abj404_ngram_rebuild_offset', 0); |
| 184 |
return is_scalar($raw) ? (int)$raw : 0; |
| 185 |
} |
| 186 |
|
| 187 |
/** |
| 188 |
* Count the total number of permalink-cache rows the rebuild has |
| 189 |
* to cover. In a network-activated multisite install this sums |
| 190 |
* across every site; in single-site it returns the current site |
| 191 |
* only. |
| 192 |
* |
| 193 |
* @return int |
| 194 |
*/ |
| 195 |
public function countTotalPagesForRebuild() { |
| 196 |
if (!$this->optionStore->isNetworkActivated()) { |
| 197 |
$permalinkCacheTable = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_permalink_cache'); |
| 198 |
return $this->dbCore->queryScalarInt("SELECT COUNT(*) AS c FROM {$permalinkCacheTable}"); |
| 199 |
} |
| 200 |
|
| 201 |
$sites = get_sites(array('fields' => 'ids', 'number' => 0)); |
| 202 |
$totalPages = 0; |
| 203 |
|
| 204 |
foreach ($sites as $blog_id) { |
| 205 |
switch_to_blog($blog_id); |
| 206 |
$permalinkCacheTable = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_permalink_cache'); |
| 207 |
$totalPages += $this->dbCore->queryScalarInt("SELECT COUNT(*) AS c FROM {$permalinkCacheTable}"); |
| 208 |
restore_current_blog(); |
| 209 |
} |
| 210 |
|
| 211 |
return $totalPages; |
| 212 |
} |
| 213 |
|
| 214 |
/** |
| 215 |
* Initial schedule failed: emit a diagnostic error log and, if a |
| 216 |
* concurrent infra DB error is in play, surface it through the |
| 217 |
* plugin-page admin-notice classifier. |
| 218 |
*/ |
| 219 |
private function reportScheduleFailure(string $hookName, int $scheduleTime): void { |
| 220 |
if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) { |
| 221 |
$this->logger->errorMessage( |
| 222 |
"Cannot schedule N-gram cache rebuild: WP-Cron is disabled (DISABLE_WP_CRON=true). " . |
| 223 |
"Consider enabling WP-Cron or using server-side cron with a fallback mechanism." |
| 224 |
); |
| 225 |
return; |
| 226 |
} |
| 227 |
|
| 228 |
global $wpdb; |
| 229 |
|
| 230 |
$cronDisabled = defined('DISABLE_WP_CRON') && DISABLE_WP_CRON; |
| 231 |
// Asked through the armed check rather than probed directly: WP-Cron |
| 232 |
// identifies an event by hook AND args, and a resumed chain carries its |
| 233 |
// offset as an argument, so a bare no-args probe here reported |
| 234 |
// "Already scheduled: no" for a hook that had an event queued. Same |
| 235 |
// defect, same hook, as production report 294 on the batch runner's own |
| 236 |
// refusal report. |
| 237 |
$alreadyScheduled = $this->armedRebuildTimestamp(); |
| 238 |
$dbError = !empty($wpdb->last_error) ? $wpdb->last_error : 'none'; |
| 239 |
$rawRebuildOffset = $this->optionStore->getOption('abj404_ngram_rebuild_offset', 'not set'); |
| 240 |
$rebuildOffset = is_scalar($rawRebuildOffset) ? (string)$rawRebuildOffset : 'not set'; |
| 241 |
$rawCacheInit = $this->optionStore->getOption('abj404_ngram_cache_initialized', 'not set'); |
| 242 |
$cacheInitialized = is_scalar($rawCacheInit) ? (string)$rawCacheInit : 'not set'; |
| 243 |
|
| 244 |
$errorMsg = sprintf( |
| 245 |
"Failed to schedule N-gram cache rebuild. Hook: %s, Schedule time: %d (current: %d), " . |
| 246 |
"Already scheduled: %s, WP-Cron disabled: %s, DB error: %s, " . |
| 247 |
"Rebuild offset: %s, Cache initialized: %s, Multisite: %s, Blog ID: %d", |
| 248 |
$hookName, |
| 249 |
$scheduleTime, |
| 250 |
$this->cronScheduler->now(), |
| 251 |
// Site-local, not date(), for the same reason the batch runner's |
| 252 |
// refusal report is: date() renders in the host process's default |
| 253 |
// timezone, so the same event reads differently on two hosts. |
| 254 |
$alreadyScheduled |
| 255 |
? ABJ_404_Solution_SiteLocalTimestamp::format('Y-m-d H:i:s T', (int)$alreadyScheduled) |
| 256 |
: 'no', |
| 257 |
$cronDisabled ? 'yes' : 'no', |
| 258 |
$dbError, |
| 259 |
$rebuildOffset, |
| 260 |
$cacheInitialized, |
| 261 |
is_multisite() ? 'yes' : 'no', |
| 262 |
get_current_blog_id() |
| 263 |
); |
| 264 |
|
| 265 |
// Pattern 7 (defense-in-depth): a concurrent infra-level DB |
| 266 |
// error (disk full, read-only, crashed table) may have |
| 267 |
// contributed to wp_schedule_single_event() failing: surface |
| 268 |
// the hosting cause as an admin notice while keeping the cron |
| 269 |
// failure ERROR level so the user must act on it. |
| 270 |
if (!empty($wpdb->last_error)) { |
| 271 |
$this->dbCore->errorClassifier()->classifyAndHandleInfrastructureError($wpdb->last_error); |
| 272 |
} |
| 273 |
|
| 274 |
$this->logger->errorMessage($errorMsg); |
| 275 |
} |
| 276 |
|
| 277 |
} |
| 278 |
|