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 / LockOwnerStore.php

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

385 lines 14.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /**
9 * Where a synchronizer lock's owner record lives, and how it is claimed, read
10 * and released.
11 *
12 * This is the data-access half of the lock machinery: it knows about the
13 * WordPress options table, the uploads directory, and multisite network
14 * options, and nothing about waiting, breaking, or bookkeeping a lock.
15 * ABJ_404_Solution_SynchronizationUtils owns that protocol and talks to this
16 * class for storage.
17 *
18 * Every operation here is atomic and answers from the SHARED store, never from
19 * a per-request cache. claimOwner() is a compare-and-set (an exclusive file
20 * create, or an INSERT arbitrated by UNIQUE(option_name)); deleteOwner() only
21 * removes a record whose value the caller named; and the read in options mode
22 * is direct SQL rather than get_option(). That last point is not a performance
23 * choice: WordPress serves get_option() from the object cache and primes it on
24 * write, so without a persistent object cache a request reads back its own
25 * write. A protocol built on such a read cannot detect the loser of a race, and
26 * error report 270 is what that looks like in production -- two requests
27 * running the 4.3.2 to 4.3.3 database upgrade in the same second, each holding
28 * what it believed was the exclusive 'update_db_version' lock.
29 *
30 * The split matters because the storage decision is genuinely independent
31 * behavior with its own persistent state and its own failure modes: a host
32 * whose options table will not round-trip a value latches this site onto
33 * file-based records for good, and that latch has to be re-derived per blog on
34 * multisite. SynchronizationUtilsBlogScopeTest exercises exactly that, without
35 * taking a single lock.
36 */
37 class ABJ_404_Solution_LockOwnerStore {
38
39 /** Whether owner records are stored as files rather than options.
40 * @var bool|null */
41 static $usingFileMode = null;
42
43 /**
44 * Blog id self::$usingFileMode was decided for. isFileMode() derives the
45 * decision from per-blog state (abj404_getUploadsDir() -> wp_upload_dir(),
46 * and a get_option()/update_option()/delete_option() round-trip against
47 * the per-blog options table), but the decision itself is cached in a
48 * bare static for the lifetime of the process. Multisite background
49 * batches (e.g. ABJ_404_Solution_DatabaseUpgradeMultiSite's per-site
50 * work) switch_to_blog()/restore_current_blog() around per-site work in
51 * the SAME request/singleton lifetime; without this blog-id check, a
52 * decision minted for one blog (e.g. "file mode" because that blog's
53 * options table round-trip failed) would silently be reused for a
54 * different, healthy blog's lock operations.
55 *
56 * @var int|null
57 */
58 static $usingFileModeBlogId = null;
59
60 /** A prefix for keys used for synchronization methods.
61 * @var string */
62 const SYNC_KEY_PREFIX = 'SYNC_';
63 const NETWORK_ATOMIC_READY_OPTION = 'abj404_network_lock_atomic_ready_at';
64 const NETWORK_ATOMIC_MIGRATION_DELAY = 86400;
65
66 /**
67 * Test seam: clear the cached file-vs-options latch so the next call
68 * re-derives it.
69 *
70 * @return void
71 */
72 public static function resetForTests() {
73 self::$usingFileMode = null;
74 self::$usingFileModeBlogId = null;
75 }
76
77 /** Build the storage key an owner record is filed under.
78 *
79 * @param string $keyFromUser
80 * @return string
81 */
82 function createInternalKey($keyFromUser) {
83 return ABJ404_PP . "_" . self::SYNC_KEY_PREFIX . $keyFromUser;
84 }
85
86 /** @return string */
87 private function getFileModePath() {
88 return abj404_getUploadsDir() . 'sync_mode_file.txt';
89 }
90
91 /** @return string */
92 private function getOptionsModePath() {
93 return abj404_getUploadsDir() . 'sync_mode_options.txt';
94 }
95
96 /** @return bool */
97 function isFileMode() {
98 $currentBlogId = function_exists('get_current_blog_id') ? (int)get_current_blog_id() : 0;
99
100 if (self::$usingFileMode == null || self::$usingFileModeBlogId !== $currentBlogId) {
101 $fileModePath = $this->getFileModePath();
102 $optionsModePath = $this->getOptionsModePath();
103 if (file_exists($fileModePath) && file_exists($optionsModePath)) {
104 ABJ_404_Solution_FileSystemService::safeUnlink($fileModePath);
105 ABJ_404_Solution_FileSystemService::safeUnlink($optionsModePath);
106 }
107
108 if (file_exists($fileModePath)) {
109 $usingFileMode = true;
110
111 } else if (file_exists($optionsModePath)) {
112 $usingFileMode = false;
113
114 } else {
115 // initialize
116 $pass = true;
117 $keyForTesting = $this->createInternalKey('testing');
118 $uniqueID = 'testing_' . uniqid('', true);
119
120 // test saving.
121 update_option($keyForTesting, $uniqueID);
122 $result = get_option($keyForTesting);
123 if ($result != $uniqueID) {
124 $pass = false;
125 }
126
127 // test deleting.
128 delete_option($keyForTesting);
129 $result = get_option($keyForTesting);
130 if ($result != null && $result != '') {
131 $pass = false;
132 }
133
134 ABJ_404_Solution_FileSystemService::createDirectoryWithErrorMessages(dirname($optionsModePath));
135 if ($pass) {
136 $usingFileMode = false;
137 touch($optionsModePath);
138 } else {
139 $usingFileMode = true;
140 touch($fileModePath);
141 }
142 }
143 self::$usingFileMode = $usingFileMode;
144 self::$usingFileModeBlogId = $currentBlogId;
145 }
146
147 return self::$usingFileMode;
148 }
149
150 /** Latch this site onto file-based owner records.
151 *
152 * Any lock a request already holds was written to the options table and
153 * becomes unreachable once the mode flips, so neither the normal release
154 * nor the crash-safe release can delete it. That is not a wedge: the mode
155 * files persist, so later requests read owner records from disk and never
156 * consult the orphaned option row again. If the site is ever flipped back
157 * to options mode, the orphan carries an old acquisition timestamp and is
158 * broken by the stale-lock check.
159 *
160 * @return void */
161 function switchToFileSyncMode() {
162 self::$usingFileMode = true;
163 self::$usingFileModeBlogId = function_exists('get_current_blog_id') ? (int)get_current_blog_id() : 0;
164 $optionsModePath = $this->getOptionsModePath();
165 ABJ_404_Solution_FileSystemService::safeUnlink($optionsModePath);
166
167 $fileModePath = $this->getFileModePath();
168 ABJ_404_Solution_FileSystemService::createDirectoryWithErrorMessages(dirname($fileModePath));
169 touch($fileModePath);
170 }
171
172 /**
173 * Take ownership of $key, but only if nobody owns it yet.
174 *
175 * This is the mutual-exclusion primitive itself, and it is atomic in both
176 * storage modes: an O_CREAT|O_EXCL file create, or an INSERT that the
177 * options table's UNIQUE(option_name) index can satisfy exactly once. The
178 * caller never reads first and then decides, because the gap between a read
179 * and the write that follows it is a window in which a second request reads
180 * the same "unowned" answer -- which is how error report 270 ended up with
181 * two requests holding 'update_db_version' at the same moment.
182 *
183 * @param array{key: string, owner: string} $claim
184 * @return bool true only if this call created the owner record.
185 */
186 function claimOwner(array $claim) {
187 $key = $claim['key'];
188 $uniqueID = $claim['owner'];
189 if (!$this->networkAtomicStorageReady($key, true)) {
190 return false;
191 }
192 if ($this->isFileMode()) {
193 $fileSync = ABJ_404_Solution_FileSync::getInstance();
194 try {
195 return $fileSync->claimOwnerFile($claim);
196 } catch (Throwable $e) {
197 // An unwritable uploads directory, a full disk, a revoked
198 // permission. Report the claim as lost, which stops the caller
199 // entering its critical section, and record why: a lock that can
200 // never be taken silently disables every synchronized section in
201 // the plugin, and that has to be diagnosable.
202 $this->logStorageFailure('claim the lock owner record for key "' . $key . '"', $e);
203 return false;
204 }
205 }
206
207 return $this->optionRowFor($key)->claim(array('optionName' => $key, 'value' => $uniqueID));
208 }
209
210 /**
211 * @param string $key
212 * @return string
213 */
214 function readOwner($key) {
215 if (!$this->networkAtomicStorageReady($key, false)) {
216 return '';
217 }
218 $owner = '';
219 if ($this->isFileMode()) {
220 $fileSync = ABJ_404_Solution_FileSync::getInstance();
221 try {
222 $owner = $fileSync->getOwnerFromFile($key);
223 } catch (Throwable $e) {
224 // The lock file is present but unreadable. Treating that as
225 // "unlocked" is what the caller will do with '', and it is the
226 // only answer available -- but it is a guess, and an I/O failure
227 // read as an unlocked resource lets two workers into the same
228 // critical section. Record it so the corruption that may follow
229 // has something pointing back here.
230 $logger = abj_service('logging');
231 if (is_object($logger) && method_exists($logger, 'debugMessage')) {
232 $logger->debugMessage(
233 'Lock owner file for key "' . $key . '" exists but could not be read; '
234 . 'proceeding as if unowned. ' . get_class($e) . ' (code '
235 . (string)$e->getCode() . '): ' . $e->getMessage(),
236 $e
237 );
238 }
239 $owner = '';
240 }
241
242 } else {
243 $owner = $this->readOwnerRow($key);
244 }
245
246 return $owner;
247 }
248
249 /**
250 * Release ownership of $key, but only if $owner still holds it.
251 *
252 * The condition is not a nicety. Every caller decides to delete on the
253 * strength of a PRIOR read, and between that read and this call the record
254 * can have been broken as stale or taken over by another request. Making
255 * the delete itself carry the expected owner means a request can only ever
256 * remove its own record, which is what stops a request that lost a race
257 * from wiping the winner's lock.
258 *
259 * @param array{key: string, owner: string} $release
260 * @return bool true if the record named by $owner was removed.
261 */
262 function deleteOwner(array $release) {
263 $key = $release['key'];
264 $owner = $release['owner'];
265 if (!$this->networkAtomicStorageReady($key, false)) {
266 return false;
267 }
268 if ($this->isFileMode()) {
269 $fileSync = ABJ_404_Solution_FileSync::getInstance();
270 return $fileSync->releaseLock($release);
271 }
272
273 return $this->optionRowFor($key)->releaseIfValueIs(array('optionName' => $key, 'value' => $owner));
274 }
275
276 /** The owner value recorded in the options table.
277 *
278 * @param string $key
279 * @return string '' when no record exists, or when the storage cannot answer.
280 */
281 private function readOwnerRow($key) {
282 return $this->optionRowFor($key)->valueOf($key);
283 }
284
285 /** The exclusive options row that holds $key's owner record.
286 *
287 * Network-wide locks contend on the network's main site. Acquisitions pause
288 * for a drain window before this store is used, and remain paused while a
289 * legacy sitemeta owner exists, so deployment cannot split one lock across
290 * the old and new stores.
291 *
292 * @param string $key
293 * @return ABJ_404_Solution_ExclusiveOptionRow
294 */
295 private function optionRowFor($key) {
296 return new ABJ_404_Solution_ExclusiveOptionRow($this->shouldUseNetworkStorage($key)
297 ? ABJ_404_Solution_ExclusiveOptionRow::SCOPE_NETWORK_MAIN_SITE
298 : ABJ_404_Solution_ExclusiveOptionRow::SCOPE_CURRENT_BLOG);
299 }
300
301 /** Pause network locks while old sitemeta-based requests drain. */
302 private function networkAtomicStorageReady(string $key, bool $initialize): bool {
303 if (!$this->shouldUseNetworkStorage($key)) {
304 return true;
305 }
306 $readyAt = get_site_option(self::NETWORK_ATOMIC_READY_OPTION, false);
307 if ($readyAt === false || !is_numeric($readyAt)) {
308 if ($initialize) {
309 $stored = update_site_option(self::NETWORK_ATOMIC_READY_OPTION,
310 (string)(abj_clock()->now() + self::NETWORK_ATOMIC_MIGRATION_DELAY));
311 if (!$stored && function_exists('abj_service')) {
312 $logger = abj_service('logging');
313 if (is_object($logger) && method_exists($logger, 'warn')) {
314 $logger->warn('Could not persist the network lock migration deadline; network lock work remains paused.');
315 }
316 }
317 }
318 return false;
319 }
320 if (abj_clock()->now() < (int)$readyAt) {
321 return false;
322 }
323 // An old request still owns the legacy site option. Stay paused until
324 // that owner releases it instead of opening a cross-store overlap.
325 return get_site_option($key, false) === false;
326 }
327
328 /** Record a storage failure that cost the caller a lock.
329 *
330 * @param string $attempted what the store was trying to do
331 * @param Throwable $e
332 * @return void
333 */
334 private function logStorageFailure($attempted, Throwable $e) {
335 if (!function_exists('abj_service')) {
336 return;
337 }
338 $logger = abj_service('logging');
339 if (is_object($logger) && method_exists($logger, 'warn')) {
340 $logger->warn('Could not ' . $attempted . '; treating the lock as unavailable. '
341 . get_class($e) . ' (code ' . (string)$e->getCode() . '): ' . $e->getMessage());
342 }
343 }
344
345 /**
346 * Check if the plugin is network-activated in a multisite environment.
347 *
348 * @return bool True if network-activated, false otherwise
349 */
350 private function isNetworkActivated() {
351 // Synchronizer shutdown recovery can run from a deliberately minimal
352 // bootstrap (and some hosts invoke shutdown handlers after WordPress
353 // has only partially loaded). A missing multisite API means this
354 // cannot be a network-wide lock; do not turn recovery into a fatal.
355 if (!function_exists('is_multisite') || !is_multisite()) {
356 return false;
357 }
358
359 if (!function_exists('is_plugin_active_for_network')) {
360 require_once ABSPATH . '/wp-admin/includes/plugin.php';
361 }
362
363 return is_plugin_active_for_network(plugin_basename(ABJ404_FILE));
364 }
365
366 /**
367 * Determine if this lock key should use network-wide storage.
368 *
369 * N-gram rebuild locks (ngram_rebuild, ngram_schedule) must be network-wide
370 * to coordinate across all sites. Other locks remain site-specific.
371 *
372 * @param string $key The lock key
373 * @return bool True if should use network-wide storage
374 */
375 private function shouldUseNetworkStorage($key) {
376 // Extract the user-provided key from the internal key format
377 $userKey = str_replace(ABJ404_PP . "_" . self::SYNC_KEY_PREFIX, '', $key);
378
379 // N-gram locks must be network-wide when network-activated
380 $networkWideLocks = ['ngram_rebuild', 'ngram_schedule'];
381
382 return $this->isNetworkActivated() && in_array($userKey, $networkWideLocks);
383 }
384 }
385