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

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

277 lines 13.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 * Admin-mutation visibility gate (Phase 4 of the staged view-build watermark
9 * refactor; see docs/refactor-staged-view-build-watermark.md and queue task
10 * t_260516_140130_872).
11 *
12 * The single observable contract: after an admin clicks Save on the redirects
13 * UI, the next AJAX fetch must NOT return a snapshot built before the
14 * mutation, OR it must fall back to stale-serving once the sanity timeout
15 * elapses (so a stuck cron does not block the admin redirects screen
16 * forever). Before Phase 4 the gate was driven by a unix timestamp
17 * comparison (`viewDoneMutationInvalidatedAt > viewDoneDataBuiltAt`). Phase
18 * 4 swaps the inputs to watermark comparison: `built_watermark` (published
19 * at S11 by the runner) vs `mutation_watermark_observed_by_admin_action`
20 * (the post-increment value the admin's bump returned at click-Save time).
21 *
22 * Three options:
23 *
24 * - `wp_abj404_view_done_mutation_watermark_observed_by_admin_action`:
25 * the post-increment watermark value the admin's bump returned. Set by
26 * {@see markViewDoneInvalidatedByAdminMutation()}; cleared by
27 * {@see markViewDoneBuildCompleted()} on success.
28 * - `wp_abj404_view_done_mutation_watermark_observed_at`: wall-clock
29 * timestamp paired with the observed value for the sanity-window
30 * fallback. Same lifetime as the observed-watermark option.
31 * - `wp_abj404_view_done_mutation_invalidated_at`: pre-Phase-4 timestamp
32 * option. Read as a cold-bootstrap fallback when the watermark class is
33 * not yet loaded (defensive guard); written by the same fallback path
34 * so an upgraded install that hits a cold path still gates reads.
35 * Cleaned up by {@see markViewDoneBuildCompleted()} for convergence.
36 *
37 * Comparison semantics (pinned by ViewDoneServeabilityWatermarkGateTest):
38 *
39 * - `built_watermark >= observed` releases the gate (the snapshot covers
40 * the admin mutation). `>=`, not `>`, so a build that exactly covers
41 * the observed watermark unblocks reads.
42 * - The comparison uses the OBSERVED value, NOT the live counter
43 * ({@see ABJ_404_Solution_MutationWatermark::current()}). Unrelated
44 * later mutations on a busy site advance the live counter past the
45 * admin's observed value; gating against the live counter would block
46 * reads forever even after a covering build completed.
47 * - The gate respects the same `VIEW_DONE_MUTATION_INVALIDATED_SANITY_
48 * SECONDS` upper bound the legacy timestamp gate did. After the window
49 * elapses the gate falls back to fbc270d8 stale-serving + the
50 * hard-stale admin notice; a stuck cron / broken build cannot block
51 * the admin redirects screen indefinitely.
52 *
53 * Composition. Mixed into `ABJ_404_Solution_DataAccess` alongside
54 * `ABJ_404_Solution_DataAccess_ViewQueriesStagedTrait` (which holds
55 * `viewDoneIsServeable()` and consults this trait's reader helpers),
56 * `ABJ_404_Solution_DataAccess_MutationWatermarkSeamTrait` (the
57 * `bumpMutationWatermark()` source-mutation seam), and
58 * `ABJ_404_Solution_DataAccess_ViewBuildStartedWatermarkTrait` (which
59 * supplies `builtWatermarkOptionName()` + `readWatermarkOption()`).
60 *
61 * @see ABJ_404_Solution_DataAccess_ViewQueriesStagedTrait
62 * @see ABJ_404_Solution_DataAccess_MutationWatermarkSeamTrait
63 * @see ABJ_404_Solution_DataAccess_ViewBuildStartedWatermarkTrait
64 */
65 trait ABJ_404_Solution_DataAccess_AdminMutationGateTrait {
66
67 /**
68 * Phase 4 replacement for the timestamp-based admin-mutation gate.
69 * Records the {@see ABJ_404_Solution_MutationWatermark::current()}
70 * value observed at the moment {@see markViewDoneInvalidatedByAdmin
71 * Mutation()} fires, so {@see viewDoneIsServeable()} can compare it
72 * against `built_watermark` (the watermark covered by the last
73 * successful build, published at S11 by {@see publishBuiltWatermark
74 * FromActiveBuildStartedWatermark()}). The gate blocks reads while
75 * `built_watermark < observed`, i.e. while the snapshot on disk
76 * does not yet cover the admin's mutation.
77 */
78 private function mutationWatermarkObservedByAdminActionOptionName(): string {
79 return $this->getLowercasePrefix() . 'abj404_view_done_mutation_watermark_observed_by_admin_action';
80 }
81
82 /**
83 * Sanity-window timestamp for the observed-watermark gate. Set
84 * alongside the observed-watermark value at click-Save time so {@see
85 * viewDoneIsServeable()} can apply the same `VIEW_DONE_MUTATION_
86 * INVALIDATED_SANITY_SECONDS` bound as the legacy timestamp-based
87 * gate: a stuck cron / broken build cannot keep view_done unserveable
88 * forever; after the sanity window the gate falls back to fbc270d8
89 * stale-serving.
90 */
91 private function mutationWatermarkObservedByAdminActionAtOptionName(): string {
92 return $this->getLowercasePrefix() . 'abj404_view_done_mutation_watermark_observed_at';
93 }
94
95 /**
96 * Pre-Phase-4 timestamp option. Retained for two reasons: (1) cold-
97 * bootstrap fallback when the watermark class is not yet loaded;
98 * (2) cleanup target on `markViewDoneBuildCompleted()` so upgraded
99 * installs converge to "no admin gate" after their first successful
100 * build.
101 */
102 private function viewDoneMutationInvalidatedAtOptionName(): string {
103 return $this->getLowercasePrefix() . 'abj404_view_done_mutation_invalidated_at';
104 }
105
106 /**
107 * Read the watermark value the admin observed at click-Save time.
108 * Returns 0 when no admin mutation has been recorded since the last
109 * build completion (option absent or cleared by
110 * {@see markViewDoneBuildCompleted()}).
111 */
112 private function mutationWatermarkObservedByAdminAction(): int {
113 if (!function_exists('get_option')) {
114 return 0;
115 }
116 $val = get_option($this->mutationWatermarkObservedByAdminActionOptionName(), 0);
117 return is_scalar($val) ? max(0, intval($val)) : 0;
118 }
119
120 /** @return int Unix timestamp the admin-mutation watermark was observed, or 0. */
121 private function mutationWatermarkObservedByAdminActionAt(): int {
122 if (!function_exists('get_option')) {
123 return 0;
124 }
125 $val = get_option($this->mutationWatermarkObservedByAdminActionAtOptionName(), 0);
126 return is_scalar($val) ? max(0, intval($val)) : 0;
127 }
128
129 /** @return int Unix timestamp of the last admin-initiated mutation, or 0. */
130 private function viewDoneMutationInvalidatedAt(): int {
131 if (!function_exists('get_option')) {
132 return 0;
133 }
134 $val = get_option($this->viewDoneMutationInvalidatedAtOptionName(), 0);
135 return is_scalar($val) ? max(0, intval($val)) : 0;
136 }
137
138 /**
139 * Read the `built_watermark` published at the last successful S11
140 * completion. Returns 0 when no successful build has run on this
141 * install yet, so the gate naturally treats a fresh install as "no
142 * admin mutation is covered yet" -- harmless because on a fresh
143 * install the observed-admin-mutation-watermark option is also
144 * absent.
145 */
146 private function viewDoneBuiltWatermark(): int {
147 $value = $this->readWatermarkOption($this->builtWatermarkOptionName());
148 return $value < 0 ? 0 : $value;
149 }
150
151 /**
152 * True when the admin-mutation gate is currently blocking reads. The
153 * gate fires when an observed-watermark is recorded, the observation
154 * is within the sanity window, AND `built_watermark` has not yet
155 * caught up to the observed value. Otherwise false (no gate state,
156 * gate expired, or build already covers the mutation).
157 *
158 * Called from {@see viewDoneIsServeable()} as the sole admin-gate
159 * check; the staged-queries trait does not consult any of the
160 * underlying options directly.
161 */
162 private function adminMutationGateBlocks(): bool {
163 $observedWatermark = $this->mutationWatermarkObservedByAdminAction();
164 if ($observedWatermark <= 0) {
165 return false;
166 }
167 $observedAt = $this->mutationWatermarkObservedByAdminActionAt();
168 if ($observedAt <= 0) {
169 return false;
170 }
171 $sanity = ABJ_404_Solution_ViewBuildConfig::VIEW_DONE_MUTATION_INVALIDATED_SANITY_SECONDS;
172 if ($observedAt <= time() - $sanity) {
173 return false;
174 }
175 $builtWatermark = $this->viewDoneBuiltWatermark();
176 return $builtWatermark < $observedWatermark;
177 }
178
179 /**
180 * Mark view_done as needing a fresh build because the admin just
181 * mutated a redirect through the UI (add/edit/trash/delete). Phase 4
182 * mechanism: bump the mutation watermark and record the
183 * post-increment value in
184 * `mutation_watermark_observed_by_admin_action`;
185 * {@see viewDoneIsServeable()} then blocks reads until
186 * `built_watermark >= the recorded value` (or the sanity timeout
187 * elapses). The runner observes the bump at the next stage boundary
188 * and aborts/restarts the in-flight build so the next snapshot
189 * covers the admin's change.
190 *
191 * Differs from a plain `bumpMutationWatermark()` call (Cluster A-D
192 * callers): admin actions need IMMEDIATE feedback, so the recorded
193 * observed value drives the stricter gate that pends the AJAX fetch
194 * until a covering build completes. Non-admin mutations only need
195 * the runner to abort/restart at the next stage boundary; they don't
196 * need to block reads in the meantime (fbc270d8 stale-serving).
197 */
198 public function markViewDoneInvalidatedByAdminMutation(): void {
199 // Read CURRENT watermark first. The caller's prior setupRedirect /
200 // updateRedirect / etc. has typically already bumped via
201 // invalidateStatusCountsCache -> invalidateViewSnapshotCache ->
202 // bumpMutationWatermark, so we just record that post-mutation
203 // value. Skipping the bump here avoids double-counting (one
204 // mutation -> two ticks of the counter), which
205 // MixedSourceConcurrentMutationIntegrationTest pins as a contract
206 // violation: each entry-point handler must advance the counter by
207 // exactly one tick regardless of how many internal seams it routes
208 // through.
209 //
210 // Fallback bump: when current() returns 0 we self-bump to record a
211 // non-zero observed value. This covers two cases. (1) Callers that
212 // invoke markView without a preceding source-data mutation (no
213 // chain bump fired) still get a functioning gate. (2) The
214 // primitive class is unavailable (cold bootstrap before autoload):
215 // the bump() seam returns 0 too, and we fall through to the
216 // legacy timestamp option below.
217 $observed = $this->safeCurrentMutationWatermark();
218 if ($observed <= 0) {
219 $observed = $this->bumpMutationWatermark();
220 }
221 if (!function_exists('update_option')) {
222 return;
223 }
224 if ($observed <= 0) {
225 // Watermark primitive still unavailable after the fallback
226 // bump attempt (cold-bootstrap path before the autoloader
227 // resolves MutationWatermark.php). Stamp the legacy timestamp
228 // option so a legacy installation of viewDoneIsServeable() can
229 // still gate reads if it ever sees this state.
230 update_option($this->viewDoneMutationInvalidatedAtOptionName(), time(), false);
231 return;
232 }
233 // Record the watermark and a wall-clock timestamp so
234 // viewDoneIsServeable() can apply the sanity timeout to the gate
235 // the same way the legacy timestamp gate did.
236 update_option($this->mutationWatermarkObservedByAdminActionOptionName(), $observed, false);
237 update_option($this->mutationWatermarkObservedByAdminActionAtOptionName(), time(), false);
238 $this->invalidateViewDoneServeableCache();
239 }
240
241 /**
242 * Read the current per-blog mutation watermark, returning 0 when the
243 * primitive is unavailable for any reason (class not autoloaded,
244 * degraded wpdb that lacks get_var / prepare, transient DB error).
245 * Same fallback contract as readMutationWatermarkForCacheKey in
246 * DataAccessTrait_ViewSnapshotCache: 0 means "treat as unversioned"
247 * and the caller falls through to its degraded path.
248 */
249 private function safeCurrentMutationWatermark(): int {
250 if (!class_exists('ABJ_404_Solution_MutationWatermark')) {
251 return 0;
252 }
253 try {
254 return ABJ_404_Solution_MutationWatermark::current();
255 // allow-silent-catch: degraded wpdb (test mocks lacking get_var, transient connection errors) collapses to fallback bump in markView; we never want the gate setter to throw and abort the admin response
256 } catch (\Throwable $e) {
257 return 0;
258 }
259 }
260
261 /**
262 * Clear the admin-mutation gate options after a build covers the
263 * recorded watermark. Called from {@see markViewDoneBuildCompleted()}
264 * on the S11-success and reconcile-promote paths. Cleans up the
265 * legacy timestamp option as well, so installs upgrading from a
266 * pre-Phase-4 build converge to "no admin gate".
267 */
268 private function clearAdminMutationGateOptions(): void {
269 if (!function_exists('delete_option')) {
270 return;
271 }
272 delete_option($this->mutationWatermarkObservedByAdminActionOptionName());
273 delete_option($this->mutationWatermarkObservedByAdminActionAtOptionName());
274 delete_option($this->viewDoneMutationInvalidatedAtOptionName());
275 }
276 }
277