PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 2.5.0
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v2.5.0
2.5.0 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 All 34 releases
fluent-booking / app / Modules / MCP / Support / SlotLock.php

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

365 lines 11.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 slot for one host.
9 *
10 * Checking availability and inserting the booking is a race, and the schema
11 * can't close it: group events seat several bookings in one slot, so there is
12 * no unique index on (event, host, start_time). MCP makes the race likelier
13 * because agents retry and the confirm round-trip adds a human-length pause.
14 *
15 * The lock is an INSERT IGNORE on the unique `option_name` index, so exactly
16 * one concurrent caller wins. GET_LOCK is avoided: some managed hosts disable
17 * it, and it is per-connection, which connection pooling breaks.
18 *
19 * Not a general-purpose lock: the TTL is seconds, a failed acquire returns
20 * instead of waiting, and an expired lock is stolen.
21 *
22 * Only MCP writes take these locks. Bookings from the public page and admin UI
23 * don't, so against those, availability re-checking is still the only guard.
24 *
25 * @since 2.3.0
26 */
27 class SlotLock
28 {
29 // Covers a slot query plus the insert and its hooks; short enough that a
30 // fatal mid-write frees the slot quickly.
31 const TTL = 15;
32
33 const PREFIX = 'fcal_mcp_slot_';
34
35 // Lock granularity in seconds: the finest slot interval the plugin offers.
36 const BUCKET = 900;
37
38 // A day of buckets, so a bad end time can't become an unbounded insert loop.
39 const MAX_BUCKETS = 96;
40
41 /**
42 * Take the lock for one slot, or return false when someone else holds it.
43 *
44 * @param int $eventId
45 * @param string $startTimeUtc 'Y-m-d H:i:s'
46 * @param int|null $hostId null on single-host events
47 *
48 * @return string|false the lock key to pass to release(), or false
49 */
50 public static function acquire($eventId, $startTimeUtc, $hostId = null)
51 {
52 global $wpdb;
53
54 $key = self::key($eventId, $startTimeUtc, $hostId);
55 $owner = wp_generate_password(20, false, false);
56
57 // Read the stored bytes, not get_option()'s unserialized (and possibly
58 // cached) copy: the steal below deletes by exact value.
59 $existing = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
60 $wpdb->prepare(
61 "SELECT `option_value` FROM `{$wpdb->options}` WHERE `option_name` = %s",
62 $key
63 )
64 );
65
66 $record = ($existing === null) ? null : maybe_unserialize($existing);
67
68 // Steal an expired lock left by a request that died mid-write. Deleting
69 // by the value just read stops a slower racer from deleting the
70 // winner's fresh row.
71 if (is_array($record) && !empty($record['expires']) && $record['expires'] < time()) {
72 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
73 $wpdb->prepare(
74 "DELETE FROM `{$wpdb->options}` WHERE `option_name` = %s AND `option_value` = %s",
75 $key,
76 $existing
77 )
78 );
79
80 wp_cache_delete($key, 'options');
81 }
82
83 // Same primitive as WP_Upgrader::create_lock. Not add_option(): its ON
84 // DUPLICATE KEY UPDATE reports success even when the row already existed.
85 $inserted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
86 $wpdb->prepare(
87 "INSERT IGNORE INTO `{$wpdb->options}` (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, 'no')",
88 $key,
89 maybe_serialize(['expires' => time() + self::TTL, 'owner' => $owner])
90 )
91 );
92
93 // The row went in behind the options cache's back.
94 wp_cache_delete($key, 'options');
95
96 $notoptions = wp_cache_get('notoptions', 'options');
97
98 if (is_array($notoptions) && isset($notoptions[$key])) {
99 unset($notoptions[$key]);
100 wp_cache_set('notoptions', $notoptions, 'options');
101 }
102
103 if (!$inserted) {
104 return false;
105 }
106
107 // The owner in the handle lets release() prove it holds this lock and
108 // not a successor's.
109 return $key . '|' . $owner;
110 }
111
112 /**
113 * Claim one instant for every host a booking would occupy, all or nothing.
114 *
115 * A collective event books each of its hosts. Partial claims are released
116 * before returning false.
117 *
118 * @param int $eventId
119 * @param string $startTimeUtc
120 * @param array $hostIds
121 *
122 * @return array|false handles to pass to releaseAll(), or false
123 */
124 public static function acquireAll($eventId, $startTimeUtc, $hostIds)
125 {
126 $handles = [];
127
128 foreach (array_unique(array_map('intval', (array) $hostIds)) as $hostId) {
129 $handle = self::acquire($eventId, $startTimeUtc, $hostId);
130
131 if (!$handle) {
132 self::releaseAll($handles);
133
134 return false;
135 }
136
137 $handles[] = $handle;
138 }
139
140 return $handles;
141 }
142
143 /**
144 * Claim every bucket a booking would occupy, for every host it would
145 * occupy, all or nothing.
146 *
147 * Overlapping bookings with different starts (10:00 for 30 min, 10:15 for
148 * 15) always share a bucket, so they collide. Abutting ones don't.
149 *
150 * @param int $eventId
151 * @param string $startTimeUtc 'Y-m-d H:i:s'
152 * @param string $endTimeUtc 'Y-m-d H:i:s'
153 * @param array $hostIds
154 *
155 * @return array|false handles to pass to releaseAll(), or false
156 */
157 public static function acquireInterval($eventId, $startTimeUtc, $endTimeUtc, $hostIds)
158 {
159 $buckets = self::buckets($startTimeUtc, $endTimeUtc);
160
161 if (!$buckets) {
162 return false;
163 }
164
165 $handles = [];
166
167 foreach (array_unique(array_map('intval', (array) $hostIds)) as $hostId) {
168 foreach ($buckets as $bucket) {
169 $handle = self::acquire($eventId, $bucket, $hostId);
170
171 if (!$handle) {
172 self::releaseAll($handles);
173
174 return false;
175 }
176
177 $handles[] = $handle;
178 }
179 }
180
181 return $handles;
182 }
183
184 /**
185 * Bucket starts an interval touches. Half open, so a booking ending on a
186 * boundary doesn't claim the next bucket.
187 *
188 * @return array of 'Y-m-d H:i:s'
189 */
190 private static function buckets($startTimeUtc, $endTimeUtc)
191 {
192 $start = strtotime($startTimeUtc . ' UTC');
193 $end = strtotime($endTimeUtc . ' UTC');
194
195 if (!$start) {
196 return [];
197 }
198
199 if (!$end || $end <= $start) {
200 $end = $start + 1;
201 }
202
203 $buckets = [];
204
205 for ($t = $start - ($start % self::BUCKET); $t < $end; $t += self::BUCKET) {
206 $buckets[] = gmdate('Y-m-d H:i:s', $t); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
207
208 if (count($buckets) >= self::MAX_BUCKETS) {
209 break;
210 }
211 }
212
213 return $buckets;
214 }
215
216 /**
217 * @param array|string|false $handles
218 *
219 * @return bool whether every lease is still held
220 */
221 public static function renewAll($handles)
222 {
223 foreach ((array) $handles as $handle) {
224 if (!self::renew($handle)) {
225 return false;
226 }
227 }
228
229 return true;
230 }
231
232 /**
233 * @param array|string|false $handles
234 */
235 public static function releaseAll($handles)
236 {
237 foreach ((array) $handles as $handle) {
238 self::release($handle);
239 }
240 }
241
242 /**
243 * Re-assert a lease this caller still owns, pushing its expiry out.
244 *
245 * isSpotAvailable() can make a live FreeBusy call per calendar per host,
246 * which on a team event can outlast TTL, and acquire() steals expired
247 * leases. Renewing before the write keeps the lock held.
248 *
249 * Matches the exact stored bytes, so a caller whose lease was stolen gets
250 * false instead of overwriting the new owner.
251 *
252 * @param string|false $handle the value returned by acquire()
253 *
254 * @return bool whether the caller still holds the lock
255 */
256 public static function renew($handle)
257 {
258 global $wpdb;
259
260 if (!$handle || strpos($handle, '|') === false) {
261 return false;
262 }
263
264 list($key, $owner) = explode('|', $handle, 2);
265
266 $existing = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
267 $wpdb->prepare(
268 "SELECT `option_value` FROM `{$wpdb->options}` WHERE `option_name` = %s",
269 $key
270 )
271 );
272
273 if ($existing === null) {
274 return false;
275 }
276
277 $record = maybe_unserialize($existing);
278
279 if (!is_array($record) || !isset($record['owner']) || !hash_equals((string) $record['owner'], $owner)) {
280 return false;
281 }
282
283 $renewed = maybe_serialize(['expires' => time() + self::TTL, 'owner' => $owner]);
284
285 $updated = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
286 $wpdb->prepare(
287 "UPDATE `{$wpdb->options}` SET `option_value` = %s WHERE `option_name` = %s AND `option_value` = %s",
288 $renewed,
289 $key,
290 $existing
291 )
292 );
293
294 wp_cache_delete($key, 'options');
295
296 // MySQL counts changed rows, not matched ones, so a renew within the
297 // same second reports zero even though the row is still ours.
298 return $updated ? true : ($renewed === $existing);
299 }
300
301 /**
302 * Release a lock this caller actually holds.
303 *
304 * A lease can expire during a slow booking hook and be taken by another
305 * request; a plain delete_option() would then free the successor's lock.
306 * The delete matches the exact value checked, so a lapsed owner's delete
307 * matches nothing.
308 *
309 * @param string|false $handle the value returned by acquire()
310 */
311 public static function release($handle)
312 {
313 global $wpdb;
314
315 if (!$handle || strpos($handle, '|') === false) {
316 return;
317 }
318
319 list($key, $owner) = explode('|', $handle, 2);
320
321 $existing = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
322 $wpdb->prepare(
323 "SELECT `option_value` FROM `{$wpdb->options}` WHERE `option_name` = %s",
324 $key
325 )
326 );
327
328 if ($existing === null) {
329 return;
330 }
331
332 $record = maybe_unserialize($existing);
333
334 if (!is_array($record) || !isset($record['owner']) || !hash_equals((string) $record['owner'], $owner)) {
335 return;
336 }
337
338 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
339 $wpdb->prepare(
340 "DELETE FROM `{$wpdb->options}` WHERE `option_name` = %s AND `option_value` = %s",
341 $key,
342 $existing
343 )
344 );
345
346 wp_cache_delete($key, 'options');
347 }
348
349 /**
350 * Keyed on the host, since the person is the constrained resource: it blocks
351 * one host double-booked via two event types, while two team hosts can
352 * still take the same minute. Round robin has no host until
353 * isSpotAvailable() picks one, so it keys on the event and the caller takes
354 * a host-keyed lock afterwards.
355 *
356 * @return string
357 */
358 private static function key($eventId, $startTimeUtc, $hostId)
359 {
360 $scope = $hostId ? 'h' . (int) $hostId : 'e' . (int) $eventId;
361
362 return self::PREFIX . md5($scope . '|' . $startTimeUtc);
363 }
364 }
365