PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.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 / core / SynchronizationUtils.php

SynchronizationUtils.php in 404 Solution 4.3.0, at includes/core/SynchronizationUtils.php

427 lines 13.7 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 class ABJ_404_Solution_SynchronizationUtils {
9
10 /** A prefix for keys used for synchronization methods.
11 * @var string */
12 const SYNC_KEY_PREFIX = 'SYNC_';
13
14 /** @var bool|null */
15 static $usingFileMode = null;
16
17 /** @var self|null */
18 private static $instance = null;
19 /**
20 * Test seam: install or clear the cached singleton instance without
21 * private-field reflection. Pass null to reset between tests; pass a
22 * configured instance (or double) to install it. Mirrors the setInstance()
23 * contract on DataAccess / PluginLogic (M105 singleton-reset seam).
24 *
25 * @param self|null $instance
26 * @return void
27 */
28 public static function setInstance($instance) {
29 self::$instance = $instance;
30 }
31
32 /**
33 * Test seam: clear all cached static state (the singleton instance and the
34 * file-vs-DB lock-mode latch) without private-field reflection.
35 *
36 * @return void
37 */
38 public static function resetForTests() {
39 self::$instance = null;
40 self::$usingFileMode = null;
41 }
42
43
44 /** @return self */
45 public static function getInstance() {
46 if (self::$instance == null) {
47 self::$instance = new ABJ_404_Solution_SynchronizationUtils();
48 }
49
50 return self::$instance;
51 }
52
53 /** @return string */
54 private function getFileModePath() {
55 return abj404_getUploadsDir() . 'sync_mode_file.txt';
56 }
57
58 /** @return string */
59 private function getOptionsModePath() {
60 return abj404_getUploadsDir() . 'sync_mode_options.txt';
61 }
62
63 /** @return bool */
64 private function isFileMode() {
65 if (self::$usingFileMode == null) {
66 $fileModePath = $this->getFileModePath();
67 $optionsModePath = $this->getOptionsModePath();
68 if (file_exists($fileModePath) && file_exists($optionsModePath)) {
69 ABJ_404_Solution_FileSystemService::safeUnlink($fileModePath);
70 ABJ_404_Solution_FileSystemService::safeUnlink($optionsModePath);
71 }
72
73 if (file_exists($fileModePath)) {
74 $usingFileMode = true;
75
76 } else if (file_exists($optionsModePath)) {
77 $usingFileMode = false;
78
79 } else {
80 // initialize
81 $pass = true;
82 $keyForTesting = ABJ404_PP . "_" . self::SYNC_KEY_PREFIX . 'testing';
83 $uniqueID = $this->createUniqueID('testing');
84
85 // test saving.
86 update_option($keyForTesting, $uniqueID);
87 $result = get_option($keyForTesting);
88 if ($result != $uniqueID) {
89 $pass = false;
90 }
91
92 // test deleting.
93 delete_option($keyForTesting);
94 $result = get_option($keyForTesting);
95 if ($result != null && $result != '') {
96 $pass = false;
97 }
98
99 ABJ_404_Solution_FileSystemService::createDirectoryWithErrorMessages(dirname($optionsModePath));
100 if ($pass) {
101 $usingFileMode = false;
102 touch($optionsModePath);
103 } else {
104 $usingFileMode = true;
105 touch($fileModePath);
106 }
107 }
108 self::$usingFileMode = $usingFileMode;
109 }
110
111 return self::$usingFileMode;
112 }
113
114 /** @return void */
115 function switchToFileSyncMode() {
116 self::$usingFileMode = true;
117 $optionsModePath = $this->getOptionsModePath();
118 ABJ_404_Solution_FileSystemService::safeUnlink($optionsModePath);
119
120 $fileModePath = $this->getFileModePath();
121 ABJ_404_Solution_FileSystemService::createDirectoryWithErrorMessages(dirname($fileModePath));
122 touch($fileModePath);
123 }
124
125 /**
126 * @param string $keyFromUser
127 * @return string
128 */
129 private function createInternalKey($keyFromUser) {
130 return ABJ404_PP . "_" . self::SYNC_KEY_PREFIX . $keyFromUser;
131 }
132
133 /**
134 * @param string $keyFromUser
135 * @return string
136 */
137 private function createUniqueID($keyFromUser) {
138 return abj_clock()->nowFloat() . "_" . $keyFromUser . '_' . $this->uniqidReal() . uniqid('', true);
139 }
140
141 /** Returns an empty string if the lock is not acquired.
142 * @param string $synchronizedKeyFromUser
143 * @return string the unique ID that was used. This is needed to release the lock. Or an empty string if
144 * the lock wasn't acquired.
145 */
146 function synchronizerAcquireLockTry($synchronizedKeyFromUser) {
147 $uniqueID = $this->createUniqueID($synchronizedKeyFromUser);
148 $internalSynchronizedKey = $this->createInternalKey($synchronizedKeyFromUser);
149
150 // don't let anyone hold the lock for too long.
151 $this->fixAnUnforeseenIssue($synchronizedKeyFromUser);
152
153 // acquire the lock.
154 $currentOwner = $this->readOwner($internalSynchronizedKey);
155 // only write the value if it's empty.
156 if (empty($currentOwner)) {
157 $this->writeOwner($internalSynchronizedKey, $uniqueID);
158 }
159 // give a different thread that ran at the same time a chance to overwrite our value.
160 time_nanosleep(0, 10000000 * 30); // 10000000 is 1/100 of a second.
161 // check and see if we're the owner yet.
162 $currentOwner = $this->readOwner($internalSynchronizedKey);
163
164 if ($currentOwner == $uniqueID) {
165 return $uniqueID;
166 }
167
168 return '';
169 }
170
171 /** Remove the lock if it's been in place for too long.
172 * @param string $synchronizedKeyFromUser
173 * @return void
174 */
175 function fixAnUnforeseenIssue($synchronizedKeyFromUser) {
176 $internalSynchronizedKey = $this->createInternalKey($synchronizedKeyFromUser);
177
178 $uniqueID = $this->readOwner($internalSynchronizedKey);
179
180 if (empty($uniqueID)) {
181 return;
182 }
183
184 $uniqueIDInfo = explode("_", $uniqueID);
185
186 $createTime = $uniqueIDInfo[0];
187
188 $timePassed = abj_clock()->nowFloat() - (float)$createTime;
189
190 $maxExecutionTime = ini_get('max_execution_time');
191 if (empty($maxExecutionTime) || $maxExecutionTime < 1) {
192 $maxExecutionTime = 60;
193 } else {
194 $maxExecutionTime *= 2;
195 }
196
197 // it should have been released by now.
198 if ($timePassed > $maxExecutionTime) {
199 $this->deleteOwner($uniqueID, $internalSynchronizedKey);
200 $valueAfterDelete = $this->readOwner($internalSynchronizedKey);
201
202 // if options mode failed for some reason then switch to file sync mode.
203 if ($valueAfterDelete != null && $valueAfterDelete != '' &&
204 !$this->isFileMode()) {
205 $this->switchToFileSyncMode();
206 return;
207 }
208
209 $uniqueIDForDebugging = $this->createUniqueID('DEBUG_KEY');
210 $logger = abj_service('logging');
211 $logger->errorMessage("Forcibly removed synchronization after " .
212 $timePassed . " seconds for the " . "key " . $internalSynchronizedKey .
213 " with value: " . $uniqueID . ', value after delete: ' . $valueAfterDelete .
214 ", microtime: " . abj_clock()->nowFloat() . ", unique ID for debugging: " .
215 $uniqueIDForDebugging . ", File sync mode: " . json_encode($this->isFileMode()));
216 }
217 }
218
219 /** Waits until the lock can be acquired and then returns the unique ID.
220 * @param string $synchronizedKeyFromUser
221 * @return string the unique ID that was used. This is needed to release the lock.
222 */
223 function synchronizerAcquireLockWithWait($synchronizedKeyFromUser) {
224 $uniqueID = $this->createUniqueID($synchronizedKeyFromUser);
225 $internalSynchronizedKey = $this->createInternalKey($synchronizedKeyFromUser);
226
227 $this->fixAnUnforeseenIssue($synchronizedKeyFromUser);
228 $iterations = 0;
229
230 // acquire the lock.
231 $currentOwner = $this->readOwner($internalSynchronizedKey);
232 while ($currentOwner != $uniqueID) {
233 // only write the value if it's empty.
234 if (empty($currentOwner)) {
235 $this->writeOwner($internalSynchronizedKey, $uniqueID);
236 }
237 // give a different thread that ran at the same time a chance to overwrite our value.
238 time_nanosleep(0, 500000000); // 10000000 is 1/100 of a second. 500000000 is 1/2 of a second.
239 // check and see if we're the owner yet.
240 $currentOwner = $this->readOwner($internalSynchronizedKey);
241
242 $iterations++;
243 if ($iterations % 500 == 0) {
244 $this->fixAnUnforeseenIssue($synchronizedKeyFromUser);
245 }
246 }
247
248 return $uniqueID;
249 }
250
251 /** Release the lock for a synchronized block. Should be done in a finally block.
252 * @param string $uniqueID
253 * @param string $synchronizedKeyFromUser
254 * @return void
255 * @throws Exception
256 */
257 function synchronizerReleaseLock($uniqueID, $synchronizedKeyFromUser) {
258 $internalSynchronizedKey = $this->createInternalKey($synchronizedKeyFromUser);
259
260 $currentLockHolder = $this->readOwner($internalSynchronizedKey);
261
262 if ($uniqueID == $currentLockHolder) {
263 $this->deleteOwner($uniqueID, $internalSynchronizedKey);
264
265 } else {
266 // Fail silently instead of throwing fatal exception.
267 $logger = abj_service('logging');
268 $logger->debugMessage("Synchronization lock release mismatch. " .
269 "Synchronized key: $synchronizedKeyFromUser, current holder: $currentLockHolder, " .
270 "attempted release by: $uniqueID");
271 }
272 }
273
274 /**
275 * @param string $key
276 * @return string
277 */
278 function readOwner($key) {
279 $owner = '';
280 if ($this->isFileMode()) {
281 $fileSync = ABJ_404_Solution_FileSync::getInstance();
282 $owner = $fileSync->getOwnerFromFile($key);
283
284 } else {
285 // MULTISITE: Use network-aware option for N-gram locks
286 $ownerRaw = $this->getNetworkAwareOption($key);
287 $owner = is_string($ownerRaw) ? $ownerRaw : '';
288 }
289
290 return $owner;
291 }
292 /**
293 * @param string $key
294 * @param string $owner
295 * @return void
296 */
297 function writeOwner($key, $owner) {
298 if ($this->isFileMode()) {
299 $fileSync = ABJ_404_Solution_FileSync::getInstance();
300 $fileSync->writeOwnerToFile($key, $owner);
301 } else {
302 // MULTISITE: Use network-aware option for N-gram locks
303 $this->updateNetworkAwareOption($key, $owner);
304 }
305 }
306 /**
307 * @param string $owner
308 * @param string $key
309 * @return void
310 */
311 function deleteOwner($owner, $key) {
312 if ($this->isFileMode()) {
313 $fileSync = ABJ_404_Solution_FileSync::getInstance();
314 $fileSync->releaseLock($owner, $key);
315 } else {
316 // MULTISITE: Use network-aware option for N-gram locks
317 $this->deleteNetworkAwareOption($key);
318 }
319 }
320
321 /**
322 * Check if the plugin is network-activated in a multisite environment.
323 *
324 * @return bool True if network-activated, false otherwise
325 */
326 private function isNetworkActivated() {
327 if (!is_multisite()) {
328 return false;
329 }
330
331 if (!function_exists('is_plugin_active_for_network')) {
332 require_once ABSPATH . '/wp-admin/includes/plugin.php';
333 }
334
335 return is_plugin_active_for_network(plugin_basename(ABJ404_FILE));
336 }
337
338 /**
339 * Determine if this lock key should use network-wide storage.
340 *
341 * N-gram rebuild locks (ngram_rebuild, ngram_schedule) must be network-wide
342 * to coordinate across all sites. Other locks remain site-specific.
343 *
344 * @param string $key The lock key
345 * @return bool True if should use network-wide storage
346 */
347 private function shouldUseNetworkStorage($key) {
348 // Extract the user-provided key from the internal key format
349 $userKey = str_replace(ABJ404_PP . "_" . self::SYNC_KEY_PREFIX, '', $key);
350
351 // N-gram locks must be network-wide when network-activated
352 $networkWideLocks = ['ngram_rebuild', 'ngram_schedule'];
353
354 return $this->isNetworkActivated() && in_array($userKey, $networkWideLocks);
355 }
356
357 /**
358 * Get an option value, using network-wide storage for N-gram locks.
359 *
360 * @param string $key The option key
361 * @param mixed $default Default value if option doesn't exist
362 * @return mixed The option value
363 */
364 private function getNetworkAwareOption($key, $default = false) {
365 if ($this->shouldUseNetworkStorage($key)) {
366 return get_site_option($key, $default);
367 }
368 return get_option($key, $default);
369 }
370
371 /**
372 * Update an option value, using network-wide storage for N-gram locks.
373 *
374 * @param string $key The option key
375 * @param mixed $value The value to store
376 * @return bool True if updated successfully
377 */
378 private function updateNetworkAwareOption($key, $value) {
379 if ($this->shouldUseNetworkStorage($key)) {
380 return update_site_option($key, $value);
381 }
382 return update_option($key, $value);
383 }
384
385 /**
386 * Delete an option, using network-wide storage for N-gram locks.
387 *
388 * @param string $key The option key
389 * @return bool True if deleted successfully
390 */
391 private function deleteNetworkAwareOption($key) {
392 if ($this->shouldUseNetworkStorage($key)) {
393 return delete_site_option($key);
394 }
395 return delete_option($key);
396 }
397
398 /**
399 * @return string a random string of characters.
400 * @throws Exception
401 */
402 function uniqidReal() {
403 $bytes = null;
404 if (function_exists("random_bytes")) {
405 try {
406 $bytes = random_bytes(max(1, (int)ceil(13 / 2)));
407 } catch (Exception $e) { // allow-silent-catch: random_bytes unavailable; fall through to openssl then uniqid
408 $bytes = null;
409 }
410 }
411
412 if ($bytes == null && function_exists("openssl_random_pseudo_bytes")) {
413 try {
414 $bytes = openssl_random_pseudo_bytes((int)ceil(13 / 2));
415 } catch (Exception $e) { // allow-silent-catch: openssl fallback unavailable; fall through to uniqid
416 $bytes = null;
417 }
418 }
419
420 if ($bytes != null) {
421 return bin2hex($bytes);
422 }
423 return uniqid("", true);
424 }
425
426 }
427