| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Tunable constants for the staged getRedirectsForView rebuild pipeline. |
| 9 |
* |
| 10 |
* These would naturally live as `const` declarations on the stage pipeline, |
| 11 |
* but PHP 7.4 (the |
| 12 |
* declared minimum, see Requires PHP in 404-solution.php) does not allow |
| 13 |
* constants inside a trait body, that arrived in PHP 8.2. They sat on |
| 14 |
* ABJ_404_Solution_DataAccess for one release (4.1.14) and pushed that |
| 15 |
* file past the 1500-line cap, so they were extracted here. |
| 16 |
* |
| 17 |
* Override at runtime via the corresponding define() (for build-stage |
| 18 |
* timeout tuning, see ABJ404_VIEW_BUILD_PER_STAGE_BUDGET_SECONDS) or via |
| 19 |
* the matching abj404_view_build_* filters where wired. |
| 20 |
*/ |
| 21 |
final class ABJ_404_Solution_ViewBuildConfig { |
| 22 |
|
| 23 |
const VIEW_DONE_FRESHNESS_TTL_SECONDS = 120; |
| 24 |
const VIEW_DONE_BUILD_LOCK_NAME = 'abj404_view_build'; |
| 25 |
|
| 26 |
/** |
| 27 |
* Safety-net TTL for the option-row used as the fallback build lock on |
| 28 |
* managed/sharded MySQL providers (PlanetScale, Vitess, ProxySQL splits) |
| 29 |
* where session-scoped GET_LOCK is unavailable. Sized at one full |
| 30 |
* advance-tick budget plus headroom: a single request never holds the |
| 31 |
* lock longer than VIEW_BUILD_PER_STAGE_BUDGET_SECONDS, but a crashed |
| 32 |
* request must not strand the lock indefinitely. Production code |
| 33 |
* always releases the option in `releaseViewBuildLock`; this TTL only |
| 34 |
* applies when the releasing request died (PHP fatal, OOM, lost |
| 35 |
* connection) and the next worker needs to take over. |
| 36 |
*/ |
| 37 |
const VIEW_BUILD_TRANSIENT_LOCK_TTL_SECONDS = 600; |
| 38 |
|
| 39 |
/** |
| 40 |
* Default batch size for the resumable bulk INSERT (S2) and per-id-range |
| 41 |
* UPDATEs (S4/S5). Tuned to fit comfortably within a single per-query |
| 42 |
* timeout on a slow shared host (5 to 10s typical). Override via define() |
| 43 |
* or the abj404_view_build_batch_size filter. |
| 44 |
*/ |
| 45 |
const VIEW_BUILD_DEFAULT_BATCH_SIZE = 2000; |
| 46 |
|
| 47 |
/** |
| 48 |
* Floor for the adaptive batch shrink. When a stage's batch query is |
| 49 |
* killed by the host (max_statement_time, lock-wait), the runtime halves |
| 50 |
* the per-stage batch size and persists the new value so the next tick |
| 51 |
* resumes at the smaller size. Floor at this value so we never spin on |
| 52 |
* a 1-row batch that adds N database round-trips per row. |
| 53 |
* |
| 54 |
* Lowered from 50 to 10 (2026-05-08, deadline-math-audit-2026-05-08.md |
| 55 |
* concern #1). The previous 50 was high enough that on the tightest |
| 56 |
* shared hosts (max_statement_time = 3, slow disk, big wp_posts JOIN) |
| 57 |
* EVERY batch at floor size would still be killed, locking the build |
| 58 |
* into a runaway shrink loop. The fingerprint never advances because |
| 59 |
* killed batches yield before writing high-water, so the JS poller |
| 60 |
* trips its no-progress deadline (240s) and gives up. 10 is small |
| 61 |
* enough to actually finish on hosts where 50 cannot, slow but |
| 62 |
* progressing. |
| 63 |
*/ |
| 64 |
const VIEW_BUILD_MIN_BATCH_SIZE = 10; |
| 65 |
|
| 66 |
/** |
| 67 |
* Max wall-clock time a single request will spend executing batches in |
| 68 |
* any one stage before yielding so the request can finish. Resumable |
| 69 |
* builds pick up the remaining batches on the next request (driven by |
| 70 |
* WP-Cron or by JS poll-triggered re-requests). |
| 71 |
* |
| 72 |
* Set to match VIEW_SNAPSHOT_WARMUP_STAGE_TIMEOUT_SECONDS (28s) so a |
| 73 |
* single staged query has the full warmup query timeout to complete |
| 74 |
* within one budget tick. The prior 10s value caused Bruno Martinez's |
| 75 |
* 484K-row install (May 2026) to yield mid-stage before any single |
| 76 |
* INSERT batch could finish on a slow shared host, so the build never |
| 77 |
* made forward progress within the JS poller's deadline. |
| 78 |
* |
| 79 |
* Higher values (>28s) start to risk PHP killing the request at |
| 80 |
* max_execution_time on shared hosts that default to 30s; the build |
| 81 |
* still resumes safely on the next request via MAX(id) on the buffer, |
| 82 |
* but a graceful yield is preferable. |
| 83 |
*/ |
| 84 |
const VIEW_BUILD_PER_STAGE_BUDGET_SECONDS = 28; |
| 85 |
|
| 86 |
/** |
| 87 |
* After this many seconds with no progress, an abandoned partial build |
| 88 |
* is considered stale: the buffer table and high-water options are |
| 89 |
* dropped on the next entry and the build restarts from scratch. |
| 90 |
* |
| 91 |
* Bumped from 600 to 3600 (2026-05-08, deadline-math-audit-2026-05-08.md |
| 92 |
* concern #3). On Bruno-scale installs (484K redirects, slow shared |
| 93 |
* host) a full build can legitimately take longer than 10 minutes; if |
| 94 |
* the user closes the browser mid-build, the prior 600s TTL would |
| 95 |
* discard the partial buffer on the next visit and force a fresh |
| 96 |
* restart, so the build never converged across sessions. 3600s (1 |
| 97 |
* hour) is long enough to survive a normal user session gap while |
| 98 |
* still bounding stale-buffer disk cost. |
| 99 |
*/ |
| 100 |
const VIEW_BUILD_RESUME_TTL_SECONDS = 3600; |
| 101 |
|
| 102 |
const VIEW_BUILD_FOREGROUND_LEASE_SECONDS = 120; |
| 103 |
|
| 104 |
/** |
| 105 |
* Cap on the per-query SET STATEMENT max_statement_time hint applied |
| 106 |
* to a non-batched stage (S3 / S9 / S10) on retry after a kill. The |
| 107 |
* hint is also bounded by the request's remaining PHP execution time |
| 108 |
* minus a 2s safety margin -- this constant is the absolute ceiling |
| 109 |
* regardless of how much PHP time is left. |
| 110 |
* |
| 111 |
* 240s matches the JS poller's no-progress deadline: if a single |
| 112 |
* non-batched query needs longer than that, the user-facing UI gives |
| 113 |
* up anyway, so giving the query more time would only delay the |
| 114 |
* eventual failure. |
| 115 |
*/ |
| 116 |
const VIEW_BUILD_NON_BATCHED_KILL_RETRY_CAP_SECONDS = 240; |
| 117 |
|
| 118 |
/** |
| 119 |
* Floor-kill streak threshold. After this many consecutive kills at |
| 120 |
* VIEW_BUILD_MIN_BATCH_SIZE on the same stage, the build halts: the |
| 121 |
* host cannot finish the plugin's smallest unit of work, retrying |
| 122 |
* will only loop forever and trip the JS poller's no-progress |
| 123 |
* deadline. Surfaces as a "host_unfit" admin notice. |
| 124 |
*/ |
| 125 |
const VIEW_BUILD_FLOOR_KILL_STREAK_HALT_THRESHOLD = 5; |
| 126 |
|
| 127 |
/** |
| 128 |
* TTL for the deduplicated admin notice transients raised when a |
| 129 |
* stage is permanently skipped or the build halts. One notice per |
| 130 |
* 24h per failure type per the self-healing reliability rules in |
| 131 |
* CLAUDE.md (notices on the plugin's own admin screen, never email, |
| 132 |
* never wp-admin-wide banner). |
| 133 |
*/ |
| 134 |
const VIEW_BUILD_DEGRADED_NOTICE_TTL_SECONDS = 86400; |
| 135 |
|
| 136 |
/** |
| 137 |
* Per-stage failure policy used by the host-failure classifier. When |
| 138 |
* a stage's callback raises an error that the classifier identifies |
| 139 |
* as a permanent host-side constraint (access denied, read-only, |
| 140 |
* disk-full, quota), this map decides whether the build can degrade |
| 141 |
* gracefully past the stage ('optional': skip + advance) or must |
| 142 |
* stop retrying and surface a critical notice ('critical': halt). |
| 143 |
* |
| 144 |
* Optional stages (skippable on permanent failure): |
| 145 |
* - S3 (ALTER ADD INDEX on view_build): build runs slower without |
| 146 |
* the index, but completes correctly. |
| 147 |
* - S9 (CREATE TEMPORARY hits aggregate): hit-count column is |
| 148 |
* null/0 but the rest of the redirect listing is intact. |
| 149 |
* - S10 (ALTER ADD sort indexes): sorted reads slower, correct. |
| 150 |
* |
| 151 |
* Critical stages (halt on permanent failure): |
| 152 |
* - S1 (create build buffer): nothing can run without the buffer. |
| 153 |
* - S2 (insert redirects): empty buffer means empty published view. |
| 154 |
* - S4-S8 (UPDATE-JOIN against wp_posts/wp_terms/external/special): |
| 155 |
* resolved fields are mandatory for the rendered view; partial |
| 156 |
* resolution would produce a broken admin screen. |
| 157 |
* - S11 (RENAME swap): without the swap, view_done is never |
| 158 |
* published and the build is wasted work. |
| 159 |
* |
| 160 |
* @var array<int, string> |
| 161 |
*/ |
| 162 |
private const STAGE_FAILURE_POLICY = array( |
| 163 |
1 => 'critical', |
| 164 |
2 => 'critical', |
| 165 |
3 => 'optional', |
| 166 |
4 => 'critical', |
| 167 |
5 => 'critical', |
| 168 |
6 => 'critical', |
| 169 |
7 => 'critical', |
| 170 |
8 => 'critical', |
| 171 |
9 => 'optional', |
| 172 |
10 => 'optional', |
| 173 |
11 => 'critical', |
| 174 |
); |
| 175 |
|
| 176 |
/** |
| 177 |
* Look up the per-stage failure policy. Stages not registered in |
| 178 |
* STAGE_FAILURE_POLICY default to 'critical' (fail safely: any |
| 179 |
* unknown stage that fails permanently halts rather than silently |
| 180 |
* skipping data the user expects). |
| 181 |
* |
| 182 |
* @param int $stageNumber 1-based staged build number. |
| 183 |
* @return string 'optional' or 'critical'. |
| 184 |
*/ |
| 185 |
public static function stageFailurePolicy(int $stageNumber): string { |
| 186 |
return self::STAGE_FAILURE_POLICY[$stageNumber] ?? 'critical'; |
| 187 |
} |
| 188 |
|
| 189 |
/** |
| 190 |
* Total number of stages in the staged view-build pipeline. This is the |
| 191 |
* single source of truth for the "of N" denominator in every build-progress |
| 192 |
* payload (`progress.of`). All progress producers (pending response, advance |
| 193 |
* loop, freshness short-circuit, paused gate) must report this same total so |
| 194 |
* the value is internally consistent across the AJAX contract. Derived from |
| 195 |
* STAGE_FAILURE_POLICY so adding/removing a stage updates the total in one |
| 196 |
* place. |
| 197 |
* |
| 198 |
* @return int Count of staged build stages (currently 11). |
| 199 |
*/ |
| 200 |
public static function totalStages(): int { |
| 201 |
return count(self::STAGE_FAILURE_POLICY); |
| 202 |
} |
| 203 |
|
| 204 |
/** Recommended floor for memory_limit (128M) in the PHP env probe. */ |
| 205 |
const PHP_MEMORY_LIMIT_RECOMMENDED_BYTES = 134217728; |
| 206 |
|
| 207 |
/** Floor for free space on @@tmpdir's volume before warning (100MB). */ |
| 208 |
const PHP_TMPDIR_FREE_FLOOR_BYTES = 104857600; |
| 209 |
|
| 210 |
/** |
| 211 |
* Out-of-range thresholds for the operational + DDL-safety MySQL session |
| 212 |
* variables probed at S1 entry. Centralized here (rather than on the |
| 213 |
* trait) because PHP < 8.2 forbids constants in trait bodies and the |
| 214 |
* plugin supports 7.4+. |
| 215 |
*/ |
| 216 |
const SESSION_PROBE_THRESHOLDS = array( |
| 217 |
'innodb_lock_wait_timeout_min' => 30, |
| 218 |
'tmp_table_size_min' => 16777216, |
| 219 |
'max_heap_table_size_min' => 16777216, |
| 220 |
'long_query_time_min' => 1.0, |
| 221 |
'innodb_buffer_pool_size_min' => 268435456, |
| 222 |
'wait_timeout_min' => 600, |
| 223 |
'interactive_timeout_min' => 600, |
| 224 |
'thread_stack_min' => 196608, |
| 225 |
'open_files_limit_min' => 1024, |
| 226 |
'innodb_online_alter_log_max_size_min' => 134217728, |
| 227 |
); |
| 228 |
|
| 229 |
/** |
| 230 |
* Transient gate key for the c384 on-page-load fallback advance. |
| 231 |
* One inline advance per gate window; bursts of admin sub-requests |
| 232 |
* (prefetch, browser refresh, multiple tabs) inside the window |
| 233 |
* short-circuit so the page-load cost cannot compound. Defined |
| 234 |
* here (rather than on the consuming trait) because PHP < 8.2 |
| 235 |
* forbids constants in trait bodies. |
| 236 |
*/ |
| 237 |
const PAGE_LOAD_FALLBACK_GATE_KEY = 'abj404_page_load_fallback_advance'; |
| 238 |
|
| 239 |
/** |
| 240 |
* Seconds the page-load fallback gate transient survives. 60s is |
| 241 |
* short enough that an attentive admin sees real per-load progress |
| 242 |
* (one stage per minute of navigation) and long enough that a |
| 243 |
* burst of clicks within a single working moment does not stack |
| 244 |
* inline build work. |
| 245 |
*/ |
| 246 |
const PAGE_LOAD_FALLBACK_GATE_SECONDS = 60; |
| 247 |
|
| 248 |
/** |
| 249 |
* Per-stage budget seconds during a page-load fallback advance. |
| 250 |
* The admin is blocking on the response, so a single tick must |
| 251 |
* not exceed roughly the human-perceived "loading" tolerance. |
| 252 |
* Picked at 2.0s because that matches the c377 page-load contract |
| 253 |
* and stays well under the WordPress admin heartbeat default. The |
| 254 |
* fallback registers this as a ceiling via add_filter on |
| 255 |
* abj404_view_build_per_stage_budget_seconds (clamping with min(), |
| 256 |
* not overwriting, so any operator-set smaller budget wins). |
| 257 |
*/ |
| 258 |
const PAGE_LOAD_FALLBACK_BUDGET_SECONDS = 2.0; |
| 259 |
|
| 260 |
private function __construct() {} |
| 261 |
} |
| 262 |
|