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 / view-build / ViewCacheInvalidator.php

ViewCacheInvalidator.php in 404 Solution trunk, at includes/view-build/ViewCacheInvalidator.php

240 lines 10.3 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 * Cache invalidation orchestration for admin view queries. Coordinates
9 * status-count cache invalidation, view snapshot invalidation, and regex
10 * cache clearing when redirects mutate.
11 */
12 class ABJ_404_Solution_ViewCacheInvalidator {
13
14 /** @var ABJ_404_Solution_DatabaseCore */
15 private $dbCore;
16
17 /** @var ABJ_404_Solution_RedirectsRepository */
18 private $redirectsRepo;
19
20 /** @var string */
21 private $viewDoneFreshnessOptionName;
22
23 /**
24 * @param ABJ_404_Solution_DatabaseCore $dbCore
25 * @param ABJ_404_Solution_RedirectsRepository $redirectsRepo
26 * @param string $viewDoneFreshnessOptionName
27 */
28 public function __construct(
29 ABJ_404_Solution_DatabaseCore $dbCore,
30 ABJ_404_Solution_RedirectsRepository $redirectsRepo,
31 string $viewDoneFreshnessOptionName
32 ) {
33 $this->dbCore = $dbCore;
34 $this->redirectsRepo = $redirectsRepo;
35 $this->viewDoneFreshnessOptionName = $viewDoneFreshnessOptionName;
36 }
37
38 /** @return void */
39 public function setSqlBigSelects(): void {
40 $ignoreErrorsOptions = array('log_errors' => false);
41 $this->dbCore->queryAndGetResults("set session max_join_size = 18446744073709551615",
42 $ignoreErrorsOptions);
43 $this->dbCore->queryAndGetResults("set session sql_big_selects = 1", $ignoreErrorsOptions);
44 }
45
46 /**
47 * Open/close the bulk-mutation window.
48 *
49 * @template T
50 * @param callable():T $work
51 * @return T
52 */
53 public function runWithDeferredInvalidation(callable $work) {
54 $prior = ABJ_404_Solution_ViewReadRuntimeState::$bulkMutationInProgress;
55 ABJ_404_Solution_ViewReadRuntimeState::$bulkMutationInProgress = true;
56 try {
57 return $work();
58 } finally {
59 ABJ_404_Solution_ViewReadRuntimeState::$bulkMutationInProgress = $prior;
60 }
61 }
62
63 /**
64 * Invalidate cached status counts.
65 * Call this when redirects are created, updated, or deleted.
66 *
67 * @return void
68 */
69 public function invalidateStatusCountsCache(): void {
70 if (ABJ_404_Solution_ViewReadRuntimeState::$bulkMutationInProgress) {
71 return;
72 }
73 self::markTransientStale(array(
74 'current' => ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_REDIRECT_STATUS,
75 'last_known' => ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_REDIRECT_STATUS_LAST_KNOWN,
76 'kind' => 'array',
77 ));
78 self::markTransientStale(array(
79 'current' => ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_CAPTURED_STATUS,
80 'last_known' => ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_CAPTURED_STATUS_LAST_KNOWN,
81 'kind' => 'array',
82 ));
83 self::markTransientStale(array(
84 'current' => ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_HIGH_IMPACT_CAPTURED,
85 'last_known' => ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_HIGH_IMPACT_CAPTURED_LAST_KNOWN,
86 'kind' => 'count',
87 ));
88 $this->invalidateViewSnapshotCache();
89 }
90
91 /**
92 * Debounced, captured-scoped status-count invalidation for the
93 * high-frequency captured-insert path (report.md Finding 4).
94 *
95 * A captured 404 insert only changes the captured + high-impact counts; it
96 * never changes the redirect (manual/auto/regex) counts, so this leaves the
97 * REDIRECT status-count cache intact (the redirects tab stays warm through a
98 * capture burst). It also collapses a burst into at most one invalidation per
99 * cooldown window, so the SUM(CASE) captured-count aggregate is recomputed at
100 * most once per window instead of cold on every admin load.
101 *
102 * It does NOT clear the view snapshot: the admin table read is live off
103 * wp_abj404_redirects (Denorm Step 3b -- no snapshot result cache), so a new
104 * captured row appears on the next read regardless. The detect-only poll
105 * (Finding 3) reads the same live source, so staleness is still detected.
106 *
107 * Static + pure transient ops: holds no instance state, so the
108 * frontend-capture hot path can call it without a fully-wired invalidator.
109 *
110 * @return void
111 */
112 public static function invalidateCapturedStatusCountsCacheDebounced(): void {
113 if (ABJ_404_Solution_ViewReadRuntimeState::$bulkMutationInProgress) {
114 return;
115 }
116 if (!self::claimCapturedCountInvalidateCooldown()) {
117 return;
118 }
119 self::invalidateCapturedStatusCountsCache();
120 }
121
122 /**
123 * Single-flight claim on the captured-count invalidation cooldown.
124 * Returns true only for the caller that wins the race.
125 *
126 * A plain get_transient()/set_transient() pair is check-then-act: under
127 * a burst of near-simultaneous captured-URL inserts (bot-scanner flood
128 * traffic across parallel PHP-FPM workers -- the exact profile this
129 * debounce exists for), multiple requests can each read an empty
130 * cooldown before any of them writes it, so the "collapse a burst to
131 * one invalidation" guarantee silently fails under real concurrency.
132 * Same TOCTOU shape as Ajax_Php::consumeRateLimit(), fixed the same way:
133 * wp_cache_add() only succeeds in creating the key if it doesn't already
134 * exist, so concurrent callers on a persistent object cache serialize on
135 * that add. Sites without a persistent object cache keep the narrower
136 * pre-existing transient race rather than gain a DB dependency in this
137 * intentionally zero-dependency method (called from the frontend
138 * capture hot path without a wired invalidator instance).
139 */
140 private static function claimCapturedCountInvalidateCooldown(): bool {
141 $key = ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_CAPTURED_COUNT_INVALIDATE_COOLDOWN;
142 $ttl = ABJ_404_Solution_ViewReadRuntimeState::CAPTURED_COUNT_INVALIDATE_COOLDOWN_SECONDS;
143 if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()
144 && function_exists('wp_cache_add')) {
145 return (bool)wp_cache_add($key, 1, 'abj404_view_cache_invalidate', $ttl);
146 }
147 if (get_transient($key)) {
148 return false;
149 }
150 set_transient($key, 1, $ttl);
151 return true;
152 }
153
154 /**
155 * Mark captured-scoped count caches stale without applying a debounce.
156 */
157 public static function invalidateCapturedStatusCountsCache(): void {
158 if (ABJ_404_Solution_ViewReadRuntimeState::$bulkMutationInProgress) {
159 return;
160 }
161 self::markTransientStale(array(
162 'current' => ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_CAPTURED_STATUS,
163 'last_known' => ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_CAPTURED_STATUS_LAST_KNOWN,
164 'kind' => 'array',
165 ));
166 self::markTransientStale(array(
167 'current' => ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_HIGH_IMPACT_CAPTURED,
168 'last_known' => ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_HIGH_IMPACT_CAPTURED_LAST_KNOWN,
169 'kind' => 'count',
170 ));
171 }
172
173 /**
174 * Preserve a trustworthy current value before expiring its fresh cache key.
175 *
176 * @param array{current:string,last_known:string,kind:'array'|'count'} $cache
177 */
178 private static function markTransientStale(array $cache): void {
179 $current = get_transient($cache['current']);
180 $isTrustworthy = ($cache['kind'] === 'array' && is_array($current))
181 || ($cache['kind'] === 'count' && is_numeric($current));
182 if ($isTrustworthy) {
183 // allow-cache-empty: numeric zero is a trustworthy computed count and shaped all-zero status arrays still contain their named keys.
184 set_transient(
185 $cache['last_known'],
186 $cache['kind'] === 'count' ? intval($current) : $current,
187 ABJ_404_Solution_ViewReadRuntimeState::STATUS_LAST_KNOWN_CACHE_TTL
188 );
189 }
190 delete_transient($cache['current']);
191 }
192
193 /**
194 * Expire the view snapshot: mark the built-at freshness marker stale so the
195 * next admin read treats the derived view as out of date.
196 *
197 * Deliberately one delete_option() and nothing else. Through 4.2.x this
198 * also cleared a snapshot RESULT cache -- rows in {prefix}abj404_view_cache
199 * and per-key transients named abj404_view_* -- but denorm Step 3e-B
200 * (5f4fcfb4, shipped in 4.3.1) removed that subsystem: the admin table read
201 * is now live off the abj404_redirects denorm columns, and no code path has
202 * written either store since. The two DELETEs outlived their writers and
203 * ran on every redirect create, update and delete to remove rows nothing
204 * can create, so they were removed rather than bounded:
205 *
206 * - `DELETE FROM {wp_abj404_view_cache} WHERE 1=1` -- a table-wide
207 * destructive statement whose result set is permanently empty.
208 * - `option_name LIKE '_transient_abj404_view_%'` against wp_options --
209 * worse, because the pattern begins with `_`, LIKE's single-character
210 * wildcard, leaving the range optimizer no literal prefix to seek on.
211 * Every row of the site's largest, hottest shared table was read, on
212 * the redirect-mutation path, to delete none of them. (The plugin's
213 * other wp_options sweeps -- Uninstaller, PluginLogicLifecycle,
214 * DatabaseUpgradeDailyMaintenance -- all go through prepare() with
215 * esc_like(); this one never did.)
216 *
217 * Residue on sites that upgraded from a snapshot-cache version is inert:
218 * the view_cache rows are read by nothing and the table is dropped at
219 * uninstall, and the orphaned transients carry their `_transient_timeout_`
220 * companions, so WordPress's own expired-transient collection reaps them.
221 * Physically dropping the vestigial table is the separately-tracked
222 * one-way-door step (i463-C/D), alongside DropStagedViewTables.
223 *
224 * @return void
225 */
226 public function invalidateViewSnapshotCache(): void {
227 if (function_exists('delete_option')) {
228 delete_option($this->viewDoneFreshnessOptionName);
229 }
230 }
231
232 /**
233 * @return void
234 */
235 public function clearRegexRedirectsCache(): void {
236 $this->redirectsRepo->clearRegexRedirectsCache();
237 }
238
239 }
240