PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.0
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 / MutationWatermark.php

MutationWatermark.php in 404 Solution 4.2.0, at includes/MutationWatermark.php

226 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 * Monotonically-increasing counter that the staged view-build runner uses
9 * to know "did anything change since I started?". Phase 1 of the staged
10 * view-build watermark refactor (see
11 * docs/refactor-staged-view-build-watermark.md).
12 *
13 * Why it exists. The staged view-build runner needs a single, atomic signal
14 * that source data (`abj404_redirects`) has changed. Before Phase 4, every
15 * mutation call site invoked `invalidateViewDone()`, a god method that
16 * dropped the runner's buffer table mid-build (the "Aharon" bug class).
17 * Phase 4 (commit 2994e21c) deleted the symbol entirely; the runner is the
18 * sole owner of buffer state and external callers signal "data changed"
19 * exclusively via this primitive.
20 *
21 * Atomicity contract. `bump()` issues a single SQL statement that both
22 * creates the row on first use and increments the counter on every other
23 * call. Under parallel writers (cron + admin save + visitor 404 capture
24 * arriving in the same tick) the row-level lock on the upsert serialises
25 * the increments; no lost-update class. The post-increment value is
26 * returned via the `LAST_INSERT_ID(expr)` per-connection trick, so each
27 * caller observes its own contribution rather than re-reading and
28 * potentially seeing a value bumped by a sibling between the write and
29 * the read. See https://dev.mysql.com/doc/refman/8.0/en/information-functions.html#function_last-insert-id
30 *
31 * Object cache. `bump()` invalidates the per-blog cache key so the next
32 * `current()` reads from disk. `current()` always issues a fresh SELECT
33 * rather than trusting a possibly-stale cache: the caller is the runner
34 * comparing watermarks at stage boundaries, where correctness beats the
35 * one-query saving.
36 *
37 * Multisite. The table lives at `{$wpdb->prefix}abj404_mutation_watermark`,
38 * so each blog gets its own counter automatically (each blog has its own
39 * `$wpdb->prefix`). `switch_to_blog()` rotates `$wpdb->prefix` and
40 * therefore implicitly rotates the watermark target table.
41 *
42 * Schema. `BIGINT UNSIGNED` (1.8e19 range) with `name` as the primary
43 * key. Single-row today; the `name` column leaves room for future
44 * separate counters (per-table mutation streams) without a schema change.
45 */
46 final class ABJ_404_Solution_MutationWatermark {
47
48 /**
49 * Logical name of the only counter row that currently exists. Future
50 * sub-watermarks (e.g. one per source table) would be additional rows
51 * with different names.
52 */
53 const NAME_REDIRECTS = 'redirects';
54
55 /** Object-cache group used for the post-bump invalidation key. */
56 const CACHE_GROUP = 'abj404';
57
58 /** Object-cache key prefix; final key is suffixed with `name`. */
59 const CACHE_KEY_PREFIX = 'mutation_watermark.';
60
61 /**
62 * Per-PHP-process flag that `ensureTable()` has run successfully
63 * against the current `$wpdb->prefix`. Reset by tests that rotate
64 * the prefix (multisite simulation) or that drop the table.
65 *
66 * Keyed by table name, not just a bool, so that rotating
67 * `$wpdb->prefix` mid-process (the multisite test does this)
68 * triggers a re-ensure for the new prefix's table.
69 *
70 * @var array<string,true>
71 */
72 private static $tableEnsured = array();
73
74 /**
75 * Fully-qualified watermark table name for the active blog
76 * (`{$wpdb->prefix}abj404_mutation_watermark`). Recomputed every call
77 * so multisite `switch_to_blog()` is honoured automatically.
78 */
79 public static function tableName(): string {
80 global $wpdb;
81 $prefix = isset($wpdb) && isset($wpdb->prefix) ? strtolower((string)$wpdb->prefix) : 'wp_';
82 return $prefix . 'abj404_mutation_watermark';
83 }
84
85 /**
86 * Clear the per-process "table ensured" flag. Tests call this when
87 * they have just dropped the table or rotated the active prefix; in
88 * production it is never invoked because the table, once created, is
89 * never dropped.
90 */
91 public static function resetEnsuredForTesting(): void {
92 self::$tableEnsured = array();
93 }
94
95 /**
96 * Idempotent table creator. `CREATE TABLE IF NOT EXISTS` so a
97 * concurrent caller that wins the race produces no error on the
98 * loser's side.
99 *
100 * Returns void; failure is swallowed by `$wpdb->query()` and surfaces
101 * later when the bump/current SQL fails. We deliberately do not
102 * raise here so a transient permission error on the first call does
103 * not crash a request that would otherwise succeed once the table
104 * was ensured by a sibling call.
105 */
106 public static function ensureTable(): void {
107 global $wpdb;
108 $table = self::tableName();
109 if (isset(self::$tableEnsured[$table])) {
110 return;
111 }
112
113 $charsetCollate = '';
114 if (is_object($wpdb) && method_exists($wpdb, 'get_charset_collate')) {
115 $maybe = $wpdb->get_charset_collate();
116 if (is_string($maybe)) {
117 $charsetCollate = $maybe;
118 }
119 }
120
121 // BIGINT UNSIGNED: 1.8e19 mutations before overflow. PRIMARY KEY
122 // on `name` doubles as the fast-read index; current() probes by
123 // exact name and the PK answers in O(log n) (n is at most a
124 // handful of rows even in the long-term expansion).
125 $sql = "CREATE TABLE IF NOT EXISTS `{$table}` (
126 `name` VARCHAR(64) NOT NULL,
127 `counter` BIGINT UNSIGNED NOT NULL DEFAULT 0,
128 PRIMARY KEY (`name`)
129 ) {$charsetCollate}";
130
131 // Lazy schema bootstrap for a primitive that must function before
132 // the DAO is fully initialised (Phase 1 dormant primitive callable
133 // from any context including cron boot, where the DAO's recovery
134 // machinery may itself be mid-initialisation).
135 // DAO-bypass-approved: idempotent CREATE TABLE IF NOT EXISTS, no DAO retry needed.
136 $wpdb->query($sql);
137 self::$tableEnsured[$table] = true;
138 }
139
140 /**
141 * Atomically increment the counter for `$name` and return the new
142 * value. Creates the row on first use (with counter=1) and the table
143 * on first plugin call (lazy migration).
144 *
145 * Implementation detail. `LAST_INSERT_ID(expr)` sets the per-
146 * connection last_insert_id AND returns `expr`. Combining it with
147 * `ON DUPLICATE KEY UPDATE` lets the caller read the post-increment
148 * value from `$wpdb->insert_id` rather than issuing a follow-up
149 * SELECT (which under parallel writers can return a higher value
150 * than the caller's contribution). The trick works on every supported
151 * engine cell: MySQL 5.7+, MariaDB 10.3+.
152 *
153 * @param string $name Logical counter row. Default = the only row in
154 * use today; pass a different name to grow the
155 * table for per-table sub-watermarks (Phase 5).
156 * @return int New post-increment counter value.
157 */
158 public static function bump(string $name = self::NAME_REDIRECTS): int {
159 global $wpdb;
160 self::ensureTable();
161 $table = self::tableName();
162
163 $sql = "INSERT INTO `{$table}` (`name`, `counter`) "
164 . "VALUES (%s, LAST_INSERT_ID(1)) "
165 . "ON DUPLICATE KEY UPDATE `counter` = LAST_INSERT_ID(`counter` + 1)";
166
167 // LAST_INSERT_ID(expr) is per-connection; queryAndGetResults can
168 // swap the underlying connection on transient-error retry, which
169 // would lose the post-increment value we just wrote. The bump
170 // must execute exactly once on exactly one connection so the
171 // caller can read the contribution back from $wpdb->insert_id.
172 // DAO-bypass-approved: bump must stay on one connection (LAST_INSERT_ID is per-connection).
173 $prepared = $wpdb->prepare($sql, $name);
174 // DAO-bypass-approved: bump must stay on one connection (LAST_INSERT_ID is per-connection).
175 $wpdb->query($prepared);
176
177 self::invalidateCache($name);
178
179 return (int)($wpdb->insert_id ?? 0);
180 }
181
182 /**
183 * Read the current counter for `$name`. Returns 0 when the row does
184 * not exist (fresh install or pre-Phase-1 upgrade); callers therefore
185 * never see undefined behavior, only "no mutations observed yet".
186 *
187 * @param string $name Logical counter row.
188 * @return int Current counter value, or 0 if the row is absent.
189 */
190 public static function current(string $name = self::NAME_REDIRECTS): int {
191 global $wpdb;
192 self::ensureTable();
193 $table = self::tableName();
194
195 $sql = "SELECT `counter` FROM `{$table}` WHERE `name` = %s";
196 // Stage-boundary read for a runner that has just bumped on
197 // another connection; routing through the DAO's auto-CREATE-
198 // and-retry machinery on missing-table would mask the "table
199 // just got dropped under us" signal Phase 2 needs to detect.
200 // DAO-bypass-approved: stage-boundary read must not auto-CREATE on missing-table.
201 $prepared = $wpdb->prepare($sql, $name);
202 // DAO-bypass-approved: stage-boundary read must not auto-CREATE on missing-table.
203 $value = $wpdb->get_var($prepared);
204
205 if ($value === null) {
206 return 0;
207 }
208 return (int)$value;
209 }
210
211 /**
212 * Drop the cached current() value for `$name`. Bump invalidates so
213 * a subsequent get_option-style read elsewhere (none exists today,
214 * but Phase 2 may add one) sees the fresh disk value rather than
215 * a write-through stale cache.
216 *
217 * Best-effort: when the WP object cache stack is unavailable
218 * (very early bootstrap) the call is a no-op.
219 */
220 private static function invalidateCache(string $name): void {
221 if (function_exists('wp_cache_delete')) {
222 wp_cache_delete(self::CACHE_KEY_PREFIX . $name, self::CACHE_GROUP);
223 }
224 }
225 }
226