| @@ -18,10 +18,17 @@ | ||
| 18 | 18 | * Provides centralized cron management with locking and multisite execution control. |
| 19 | 19 | */ |
| 20 | 20 | class Network_Cron_Manager extends Component { |
| 21 | 21 | /** |
| 22 | - * Array of registered callbacks. | |
| 22 | + * Log file name constant. | |
| 23 | 23 | * |
| 24 | + * @var string | |
| 25 | + */ | |
| 26 | + public const LOG_FILE_NAME = 'network-cron-manager'; | |
| 27 | + | |
| 28 | + /** | |
| 29 | + * Array of registered hook configurations. | |
| 30 | + * | |
| 24 | 31 | * @var array |
| 25 | 32 | */ |
| 26 | 33 | private $callbacks = array(); |
| 27 | 34 | |
| @@ -51,16 +58,18 @@ | ||
| 51 | 58 | * |
| 52 | 59 | * Initializes the cron manager and hooks into WordPress. |
| 53 | 60 | */ |
| 54 | 61 | public function __construct() { |
| 55 | - add_action( 'init', array( $this, 'check_and_execute_callbacks' ), 20 ); | |
| 62 | + add_action( 'shutdown', array( $this, 'check_and_execute_callbacks' ), PHP_INT_MAX ); | |
| 56 | 63 | } |
| 57 | 64 | |
| 58 | 65 | /** |
| 59 | 66 | * Load callbacks from network options. |
| 60 | 67 | */ |
| 61 | - private function load_callbacks() { | |
| 62 | - $this->callbacks = get_network_option( get_main_network_id(), $this->callbacks_option, array() ); | |
| 68 | + public function load_callbacks() { | |
| 69 | + if ( array() === $this->callbacks ) { | |
| 70 | + $this->callbacks = get_network_option( get_main_network_id(), $this->callbacks_option, array() ); | |
| 71 | + } | |
| 63 | 72 | } |
| 64 | 73 | |
| 65 | 74 | /** |
| 66 | 75 | * Save callbacks to network options. |
| @@ -71,41 +80,140 @@ | ||
| 71 | 80 | |
| 72 | 81 | /** |
| 73 | 82 | * Register a callback for cron execution. |
| 74 | 83 | * |
| 75 | - * @param string $hook_name The hook name. | |
| 76 | - * @param callable $callback The callback function. | |
| 77 | - * @param int $interval_seconds The interval in seconds. | |
| 78 | - * @param array $args Arguments for the callback. | |
| 84 | + * Automatically handles both multisite and single-site setups: | |
| 85 | + * - For multisite: Uses Network Cron Manager execution with start_time support | |
| 86 | + * - For single-site: Uses WordPress native cron with proper scheduling | |
| 87 | + * | |
| 88 | + * @param string $hook_name The hook name. | |
| 89 | + * @param callable $callback The callback function. | |
| 90 | + * @param int $interval_seconds The interval in seconds. | |
| 91 | + * @param int|string|null $start_time When to start: timestamp, 'next Thursday', etc. Defaults to defender_get_current_time(). | |
| 92 | + * @param array $args Arguments for the callback. | |
| 93 | + * | |
| 79 | 94 | * @return bool|void False on validation failure, void on success. |
| 80 | 95 | */ |
| 81 | - public function register_callback( $hook_name, $callback, $interval_seconds, $args = array() ) { | |
| 96 | + public function register_callback( string $hook_name, callable $callback, int $interval_seconds, $start_time = null, array $args = array() ) { | |
| 82 | 97 | $hook_name = sanitize_key( $hook_name ); |
| 83 | - if ( empty( $hook_name ) || ! is_string( $hook_name ) ) { | |
| 98 | + if ( '' === $hook_name ) { | |
| 99 | + $this->log( 'Task registration failed: Invalid task name provided', self::LOG_FILE_NAME ); | |
| 84 | 100 | return false; |
| 85 | 101 | } |
| 86 | 102 | if ( ! is_callable( $callback ) ) { |
| 103 | + $this->log( "Task registration failed: Task '{$hook_name}' function cannot be called", self::LOG_FILE_NAME ); | |
| 87 | 104 | return false; |
| 88 | 105 | } |
| 89 | 106 | if ( ! is_numeric( $interval_seconds ) || $interval_seconds < 1 ) { |
| 107 | + $this->log( "Task registration failed: Task '{$hook_name}' has invalid run interval ({$interval_seconds} seconds)", self::LOG_FILE_NAME ); | |
| 90 | 108 | return false; |
| 91 | 109 | } |
| 92 | - $this->callbacks[ $hook_name ] = array( | |
| 93 | - 'callback' => $callback, | |
| 94 | - 'interval' => $interval_seconds, | |
| 95 | - 'args' => $args, | |
| 96 | - ); | |
| 97 | - $this->save_callbacks(); | |
| 110 | + | |
| 111 | + if ( is_multisite() ) { | |
| 112 | + if ( wp_next_scheduled( $hook_name ) ) { | |
| 113 | + wp_clear_scheduled_hook( $hook_name ); | |
| 114 | + $this->log( "Cleared existing WordPress cron event for '{$hook_name}' to prevent conflicts", self::LOG_FILE_NAME ); | |
| 115 | + } | |
| 116 | + | |
| 117 | + // Only save if this is a new callback or something changed. | |
| 118 | + if ( ! isset( $this->callbacks[ $hook_name ] ) || $this->callbacks[ $hook_name ]['interval'] !== $interval_seconds || $this->callbacks[ $hook_name ]['start_time'] !== $start_time ) { | |
| 119 | + $this->callbacks[ $hook_name ] = array( | |
| 120 | + 'interval' => $interval_seconds, | |
| 121 | + 'start_time' => $start_time, | |
| 122 | + 'args' => $args, | |
| 123 | + ); | |
| 124 | + $this->save_callbacks(); | |
| 125 | + } | |
| 126 | + } elseif ( ! wp_next_scheduled( $hook_name ) ) { | |
| 127 | + $start_timestamp = $this->calculate_start_time( $start_time ); | |
| 128 | + $schedule = $this->get_schedule_name( $interval_seconds ); | |
| 129 | + wp_schedule_event( $start_timestamp, $schedule, $hook_name ); | |
| 130 | + } | |
| 131 | + | |
| 132 | + // Add the action to the hook (applies to both multisite and single-site). | |
| 133 | + add_action( $hook_name, $callback ); | |
| 98 | 134 | } |
| 99 | 135 | |
| 100 | 136 | /** |
| 137 | + * Calculate the start time for a cron event. | |
| 138 | + * | |
| 139 | + * @param int|string|null $start_time The start time specification. | |
| 140 | + * | |
| 141 | + * @return int The timestamp when the cron should first run. | |
| 142 | + */ | |
| 143 | + private function calculate_start_time( $start_time = null ) { | |
| 144 | + if ( null === $start_time ) { | |
| 145 | + return defender_get_current_time(); | |
| 146 | + } | |
| 147 | + | |
| 148 | + if ( is_numeric( $start_time ) ) { | |
| 149 | + return (int) $start_time; | |
| 150 | + } | |
| 151 | + | |
| 152 | + if ( is_string( $start_time ) ) { | |
| 153 | + $timestamp = strtotime( $start_time ); | |
| 154 | + | |
| 155 | + return false !== $timestamp ? $timestamp : defender_get_current_time(); | |
| 156 | + } | |
| 157 | + | |
| 158 | + return defender_get_current_time(); | |
| 159 | + } | |
| 160 | + | |
| 161 | + /** | |
| 162 | + * Get WordPress cron schedule name from interval seconds. | |
| 163 | + * | |
| 164 | + * @param int $interval_seconds The interval in seconds. | |
| 165 | + * | |
| 166 | + * @return string The schedule name. | |
| 167 | + */ | |
| 168 | + private function get_schedule_name( $interval_seconds ) { | |
| 169 | + $schedules = wp_get_schedules(); | |
| 170 | + | |
| 171 | + foreach ( $schedules as $schedule_name => $schedule_data ) { | |
| 172 | + if ( isset( $schedule_data['interval'] ) && $schedule_data['interval'] === $interval_seconds ) { | |
| 173 | + return $schedule_name; | |
| 174 | + } | |
| 175 | + } | |
| 176 | + | |
| 177 | + switch ( $interval_seconds ) { | |
| 178 | + case 12 * HOUR_IN_SECONDS: | |
| 179 | + return 'twicedaily'; | |
| 180 | + case DAY_IN_SECONDS: | |
| 181 | + return 'daily'; | |
| 182 | + case WEEK_IN_SECONDS: | |
| 183 | + return 'weekly'; | |
| 184 | + case HOUR_IN_SECONDS: | |
| 185 | + default: | |
| 186 | + return 'hourly'; | |
| 187 | + } | |
| 188 | + } | |
| 189 | + | |
| 190 | + /** | |
| 101 | 191 | * Check and execute registered callbacks. |
| 102 | 192 | */ |
| 103 | 193 | public function check_and_execute_callbacks() { |
| 104 | - $this->load_callbacks(); | |
| 105 | - if ( empty( $this->callbacks ) ) { | |
| 194 | + // Early exit during plugin deletion/uninstall. | |
| 195 | + if ( defined( 'WP_UNINSTALL_PLUGIN' ) ) { | |
| 106 | 196 | return; |
| 107 | 197 | } |
| 198 | + | |
| 199 | + // Skip if page post ID exists, or during AJAX/cron execution to prevent concurrent runs. | |
| 200 | + $post_data = defender_get_data_from_request( null, 'p' ); | |
| 201 | + if ( | |
| 202 | + ( is_array( $post_data ) && array() !== $post_data ) | |
| 203 | + || defined( 'DOING_AJAX' ) | |
| 204 | + || defined( 'DOING_CRON' ) | |
| 205 | + ) { | |
| 206 | + return; | |
| 207 | + } | |
| 208 | + | |
| 209 | + if ( did_action( 'after_setup_theme' ) > 0 ) { | |
| 210 | + $this->load_callbacks(); | |
| 211 | + } | |
| 212 | + | |
| 213 | + if ( array() === $this->callbacks ) { | |
| 214 | + return; | |
| 215 | + } | |
| 108 | 216 | foreach ( $this->callbacks as $hook_name => $config ) { |
| 109 | 217 | $this->execute_callback( $hook_name, $config ); |
| 110 | 218 | } |
| 111 | 219 | } |
| @@ -116,22 +224,21 @@ | ||
| 116 | 224 | * @param string $hook_name The hook name. |
| 117 | 225 | * @param array $config The callback configuration. |
| 118 | 226 | */ |
| 119 | 227 | private function execute_callback( $hook_name, $config ) { |
| 120 | - if ( ! $this->should_execute( $hook_name, $config['interval'] ) ) { | |
| 228 | + if ( ! $this->should_execute( $hook_name, $config ) ) { | |
| 121 | 229 | return; |
| 122 | 230 | } |
| 123 | 231 | if ( ! $this->acquire_lock( $hook_name ) ) { |
| 124 | - $this->log( "Failed to acquire lock for {$hook_name}", 'cron-manager' ); | |
| 125 | 232 | return; |
| 126 | 233 | } |
| 234 | + | |
| 127 | 235 | try { |
| 128 | - if ( is_callable( $config['callback'] ) ) { | |
| 129 | - call_user_func_array( $config['callback'], $config['args'] ); | |
| 130 | - $this->update_last_run( $hook_name ); | |
| 131 | - } | |
| 236 | + // Execute the hook with arguments. | |
| 237 | + do_action( $hook_name, ...$config['args'] ); | |
| 238 | + $this->update_last_run( $hook_name ); | |
| 132 | 239 | } catch ( \Exception $exception ) { |
| 133 | - $this->log( "Exception in {$hook_name}: " . $exception->getMessage(), 'cron-manager' ); | |
| 240 | + $this->log( "Task '{$hook_name}' failed to run: " . $exception->getMessage(), self::LOG_FILE_NAME ); | |
| 134 | 241 | } finally { |
| 135 | 242 | $this->release_lock( $hook_name ); |
| 136 | 243 | } |
| 137 | 244 | } |
| @@ -136,15 +243,18 @@ | ||
| 136 | 243 | } |
| 137 | 244 | } |
| 138 | 245 | |
| 139 | 246 | /** |
| 140 | - * Check if a callback should be executed based on interval. | |
| 247 | + * Check if a callback should be executed based on interval and start_time. | |
| 141 | 248 | * |
| 142 | 249 | * @param string $hook_name The hook name. |
| 143 | - * @param int $interval The interval in seconds. | |
| 250 | + * @param array $config The callback configuration. | |
| 251 | + * | |
| 144 | 252 | * @return bool Whether the callback should execute. |
| 145 | 253 | */ |
| 146 | - private function should_execute( $hook_name, $interval ) { | |
| 254 | + private function should_execute( $hook_name, $config ) { | |
| 255 | + $interval = $config['interval']; | |
| 256 | + | |
| 147 | 257 | /** |
| 148 | 258 | * Filter to modify execution intervals for network cron jobs. |
| 149 | 259 | * |
| 150 | 260 | * @param int $interval The interval in seconds. |
| @@ -150,15 +260,26 @@ | ||
| 150 | 260 | * @param int $interval The interval in seconds. |
| 151 | 261 | * @param string $hook_name The hook name being executed. |
| 152 | 262 | */ |
| 153 | 263 | $interval = apply_filters( 'wpdef_network_cron_interval', $interval, $hook_name ); |
| 264 | + $interval = ! is_int( $interval ) ? (int) $interval : $interval; | |
| 154 | 265 | $last_run = $this->get_last_run( $hook_name ); |
| 155 | - $current_time = time(); | |
| 156 | - $time_diff = $current_time - $last_run; | |
| 157 | - if ( $last_run && $time_diff < $interval ) { | |
| 158 | - $this->log( "Skipping {$hook_name}: not enough time elapsed ({$time_diff} < {$interval})", 'cron-manager' ); | |
| 159 | - return false; | |
| 266 | + $current_time = defender_get_current_time(); | |
| 267 | + | |
| 268 | + if ( ! $last_run && isset( $config['start_time'] ) && null !== $config['start_time'] ) { | |
| 269 | + $start_timestamp = $this->calculate_start_time( $config['start_time'] ); | |
| 270 | + if ( $current_time < $start_timestamp ) { | |
| 271 | + return false; | |
| 272 | + } | |
| 160 | 273 | } |
| 274 | + | |
| 275 | + if ( $last_run ) { | |
| 276 | + $time_diff = $current_time - $last_run; | |
| 277 | + if ( $time_diff < $interval ) { | |
| 278 | + return false; | |
| 279 | + } | |
| 280 | + } | |
| 281 | + | |
| 161 | 282 | return true; |
| 162 | 283 | } |
| 163 | 284 | |
| 164 | 285 | /** |
| @@ -174,14 +295,15 @@ | ||
| 174 | 295 | * @param int $timeout The timeout in seconds. |
| 175 | 296 | * @param string $hook_name The hook name being locked. |
| 176 | 297 | */ |
| 177 | 298 | $lock_timeout = apply_filters( 'wpdef_network_cron_lock_timeout', 300, $hook_name ); |
| 299 | + $lock_timeout = ! is_int( $lock_timeout ) ? (int) $lock_timeout : $lock_timeout; | |
| 178 | 300 | $lock_key = $this->lock_prefix . $hook_name; |
| 179 | 301 | $existing_lock = get_network_option( get_main_network_id(), $lock_key ); |
| 180 | - if ( $existing_lock && ( time() - $existing_lock ) < $lock_timeout ) { | |
| 302 | + if ( 0 < $existing_lock && ( defender_get_current_time() - $existing_lock ) < $lock_timeout ) { | |
| 181 | 303 | return false; |
| 182 | 304 | } |
| 183 | - $lock_value = time(); | |
| 305 | + $lock_value = defender_get_current_time(); | |
| 184 | 306 | return update_network_option( get_main_network_id(), $lock_key, $lock_value ); |
| 185 | 307 | } |
| 186 | 308 | |
| 187 | 309 | /** |
| @@ -211,9 +333,9 @@ | ||
| 211 | 333 | * @param string $hook_name The hook name. |
| 212 | 334 | */ |
| 213 | 335 | private function update_last_run( $hook_name ) { |
| 214 | 336 | $lastrun_key = $this->lastrun_prefix . $hook_name; |
| 215 | - $timestamp = time(); | |
| 337 | + $timestamp = defender_get_current_time(); | |
| 216 | 338 | update_network_option( get_main_network_id(), $lastrun_key, $timestamp ); |
| 217 | 339 | } |
| 218 | 340 | |
| 219 | 341 | /** |
| @@ -244,6 +366,51 @@ | ||
| 244 | 366 | $this->lock_prefix . '%', |
| 245 | 367 | $this->lastrun_prefix . '%' |
| 246 | 368 | ) |
| 247 | 369 | ); |
| 370 | + } | |
| 371 | + | |
| 372 | + /** | |
| 373 | + * Remove a specific callback. | |
| 374 | + * | |
| 375 | + * - For multisite: Remove the callback from the network option and release the lock. | |
| 376 | + * - For single-site: Unschedule the task. | |
| 377 | + * | |
| 378 | + * @param string $hook_name The hook name. | |
| 379 | + * @return bool Whether the callback was removed. | |
| 380 | + */ | |
| 381 | + public function remove_callback( string $hook_name ) { | |
| 382 | + $hook_name = sanitize_key( $hook_name ); | |
| 383 | + | |
| 384 | + if ( '' === $hook_name ) { | |
| 385 | + return false; | |
| 386 | + } | |
| 387 | + | |
| 388 | + if ( is_multisite() ) { | |
| 389 | + // Ensure callbacks are loaded. | |
| 390 | + $this->load_callbacks(); | |
| 391 | + | |
| 392 | + if ( isset( $this->callbacks[ $hook_name ] ) ) { | |
| 393 | + unset( $this->callbacks[ $hook_name ] ); | |
| 394 | + $this->save_callbacks(); | |
| 395 | + $this->log( "Task '{$hook_name}' removed.", self::LOG_FILE_NAME ); | |
| 396 | + } | |
| 397 | + | |
| 398 | + // Cleanup hook history. | |
| 399 | + $last_run_key = $this->lastrun_prefix . $hook_name; | |
| 400 | + delete_network_option( get_main_network_id(), $last_run_key ); | |
| 401 | + | |
| 402 | + // Release the lock. | |
| 403 | + $this->release_lock( $hook_name ); | |
| 404 | + } else { | |
| 405 | + // Unschedule the task. | |
| 406 | + $timestamp = wp_next_scheduled( $hook_name ); | |
| 407 | + | |
| 408 | + if ( $timestamp ) { | |
| 409 | + wp_unschedule_event( $timestamp, $hook_name ); | |
| 410 | + $this->log( "Task '{$hook_name}' unscheduled.", self::LOG_FILE_NAME ); | |
| 411 | + } | |
| 412 | + } | |
| 413 | + | |
| 414 | + return true; | |
| 248 | 415 | } |
| 249 | 416 | } |