PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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 / DataAccessTrait_ViewBuildSessionEnvProbe.php

DataAccessTrait_ViewBuildSessionEnvProbe.php in 404 Solution 4.1.19, at includes/DataAccessTrait_ViewBuildSessionEnvProbe.php

423 lines 17.7 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 * MySQL-session environment probe for the staged view-build pipeline.
9 *
10 * Read-and-warn-only check of operational + DDL-safety MySQL session
11 * variables that can silently degrade or break a staged view-build but
12 * are not severe enough to halt it. Each out-of-range variable produces
13 * one warning-level log line and contributes to a single consolidated
14 * deduplicated admin notice (per the task spec: "One concise warning per
15 * S1 entry -- no per-stage spam").
16 *
17 * Sibling to ABJ_404_Solution_DataAccess_ViewBuildHelpersTrait. Extracted
18 * from that trait to keep both files under the project's 1500-line cap.
19 * The public probe method probeSessionVariablesAtS1Entry() is the entry
20 * point called from runStagedBuildOnce() right after the existing
21 * probeSqlModeForBuild() call.
22 *
23 * Variables covered (P3 + P4 ride-along, see task brief):
24 * - innodb_lock_wait_timeout (< 30s)
25 * - tmp_table_size + max_heap_table_size (< 16M)
26 * - slow_query_log + long_query_time (log on AND threshold very low)
27 * - innodb_buffer_pool_size (< 256M)
28 * - wait_timeout + interactive_timeout (< 600s; orphan-table cause)
29 * - innodb_flush_method (== O_DSYNC)
30 * - character_set_server / collation_server (not utf8mb4)
31 * - sql_require_primary_key (ON; future CREATE TABLE breaks)
32 * - innodb_file_per_table (OFF; tablespace bloat / un-reclaimable DROP)
33 * - thread_stack + open_files_limit (very low)
34 * - innodb_online_alter_log_max_size (small; S3/S10 ALTER risk)
35 */
36 trait ABJ_404_Solution_DataAccess_ViewBuildSessionEnvProbeTrait {
37
38 /**
39 * Cached session-variables probe result for the current request. Holds
40 * the raw values pulled via SHOW VARIABLES at S1 entry plus the per-key
41 * out-of-range flags so callers can render a single consolidated warning
42 * without re-querying.
43 *
44 * @var array<string,mixed>|null
45 */
46 private $sessionVariablesProbeCache = null;
47
48 /** @return string Option name for the persisted session-variables probe. */
49 private function sessionVariablesProbeOptionName(): string {
50 return 'abj404_view_build_session_env_probe';
51 }
52
53 /**
54 * Read an int from a probe values map, returning 0 when the value is
55 * non-numeric. Avoids the (int) cast on `mixed` which PHPStan level 9
56 * flags as `cast.int`.
57 *
58 * @param array<string,mixed> $values
59 */
60 private static function probeIntFromValues(array $values, string $key): int {
61 $v = $values[$key] ?? 0;
62 return is_numeric($v) ? (int)$v : 0;
63 }
64
65 /**
66 * Read a float from a probe values map, returning 0.0 when non-numeric.
67 *
68 * @param array<string,mixed> $values
69 */
70 private static function probeFloatFromValues(array $values, string $key): float {
71 $v = $values[$key] ?? 0;
72 return is_numeric($v) ? (float)$v : 0.0;
73 }
74
75 /**
76 * Read a string from a probe values map, returning '' when not scalar.
77 *
78 * @param array<string,mixed> $values
79 */
80 private static function probeStringFromValues(array $values, string $key): string {
81 $v = $values[$key] ?? '';
82 return is_scalar($v) ? (string)$v : '';
83 }
84
85 /**
86 * Probe the live MySQL session for operational + DDL-safety variables
87 * that can silently degrade or break the staged view-build pipeline.
88 * Read-and-warn-only: never throws, never blocks the build. Logs each
89 * out-of-range variable at warning level (per defensive philosophy §8 --
90 * infrastructure issues the plugin can degrade past) and surfaces ONE
91 * consolidated admin notice per 24h on the plugin's own admin screen.
92 *
93 * Idempotent within a request: repeat calls return the cached result.
94 * Cleared by clearSessionVariablesProbeCache() (chained from
95 * clearSqlModeProbeCache) on a fresh build.
96 *
97 * Filterable via `apply_filters('abj404_session_env_probe', $defaults)`
98 * so tests and operators can simulate a constrained host without
99 * mutating the live MySQL session.
100 *
101 * @return array<string,mixed>
102 */
103 public function probeSessionVariablesAtS1Entry(): array {
104 if (is_array($this->sessionVariablesProbeCache)) {
105 return $this->sessionVariablesProbeCache;
106 }
107
108 $defaults = array(
109 'innodb_lock_wait_timeout' => 0,
110 'tmp_table_size' => 0,
111 'max_heap_table_size' => 0,
112 'slow_query_log' => 0,
113 'long_query_time' => 0.0,
114 'innodb_buffer_pool_size' => 0,
115 'wait_timeout' => 0,
116 'interactive_timeout' => 0,
117 'innodb_flush_method' => '',
118 'character_set_server' => '',
119 'collation_server' => '',
120 'sql_require_primary_key' => '',
121 'innodb_file_per_table' => '',
122 'thread_stack' => 0,
123 'open_files_limit' => 0,
124 'innodb_online_alter_log_max_size' => 0,
125 'probe_succeeded' => false,
126 );
127
128 $row = $this->fetchSessionVariablesRowOrEmpty();
129 $values = $defaults;
130 if (!empty($row)) {
131 $values['probe_succeeded'] = true;
132 foreach ($row as $k => $v) {
133 $klow = strtolower((string)$k);
134 if (!array_key_exists($klow, $defaults)) { continue; }
135 if ($klow === 'long_query_time') {
136 $values[$klow] = is_scalar($v) ? (float)$v : 0.0;
137 } elseif (is_int($defaults[$klow])) {
138 $values[$klow] = is_scalar($v) ? (int)$v : 0;
139 } else {
140 $values[$klow] = is_scalar($v) ? (string)$v : '';
141 }
142 }
143 }
144
145 if (function_exists('apply_filters')) {
146 $filtered = apply_filters('abj404_session_env_probe', $values);
147 if (is_array($filtered)) {
148 $values = array_merge($values, $filtered);
149 }
150 }
151
152 $warnings = $this->classifySessionVariableWarnings($values);
153 $values['warnings'] = $warnings;
154
155 foreach ($warnings as $w) {
156 $this->logger->warn('[staged] ' . $w);
157 }
158 if (!empty($warnings)) {
159 $this->setSessionEnvAdminNotice($warnings);
160 }
161
162 if (function_exists('update_option')) {
163 update_option($this->sessionVariablesProbeOptionName(), $values, false);
164 }
165
166 $this->sessionVariablesProbeCache = $values;
167 return $values;
168 }
169
170 /**
171 * Single-query SHOW VARIABLES read for every name we care about. Returns
172 * a key=>value map keyed by lowercase Variable_name (case-insensitive
173 * driver tolerance per defensive philosophy §5). Empty on probe failure.
174 *
175 * @return array<string,string>
176 */
177 private function fetchSessionVariablesRowOrEmpty(): array {
178 global $wpdb;
179 if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'get_results')) {
180 return array();
181 }
182 /** @var \wpdb $wpdb */
183 $names = array(
184 'innodb_lock_wait_timeout',
185 'tmp_table_size',
186 'max_heap_table_size',
187 'slow_query_log',
188 'long_query_time',
189 'innodb_buffer_pool_size',
190 'wait_timeout',
191 'interactive_timeout',
192 'innodb_flush_method',
193 'character_set_server',
194 'collation_server',
195 'sql_require_primary_key',
196 'innodb_file_per_table',
197 'thread_stack',
198 'open_files_limit',
199 'innodb_online_alter_log_max_size',
200 );
201 $placeholders = implode(',', array_fill(0, count($names), '%s'));
202 $sql = "SHOW SESSION VARIABLES WHERE Variable_name IN ($placeholders)";
203 $prevSuppress = method_exists($wpdb, 'suppress_errors') ? $wpdb->suppress_errors(true) : false;
204 try {
205 $prepared = method_exists($wpdb, 'prepare') ? $wpdb->prepare($sql, $names) : $sql;
206 // DAO-bypass-approved: read-only probe of @@SESSION on this connection.
207 $rows = $wpdb->get_results($prepared, ARRAY_A);
208 } catch (\Throwable $e) { // allow-silent-catch: probe is best-effort; suppress restored below
209 $rows = null;
210 }
211 if (method_exists($wpdb, 'suppress_errors')) {
212 $wpdb->suppress_errors($prevSuppress);
213 }
214
215 $out = array();
216 if (!is_array($rows)) { return $out; }
217 foreach ($rows as $row) {
218 if (!is_array($row)) { continue; }
219 $name = '';
220 $value = '';
221 foreach ($row as $k => $v) {
222 $klow = strtolower((string)$k);
223 if ($klow === 'variable_name' && is_scalar($v)) { $name = strtolower((string)$v); }
224 if ($klow === 'value' && is_scalar($v)) { $value = (string)$v; }
225 }
226 if ($name !== '') { $out[$name] = $value; }
227 }
228 return $out;
229 }
230
231 /**
232 * Apply the SESSION_PROBE_THRESHOLDS to a value map and return a flat
233 * list of human-readable warning strings (one per out-of-range variable).
234 * Pure / side-effect free so tests can assert classification independently
235 * from logging and admin-notice surfacing.
236 *
237 * @param array<string,mixed> $values
238 * @return array<int,string>
239 */
240 private function classifySessionVariableWarnings(array $values): array {
241 $warnings = array();
242 $t = ABJ_404_Solution_ViewBuildConfig::SESSION_PROBE_THRESHOLDS;
243
244 $iLockWait = self::probeIntFromValues($values, 'innodb_lock_wait_timeout');
245 if ($iLockWait > 0 && $iLockWait < $t['innodb_lock_wait_timeout_min']) {
246 $warnings[] = sprintf(
247 'innodb_lock_wait_timeout=%ds (< %ds); the staged build may abort '
248 . 'with "Lock wait timeout exceeded" on busy hosts.',
249 $iLockWait, $t['innodb_lock_wait_timeout_min']
250 );
251 }
252
253 $tmpTable = self::probeIntFromValues($values, 'tmp_table_size');
254 $maxHeap = self::probeIntFromValues($values, 'max_heap_table_size');
255 if ($tmpTable > 0 && $tmpTable < $t['tmp_table_size_min']) {
256 $warnings[] = sprintf(
257 'tmp_table_size=%d (< %d MB); MySQL will spill GROUP BY work to '
258 . 'disk earlier and the S9 hits aggregate may slow significantly.',
259 $tmpTable, (int)($t['tmp_table_size_min'] / 1048576)
260 );
261 }
262 if ($maxHeap > 0 && $maxHeap < $t['max_heap_table_size_min']) {
263 $warnings[] = sprintf(
264 'max_heap_table_size=%d (< %d MB); MEMORY-engine temp tables will '
265 . 'truncate or spill earlier than expected.',
266 $maxHeap, (int)($t['max_heap_table_size_min'] / 1048576)
267 );
268 }
269
270 $rawSlow = $values['slow_query_log'] ?? '';
271 $rawSlowStr = is_scalar($rawSlow) ? (string)$rawSlow : '';
272 $slowOn = (is_numeric($rawSlow) && (int)$rawSlow > 0)
273 || strtoupper($rawSlowStr) === 'ON';
274 $longTime = self::probeFloatFromValues($values, 'long_query_time');
275 if ($slowOn && $longTime > 0 && $longTime < $t['long_query_time_min']) {
276 $warnings[] = sprintf(
277 'slow_query_log=ON with long_query_time=%.3fs (< %.1fs); the staged '
278 . 'build will flood the slow log with stage-batch queries.',
279 $longTime, (float)$t['long_query_time_min']
280 );
281 }
282
283 $bufferPool = self::probeIntFromValues($values, 'innodb_buffer_pool_size');
284 if ($bufferPool > 0 && $bufferPool < $t['innodb_buffer_pool_size_min']) {
285 $warnings[] = sprintf(
286 'innodb_buffer_pool_size=%d (< %d MB); large logsv2 reads at S2/S9 '
287 . 'will thrash the buffer pool.',
288 $bufferPool, (int)($t['innodb_buffer_pool_size_min'] / 1048576)
289 );
290 }
291
292 $waitTimeout = self::probeIntFromValues($values, 'wait_timeout');
293 $interactiveTimeout = self::probeIntFromValues($values, 'interactive_timeout');
294 if ($waitTimeout > 0 && $waitTimeout < $t['wait_timeout_min']) {
295 $warnings[] = sprintf(
296 'wait_timeout=%ds (< %ds); the build connection can drop mid-stage '
297 . 'leaving the buffer table orphaned (cf. orphan-table cleanup at runner startup).',
298 $waitTimeout, $t['wait_timeout_min']
299 );
300 }
301 if ($interactiveTimeout > 0 && $interactiveTimeout < $t['interactive_timeout_min']) {
302 $warnings[] = sprintf(
303 'interactive_timeout=%ds (< %ds); same orphan-table risk as wait_timeout.',
304 $interactiveTimeout, $t['interactive_timeout_min']
305 );
306 }
307
308 $flush = strtoupper(self::probeStringFromValues($values, 'innodb_flush_method'));
309 if ($flush === 'O_DSYNC') {
310 $warnings[] = 'innodb_flush_method=O_DSYNC; this is the slowest flush '
311 . 'mode and large stage writes will be much slower than O_DIRECT.';
312 }
313
314 $charset = strtolower(self::probeStringFromValues($values, 'character_set_server'));
315 $collation = strtolower(self::probeStringFromValues($values, 'collation_server'));
316 if ($charset !== '' && strpos($charset, 'utf8mb4') !== 0) {
317 $warnings[] = sprintf(
318 'character_set_server=%s (not utf8mb4); 4-byte characters in URLs '
319 . 'will be truncated or rejected by the server default.',
320 $charset
321 );
322 }
323 if ($collation !== '' && strpos($collation, 'utf8mb4') !== 0) {
324 $warnings[] = sprintf(
325 'collation_server=%s (not utf8mb4); 4-byte characters in URLs '
326 . 'may sort or compare unexpectedly.',
327 $collation
328 );
329 }
330
331 $requirePk = strtoupper(self::probeStringFromValues($values, 'sql_require_primary_key'));
332 if ($requirePk === 'ON' || $requirePk === '1') {
333 $warnings[] = 'sql_require_primary_key=ON; future CREATE TABLE without '
334 . 'a primary key will be rejected by the server.';
335 }
336
337 $filePerTable = strtoupper(self::probeStringFromValues($values, 'innodb_file_per_table'));
338 if ($filePerTable === 'OFF' || $filePerTable === '0') {
339 $warnings[] = 'innodb_file_per_table=OFF; new InnoDB tables share the '
340 . 'system tablespace and cannot be reclaimed by DROP.';
341 }
342
343 $threadStack = self::probeIntFromValues($values, 'thread_stack');
344 if ($threadStack > 0 && $threadStack < $t['thread_stack_min']) {
345 $warnings[] = sprintf(
346 'thread_stack=%d bytes (< %dK); deeply nested SQL may exhaust '
347 . 'the connection thread stack.',
348 $threadStack, (int)($t['thread_stack_min'] / 1024)
349 );
350 }
351 $openFiles = self::probeIntFromValues($values, 'open_files_limit');
352 if ($openFiles > 0 && $openFiles < $t['open_files_limit_min']) {
353 $warnings[] = sprintf(
354 'open_files_limit=%d (< %d); high-concurrency table opens may '
355 . 'fail with "Too many open files".',
356 $openFiles, $t['open_files_limit_min']
357 );
358 }
359
360 $alterLog = self::probeIntFromValues($values, 'innodb_online_alter_log_max_size');
361 if ($alterLog > 0 && $alterLog < $t['innodb_online_alter_log_max_size_min']) {
362 $warnings[] = sprintf(
363 'innodb_online_alter_log_max_size=%d (< %d MB); the S3 / S10 '
364 . 'ALTER TABLE ADD INDEX may fail with "Online DDL log overflow" '
365 . 'on busy hosts.',
366 $alterLog, (int)($t['innodb_online_alter_log_max_size_min'] / 1048576)
367 );
368 }
369
370 return $warnings;
371 }
372
373 /**
374 * Surface a single deduplicated admin notice consolidating the session
375 * variable warnings (per the task spec: one concise warning per S1 entry,
376 * no per-stage spam). Dedup window matches the existing self-healing
377 * notice TTL (24h).
378 *
379 * @param array<int,string> $warnings
380 * @return void
381 */
382 private function setSessionEnvAdminNotice(array $warnings): void {
383 $key = 'abj404_view_build_session_env_notice';
384 $payload = array(
385 'kind' => 'session_env',
386 'warnings' => $warnings,
387 'message' => 'The 404 Solution view-build pipeline detected MySQL '
388 . 'session-variable settings that may degrade the next rebuild: '
389 . implode(' | ', $warnings),
390 'when' => $this->clock()->now(),
391 );
392 $dedupKey = 'abj404_view_build_session_env_dedup';
393 if (function_exists('get_transient') && get_transient($dedupKey) !== false) {
394 return;
395 }
396 if (function_exists('set_transient')) {
397 set_transient(
398 $key,
399 $payload,
400 ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_DEGRADED_NOTICE_TTL_SECONDS
401 );
402 set_transient(
403 $dedupKey,
404 1,
405 ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_DEGRADED_NOTICE_TTL_SECONDS
406 );
407 } elseif (function_exists('update_option')) {
408 update_option($key, $payload, false);
409 }
410 }
411
412 /** @return void */
413 private function clearSessionVariablesProbeCache(): void {
414 $this->sessionVariablesProbeCache = null;
415 if (function_exists('delete_option')) {
416 delete_option($this->sessionVariablesProbeOptionName());
417 }
418 if (function_exists('delete_transient')) {
419 delete_transient('abj404_view_build_session_env_dedup');
420 }
421 }
422 }
423