PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / trunk
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution vtrunk
2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 1.7.2 All 33 releases
fluent-booking / app / Modules / MCP / Support / SlotLock.php

SlotLock.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution trunk, at app/Modules/MCP/Support/SlotLock.php

413 lines 14.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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