2026-03-27 15:53:29 +00:00

504 lines
15 KiB
PHP

<?php
/**
* Simply Schedule Appointments Availability Cache.
*
* @since 4.0.1
* @package Simply_Schedule_Appointments
*/
use League\Period\Period;
/**
* Simply Schedule Appointments Availability Cache.
*
* @since 4.0.1
*/
class SSA_Availability_Cache {
/**
* Parent plugin class.
*
* @since 4.0.1
*
* @var Simply_Schedule_Appointments
*/
protected $plugin = null;
const CACHE_MODE_DISABLED = 0;
const CACHE_MODE_ENABLED = 10;
/**
* Constructor.
*
* @since 4.0.1
*
* @param Simply_Schedule_Appointments $plugin Main plugin object.
*/
public function __construct( $plugin ) {
$this->plugin = $plugin;
$this->hooks();
}
/**
* Initiate our hooks.
*
* @since 4.0.1
*/
public function hooks() {
// add_action( 'ssa_populate_cache', array( $this, 'async_process_populate_cache' ), 10, 2 );
}
public function generate_cache_key( $args ) {
$args = $this->get_args( $args );
$args['start_date'] = '1970-01-01';
$args['end_date'] = '1970-01-01';
$availability_id = $this->plugin->availability_model->insert( $args );
$obj_cache_key = 'args:'.$args['cache_args_hash'].'/latest_cache_key';
ssa_cache_set( $obj_cache_key, $availability_id );
return $availability_id;
}
public function get_cache_args_hash( $args ) {
return ssa_int_hash( json_encode( $args ) );
}
public function get_args( $args ) {
$args = shortcode_atts( array(
'appointment_type_id' => 0,
'appointment_id' => 0,
'staff_id' => 0,
'resource_group_id' => 0,
'type' => '',
'subtype' => '',
'skip_appointment_id' => '',
'cache_key' => '',
'cache_args_hash' => '',
'cache_force' => false,
'cache_level_read' => 1,
'cache_level_write' => 1,
), $args );
$args_to_hash = $args;
$args_to_hash['cache_key'] = '';
$args_to_hash['cache_args_hash'] = '';
$args_to_hash['cache_level_read'] = '';
$args_to_hash['cache_level_write'] = '';
$args_to_hash['cache_force'] = false;
$args['cache_args_hash'] = $this->get_cache_args_hash( $args_to_hash );
return $args;
}
public function get_cache_mode() {
$developer_settings = $this->plugin->developer_settings->get();
if ( ! empty( $developer_settings['disable_availability_caching'] ) ) {
return self::CACHE_MODE_DISABLED;
}
return self::CACHE_MODE_ENABLED;
}
public function is_cache_mode( $mode ) {
return $mode === $this->get_cache_mode();
}
public function is_enabled() {
return ! $this->is_cache_mode( self::CACHE_MODE_DISABLED );
}
public function get_latest_cache_key( $args ) {
$args = $this->get_args( $args );
$obj_cache_key = 'args:'.$args['cache_args_hash'].'/latest_cache_key';
$latest_cache_key = ssa_cache_get( $obj_cache_key );
if ( false !== $latest_cache_key ) {
return $latest_cache_key;
}
global $wpdb;
$sql = 'SELECT cache_key FROM '.$this->plugin->availability_model->get_table_name().' WHERE cache_args_hash=%d ORDER BY cache_key DESC LIMIT 1';
$sql = $wpdb->prepare(
$sql,
$args['cache_args_hash']
);
$latest_cache_key = $wpdb->get_row( $sql, ARRAY_A );
$latest_cache_key = $latest_cache_key['cache_key'];
ssa_cache_set( $obj_cache_key, $latest_cache_key );
return $latest_cache_key;
}
public function query( SSA_Appointment_Type_Object $appointment_type, Period $query_period, $args ) {
if ( ! $this->is_enabled() ) {
return;
}
if ( empty( $args['cache_level_read'] ) || $args['cache_level_read'] > 2 ) {
return;
}
$appointment_type_id = ( empty( $appointment_type ) ) ? 0 : $appointment_type->id;
if ( ! empty( $appointment_type_id ) || empty( $args['appointment_type_id'] ) ) {
$args['appointment_type_id'] = $appointment_type_id;
}
$args = $this->get_args( $args );
$query_args = array(
'number' => -1,
'cache_args_hash' => $args['cache_args_hash'],
'intersects_period' => $query_period,
'order' => 'ASC',
'orderby' => 'start_date',
);
// $latest_cache_key = $this->get_latest_cache_key( $args );
// if ( ! empty( $latest_cache_key ) ) {
// $query_args['cache_key'] = $latest_cache_key;
// }
$availability_rows = $this->plugin->availability_model->query( $query_args );
$schedule = new SSA_Availability_Schedule();
$availability_blocks = array();
foreach ($availability_rows as $availability_row) {
if ( $availability_row['start_date'] > $availability_row['end_date'] ) {
ssa_debug_log( 'availability-cache query()', 100 );
ssa_debug_log( 'Invalid Period returned in query', 100 );
$this->plugin->availability_model->truncate();
return;
}
unset( $availability_row['id'] );
$start_date = $availability_row['start_date'];
$availability_row = shortcode_atts( array(
'capacity_available' => '',
'capacity_reserved' => '',
'buffer_available' => '',
'buffer_reserved' => '',
'period' => new Period(
$availability_row['start_date'],
$availability_row['end_date']
),
), $availability_row );
$availability_block = SSA_Availability_Block_Factory::create( $availability_row );
$availability_blocks[$start_date] = $availability_block;
}
$schedule = $schedule->set_blocks( $availability_blocks, true ); // added for performance October 19, 2023
// $schedule = $schedule->pushmerge( $availability_blocks ); // removed October 19, 2023
if ( $schedule->is_empty() ) {
return;
}
$boundaries = $schedule->boundaries();
if ( empty( $boundaries ) || ! $boundaries instanceof Period ) {
return;
}
if ( ! $boundaries->contains( $query_period ) ) {
return;
}
if ( ! $schedule->is_continuous() ) {
return;
}
return $schedule;
}
private function insert( SSA_Availability_Block $block, $args = array() ) {
if ( ! $this->is_enabled() ) {
return;
}
// $args = $this->get_args( $args ); // <--- not needed if insert() remains a private function
$args = array_merge( $args, array(
'start_date' => $block->get_period()->getStartDate()->format( 'Y-m-d H:i:s' ),
'end_date' => $block->get_period()->getEndDate()->format( 'Y-m-d H:i:s' ),
'capacity_reserved' => $block->capacity_reserved,
'capacity_available' => $block->capacity_available,
) );
$availability_id = $this->plugin->availability_model->db_insert( $args );
}
public function insert_schedule( SSA_Availability_Schedule $schedule, $args = array() ) {
if ( ! $this->is_enabled() ) {
return;
}
if ( empty( $args['cache_level_write'] ) || $args['cache_level_write'] > 2 ) {
return;
}
$args = $this->get_args( $args );
unset( $args['cache_level_read'] );
unset( $args['cache_level_write'] );
$args['cache_key'] = $this->generate_cache_key( $args );
$availability_rows = array();
foreach ($schedule->get_blocks() as $block) {
$availability_rows[] = array_merge( $args, array(
'start_date' => $block->get_period()->getStartDate()->format( 'Y-m-d H:i:s' ),
'end_date' => $block->get_period()->getEndDate()->format( 'Y-m-d H:i:s' ),
'capacity_reserved' => $block->capacity_reserved,
'capacity_available' => $block->capacity_available,
'buffer_reserved' => $block->buffer_reserved,
'buffer_available' => $block->buffer_available,
) );
}
$this->plugin->availability_model->db_bulk_insert( $availability_rows );
$this->delete_schedule( $schedule, $args['cache_args_hash'], $args['cache_key'] );
}
public function delete_schedule( SSA_Availability_Schedule $schedule, $cache_args_hash, $below_this_cache_key = null ) {
$boundaries = $schedule->boundaries();
if ( empty( $boundaries ) ) {
return;
}
global $wpdb;
$start_date_string = $boundaries->getStartDate()->format( 'Y-m-d H:i:s' );
$end_date_string = $boundaries->getEndDate()->format( 'Y-m-d H:i:s' );
$sql = 'DELETE FROM '.$this->plugin->availability_model->get_table_name()." WHERE cache_args_hash=%d AND (
(end_date > '{$start_date_string}' AND end_date < '{$end_date_string}' )
OR
(start_date < '{$end_date_string}' AND start_date > '{$start_date_string}' )
OR
(start_date < '{$start_date_string}' AND end_date > '{$end_date_string}' )
)"; // same as intersects_period, except we use < rather than <= (so we don't delete the neighboring availability). This will delete any availability cache that starts in the period OR ends in the period OR contains the entire period
$sql = $wpdb->prepare(
$sql, array(
$cache_args_hash,
)
);
if ( empty( $below_this_cache_key ) ) {
$this->wpdb_query_while_preventing_deadlock($sql);
return;
}
$sql .= $wpdb->prepare( ' AND cache_key < %d', $below_this_cache_key );
$this->wpdb_query_while_preventing_deadlock($sql);
$sql = 'DELETE FROM '.$this->plugin->availability_model->get_table_name().' WHERE id=%d';
$sql = $wpdb->prepare(
$sql,
$below_this_cache_key
);
$this->wpdb_query_while_preventing_deadlock( $sql );
}
private function wpdb_query_while_preventing_deadlock( $sql ) {
global $wpdb;
// This query can potentially be deadlocked if there is
// high concurrency, in which case DB will abort the query which has done less work to resolve deadlock.
// We will try up to 3 times before giving up.
for ($count = 0; $count < 10; $count++) {
$result = $wpdb->query( $sql ); // WPCS: unprepared SQL ok.
if ( false !== $result ) {
if( $count > 0 ) {
ssa_debug_log("Deadlock successfully resolved after trying " . ( $count + 1 ) . " times");
}
break;
}
// sleep 0, 1 or 2 seconds randomly
sleep( rand( 0, 2 ) );
}
if ( false === $result ) {
ssa_debug_log("Deadlock could not be resolved after trying 10 times. Query: " . $sql);
}
return $result;
}
public static function object_cache_get( $key, $group = '', $force = false, &$found = null ) {
if ( ! ssa()->availability_cache->is_enabled() ) {
return false;
}
$group .= SSA_Availability_Cache_Invalidation::get_cache_group();
$key = 'ssa/' . $group . '/' . $key;
return get_transient( $key );
}
public static function object_cache_set( $key, $data, $group = '', $expire = 0 ) {
if ( ! ssa()->availability_cache->is_enabled() ) {
return false;
}
$group .= SSA_Availability_Cache_Invalidation::get_cache_group();
$key = 'ssa/' . $group . '/' . $key;
if ( empty( $expire ) ) {
$expire = WEEK_IN_SECONDS;
}
set_transient( $key, $data, $expire );
return true;
}
public function remember_recent( $key, $value, $number_to_remember = 10 ) {
$key = 'ssa/recent_'.$key;
$recent_values = get_transient( $key );
if ( empty( $recent_values ) ) {
$recent_values = array();
}
$recent_values[] = $value;
if ( count( $recent_values ) > $number_to_remember ) {
array_shift( $recent_values );
}
set_transient( $key, $recent_values, MONTH_IN_SECONDS );
}
public function async_process_populate_cache( $payload, $async_action ) {
// this won't be called until re-enabled in hooks()
$start = microtime(true);
$this->populate_cache();
$end = microtime(true);
ssa_complete_action( $async_action['id'], 'Execution time: '.( $end - $start ) . 's' );
}
public function populate_cache() {
return; // disable
if ( ! $this->is_enabled() ) {
return;
}
$developer_settings = $this->plugin->developer_settings->get();
if ( empty( $developer_settings['populate_cache'] ) ) {
return;
}
$recent_availability_query_args = get_transient( 'ssa/recent_availability_query_args' );
if ( empty( $recent_availability_query_args ) ) {
return;
}
$recent_availability_query_args = array_reverse( $recent_availability_query_args ); // so start with the most recent queries
$hashes_to_process = array();
$availability_query_args_to_process = array();
foreach ($recent_availability_query_args as $value) {
if ( in_array( $value['query_hash'], $hashes_to_process ) ) {
continue;
}
$query_hash = $value['query_hash'];
$hashes_to_process[] = $query_hash;
set_transient( 'ssa/cache/lock_'.$query_hash, true, 30 );
unset( $value['query_hash'] );
$availability_query_args_to_process[$query_hash] = $value;
if ( count( $availability_query_args_to_process ) >= 5 ) {
break;
}
}
set_transient( 'ssa/cache/lock_global', false, 0 ); // once we've locked individual queries, unlock the global
foreach ($availability_query_args_to_process as $query_hash => $value) {
$appointment_type = new SSA_Appointment_Type_Object( $value['appointment_type_id'] );
$availability_query = new SSA_Availability_Query(
$appointment_type,
$value['period'],
$value['args']
);
$bookable_start_datetime_strings = $availability_query->get_bookable_appointment_start_datetime_strings();
set_transient( 'ssa/cache/lock_'.$query_hash, false, 5 );
}
}
public static function delete_expired_transients( $force_db = false ) {
global $wpdb;
if ( ! $force_db && wp_using_ext_object_cache() ) {
return;
}
$wpdb->query(
$wpdb->prepare(
"DELETE a, b FROM {$wpdb->options} a, {$wpdb->options} b
WHERE a.option_name LIKE %s
AND a.option_name NOT LIKE %s
AND b.option_name = CONCAT( '_transient_timeout_', SUBSTRING( a.option_name, 12 ) )
AND b.option_value < %d",
$wpdb->esc_like( '_transient_ssa/' ) . '%',
$wpdb->esc_like( '_transient_timeout_' ) . '%',
time()
)
);
if ( ! is_multisite() ) {
// Single site stores site transients in the options table.
$wpdb->query(
$wpdb->prepare(
"DELETE a, b FROM {$wpdb->options} a, {$wpdb->options} b
WHERE a.option_name LIKE %s
AND a.option_name NOT LIKE %s
AND b.option_name = CONCAT( '_site_transient_timeout_', SUBSTRING( a.option_name, 17 ) )
AND b.option_value < %d",
$wpdb->esc_like( '_site_transient_ssa/' ) . '%',
$wpdb->esc_like( '_site_transient_timeout_' ) . '%',
time()
)
);
} elseif ( is_multisite() && is_main_site() && is_main_network() ) {
// Multisite stores site transients in the sitemeta table.
$wpdb->query(
$wpdb->prepare(
"DELETE a, b FROM {$wpdb->sitemeta} a, {$wpdb->sitemeta} b
WHERE a.meta_key LIKE %s
AND a.meta_key NOT LIKE %s
AND b.meta_key = CONCAT( '_site_transient_timeout_', SUBSTRING( a.meta_key, 17 ) )
AND b.meta_value < %d",
$wpdb->esc_like( '_site_transient_ssa/' ) . '%',
$wpdb->esc_like( '_site_transient_timeout_' ) . '%',
time()
)
);
}
}
public static function delete_all_transients( $force_db = false ) {
global $wpdb;
if ( ! $force_db && wp_using_ext_object_cache() ) {
return;
}
$wpdb->query(
$wpdb->prepare(
"DELETE a FROM {$wpdb->options} a
WHERE (a.option_name LIKE %s
OR a.option_name LIKE %s)",
$wpdb->esc_like( '_transient_ssa/v' ) . '%',
$wpdb->esc_like( '_transient_timeout_ssa/v' ) . '%'
)
);
if ( ! is_multisite() ) {
// Single site stores site transients in the options table.
$wpdb->query(
$wpdb->prepare(
"DELETE a FROM {$wpdb->options} a
WHERE a.option_name LIKE %s
OR a.option_name LIKE %s",
$wpdb->esc_like( '_site_transient_ssa/v' ) . '%',
$wpdb->esc_like( '_site_transient_timeout_ssa/v' ) . '%'
)
);
} elseif ( is_multisite() && is_main_site() && is_main_network() ) {
// Multisite stores site transients in the sitemeta table.
$wpdb->query(
$wpdb->prepare(
"DELETE a FROM {$wpdb->sitemeta} a
WHERE a.meta_key LIKE %s
OR a.meta_key LIKE %s",
$wpdb->esc_like( '_site_transient_ssa/v' ) . '%',
$wpdb->esc_like( '_site_transient_timeout_ssa/v' ) . '%'
)
);
}
}
}