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_ViewBuildAdaptive.php

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

222 lines 9.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 * Adaptive-runtime helpers for the staged view-build pipeline.
9 *
10 * Two responsibilities, both keyed off behavior the build only learns at
11 * runtime on the actual host:
12 *
13 * 1. Adaptive batch size: when a host kills a batched stage's per-query
14 * (max_statement_time exceeded, lock-wait, gone-away), halve the
15 * batch size for that stage and persist it. Subsequent ticks of the
16 * same build use the smaller size, so a slow shared host eventually
17 * converges to a batch size it can actually finish.
18 *
19 * 2. Intelligent per-query timeout: probe the host's session-level
20 * max_statement_time (MariaDB) or max_execution_time (MySQL) once
21 * per request, then size our own per-query SET STATEMENT hint to
22 * fire just before the host's silent kill would. This converts a
23 * generic connection drop into a clean classifiable kill the
24 * pipeline can resume from.
25 *
26 * Sibling to ABJ_404_Solution_DataAccess_ViewQueriesStagedTrait; both are
27 * mixed into ABJ_404_Solution_DataAccess. Private members declared here
28 * are visible to the staged-build trait inside the composing class.
29 */
30 trait ABJ_404_Solution_DataAccess_ViewBuildAdaptiveTrait {
31
32 /**
33 * Request-lifetime cache of the host's per-statement timeout, in
34 * seconds. -1 means "not yet probed", 0 means "no host limit", > 0
35 * is the limit the host enforces. Probed lazily by
36 * detectHostStagedQueryLimitSeconds().
37 *
38 * @var float
39 */
40 private $hostStagedQueryLimitSecondsCache = -1.0;
41
42 /**
43 * Per-stage batch size with adaptive-shrink memory. Reads the
44 * persisted shrink option for this stage (s2_batch_size /
45 * s4_batch_size / s5_batch_size); falls back to the global default
46 * when none is set. The persisted value survives across requests so
47 * a host that has already shown it cannot handle 2000-row batches
48 * keeps using the smaller size for the rest of the build.
49 *
50 * @param string $stageShortKey One of 's2_batch_size', 's4_batch_size', 's5_batch_size'.
51 * @return int Always >= VIEW_BUILD_MIN_BATCH_SIZE.
52 */
53 private function viewBuildBatchSizeForStage(string $stageShortKey): int {
54 $defaultSize = $this->viewBuildBatchSize();
55 $persisted = $this->readProgressOption($stageShortKey, 0);
56 $effective = $persisted > 0 ? $persisted : $defaultSize;
57 return max(ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_MIN_BATCH_SIZE, $effective);
58 }
59
60 /**
61 * Record that a batch in stage $stageShortKey was killed by the host
62 * (resumable kill class: max_statement_time exceeded, gone-away,
63 * lock-wait). Halves the batch size, floors at
64 * VIEW_BUILD_MIN_BATCH_SIZE, persists. Subsequent ticks pick up the
65 * smaller size via viewBuildBatchSizeForStage().
66 *
67 * @param string $stageShortKey
68 * @return int the new batch size.
69 */
70 private function recordStageBatchKilled(string $stageShortKey): int {
71 $current = $this->viewBuildBatchSizeForStage($stageShortKey);
72 $shrunk = (int)max(
73 ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_MIN_BATCH_SIZE,
74 (int)floor($current / 2)
75 );
76 $this->writeProgressOption($stageShortKey, $shrunk);
77 return $shrunk;
78 }
79
80 /**
81 * Seconds of PHP request time remaining before max_execution_time
82 * fires. PHP_INT_MAX when no limit is set (CLI / unbounded cron).
83 *
84 * Used by the batched stages to decide whether there is room to
85 * start another batch at our full per-query limit. If not, the stage
86 * yields via the wall-clock path (no batch attempt, no shrink) and
87 * the next request resumes with a fresh PHP time budget.
88 *
89 * @return float
90 */
91 private function phpTimeRemainingSeconds(): float {
92 $limit = (int)ini_get('max_execution_time');
93 if ($limit <= 0) {
94 return (float)PHP_INT_MAX;
95 }
96 $start = isset($_SERVER['REQUEST_TIME_FLOAT']) && is_numeric($_SERVER['REQUEST_TIME_FLOAT'])
97 ? (float)$_SERVER['REQUEST_TIME_FLOAT']
98 : (float)microtime(true);
99 $elapsed = max(0.0, microtime(true) - $start);
100 return max(0.0, (float)$limit - $elapsed);
101 }
102
103 /**
104 * Probe the host for its session-level per-statement timeout. Reads
105 * the MariaDB session variable max_statement_time (seconds, decimal)
106 * first, then falls back to MySQL max_execution_time (milliseconds).
107 * Returns 0.0 when no host limit is set. Cached on the instance for
108 * the request lifetime so the build pays the SHOW VARIABLES cost
109 * once, not once per stage.
110 *
111 * @return float Seconds, or 0.0 for "no host limit".
112 */
113 private function detectHostStagedQueryLimitSeconds(): float {
114 if ($this->hostStagedQueryLimitSecondsCache >= 0.0) {
115 return $this->hostStagedQueryLimitSecondsCache;
116 }
117 $limitSeconds = 0.0;
118
119 $result = $this->queryAndGetResults(
120 "SHOW SESSION VARIABLES LIKE 'max_statement_time'",
121 array('log_errors' => false)
122 );
123 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
124 if (!empty($rows) && is_array($rows[0])) {
125 $value = $rows[0]['Value'] ?? ($rows[0]['value'] ?? null);
126 if ($value !== null && is_numeric($value) && (float)$value > 0.0) {
127 $limitSeconds = (float)$value;
128 }
129 }
130
131 if ($limitSeconds <= 0.0) {
132 $result = $this->queryAndGetResults(
133 "SHOW SESSION VARIABLES LIKE 'max_execution_time'",
134 array('log_errors' => false)
135 );
136 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
137 if (!empty($rows) && is_array($rows[0])) {
138 $value = $rows[0]['Value'] ?? ($rows[0]['value'] ?? null);
139 if ($value !== null && is_numeric($value) && (int)$value > 0) {
140 $limitSeconds = ((int)$value) / 1000.0;
141 }
142 }
143 }
144
145 $this->hostStagedQueryLimitSecondsCache = max(0.0, $limitSeconds);
146 return $this->hostStagedQueryLimitSecondsCache;
147 }
148
149 /**
150 * Compute the per-query timeout we hint to the database for a
151 * single staged-build query. The result is the smallest of:
152 *
153 * - our per-stage budget minus a 2s margin (so the hint fires
154 * before PHP max_execution_time can interrupt the request)
155 * - the host max_statement_time minus 1s (so the hint fires
156 * before the host silent kill, giving us a clean classifiable
157 * error rather than a dropped connection)
158 *
159 * Floored at 1s. The whole point of the function is to fire OUR
160 * kill before the host's; with the prior 5s floor, on hosts with
161 * `max_statement_time = 3` we would emit a 5s hint that the host
162 * pre-empts at 3s, defeating the classifiable-kill design. 1s is
163 * the smallest sensible floor (sub-second queries are noise) but
164 * still lets the function honor genuinely-tight host limits.
165 * (2026-05-08, deadline-math-audit-2026-05-08.md concern #2.)
166 *
167 * Our-limit floor stays at 5s: that one represents "this query
168 * is so small that the per-stage budget overhead dominates" and
169 * has nothing to do with the host kill. The host-limit code path
170 * uses 1s.
171 *
172 * When the host has no limit set, only the per-stage budget applies.
173 *
174 * @return float Seconds.
175 */
176 private function intelligentStagedQueryTimeoutSeconds(): float {
177 $ourLimit = max(5.0, (float)$this->viewBuildPerStageBudgetSeconds() - 2.0);
178 $hostLimit = $this->detectHostStagedQueryLimitSeconds();
179 if ($hostLimit > 0.0) {
180 return max(1.0, min($ourLimit, $hostLimit - 1.0));
181 }
182 return $ourLimit;
183 }
184
185 /**
186 * Per-query timeout for the next attempt of a non-batched stage that
187 * has been killed at least once already. When the persisted streak is
188 * 0 (no prior kill on this stage in the current build) returns the
189 * normal intelligent timeout; otherwise returns an extended timeout
190 * that intentionally overrides the host's session max_statement_time.
191 *
192 * The override works because MariaDB 10.1+ honors
193 * `SET STATEMENT max_statement_time=N FOR <query>` even when N is
194 * larger than the session limit: SET STATEMENT scopes the override
195 * to the wrapped statement only. Without this, S3 / S9 / S10 -- the
196 * non-batched stages -- would hit the host limit and retry with the
197 * same timeout forever, looping with no escape valve (the batched
198 * stages have one in the form of adaptive batch shrink; non-batched
199 * stages don't, so we extend in the time dimension instead).
200 *
201 * Bounded by:
202 * - VIEW_BUILD_NON_BATCHED_KILL_RETRY_CAP_SECONDS (absolute ceiling)
203 * - phpTimeRemainingSeconds() - 2.0 (so the request returns inside
204 * PHP max_execution_time even if the query still gets killed)
205 * - max(1.0, ...) so we never ship a non-positive hint
206 *
207 * @param string $stageKillStreakOptKey Progress option key name,
208 * e.g. 's3_kill_streak'. Production callers register the key in
209 * the staged trait's progress option name map.
210 * @return int Seconds.
211 */
212 private function extendedTimeoutForKilledNonBatchedStage(string $stageKillStreakOptKey): int {
213 $streak = $this->readProgressOption($stageKillStreakOptKey, 0);
214 if ($streak <= 0) {
215 return (int)round($this->intelligentStagedQueryTimeoutSeconds());
216 }
217 $cap = (float)ABJ_404_Solution_ViewBuildConfig::VIEW_BUILD_NON_BATCHED_KILL_RETRY_CAP_SECONDS;
218 $phpRemaining = max(1.0, $this->phpTimeRemainingSeconds() - 2.0);
219 return (int)round(max(1.0, min($cap, $phpRemaining)));
220 }
221 }
222