PluginProbe
Booking Calendar / 11.9
Booking Calendar v11.9
11.9 11.8.4 11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 All 205 releases
booking / includes / _capacity / booking_availability_guard.php

booking_availability_guard.php in Booking Calendar 11.9, at includes/_capacity/booking_availability_guard.php

312 lines 11.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Booking availability concurrency guard.
4 *
5 * @package Booking Calendar
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 /**
13 * Classify the active WordPress database implementation for booking locking.
14 *
15 * SQLite must never receive MySQL advisory-lock statements. Custom database
16 * drop-ins are classified separately because a SELECT-based advisory lock may
17 * be routed to a read replica while booking writes use another connection.
18 *
19 * @param object $database Active WordPress database object.
20 * @param mixed $database_engine Current DB_ENGINE value, expected to be a string.
21 * @param mixed $legacy_database_type Current legacy DATABASE_TYPE value, expected to be a string.
22 * @param bool $has_sqlite_dropin Whether the official SQLite drop-in marker is defined.
23 *
24 * @return string One of `sqlite`, `core_mysql`, or `custom`.
25 */
26 function wpbc_booking_availability_guard_classify_database( $database, $database_engine = '', $legacy_database_type = '', $has_sqlite_dropin = false ) {
27 $database_class = is_object( $database ) ? strtolower( get_class( $database ) ) : '';
28 $database_engine = is_scalar( $database_engine ) ? strtolower( trim( (string) $database_engine ) ) : '';
29 $legacy_database_type = is_scalar( $legacy_database_type ) ? strtolower( trim( (string) $legacy_database_type ) ) : '';
30 $is_sqlite = ( 'sqlite' === $database_engine )
31 || ( 'sqlite' === $legacy_database_type )
32 || $has_sqlite_dropin
33 || ( false !== strpos( $database_class, 'sqlite' ) );
34
35 if ( $is_sqlite ) {
36 return 'sqlite';
37 }
38
39 return ( 'wpdb' === $database_class ) ? 'core_mysql' : 'custom';
40 }
41
42 /**
43 * Return the active database classification used by the availability guard.
44 *
45 * @return string One of `sqlite`, `core_mysql`, or `custom`.
46 */
47 function wpbc_booking_availability_guard_get_database_classification() {
48 global $wpdb;
49
50 $database_engine = defined( 'DB_ENGINE' ) ? DB_ENGINE : '';
51 $legacy_database_type = defined( 'DATABASE_TYPE' ) ? DATABASE_TYPE : '';
52 $has_sqlite_dropin = defined( 'SQLITE_DB_DROPIN_VERSION' )
53 || ( defined( 'WP_SQLITE_AST_DRIVER' ) && WP_SQLITE_AST_DRIVER );
54
55 return wpbc_booking_availability_guard_classify_database( $wpdb, $database_engine, $legacy_database_type, $has_sqlite_dropin );
56 }
57
58 /**
59 * Select the concurrency-guard mode for the active database implementation.
60 *
61 * SQLite is always bypassed before filters run so MySQL lock SQL cannot be
62 * enabled accidentally on WordPress Playground or another SQLite site.
63 * Custom database drop-ins bypass the guard by default. A host may opt a
64 * custom MySQL/MariaDB adapter into `mysql_named_lock` only after guaranteeing
65 * that lock, availability, and write queries use the same primary server and
66 * connection.
67 *
68 * @return string Either `mysql_named_lock` or `bypass`.
69 */
70 function wpbc_booking_availability_guard_get_mode() {
71 global $wpdb;
72
73 $database_classification = wpbc_booking_availability_guard_get_database_classification();
74 if ( 'sqlite' === $database_classification ) {
75 return 'bypass';
76 }
77
78 $default_mode = ( 'core_mysql' === $database_classification ) ? 'mysql_named_lock' : 'bypass';
79
80 /**
81 * Filters the booking availability guard mode for non-SQLite databases.
82 *
83 * Custom adapters must opt in only when advisory-lock and booking queries
84 * are guaranteed to use the same primary database server and connection.
85 *
86 * @param string $default_mode Default `mysql_named_lock` or `bypass` mode.
87 * @param string $database_classification Database classification.
88 * @param object $wpdb Active WordPress database object.
89 */
90 $guard_mode = apply_filters( 'wpbc_booking_availability_guard_mode', $default_mode, $database_classification, $wpdb );
91
92 return ( 'mysql_named_lock' === $guard_mode ) ? 'mysql_named_lock' : 'bypass';
93 }
94
95 /**
96 * Build the server-wide advisory-lock name for the current WordPress site.
97 *
98 * The hashed database name and table prefix keep multisite locks independent
99 * and ensure the complete name remains below MySQL's 64-character limit.
100 *
101 * @return string Stable advisory-lock name.
102 */
103 function wpbc_booking_availability_guard_get_lock_name() {
104 global $wpdb;
105
106 $database_name = defined( 'DB_NAME' ) ? DB_NAME : '';
107 $table_prefix = isset( $wpdb->prefix ) ? $wpdb->prefix : '';
108
109 return 'wpbc_booking_' . md5( $database_name . '|' . $table_prefix );
110 }
111
112 /**
113 * Run one scalar database query without exposing an expected capability error.
114 *
115 * The previous WordPress database error-suppression state is restored before
116 * returning. An exception or database error is represented as `null`; callers
117 * decide whether to bypass or reject the guarded operation.
118 *
119 * @param string $query Prepared SQL query.
120 *
121 * @return mixed|null Scalar result, or null when the query could not run.
122 */
123 function wpbc_booking_availability_guard_get_var( $query ) {
124 global $wpdb;
125
126 $previous_suppression = null;
127 $has_last_error = is_object( $wpdb ) && property_exists( $wpdb, 'last_error' );
128 $previous_last_error = $has_last_error ? $wpdb->last_error : null;
129 if ( is_object( $wpdb ) && method_exists( $wpdb, 'suppress_errors' ) ) {
130 $previous_suppression = $wpdb->suppress_errors( true );
131 }
132
133 try {
134 $query_result = $wpdb->get_var( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared advisory-lock control query.
135 } catch ( Exception $exception ) {
136 $query_result = null;
137 } catch ( Throwable $throwable ) {
138 $query_result = null;
139 } finally {
140 if ( null !== $previous_suppression ) {
141 $wpdb->suppress_errors( $previous_suppression );
142 }
143 if ( $has_last_error ) {
144 $wpdb->last_error = $previous_last_error;
145 }
146 }
147
148 return $query_result;
149 }
150
151 /**
152 * Return the visitor-safe error used when a supported advisory lock is busy.
153 *
154 * @return WP_Error Retryable booking-save error.
155 */
156 function wpbc_booking_availability_guard_get_busy_error() {
157 return new WP_Error(
158 'booking_availability_guard_busy',
159 __( 'Another booking is being completed for this calendar. Please try again in a few seconds.', 'booking' )
160 );
161 }
162
163 /**
164 * Acquire the current site's booking availability guard.
165 *
166 * Unsupported databases deliberately return a bypass token so booking
167 * creation remains available. A timeout on a supported lock returns a
168 * retryable error because proceeding concurrently would defeat the guard.
169 * Nested acquisitions in the same request use a depth counter and do not call
170 * GET_LOCK() recursively.
171 *
172 * @param int $wait_seconds Maximum number of seconds to wait for the lock.
173 *
174 * @return array|WP_Error Guard token, or a retryable busy error.
175 */
176 function wpbc_booking_availability_guard_acquire( $wait_seconds = 5 ) {
177 global $wpdb;
178
179 $database_classification = wpbc_booking_availability_guard_get_database_classification();
180 $guard_mode = wpbc_booking_availability_guard_get_mode();
181 if ( 'mysql_named_lock' !== $guard_mode ) {
182 return array(
183 'mode' => 'bypass',
184 'reason' => $database_classification,
185 'lock_name' => '',
186 );
187 }
188
189 $lock_name = wpbc_booking_availability_guard_get_lock_name();
190 if ( ! isset( $GLOBALS['wpbc_booking_availability_guard_locks'] ) || ! is_array( $GLOBALS['wpbc_booking_availability_guard_locks'] ) ) {
191 $GLOBALS['wpbc_booking_availability_guard_locks'] = array();
192 }
193
194 if ( ! empty( $GLOBALS['wpbc_booking_availability_guard_locks'][ $lock_name ]['depth'] ) ) {
195 $nested_guard = array(
196 'mode' => 'mysql_named_lock',
197 'reason' => 'nested',
198 'lock_name' => $lock_name,
199 );
200 if ( wpbc_booking_availability_guard_is_owned( $nested_guard ) ) {
201 ++$GLOBALS['wpbc_booking_availability_guard_locks'][ $lock_name ]['depth'];
202
203 return $nested_guard;
204 }
205
206 unset( $GLOBALS['wpbc_booking_availability_guard_locks'][ $lock_name ] );
207 }
208
209 $wait_seconds = max( 0, min( 10, absint( $wait_seconds ) ) );
210 $lock_query = $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $lock_name, $wait_seconds );
211 $lock_result = wpbc_booking_availability_guard_get_var( $lock_query );
212
213 if ( '1' === (string) $lock_result ) {
214 $GLOBALS['wpbc_booking_availability_guard_locks'][ $lock_name ] = array( 'depth' => 1 );
215
216 return array(
217 'mode' => 'mysql_named_lock',
218 'reason' => 'acquired',
219 'lock_name' => $lock_name,
220 );
221 }
222
223 if ( '0' === (string) $lock_result ) {
224 return wpbc_booking_availability_guard_get_busy_error();
225 }
226
227 return array(
228 'mode' => 'bypass',
229 'reason' => 'named_lock_unavailable',
230 'lock_name' => '',
231 );
232 }
233
234 /**
235 * Determine whether a guard token represents an active named lock.
236 *
237 * @param array $guard_token Guard token returned by the acquire function.
238 *
239 * @return bool True for an active named-lock token.
240 */
241 function wpbc_booking_availability_guard_is_active( $guard_token ) {
242 return is_array( $guard_token )
243 && isset( $guard_token['mode'], $guard_token['lock_name'] )
244 && 'mysql_named_lock' === $guard_token['mode']
245 && '' !== $guard_token['lock_name'];
246 }
247
248 /**
249 * Confirm that the current database connection still owns the named lock.
250 *
251 * Bypass tokens return true because no database lock is required. Active
252 * tokens are checked immediately before persistence to detect a WordPress
253 * database reconnection that implicitly released the session lock.
254 *
255 * @param array $guard_token Guard token returned by the acquire function.
256 *
257 * @return bool True when persistence may proceed under this token.
258 */
259 function wpbc_booking_availability_guard_is_owned( $guard_token ) {
260 global $wpdb;
261
262 if ( ! wpbc_booking_availability_guard_is_active( $guard_token ) ) {
263 return true;
264 }
265
266 $lock_name = $guard_token['lock_name'];
267 if ( empty( $GLOBALS['wpbc_booking_availability_guard_locks'][ $lock_name ]['depth'] ) ) {
268 return false;
269 }
270
271 $ownership_query = $wpdb->prepare( 'SELECT IS_USED_LOCK(%s) = CONNECTION_ID()', $lock_name );
272 $ownership_result = wpbc_booking_availability_guard_get_var( $ownership_query );
273
274 return '1' === (string) $ownership_result;
275 }
276
277 /**
278 * Release one acquisition depth for a booking availability guard.
279 *
280 * The outermost release executes RELEASE_LOCK(). The in-memory state is always
281 * removed even when the database connection has already terminated, because
282 * MySQL/MariaDB release session locks automatically on disconnect.
283 *
284 * @param array $guard_token Guard token returned by the acquire function.
285 *
286 * @return bool True when no release was required or the lock was released.
287 */
288 function wpbc_booking_availability_guard_release( $guard_token ) {
289 global $wpdb;
290
291 if ( ! wpbc_booking_availability_guard_is_active( $guard_token ) ) {
292 return true;
293 }
294
295 $lock_name = $guard_token['lock_name'];
296 if ( empty( $GLOBALS['wpbc_booking_availability_guard_locks'][ $lock_name ]['depth'] ) ) {
297 return false;
298 }
299
300 if ( 1 < $GLOBALS['wpbc_booking_availability_guard_locks'][ $lock_name ]['depth'] ) {
301 --$GLOBALS['wpbc_booking_availability_guard_locks'][ $lock_name ]['depth'];
302
303 return true;
304 }
305
306 unset( $GLOBALS['wpbc_booking_availability_guard_locks'][ $lock_name ] );
307 $release_query = $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name );
308 $release_result = wpbc_booking_availability_guard_get_var( $release_query );
309
310 return '1' === (string) $release_result;
311 }
312