| 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 |
|