prefix ) ? $wpdb->prefix : ''; return 'wpbc_booking_' . md5( $database_name . '|' . $table_prefix ); } /** * Run one scalar database query without exposing an expected capability error. * * The previous WordPress database error-suppression state is restored before * returning. An exception or database error is represented as `null`; callers * decide whether to bypass or reject the guarded operation. * * @param string $query Prepared SQL query. * * @return mixed|null Scalar result, or null when the query could not run. */ function wpbc_booking_availability_guard_get_var( $query ) { global $wpdb; $previous_suppression = null; $has_last_error = is_object( $wpdb ) && property_exists( $wpdb, 'last_error' ); $previous_last_error = $has_last_error ? $wpdb->last_error : null; if ( is_object( $wpdb ) && method_exists( $wpdb, 'suppress_errors' ) ) { $previous_suppression = $wpdb->suppress_errors( true ); } try { $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. } catch ( Exception $exception ) { $query_result = null; } catch ( Throwable $throwable ) { $query_result = null; } finally { if ( null !== $previous_suppression ) { $wpdb->suppress_errors( $previous_suppression ); } if ( $has_last_error ) { $wpdb->last_error = $previous_last_error; } } return $query_result; } /** * Return the visitor-safe error used when a supported advisory lock is busy. * * @return WP_Error Retryable booking-save error. */ function wpbc_booking_availability_guard_get_busy_error() { return new WP_Error( 'booking_availability_guard_busy', __( 'Another booking is being completed for this calendar. Please try again in a few seconds.', 'booking' ) ); } /** * Acquire the current site's booking availability guard. * * Unsupported databases deliberately return a bypass token so booking * creation remains available. A timeout on a supported lock returns a * retryable error because proceeding concurrently would defeat the guard. * Nested acquisitions in the same request use a depth counter and do not call * GET_LOCK() recursively. * * @param int $wait_seconds Maximum number of seconds to wait for the lock. * * @return array|WP_Error Guard token, or a retryable busy error. */ function wpbc_booking_availability_guard_acquire( $wait_seconds = 5 ) { global $wpdb; $database_classification = wpbc_booking_availability_guard_get_database_classification(); $guard_mode = wpbc_booking_availability_guard_get_mode(); if ( 'mysql_named_lock' !== $guard_mode ) { return array( 'mode' => 'bypass', 'reason' => $database_classification, 'lock_name' => '', ); } $lock_name = wpbc_booking_availability_guard_get_lock_name(); if ( ! isset( $GLOBALS['wpbc_booking_availability_guard_locks'] ) || ! is_array( $GLOBALS['wpbc_booking_availability_guard_locks'] ) ) { $GLOBALS['wpbc_booking_availability_guard_locks'] = array(); } if ( ! empty( $GLOBALS['wpbc_booking_availability_guard_locks'][ $lock_name ]['depth'] ) ) { $nested_guard = array( 'mode' => 'mysql_named_lock', 'reason' => 'nested', 'lock_name' => $lock_name, ); if ( wpbc_booking_availability_guard_is_owned( $nested_guard ) ) { ++$GLOBALS['wpbc_booking_availability_guard_locks'][ $lock_name ]['depth']; return $nested_guard; } unset( $GLOBALS['wpbc_booking_availability_guard_locks'][ $lock_name ] ); } $wait_seconds = max( 0, min( 10, absint( $wait_seconds ) ) ); $lock_query = $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $lock_name, $wait_seconds ); $lock_result = wpbc_booking_availability_guard_get_var( $lock_query ); if ( '1' === (string) $lock_result ) { $GLOBALS['wpbc_booking_availability_guard_locks'][ $lock_name ] = array( 'depth' => 1 ); return array( 'mode' => 'mysql_named_lock', 'reason' => 'acquired', 'lock_name' => $lock_name, ); } if ( '0' === (string) $lock_result ) { return wpbc_booking_availability_guard_get_busy_error(); } return array( 'mode' => 'bypass', 'reason' => 'named_lock_unavailable', 'lock_name' => '', ); } /** * Determine whether a guard token represents an active named lock. * * @param array $guard_token Guard token returned by the acquire function. * * @return bool True for an active named-lock token. */ function wpbc_booking_availability_guard_is_active( $guard_token ) { return is_array( $guard_token ) && isset( $guard_token['mode'], $guard_token['lock_name'] ) && 'mysql_named_lock' === $guard_token['mode'] && '' !== $guard_token['lock_name']; } /** * Confirm that the current database connection still owns the named lock. * * Bypass tokens return true because no database lock is required. Active * tokens are checked immediately before persistence to detect a WordPress * database reconnection that implicitly released the session lock. * * @param array $guard_token Guard token returned by the acquire function. * * @return bool True when persistence may proceed under this token. */ function wpbc_booking_availability_guard_is_owned( $guard_token ) { global $wpdb; if ( ! wpbc_booking_availability_guard_is_active( $guard_token ) ) { return true; } $lock_name = $guard_token['lock_name']; if ( empty( $GLOBALS['wpbc_booking_availability_guard_locks'][ $lock_name ]['depth'] ) ) { return false; } $ownership_query = $wpdb->prepare( 'SELECT IS_USED_LOCK(%s) = CONNECTION_ID()', $lock_name ); $ownership_result = wpbc_booking_availability_guard_get_var( $ownership_query ); return '1' === (string) $ownership_result; } /** * Release one acquisition depth for a booking availability guard. * * The outermost release executes RELEASE_LOCK(). The in-memory state is always * removed even when the database connection has already terminated, because * MySQL/MariaDB release session locks automatically on disconnect. * * @param array $guard_token Guard token returned by the acquire function. * * @return bool True when no release was required or the lock was released. */ function wpbc_booking_availability_guard_release( $guard_token ) { global $wpdb; if ( ! wpbc_booking_availability_guard_is_active( $guard_token ) ) { return true; } $lock_name = $guard_token['lock_name']; if ( empty( $GLOBALS['wpbc_booking_availability_guard_locks'][ $lock_name ]['depth'] ) ) { return false; } if ( 1 < $GLOBALS['wpbc_booking_availability_guard_locks'][ $lock_name ]['depth'] ) { --$GLOBALS['wpbc_booking_availability_guard_locks'][ $lock_name ]['depth']; return true; } unset( $GLOBALS['wpbc_booking_availability_guard_locks'][ $lock_name ] ); $release_query = $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name ); $release_result = wpbc_booking_availability_guard_get_var( $release_query ); return '1' === (string) $release_result; }