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 / logs / LogsHitsTableRebuilder.php

LogsHitsTableRebuilder.php in 404 Solution trunk, at includes/logs/LogsHitsTableRebuilder.php

295 lines 15.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 // allow-no-test-found: exercised by RedirectsCanonicalUrlBackfillCompleteTest
8
9 require_once __DIR__ . '/LogsHitsCanonicalUrlJoinHelper.php';
10 require_once __DIR__ . '/../database/DatabaseCollationHelper.php';
11
12 /**
13 * Two-phase SQL execution engine for the wp_abj404_logs_hits rollup.
14 *
15 * Extracted from LogsHitsRollupService (i468) so the service stays focused on
16 * rebuild coordination (gate / lock / scheduling), staleness signaling, and
17 * freshness/id reads, while this class owns the one cohesive responsibility of
18 * materializing the rollup and swapping it in.
19 *
20 * Given a snapshot id range it:
21 * 1. (re)creates and truncates the {wp_abj404_logs_hits}_temp staging table,
22 * 2. picks the direct INSERT...SELECT path for small ranges or the chunked
23 * pre-aggregation path (Phase 1 per-id-range -> Phase 2 JOIN) for large
24 * ones,
25 * 3. stamps the temp table COMMENT with elapsed|maxLogId and renames it over
26 * the live table in a single transaction,
27 * 4. records per-phase success/failure into RebuildHealthState and cleans up
28 * the pre-aggregation temp table.
29 *
30 * The min/max log-id snapshot is supplied by the caller (not read here) so the
31 * tracking test subclass can control the id range without a live logsv2 table,
32 * and so the watermark stored in the table COMMENT is the pre-insert value.
33 *
34 * Pure data-access / execution layer: no admin-notice policy, no scheduling
35 * decision, no runtime-flag bookkeeping. Those stay on the service.
36 */
37 class ABJ_404_Solution_LogsHitsTableRebuilder {
38
39 /** @var int Number of logsv2 IDs to process per chunk during pre-aggregation. */
40 const HITS_TABLE_PREAGG_CHUNK_SIZE = 100000;
41 /** @var int Direct-path threshold for hits-table rebuild. */
42 const HITS_TABLE_DIRECT_PATH_THRESHOLD = 5000;
43
44 /** @var ABJ_404_Solution_DatabaseCore */
45 private $dbCore;
46
47 /** @var ABJ_404_Solution_Logging */
48 private $logger;
49
50 /** @var ABJ_404_Solution_RebuildHealthState|null */
51 private $rebuildHealth;
52
53 /** @var ABJ_404_Solution_LogsHitsCanonicalUrlJoinHelper */
54 private $joinHelper;
55
56 /** @var callable():bool|null */
57 private $leaseRenewer;
58
59 /**
60 * @param ABJ_404_Solution_DatabaseCore $dbCore
61 * @param ABJ_404_Solution_Logging $logger
62 * @param ABJ_404_Solution_RebuildHealthState|null $rebuildHealth
63 * @param ABJ_404_Solution_LogsHitsCanonicalUrlJoinHelper $joinHelper
64 * @param callable():bool|null $leaseRenewer
65 */
66 public function __construct(
67 ABJ_404_Solution_DatabaseCore $dbCore,
68 $logger,
69 $rebuildHealth,
70 ABJ_404_Solution_LogsHitsCanonicalUrlJoinHelper $joinHelper,
71 $leaseRenewer = null
72 ) {
73 $this->dbCore = $dbCore;
74 $this->logger = $logger;
75 $this->rebuildHealth = $rebuildHealth instanceof ABJ_404_Solution_RebuildHealthState
76 ? $rebuildHealth
77 : null;
78 $this->joinHelper = $joinHelper;
79 if ($leaseRenewer !== null && !is_callable($leaseRenewer)) {
80 throw new InvalidArgumentException(
81 'LogsHitsTableRebuilder lease renewer must be callable or null.'
82 );
83 }
84 $this->leaseRenewer = $leaseRenewer;
85 }
86
87 /**
88 * Materialize the rollup over [$minLogId, $maxLogId] into a fresh temp
89 * table and rename it over the live wp_abj404_logs_hits table. Records
90 * per-phase outcomes into RebuildHealthState and cleans up the
91 * pre-aggregation temp table.
92 *
93 * Expected SQL failures (timeout / last_error) are caught and reported via
94 * the return value; unexpected Throwables are recorded and also reported
95 * via the return value (never re-thrown) so the caller's lock release runs
96 * unconditionally.
97 *
98 * @param int $minLogId Pre-insert MIN(logsv2.id) snapshot.
99 * @param int $maxLogId Pre-insert MAX(logsv2.id) snapshot (stored as the watermark).
100 * @return array{refreshed: bool, elapsed_time: float, error: string}
101 */
102 public function rebuildAndSwap(int $minLogId, int $maxLogId): array {
103 $preAggTable = $this->dbCore->doTableNameReplacements("{wp_abj404_logs_hits}_preagg");
104 try {
105 $finalDestTable = $this->dbCore->doTableNameReplacements("{wp_abj404_logs_hits}");
106 $tempDestTable = $this->dbCore->doTableNameReplacements("{wp_abj404_logs_hits}_temp");
107 $this->renewLeaseOrThrow();
108 $this->dbCore->queryAndGetResults("drop table if exists " . $tempDestTable);
109 $this->renewLeaseOrThrow();
110 $resolvedCollation = $this->joinHelper->resolveHitsJoinCollation();
111 $createTempTableQuery = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../sql/createLogsHitsTempTable.sql");
112 $createTempTableQuery = $this->dbCore->doTableNameReplacements($createTempTableQuery);
113 $createTempTableQuery = $this->applyJoinCharsetCollation(array(
114 'ddl' => $createTempTableQuery,
115 'rawCollation' => $resolvedCollation,
116 ));
117 $this->dbCore->queryAndGetResults($createTempTableQuery);
118 $this->renewLeaseOrThrow();
119 // @cache-write-audit: opt-out - truncates an unpublished temp table before rebuilding it.
120 $this->dbCore->queryAndGetResults("truncate table " . $tempDestTable);
121 $idRange = $maxLogId - $minLogId;
122 $chunkSize = $this->getHitsRebuildChunkSize($idRange);
123 $this->renewLeaseOrThrow();
124 if ($idRange <= self::HITS_TABLE_DIRECT_PATH_THRESHOLD) { $results = $this->hitsTableInsertDirect($tempDestTable); } else { $results = $this->hitsTableInsertChunked($tempDestTable, $preAggTable, $minLogId, $maxLogId, $chunkSize); }
125 $this->renewLeaseOrThrow();
126 if ($results === false || !empty($results['timed_out']) || !empty($results['last_error'])) {
127 $rawLastError = is_array($results) && isset($results['last_error']) && is_string($results['last_error']) ? $results['last_error'] : '';
128 $errorMessage = $results === false ? 'Hits rebuild phase 1 chunk failed.' : ($rawLastError !== '' ? $rawLastError : 'Hits rebuild timed out.');
129 $this->recordHitsRebuildFailure($errorMessage);
130 if ($idRange > self::HITS_TABLE_DIRECT_PATH_THRESHOLD && $results !== false && (!empty($results['timed_out']) || !empty($results['last_error']))) {
131 $this->recordHitsChunkFailure();
132 }
133 $this->dropScratchTableIfLeaseOwned($tempDestTable);
134 $this->logger->debugMessage(__FUNCTION__ . " INSERT timed out or errored; aborting rebuild.");
135 return array('refreshed' => false, 'elapsed_time' => 0.0, 'error' => $errorMessage);
136 }
137 $rawElapsed = $results['elapsed_time'] ?? 0;
138 $elapsedTime = is_numeric($rawElapsed) ? (float)$rawElapsed : 0.0;
139 $comment = $elapsedTime . '|' . $maxLogId;
140 $this->renewLeaseOrThrow();
141 // @utf8-audit: opt-out - rebuild table comment is synthesized from numeric timing and ID values.
142 $comment = substr(esc_sql($comment), 0, 2048);
143 $this->dbCore->queryAndGetResults(sprintf("ALTER TABLE %s COMMENT '%s'", $tempDestTable, $comment));
144 $this->renewLeaseOrThrow();
145 $statements = array("drop table if exists " . $finalDestTable, "rename table " . $tempDestTable . ' to ' . $finalDestTable);
146 $this->dbCore->executeAsTransaction($statements);
147 $this->recordHitsRebuildSuccess($chunkSize);
148 $this->logger->debugMessage(__FUNCTION__ . " refreshed " . $finalDestTable . " in " . $elapsedTime . " seconds.");
149 return array('refreshed' => true, 'elapsed_time' => $elapsedTime, 'error' => '');
150 } catch (Throwable $e) {
151 $this->recordHitsRebuildFailure($e->getMessage());
152 $this->logger->errorMessage(__FUNCTION__ . " failed: " . $e->getMessage(), $e instanceof \Exception ? $e : null);
153 return array('refreshed' => false, 'elapsed_time' => 0.0, 'error' => $e->getMessage());
154 } finally {
155 $this->dropScratchTableIfLeaseOwned($preAggTable);
156 }
157 }
158
159 /**
160 * Fill the {CHARSET} / {COLLATION} pair in a staging-table DDL from the one
161 * collation the staging table must honour.
162 *
163 * The rollup's phase-2 JOIN probes redirects.canonical_url, so requested_url
164 * has to carry that column's collation or the index cannot serve the probe.
165 * The charset therefore cannot be chosen independently: the DDL used to
166 * hard-code CHARACTER SET utf8mb4 next to a column collation read from
167 * information_schema, and on an install whose redirects table is still
168 * latin1 the engine rejects the CREATE outright ("COLLATION
169 * 'latin1_swedish_ci' is not valid for CHARACTER SET 'utf8mb4'"), taking the
170 * whole rollup rebuild with it. Both halves now come from one pair.
171 *
172 * @param array{ddl: string, rawCollation: string} $options
173 * @return string DDL with a self-consistent charset/collation pair.
174 */
175 private function applyJoinCharsetCollation(array $options): string {
176 $pair = ABJ_404_Solution_DatabaseCollationHelper::charsetCollationPair(
177 $options['rawCollation']
178 );
179 return str_replace(
180 array('{CHARSET}', '{COLLATION}'),
181 array($pair['charset'], $pair['collation']),
182 $options['ddl']
183 );
184 }
185
186 /**
187 * @param string $tempDestTable
188 * @return array<string, mixed>
189 */
190 private function hitsTableInsertDirect(string $tempDestTable): array {
191 $ttSelectQuery = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../sql/getRedirectsForViewTempTable.sql");
192 if ($this->joinHelper->isLogsv2CanonicalUrlBackfillComplete()) { $ttSelectQuery = $this->joinHelper->dropLogsv2CanonicalCoalesceWrap($ttSelectQuery); }
193 $ttSelectQuery = str_replace('{redirects_canonical_url_join_rhs}', $this->joinHelper->buildDirectJoinRhs(), $ttSelectQuery);
194 $ttSelectQuery = $this->dbCore->doTableNameReplacements($ttSelectQuery);
195 $ttInsertQuery = "/* abj404:src=LogsHitsTableRebuilder::hitsTableInsertDirect */ insert into " . $tempDestTable . " (requested_url, logsid, last_used, logshits, failed_hits) \n " . $ttSelectQuery;
196 return $this->dbCore->queryAndGetResults($ttInsertQuery, array('log_too_slow' => false, 'timeout' => 60));
197 }
198
199 /** @return array<string, mixed>|false */
200 private function hitsTableInsertChunked(string $tempDestTable, string $preAggTable, int $minId, int $maxId, int $chunkSize) {
201 $logsv2Table = $this->dbCore->doTableNameReplacements("{wp_abj404_logsv2}");
202 $redirectsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_redirects}");
203 $resolvedCollation = $this->joinHelper->resolveHitsJoinCollation();
204 $startTime = abj_clock()->nowFloat();
205 $this->renewLeaseOrThrow();
206 $this->dbCore->queryAndGetResults("drop table if exists " . $preAggTable);
207 $this->renewLeaseOrThrow();
208 $createPreAggQuery = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../sql/createLogsHitsPreAggTempTable.sql");
209 $createPreAggQuery = $this->dbCore->doTableNameReplacements($createPreAggQuery);
210 $createPreAggQuery = $this->applyJoinCharsetCollation(array(
211 'ddl' => $createPreAggQuery,
212 'rawCollation' => $resolvedCollation,
213 ));
214 $this->dbCore->queryAndGetResults($createPreAggQuery);
215 $this->renewLeaseOrThrow();
216 $logsv2CanonicalExpr = $this->joinHelper->isLogsv2CanonicalUrlBackfillComplete() ? "canonical_url" : "COALESCE(canonical_url, CONCAT('/', TRIM(BOTH '/' FROM requested_url)))";
217 for ($start = $minId; $start <= $maxId; $start += $chunkSize) {
218 $this->renewLeaseOrThrow();
219 $end = $start + $chunkSize;
220 $chunkQuery = "/* abj404:src=LogsHitsTableRebuilder::hitsTableInsertChunked#phase1Chunk */ INSERT INTO " . $preAggTable . " (requested_url, logsid, last_used, logshits, failed_hits) SELECT " . $logsv2CanonicalExpr . ", MIN(id), MAX(timestamp), COUNT(*), SUM(CASE WHEN dest_url = '' OR dest_url IS NULL THEN 1 ELSE 0 END) FROM " . $logsv2Table . " WHERE id >= %d AND id < %d GROUP BY " . $logsv2CanonicalExpr;
221 $chunkResult = $this->dbCore->queryAndGetResults($chunkQuery, array('log_too_slow' => false, 'timeout' => 10, 'query_params' => array($start, $end)));
222 $this->renewLeaseOrThrow();
223 if (!empty($chunkResult['timed_out']) || !empty($chunkResult['last_error'])) { $this->recordHitsChunkFailure(); $this->logger->debugMessage(__FUNCTION__ . " Phase 1 chunk failed at id range [{$start}, {$end}); aborting."); return false; }
224 }
225 // Defensive form covers legacy and in-progress installs; optimized
226 // form lets idx_canonical_url serve the JOIN probe and gets the
227 // rebuild under the host's 60s max_statement_time on Bruno-class
228 // data (i359). See LogsHitsCanonicalUrlJoinHelper::buildPhase2JoinRhs.
229 $joinRhs = $this->joinHelper->buildPhase2JoinRhs($resolvedCollation);
230 $this->renewLeaseOrThrow();
231 $phase2Query = "/* abj404:src=LogsHitsTableRebuilder::hitsTableInsertChunked#phase2Aggregate */ INSERT INTO " . $tempDestTable . " (requested_url, logsid, last_used, logshits, failed_hits) SELECT a.requested_url, MIN(a.logsid), MAX(a.last_used), SUM(a.logshits), SUM(a.failed_hits) FROM " . $preAggTable . " a INNER JOIN " . $redirectsTable . " r ON a.requested_url = " . $joinRhs . " GROUP BY a.requested_url";
232 $results = $this->dbCore->queryAndGetResults($phase2Query, array('log_too_slow' => false, 'timeout' => 60));
233 $this->renewLeaseOrThrow();
234 $results['elapsed_time'] = round(abj_clock()->nowFloat() - $startTime, 3);
235 return $results;
236 }
237
238 /** Abort before another request can run beside a worker that lost its lease. */
239 private function renewLeaseOrThrow(): void {
240 if (!$this->renewLease()) {
241 throw new RuntimeException(
242 'The logs-hits rebuild lost its exclusive lease; aborting before staging-table work can overlap.'
243 );
244 }
245 }
246
247 /** Return whether this worker still owns (and has renewed) its lease. */
248 private function renewLease(): bool {
249 return $this->leaseRenewer === null || (bool)call_user_func($this->leaseRenewer);
250 }
251
252 /** Never let a superseded worker drop a replacement worker's scratch table. */
253 private function dropScratchTableIfLeaseOwned(string $scratchTable): void {
254 if (!$this->renewLease()) {
255 $this->logger->debugMessage(
256 __FUNCTION__ . " skipped cleanup after lease ownership was lost: " . $scratchTable
257 );
258 return;
259 }
260 $this->dbCore->queryAndGetResults("drop table if exists " . $scratchTable);
261 }
262
263 /** @param int $idRange @return int */
264 private function getHitsRebuildChunkSize(int $idRange): int {
265 if ($this->rebuildHealth === null) {
266 return self::HITS_TABLE_PREAGG_CHUNK_SIZE;
267 }
268 return $this->rebuildHealth->getHitsChunkSize($idRange);
269 }
270
271 /** @return void */
272 private function recordHitsChunkFailure(): void {
273 if ($this->rebuildHealth !== null) {
274 $this->rebuildHealth->recordHitsChunkFailure();
275 }
276 }
277
278 /** @param int $chunkSize @return void */
279 private function recordHitsRebuildSuccess(int $chunkSize): void {
280 if ($this->rebuildHealth === null) {
281 return;
282 }
283 $this->rebuildHealth->recordFullRebuildSuccess($chunkSize);
284 $this->rebuildHealth->recordSuccess();
285 }
286
287 /** @param string $message @return void */
288 private function recordHitsRebuildFailure(string $message): void {
289 if ($this->rebuildHealth === null) {
290 return;
291 }
292 $this->rebuildHealth->recordFailure($message, $this->rebuildHealth->classifyError($message));
293 }
294 }
295