| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentBooking\App\Modules\MCP\Support; |
| 4 |
|
| 5 |
defined('ABSPATH') || exit; |
| 6 |
|
| 7 |
/** |
| 8 |
* A short-lived exclusive hold on one event/slot/host combination. |
| 9 |
* |
| 10 |
* Availability is answered by a query and a booking is written by a separate |
| 11 |
* INSERT, with the whole slot engine in between. Nothing in the schema stops two |
| 12 |
* rows landing on the same host at the same minute — there is no unique index |
| 13 |
* over (event, host, start_time), and there cannot be a simple one, because |
| 14 |
* group events legitimately seat several bookings in one slot. So "check, then |
| 15 |
* write" is a race, and re-checking immediately before the write narrows it |
| 16 |
* without closing it. |
| 17 |
* |
| 18 |
* That race has always existed on the public booking page, where the two |
| 19 |
* requests have to arrive within milliseconds of each other. It matters more |
| 20 |
* here: an agent retries on its own initiative, several agents can hold |
| 21 |
* credentials for the same site, and the confirm round-trip deliberately puts a |
| 22 |
* human-length pause between the preview and the write. |
| 23 |
* |
| 24 |
* `add_option()` is the primitive, for the same reason WriteGuard uses it: it |
| 25 |
* bottoms out in one INSERT against the unique index on `option_name`, so |
| 26 |
* exactly one of N concurrent callers gets `true` back. That is a real mutual |
| 27 |
* exclusion, unlike a get-then-set on a transient, and it needs no new table and |
| 28 |
* no MySQL-specific advisory lock (`GET_LOCK` is unavailable on some managed |
| 29 |
* hosts and is per-connection, which connection pooling makes unreliable). |
| 30 |
* |
| 31 |
* Deliberately NOT a general-purpose lock: the TTL is seconds, a failure to |
| 32 |
* acquire is reported to the caller rather than waited on, and an expired lock |
| 33 |
* is stolen rather than honoured. A booking that cannot be written in fifteen |
| 34 |
* seconds has a bigger problem than contention. |
| 35 |
* |
| 36 |
* WHAT THIS DOES NOT DO, stated plainly so the next reader does not assume more |
| 37 |
* than it delivers: acquireInterval() claims every bucket a booking touches, so |
| 38 |
* partial overlaps on one host collide across event types. But only MCP writes |
| 39 |
* take these locks. The public booking page and the admin UI take none, so a |
| 40 |
* booking made there races exactly as it always has, and availability |
| 41 |
* re-checking remains the only defence against it. |
| 42 |
* |
| 43 |
* @since 2.3.0 |
| 44 |
*/ |
| 45 |
class SlotLock |
| 46 |
{ |
| 47 |
/** |
| 48 |
* Long enough for a slot query plus an insert and its hooks, short enough |
| 49 |
* that a fatal mid-write frees the slot before anyone notices. |
| 50 |
*/ |
| 51 |
const TTL = 15; |
| 52 |
|
| 53 |
const PREFIX = 'fcal_mcp_slot_'; |
| 54 |
|
| 55 |
/** |
| 56 |
* Lock granularity, in seconds. The finest slot interval the plugin offers, |
| 57 |
* so a booking aligned to any configurable duration claims whole buckets. |
| 58 |
*/ |
| 59 |
const BUCKET = 900; |
| 60 |
|
| 61 |
/** |
| 62 |
* A day of buckets. A booking cannot legitimately need more, and a bad end |
| 63 |
* time must not turn into an unbounded row-insert loop. |
| 64 |
*/ |
| 65 |
const MAX_BUCKETS = 96; |
| 66 |
|
| 67 |
/** |
| 68 |
* Take the lock for one slot, or return false when someone else holds it. |
| 69 |
* |
| 70 |
* @param int $eventId |
| 71 |
* @param string $startTimeUtc 'Y-m-d H:i:s' |
| 72 |
* @param int|null $hostId null on single-host events |
| 73 |
* |
| 74 |
* @return string|false the lock key to pass to release(), or false |
| 75 |
*/ |
| 76 |
public static function acquire($eventId, $startTimeUtc, $hostId = null) |
| 77 |
{ |
| 78 |
global $wpdb; |
| 79 |
|
| 80 |
$key = self::key($eventId, $startTimeUtc, $hostId); |
| 81 |
$owner = wp_generate_password(20, false, false); |
| 82 |
|
| 83 |
// Read the stored bytes, not get_option()'s unserialized (and possibly |
| 84 |
// cached) copy: the steal below deletes by exact value. |
| 85 |
$existing = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 86 |
$wpdb->prepare( |
| 87 |
"SELECT `option_value` FROM `{$wpdb->options}` WHERE `option_name` = %s", |
| 88 |
$key |
| 89 |
) |
| 90 |
); |
| 91 |
|
| 92 |
$record = ($existing === null) ? null : maybe_unserialize($existing); |
| 93 |
|
| 94 |
// Steal an expired lock: a request that died mid-write must not hold a |
| 95 |
// slot closed until the daily cleanup runs. Conditional on the value |
| 96 |
// just read, so of two requests racing the same expired lock the slower |
| 97 |
// one cannot delete the winner's fresh row and then insert its own. |
| 98 |
if (is_array($record) && !empty($record['expires']) && $record['expires'] < time()) { |
| 99 |
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 100 |
$wpdb->prepare( |
| 101 |
"DELETE FROM `{$wpdb->options}` WHERE `option_name` = %s AND `option_value` = %s", |
| 102 |
$key, |
| 103 |
$existing |
| 104 |
) |
| 105 |
); |
| 106 |
|
| 107 |
wp_cache_delete($key, 'options'); |
| 108 |
} |
| 109 |
|
| 110 |
// INSERT IGNORE, the primitive core uses for its own locks |
| 111 |
// (WP_Upgrader::create_lock). Deliberately not add_option(): that issues |
| 112 |
// ON DUPLICATE KEY UPDATE and reports success to a caller whose row |
| 113 |
// already existed, which is no mutual exclusion at all. |
| 114 |
$inserted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 115 |
$wpdb->prepare( |
| 116 |
"INSERT IGNORE INTO `{$wpdb->options}` (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, 'no')", |
| 117 |
$key, |
| 118 |
maybe_serialize(['expires' => time() + self::TTL, 'owner' => $owner]) |
| 119 |
) |
| 120 |
); |
| 121 |
|
| 122 |
// The row went in behind the options cache's back. |
| 123 |
wp_cache_delete($key, 'options'); |
| 124 |
|
| 125 |
$notoptions = wp_cache_get('notoptions', 'options'); |
| 126 |
|
| 127 |
if (is_array($notoptions) && isset($notoptions[$key])) { |
| 128 |
unset($notoptions[$key]); |
| 129 |
wp_cache_set('notoptions', $notoptions, 'options'); |
| 130 |
} |
| 131 |
|
| 132 |
if (!$inserted) { |
| 133 |
return false; |
| 134 |
} |
| 135 |
|
| 136 |
// The handle carries the owner, so release() can prove it holds this |
| 137 |
// lock rather than a successor's. |
| 138 |
return $key . '|' . $owner; |
| 139 |
} |
| 140 |
|
| 141 |
/** |
| 142 |
* Claim one instant for every host a booking would occupy, all or nothing. |
| 143 |
* |
| 144 |
* A collective event books each of its hosts, and two single-host event |
| 145 |
* types can share an owner, so the constrained resource is a SET of people |
| 146 |
* rather than one. Partial claims are released before returning, so a |
| 147 |
* caller never holds half a slot. |
| 148 |
* |
| 149 |
* @param int $eventId |
| 150 |
* @param string $startTimeUtc |
| 151 |
* @param array $hostIds |
| 152 |
* |
| 153 |
* @return array|false handles to pass to releaseAll(), or false |
| 154 |
*/ |
| 155 |
public static function acquireAll($eventId, $startTimeUtc, $hostIds) |
| 156 |
{ |
| 157 |
$handles = []; |
| 158 |
|
| 159 |
foreach (array_unique(array_map('intval', (array) $hostIds)) as $hostId) { |
| 160 |
$handle = self::acquire($eventId, $startTimeUtc, $hostId); |
| 161 |
|
| 162 |
if (!$handle) { |
| 163 |
self::releaseAll($handles); |
| 164 |
|
| 165 |
return false; |
| 166 |
} |
| 167 |
|
| 168 |
$handles[] = $handle; |
| 169 |
} |
| 170 |
|
| 171 |
return $handles; |
| 172 |
} |
| 173 |
|
| 174 |
/** |
| 175 |
* Claim every bucket a booking would occupy, for every host it would |
| 176 |
* occupy, all or nothing. |
| 177 |
* |
| 178 |
* Keying on the start instant alone let two bookings that overlap without |
| 179 |
* sharing a start — 10:00 for thirty minutes and 10:15 for fifteen, through |
| 180 |
* different event types — take independent keys and interleave. Overlapping |
| 181 |
* intervals always share an instant, so claiming every bucket an interval |
| 182 |
* touches makes them collide; intervals that merely abut do not, so a |
| 183 |
* booking ending at 10:30 still leaves 10:30 free. |
| 184 |
* |
| 185 |
* @param int $eventId |
| 186 |
* @param string $startTimeUtc 'Y-m-d H:i:s' |
| 187 |
* @param string $endTimeUtc 'Y-m-d H:i:s' |
| 188 |
* @param array $hostIds |
| 189 |
* |
| 190 |
* @return array|false handles to pass to releaseAll(), or false |
| 191 |
*/ |
| 192 |
public static function acquireInterval($eventId, $startTimeUtc, $endTimeUtc, $hostIds) |
| 193 |
{ |
| 194 |
$buckets = self::buckets($startTimeUtc, $endTimeUtc); |
| 195 |
|
| 196 |
if (!$buckets) { |
| 197 |
return false; |
| 198 |
} |
| 199 |
|
| 200 |
$handles = []; |
| 201 |
|
| 202 |
foreach (array_unique(array_map('intval', (array) $hostIds)) as $hostId) { |
| 203 |
foreach ($buckets as $bucket) { |
| 204 |
$handle = self::acquire($eventId, $bucket, $hostId); |
| 205 |
|
| 206 |
if (!$handle) { |
| 207 |
self::releaseAll($handles); |
| 208 |
|
| 209 |
return false; |
| 210 |
} |
| 211 |
|
| 212 |
$handles[] = $handle; |
| 213 |
} |
| 214 |
} |
| 215 |
|
| 216 |
return $handles; |
| 217 |
} |
| 218 |
|
| 219 |
/** |
| 220 |
* The bucket starts an interval touches, half open so a booking ending on a |
| 221 |
* boundary does not claim the bucket beginning there. |
| 222 |
* |
| 223 |
* @return array of 'Y-m-d H:i:s' |
| 224 |
*/ |
| 225 |
private static function buckets($startTimeUtc, $endTimeUtc) |
| 226 |
{ |
| 227 |
$start = strtotime($startTimeUtc . ' UTC'); |
| 228 |
$end = strtotime($endTimeUtc . ' UTC'); |
| 229 |
|
| 230 |
if (!$start) { |
| 231 |
return []; |
| 232 |
} |
| 233 |
|
| 234 |
if (!$end || $end <= $start) { |
| 235 |
$end = $start + 1; |
| 236 |
} |
| 237 |
|
| 238 |
$buckets = []; |
| 239 |
|
| 240 |
for ($t = $start - ($start % self::BUCKET); $t < $end; $t += self::BUCKET) { |
| 241 |
$buckets[] = gmdate('Y-m-d H:i:s', $t); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date |
| 242 |
|
| 243 |
if (count($buckets) >= self::MAX_BUCKETS) { |
| 244 |
break; |
| 245 |
} |
| 246 |
} |
| 247 |
|
| 248 |
return $buckets; |
| 249 |
} |
| 250 |
|
| 251 |
/** |
| 252 |
* @param array|string|false $handles |
| 253 |
* |
| 254 |
* @return bool whether every lease is still held |
| 255 |
*/ |
| 256 |
public static function renewAll($handles) |
| 257 |
{ |
| 258 |
foreach ((array) $handles as $handle) { |
| 259 |
if (!self::renew($handle)) { |
| 260 |
return false; |
| 261 |
} |
| 262 |
} |
| 263 |
|
| 264 |
return true; |
| 265 |
} |
| 266 |
|
| 267 |
/** |
| 268 |
* @param array|string|false $handles |
| 269 |
*/ |
| 270 |
public static function releaseAll($handles) |
| 271 |
{ |
| 272 |
foreach ((array) $handles as $handle) { |
| 273 |
self::release($handle); |
| 274 |
} |
| 275 |
} |
| 276 |
|
| 277 |
/** |
| 278 |
* Re-assert a lease this caller still owns, pushing its expiry out. |
| 279 |
* |
| 280 |
* The lease is taken before isSpotAvailable(), which fans out through |
| 281 |
* `fluent_booking/remote_booked_events` to a live FreeBusy call per |
| 282 |
* connected calendar per host. On a team event with a cold cache that can |
| 283 |
* outrun TTL before a single row is written — and acquire() steals an |
| 284 |
* expired lease unconditionally, so the race this class exists to close |
| 285 |
* reopens exactly when the check is slow. |
| 286 |
* |
| 287 |
* Conditional on the exact stored bytes, so a caller whose lease was |
| 288 |
* already stolen gets false rather than stamping over the new owner. |
| 289 |
* |
| 290 |
* @param string|false $handle the value returned by acquire() |
| 291 |
* |
| 292 |
* @return bool whether the caller still holds the lock |
| 293 |
*/ |
| 294 |
public static function renew($handle) |
| 295 |
{ |
| 296 |
global $wpdb; |
| 297 |
|
| 298 |
if (!$handle || strpos($handle, '|') === false) { |
| 299 |
return false; |
| 300 |
} |
| 301 |
|
| 302 |
list($key, $owner) = explode('|', $handle, 2); |
| 303 |
|
| 304 |
$existing = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 305 |
$wpdb->prepare( |
| 306 |
"SELECT `option_value` FROM `{$wpdb->options}` WHERE `option_name` = %s", |
| 307 |
$key |
| 308 |
) |
| 309 |
); |
| 310 |
|
| 311 |
if ($existing === null) { |
| 312 |
return false; |
| 313 |
} |
| 314 |
|
| 315 |
$record = maybe_unserialize($existing); |
| 316 |
|
| 317 |
if (!is_array($record) || !isset($record['owner']) || !hash_equals((string) $record['owner'], $owner)) { |
| 318 |
return false; |
| 319 |
} |
| 320 |
|
| 321 |
$renewed = maybe_serialize(['expires' => time() + self::TTL, 'owner' => $owner]); |
| 322 |
|
| 323 |
$updated = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 324 |
$wpdb->prepare( |
| 325 |
"UPDATE `{$wpdb->options}` SET `option_value` = %s WHERE `option_name` = %s AND `option_value` = %s", |
| 326 |
$renewed, |
| 327 |
$key, |
| 328 |
$existing |
| 329 |
) |
| 330 |
); |
| 331 |
|
| 332 |
wp_cache_delete($key, 'options'); |
| 333 |
|
| 334 |
// MySQL reports CHANGED rows, not matched ones, so renewing inside the |
| 335 |
// same second as the last write is a no-op update and reports zero. |
| 336 |
// The row is still ours and still current, which is what was asked. |
| 337 |
return $updated ? true : ($renewed === $existing); |
| 338 |
} |
| 339 |
|
| 340 |
/** |
| 341 |
* Release a lock this caller actually holds. |
| 342 |
* |
| 343 |
* The owner check is the point. A lease can expire while a slow booking hook |
| 344 |
* is still running; another request then legitimately takes the slot, and an |
| 345 |
* ownerless `delete_option()` from the first request would free the second |
| 346 |
* one's lock while it was still working. |
| 347 |
* |
| 348 |
* The check and the delete are two statements, so the delete carries the |
| 349 |
* proof with it and matches the exact value checked. A successor's row holds |
| 350 |
* a different owner token, so a lapsed owner's delete matches nothing. |
| 351 |
* |
| 352 |
* @param string|false $handle the value returned by acquire() |
| 353 |
*/ |
| 354 |
public static function release($handle) |
| 355 |
{ |
| 356 |
global $wpdb; |
| 357 |
|
| 358 |
if (!$handle || strpos($handle, '|') === false) { |
| 359 |
return; |
| 360 |
} |
| 361 |
|
| 362 |
list($key, $owner) = explode('|', $handle, 2); |
| 363 |
|
| 364 |
$existing = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 365 |
$wpdb->prepare( |
| 366 |
"SELECT `option_value` FROM `{$wpdb->options}` WHERE `option_name` = %s", |
| 367 |
$key |
| 368 |
) |
| 369 |
); |
| 370 |
|
| 371 |
if ($existing === null) { |
| 372 |
return; |
| 373 |
} |
| 374 |
|
| 375 |
$record = maybe_unserialize($existing); |
| 376 |
|
| 377 |
if (!is_array($record) || !isset($record['owner']) || !hash_equals((string) $record['owner'], $owner)) { |
| 378 |
return; |
| 379 |
} |
| 380 |
|
| 381 |
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 382 |
$wpdb->prepare( |
| 383 |
"DELETE FROM `{$wpdb->options}` WHERE `option_name` = %s AND `option_value` = %s", |
| 384 |
$key, |
| 385 |
$existing |
| 386 |
) |
| 387 |
); |
| 388 |
|
| 389 |
wp_cache_delete($key, 'options'); |
| 390 |
} |
| 391 |
|
| 392 |
/** |
| 393 |
* Host is part of the key: on a team event two hosts genuinely can be booked |
| 394 |
* for the same minute, and locking the slot across all of them would turn a |
| 395 |
* correctness guard into a throughput problem. |
| 396 |
* |
| 397 |
* @return string |
| 398 |
*/ |
| 399 |
private static function key($eventId, $startTimeUtc, $hostId) |
| 400 |
{ |
| 401 |
// Keyed on the HOST once one is known, not the event: the constrained |
| 402 |
// resource is the person, and keying on the event let two requests book |
| 403 |
// the same host at one instant through different event types. |
| 404 |
// |
| 405 |
// Round robin has no host until isSpotAvailable() settles one, so it |
| 406 |
// falls back to the event and the caller takes a second, host-keyed |
| 407 |
// lock afterwards. |
| 408 |
$scope = $hostId ? 'h' . (int) $hostId : 'e' . (int) $eventId; |
| 409 |
|
| 410 |
return self::PREFIX . md5($scope . '|' . $startTimeUtc); |
| 411 |
} |
| 412 |
} |
| 413 |
|