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 / core / ExclusiveOptionRow.php

ExclusiveOptionRow.php in 404 Solution trunk, at includes/core/ExclusiveOptionRow.php

501 lines 20.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) {
3 exit;
4 }
5
6 require_once __DIR__ . '/DatabaseMetadataLockWaitGuard.php';
7
8 /**
9 * Exclusive occupancy of a single WordPress options row: at most one request
10 * can hold a given option name, and the DATABASE decides which one.
11 *
12 * Every mutual-exclusion mechanism in this plugin that lives in the options
13 * table goes through here, because the obvious ways to write one are both
14 * wrong on a site with no persistent object cache (which is the default):
15 *
16 * - Reading the row with get_option() and then writing it. WordPress serves
17 * get_option() from the object cache and update_option() primes that cache
18 * with the value it just wrote, so each racing request reads back its OWN
19 * write and every one of them concludes it won. Error report 270
20 * (dianthus.zuidplas.net, 4.3.3, LiteSpeed, MySQL 8.0.46) is what that
21 * looks like in production: two requests ran the 4.3.2 to 4.3.3 database
22 * upgrade in the same second, each holding what it believed was the
23 * exclusive 'update_db_version' lock.
24 *
25 * - Trusting add_option() to return false when the row already exists.
26 * WordPress guards it with that same cache-served get_option(), and since
27 * 6.4 the write underneath is `INSERT ... ON DUPLICATE KEY UPDATE`, which
28 * overwrites rather than failing. Two concurrent callers can therefore
29 * both be told they added it. (On older WordPress the write was a plain
30 * INSERT that a duplicate key did reject, so this is a claim that used to
31 * hold and quietly stopped holding.)
32 *
33 * What does arbitrate is the options table's own UNIQUE(option_name) index:
34 * an INSERT can satisfy it exactly once no matter how many arrive together.
35 * So claim() is an INSERT, and every read here is direct SQL rather than
36 * get_option(), because the answer to "who holds this" can only come from the
37 * one store all the requests share.
38 *
39 * This class deliberately knows nothing about WHY a row is being held: no
40 * timeouts, no owner ids, no stale-record policy. Those differ per caller
41 * (ABJ_404_Solution_SynchronizationUtils breaks a lock on age and releases by
42 * owner id; the rebuild locks expire on a TTL) and belong with the caller.
43 */
44 class ABJ_404_Solution_ExclusiveOptionRow {
45
46 /** Rows live in the options table of the blog serving the request. */
47 const SCOPE_CURRENT_BLOG = 'current_blog';
48
49 /** Rows live in the options table of the network's MAIN SITE, so every blog
50 * in a multisite network contends for the same row.
51 *
52 * This is where a network-wide claim has to go, rather than into sitemeta
53 * where get_site_option() would put it: sitemeta indexes meta_key
54 * non-uniquely, so it cannot arbitrate anything -- N concurrent inserts all
55 * succeed there. The main site's options table is the one core table that
56 * is both visible to every blog in the network and uniquely keyed. */
57 const SCOPE_NETWORK_MAIN_SITE = 'network_main_site';
58
59 /** @var string one of the SCOPE_* constants */
60 private $scope;
61
62 /**
63 * @param string $scope one of the SCOPE_* constants. Defaults to the blog
64 * serving the request, which is what every non-network claim wants.
65 */
66 public function __construct($scope = self::SCOPE_CURRENT_BLOG) {
67 if (!in_array($scope, array(self::SCOPE_CURRENT_BLOG, self::SCOPE_NETWORK_MAIN_SITE), true)) {
68 throw new InvalidArgumentException('Unknown exclusive option-row scope: ' . (string)$scope);
69 }
70 $this->scope = $scope;
71 }
72
73 /** Mint a process- and request-specific value for a claim.
74 *
75 * The caller-provided prefix may carry policy data such as an acquisition
76 * or expiry timestamp. The suffix is what makes conditional release safe
77 * even when two requests acquire the same row during the same clock tick.
78 *
79 * @param string $prefix
80 * @return string
81 * @phpstan-impure
82 */
83 public static function uniqueClaimValue($prefix) {
84 return $prefix . ':' . ABJ_404_Solution_PhpRuntimeCapabilityAdapter::processToken() . ':' . uniqid('', true);
85 }
86
87 /** Take $optionName, but only if no row exists for it yet.
88 *
89 * @param array{optionName: string, value: string} $claim
90 * @return bool true only if this call created the row.
91 * @phpstan-impure
92 */
93 public function claim(array $claim) {
94 $optionName = $claim['optionName'];
95 $value = $claim['value'];
96 $wpdb = $this->wpdbOrNull();
97 $table = $wpdb === null ? '' : $this->optionsTable($wpdb);
98 if ($wpdb === null || $table === '') {
99 return false;
100 }
101
102 $boundRow = $this->bind($wpdb, '%s, %s', array($optionName, (string)$value));
103 if ($boundRow === '') {
104 return false;
105 }
106
107 // autoload 'no' on purpose: an autoloaded row is loaded into the
108 // alloptions cache of every single request on the site, which for a
109 // row that exists only while somebody holds it is pure overhead.
110 // DAO-bypass-approved: this is the mutual-exclusion primitive itself; the DAO's retry-and-repair path runs INSIDE synchronized sections, and create_db_tables / update_db_version are locked while the DAO is still booting.
111 $rowsInserted = $this->runWithBoundedMetadataLockWait($wpdb, array(
112 'description' => 'claiming the options row "' . $optionName . '"',
113 'operation' => function () use ($wpdb, $table, $boundRow) {
114 // DAO-bypass-approved: guarded options-row lock claim must remain available while the DAO bootstraps.
115 return $wpdb->query("INSERT IGNORE INTO `" . $table . "` "
116 . "(option_name, option_value, autoload) VALUES (" . $boundRow . ", 'no')");
117 },
118 ));
119
120 // IGNORE turns the duplicate-key rejection into zero affected rows, so
121 // "somebody else got there first" arrives as data rather than as an
122 // error the site admin would be emailed about.
123 //
124 // A hard failure has to be told apart from that, even though both mean
125 // "not claimed" to the caller. Losing a race is the protocol working
126 // and happens constantly; a statement the engine refused (a read-only
127 // replica, a full disk, an options table missing the autoload column)
128 // means NOBODY can ever take this lock, which silently disables every
129 // synchronized section that depends on it. Indistinguishable in
130 // behavior, opposite in meaning, so the second one gets logged.
131 if ($rowsInserted === false) {
132 $this->logStorageFailure('claim the options row "' . $optionName . '"', $wpdb);
133 return false;
134 }
135
136 return is_numeric($rowsInserted) && ((int)$rowsInserted) === 1;
137 }
138
139 /** The value currently recorded for $optionName, read from the table and
140 * not from WordPress's option cache.
141 *
142 * @param string $optionName
143 * @return string '' when no row exists, or when the storage cannot answer.
144 * @phpstan-impure
145 */
146 public function valueOf($optionName) {
147 $wpdb = $this->wpdbOrNull();
148 $table = $wpdb === null ? '' : $this->optionsTable($wpdb);
149 if ($wpdb === null || $table === '') {
150 return '';
151 }
152
153 $boundName = $this->bind($wpdb, '%s', array($optionName));
154 if ($boundName === '') {
155 return '';
156 }
157
158 // DAO-bypass-approved: this row is what the DAO's own bootstrap locks on (create_db_tables, update_db_version), and it lives in WordPress's options table, which the DAO's recovery path must never CREATE, REPAIR, or raise a missing-plugin-table notice about.
159 $value = $this->runWithBoundedMetadataLockWait($wpdb, array(
160 'description' => 'reading the options row "' . $optionName . '"',
161 'operation' => function () use ($wpdb, $table, $boundName) {
162 // DAO-bypass-approved: guarded lock-owner read bypasses WordPress's process-local option cache.
163 return $wpdb->get_var("SELECT option_value FROM `" . $table . "` "
164 . "WHERE option_name = " . $boundName . " LIMIT 1");
165 },
166 ));
167
168 // get_var() answers null for "no such row" and for "the statement was
169 // refused", and every other statement in this class tells those two
170 // apart. Returning '' for both is still the right ANSWER -- a caller
171 // reads it as "no holder" and then re-attempts the atomic claim, which
172 // a live holder's row still refuses, so an unreadable row can never
173 // hand out a second copy of a lock. What it must not do is happen
174 // silently: a host that refuses this SELECT refuses the claim next to
175 // it too, which stops every synchronized section on the site with
176 // nothing anywhere saying why.
177 $lastError = $this->stringPropertyOf($wpdb, 'last_error');
178 if ($lastError !== null && $lastError !== '') {
179 $this->logStorageFailure('read the options row "' . $optionName . '"', $wpdb);
180 }
181
182 return is_string($value) ? $value : '';
183 }
184
185 /** Give up $optionName, but only while it still records $value.
186 *
187 * Callers that mint a unique value per claim should prefer this: it makes
188 * "delete a row somebody else now holds" impossible rather than merely
189 * unlikely, which matters because every release decision is made on the
190 * strength of a read that happened earlier.
191 *
192 * @param array{optionName: string, value: string} $claim
193 * @return bool true if the row recording $value was removed.
194 * @phpstan-impure
195 */
196 public function releaseIfValueIs(array $claim) {
197 return $this->deleteRow($claim);
198 }
199
200 /**
201 * Replace an owned row without opening a delete/reclaim gap.
202 *
203 * @param array{optionName: string, currentValue: string, replacementValue: string} $replacement
204 * @return bool true only when the row still held currentValue and was renewed.
205 * @phpstan-impure
206 */
207 public function replaceValueIfMatches(array $replacement) {
208 $wpdb = $this->wpdbOrNull();
209 $table = $wpdb === null ? '' : $this->optionsTable($wpdb);
210 if ($wpdb === null || $table === '') {
211 return false;
212 }
213
214 $boundReplacement = $this->bind($wpdb, '%s', array($replacement['replacementValue']));
215 $boundName = $this->bind($wpdb, '%s', array($replacement['optionName']));
216 $boundCurrent = $this->bind($wpdb, '%s', array($replacement['currentValue']));
217 if ($boundReplacement === '' || $boundName === '' || $boundCurrent === '') {
218 return false;
219 }
220
221 // DAO-bypass-approved: renewing the coordination lease must remain available while the DAO itself is rebuilding tables.
222 $rowsUpdated = $this->runWithBoundedMetadataLockWait($wpdb, array(
223 'description' => 'renewing the options row "' . $replacement['optionName'] . '"',
224 'operation' => function () use ($wpdb, $table, $boundReplacement, $boundName, $boundCurrent) {
225 // DAO-bypass-approved: guarded lease renewal must stay on the same raw coordination row.
226 return $wpdb->query("UPDATE `" . $table . "` SET option_value = " . $boundReplacement
227 . " WHERE option_name = " . $boundName . " AND option_value = " . $boundCurrent);
228 },
229 ));
230 if ($rowsUpdated === false) {
231 $this->logStorageFailure('renew the options row "' . $replacement['optionName'] . '"', $wpdb);
232 return false;
233 }
234 return is_numeric($rowsUpdated) && ((int)$rowsUpdated) === 1;
235 }
236
237 /**
238 * @param array{optionName: string, value: string} $claim
239 * @return bool
240 */
241 private function deleteRow(array $claim) {
242 $optionName = $claim['optionName'];
243 $requiredValue = $claim['value'];
244 $wpdb = $this->wpdbOrNull();
245 $table = $wpdb === null ? '' : $this->optionsTable($wpdb);
246 if ($wpdb === null || $table === '') {
247 return false;
248 }
249
250 $boundName = $this->bind($wpdb, '%s', array($optionName));
251 if ($boundName === '') {
252 return false;
253 }
254 $sql = "DELETE FROM `" . $table . "` WHERE option_name = " . $boundName;
255
256 $boundValue = $this->bind($wpdb, '%s', array($requiredValue));
257 if ($boundValue === '') {
258 return false;
259 }
260 $sql .= " AND option_value = " . $boundValue;
261
262 // DAO-bypass-approved: releasing a lock must not itself need one, and this runs in finally blocks and on shutdown, after the DAO may already have been torn down.
263 $rowsDeleted = $this->runWithBoundedMetadataLockWait($wpdb, array(
264 'description' => 'releasing the options row "' . $optionName . '"',
265 'operation' => function () use ($wpdb, $sql) {
266 // DAO-bypass-approved: guarded conditional release runs during finally/shutdown paths outside DAO lifetime.
267 return $wpdb->query($sql);
268 },
269 ));
270
271 // Same reasoning as claim(): removing no row is the ordinary outcome of
272 // a conditional release whose row somebody else now holds, while a
273 // refused statement means a held lock can never be given back, and the
274 // two must not read the same way in the log.
275 if ($rowsDeleted === false) {
276 $this->logStorageFailure('release the options row "' . $optionName . '"', $wpdb);
277 return false;
278 }
279
280 return is_numeric($rowsDeleted) && ((int)$rowsDeleted) > 0;
281 }
282
283 /** Report a statement the database refused.
284 *
285 * Deliberately a warning rather than an error: a lock that cannot be taken
286 * stops synchronized WORK from running, which the plugin degrades past, and
287 * the site admin should not be emailed about their host's read-only replica.
288 * It still has to appear, because the alternative is a plugin that quietly
289 * stops rebuilding anything with nothing anywhere saying why.
290 *
291 * @param string $attempted what the statement was trying to do
292 * @param \wpdb $wpdb the handle that refused it
293 * @return void
294 */
295 private function logStorageFailure($attempted, $wpdb) {
296 if (!function_exists('abj_service')) {
297 return;
298 }
299
300 $logger = abj_service('logging');
301 if (!is_object($logger) || !method_exists($logger, 'warn')) {
302 return;
303 }
304
305 $lastError = $this->stringPropertyOf($wpdb, 'last_error');
306 $logger->warn('Could not ' . $attempted . '; treating the lock as unavailable. '
307 . 'Database error: ' . ($lastError === null || $lastError === '' ? '(none reported)' : $lastError));
308 }
309
310 /**
311 * @param \wpdb $wpdb
312 * @param array{description: string, operation: callable(): mixed} $request
313 * @return mixed
314 */
315 private function runWithBoundedMetadataLockWait($wpdb, array $request) {
316 $guard = new ABJ_404_Solution_DatabaseMetadataLockWaitGuard(static function () {
317 $candidate = function_exists('abj_service') ? abj_service('logging') : null;
318 return $candidate instanceof ABJ_404_Solution_Logging ? $candidate : null;
319 });
320 $result = $guard->runWithBoundedWait($wpdb, $request);
321 return $result['value'];
322 }
323
324 /** Bind $values into $fragment and hand back the escaped SQL text.
325 *
326 * Only VALUES are ever passed through wpdb::prepare(); the table name is
327 * concatenated by the caller, after optionsTable() has reduced it to
328 * identifier characters. prepare() has no way to escape an identifier
329 * before WordPress 6.2's %i, and this plugin supports 5.0, so feeding it a
330 * query with the table already interpolated would be asking it to vouch for
331 * something it never checked. Splitting the two makes which half is bound
332 * and which half is validated visible at every call site.
333 *
334 * @param \wpdb $wpdb
335 * @param literal-string $fragment placeholders only, no identifiers
336 * @param array<int, string> $values
337 * @return string '' when wpdb declined to bind, which aborts the statement.
338 */
339 private function bind($wpdb, $fragment, array $values) {
340 // Spread rather than passing $values as one array argument: both forms
341 // are valid for wpdb::prepare(), but the variadic one is what every
342 // call site in the wild uses and therefore the only one a replacement
343 // handle can be relied on to implement.
344 // DAO-bypass-approved: value binding for the statements above; escaping has to happen on the same handle that will execute them.
345 $prepared = $wpdb->prepare($fragment, ...array_values($values));
346
347 return is_string($prepared) ? $prepared : '';
348 }
349
350 /** The options table this instance's scope resolves to, reduced to
351 * characters legal in an identifier, or '' when it cannot be resolved.
352 *
353 * The parameter is typed in the docblock only. A native hint would enforce
354 * `instanceof wpdb` at runtime and reject any drop-in handle that does not
355 * extend core's class, which is a narrowing this code has no reason to
356 * impose: everything it needs is the three methods wpdbOrNull() confirmed.
357 *
358 * @param \wpdb $wpdb the handle wpdbOrNull() already validated
359 * @return string
360 */
361 private function optionsTable($wpdb) {
362 if ($this->scope === self::SCOPE_NETWORK_MAIN_SITE) {
363 $prefix = $this->stringPropertyOf($wpdb, 'base_prefix');
364 if ($prefix === null) {
365 // Fail closed rather than quietly resolving a NETWORK-wide
366 // claim to the current blog's table, which would narrow its
367 // scope without anything reporting that it had.
368 return '';
369 }
370 if (function_exists('get_main_site_id') && $this->canCall($wpdb, 'get_blog_prefix')) {
371 $mainSitePrefix = $wpdb->get_blog_prefix((int)get_main_site_id());
372 if (is_string($mainSitePrefix) && $mainSitePrefix !== '') {
373 $prefix = $mainSitePrefix;
374 }
375 }
376 return $this->asIdentifier($prefix . 'options');
377 }
378
379 $table = $this->stringPropertyOf($wpdb, 'options');
380 if ($table !== null && $table !== '') {
381 return $this->asIdentifier($table);
382 }
383
384 $prefix = $this->stringPropertyOf($wpdb, 'prefix');
385 if ($prefix === null) {
386 return '';
387 }
388
389 // An empty $table_prefix is unusual but legal, so this branch can
390 // legitimately produce the bare table name 'options'.
391 return $this->asIdentifier($prefix . 'options');
392 }
393
394 /** The WordPress database handle, or null when there is not a usable one.
395 *
396 * Callers read a null handle as "not claimed" and "no value", which stops
397 * work rather than running it without exclusion.
398 *
399 * @return \wpdb|null
400 */
401 private function wpdbOrNull() {
402 global $wpdb;
403 // PHPStan has no type for a WordPress global, so it sees `mixed` here
404 // and every later narrowing widens to a bare `object`. This states the
405 // one production reality -- core's wpdb, or a drop-in such as HyperDB
406 // that extends it -- rather than overriding anything PHPStan inferred,
407 // and mirrors how DatabaseQueryExecutor::prepareQueryParameters()
408 // reaches the same global. The runtime checks below still do the real
409 // work, because a test double is not an instanceof wpdb.
410 /** @var \wpdb $wpdb */
411
412 if (!is_object($wpdb)) {
413 return null;
414 }
415
416 foreach (array('prepare', 'query', 'get_var') as $method) {
417 if (!$this->canCall($wpdb, $method)) {
418 return null;
419 }
420 }
421
422 return $wpdb;
423 }
424
425 /** Whether $method can be invoked on $object, counting methods reached
426 * through __call() and not only declared ones.
427 *
428 * method_exists() alone answers "no" for anything routed through __call(),
429 * which is how WordPress drop-in database handles and this suite's $wpdb
430 * doubles expose most of their surface. Reading that "no" as "there is no
431 * usable database handle" would silently disable every claim on such a
432 * site.
433 *
434 * @param object $object
435 * @param string $method
436 * @return bool
437 */
438 private function canCall($object, $method) {
439 return method_exists($object, $method) || method_exists($object, '__call');
440 }
441
442 /** A string property of the database handle, or null when it is absent or
443 * is not a string.
444 *
445 * isset() and ?? are deliberately not used: both consult __isset(), which
446 * objects exposing their fields through __get() do not necessarily
447 * implement.
448 *
449 * @param object $object
450 * @param string $name
451 * @return string|null
452 */
453 private function stringPropertyOf($object, $name) {
454 if (!property_exists($object, $name) && !method_exists($object, '__get')) {
455 return null;
456 }
457
458 $value = $object->$name;
459
460 return is_string($value) ? $value : null;
461 }
462
463 /** Accept a resolved table name only when every character is legal in an
464 * identifier. Rewriting is unsafe: deleting one invalid character can turn
465 * a malformed name into the name of a different, real table.
466 *
467 * @param string $table
468 * @return string the unchanged identifier, or '' when invalid
469 */
470 private function asIdentifier($table) {
471 if ($table === '' || preg_match('/\A[A-Za-z0-9_]+\z/D', $table) !== 1) {
472 $this->logInvalidTableIdentifier($table);
473 return '';
474 }
475
476 return $table;
477 }
478
479 /** Report a database table name that cannot safely be put into SQL.
480 *
481 * @param string $table
482 * @return void
483 */
484 private function logInvalidTableIdentifier($table) {
485 $visibleTable = substr(str_replace(array("\r", "\n"), array('\\r', '\\n'), $table), 0, 200);
486 $message = 'Refusing to use an invalid options-table identifier: "' . $visibleTable . '".';
487
488 if (function_exists('abj_service')) {
489 $logger = abj_service('logging');
490 if (is_object($logger) && method_exists($logger, 'warn')) {
491 $logger->warn($message);
492 return;
493 }
494 }
495
496 if (function_exists('abj404_logPhpFallback')) {
497 abj404_logPhpFallback('service-resolution-fallback', $message);
498 }
499 }
500 }
501