new $job(), $this->job_classes() ); } public function register_hooks(): void { $retry = Retry\Retry_Manager::get_instance(); foreach ( $this->jobs() as $job ) { add_action( $job->hook(), [ $job, 'handle' ], 10, 4 ); if ( $job instanceof Retry\Retry_Policy ) { $retry->register( $job->hook(), $job->action_group(), $job ); } } } public function register_schedules(): void { $jobs = $this->jobs(); $register = function () use ( $jobs ): void { try { // Every request runs this, including front-end ones, and the check // below costs one Action Scheduler query per job. Recurring series // only go missing when something clears them, so re-verifying a // handful of times per hour is enough. if ( false !== get_transient( self::VERIFIED_TRANSIENT ) ) { return; } if ( $this->schedules_are_current( $jobs ) ) { set_transient( self::VERIFIED_TRANSIENT, '1', self::VERIFY_INTERVAL ); return; } $this->with_registration_lock( function () use ( $jobs ): void { foreach ( $jobs as $job ) { $this->reconcile_schedule( $job ); } } ); } catch ( Throwable $exception ) { Logging\Log_Helper::by_area( Logging\Log_Helper::LOG_CRON )->error( 'Recurring job schedules could not be reconciled.', [ 'reason' => $exception->getMessage() ] ); } }; if ( did_action( 'action_scheduler_init' ) ) { $register(); return; } add_action( 'action_scheduler_init', $register ); } /** * Fast path for normal requests. The advisory lock is only needed while a * missing, duplicated, or outdated recurring series is being repaired. * * @param Cron\Cron_Job[] $jobs */ private function schedules_are_current( array $jobs ): bool { foreach ( $jobs as $job ) { $actions = as_get_scheduled_actions( [ 'hook' => $job->hook(), 'args' => [], 'group' => $job->action_group(), 'status' => [ \ActionScheduler_Store::STATUS_PENDING, \ActionScheduler_Store::STATUS_RUNNING, ], 'per_page' => 100, 'orderby' => 'none', ] ); $recurring_count = 0; $matching_count = 0; foreach ( $actions as $action ) { $schedule = $action->get_schedule(); if ( ! $schedule->is_recurring() ) { continue; } ++$recurring_count; if ( method_exists( $schedule, 'get_recurrence' ) && $job->interval() === (int) $schedule->get_recurrence() ) { ++$matching_count; } } if ( 1 !== $recurring_count || 1 !== $matching_count ) { return false; } } return true; } private function reconcile_schedule( Cron\Cron_Job $job ): void { $query = [ 'hook' => $job->hook(), 'args' => [], 'group' => $job->action_group(), 'per_page' => 100, 'orderby' => 'none', ]; $pending = as_get_scheduled_actions( $query + [ 'status' => \ActionScheduler_Store::STATUS_PENDING ] ); $running = as_get_scheduled_actions( $query + [ 'status' => \ActionScheduler_Store::STATUS_RUNNING ] ); $matching_action_id = 0; $cancel_failed = false; $store = \ActionScheduler::store(); foreach ( $pending as $action_id => $action ) { $schedule = $action->get_schedule(); if ( ! $schedule->is_recurring() ) { continue; } $matches_interval = method_exists( $schedule, 'get_recurrence' ) && $job->interval() === (int) $schedule->get_recurrence(); if ( $matches_interval && 0 === $matching_action_id ) { $matching_action_id = (int) $action_id; continue; } try { // Cancel only the concrete recurring action. Child/retry actions use // the same hook and group but must remain untouched. $store->cancel_action( (int) $action_id ); } catch ( Throwable $exception ) { $cancel_failed = true; Logging\Log_Helper::by_area( Logging\Log_Helper::LOG_CRON )->error( 'An outdated recurring job schedule could not be canceled.', [ 'job' => get_class( $job ), 'action_id' => (int) $action_id, 'reason' => $exception->getMessage(), ] ); } } if ( 0 !== $matching_action_id || $this->has_running_recurring_action( $running ) ) { return; } // Do not add a new series while an obsolete one still exists. The next // request will retry reconciliation after the cancellation error clears. if ( $cancel_failed ) { return; } try { // Registration is already serialized by our advisory lock. Avoid // Action Scheduler's broad $unique check, which ignores action args and // can let a child/retry action block the recurring series. $action_id = as_schedule_recurring_action( $job->first_run_at(), $job->interval(), $job->hook(), [], $job->action_group(), false ); } catch ( Throwable $exception ) { $action_id = 0; $reason = $exception->getMessage(); } if ( $action_id > 0 ) { return; } Logging\Log_Helper::by_area( Logging\Log_Helper::LOG_CRON )->error( 'A recurring job schedule could not be created.', [ 'job' => get_class( $job ), 'group' => $job->action_group(), 'reason' => $reason ?? 'Action Scheduler rejected the recurring action.', ] ); } private function has_running_recurring_action( array $actions ): bool { foreach ( $actions as $action ) { if ( $action->get_schedule()->is_recurring() ) { return true; } } return false; } private function with_registration_lock( callable $callback ): void { global $wpdb; $database = defined( 'DB_NAME' ) ? (string) constant( 'DB_NAME' ) : ''; $lock_name = 'market_jobs_' . md5( $database . '|' . $wpdb->prefix ); // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $acquired = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $lock_name, self::REGISTRATION_LOCK_TIMEOUT ) ); // phpcs:enable if ( '1' !== (string) $acquired ) { throw new \RuntimeException( 'Recurring job registration lock could not be acquired.' ); } try { $callback(); } finally { // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name ) ); // phpcs:enable } } public static function clear_schedules(): void { $scheduler = new self(); // Everything is about to be unscheduled, so the cached "all series intact" // answer is wrong from here on. Reactivating must re-register immediately. self::invalidate_schedule_verification(); foreach ( $scheduler->jobs() as $job ) { try { // Fence job-owned state first. A worker that is already running can // then no longer publish state or retain a newly enqueued follow-up. $job->cleanup(); } catch ( Throwable $exception ) { Logging\Log_Helper::by_area( Logging\Log_Helper::LOG_CRON )->error( 'Failed to clean up a recurring job.', [ 'job' => get_class( $job ), 'group' => $job->action_group(), 'reason' => $exception->getMessage(), ] ); } } if ( ! function_exists( 'as_unschedule_all_actions' ) ) { return; } foreach ( Action_Registry::HOOKS as $hook ) { try { as_unschedule_all_actions( $hook ); } catch ( Throwable $exception ) { Logging\Log_Helper::by_area( Logging\Log_Helper::LOG_CRON )->error( 'Failed to clear scheduled actions for a plugin hook.', [ 'hook' => $hook, 'group' => Action_Registry::GROUP, 'reason' => $exception->getMessage(), ] ); } } } /** * @return class-string[] */ private function job_classes(): array { // Everything a job class may need at load time: the registry its constants // alias into, the Retry_Policy interface it may implement, and its base class. require_once MARKET_SYNC_PLUGIN_DIR . 'includes/class-action-registry.php'; require_once MARKET_SYNC_PLUGIN_DIR . 'includes/retry/interface-retry-policy.php'; require_once MARKET_SYNC_PLUGIN_DIR . 'includes/retry/interface-retry-schedule-guard.php'; require_once MARKET_SYNC_PLUGIN_DIR . 'includes/cron/interface-cron-job.php'; require_once MARKET_SYNC_PLUGIN_DIR . 'includes/cron/abstract-base-job.php'; require_once MARKET_SYNC_PLUGIN_DIR . 'includes/class-plugin-uploads.php'; require_once MARKET_SYNC_PLUGIN_DIR . 'includes/cron/class-catalog-export-state.php'; require_once MARKET_SYNC_PLUGIN_DIR . 'includes/orders/class-pending-order-imports.php'; $files = glob( MARKET_SYNC_PLUGIN_DIR . 'includes/cron/class-*-job.php' ); if ( false === $files ) { return []; } sort( $files ); $jobs = []; foreach ( $files as $file ) { // Loading a concrete class enforces the Cron_Job method contract. Keep // validation isolated per file so one broken job does not disable the // remaining jobs. try { require_once $file; $job = $this->job_class_from_file( $file ); if ( ! class_exists( $job ) || ! is_subclass_of( $job, Cron\Cron_Job::class ) ) { continue; } if ( ! ( new ReflectionClass( $job ) )->isInstantiable() ) { continue; } $jobs[] = $job; } catch ( Throwable $exception ) { Logging\Log_Helper::by_area( Logging\Log_Helper::LOG_CRON )->error( 'Skipped a job that could not be validated.', [ 'file' => basename( $file ), 'reason' => $exception->getMessage() ] ); } } return $jobs; } /** * @return class-string */ private function job_class_from_file( string $file ): string { $name = substr( basename( $file, '.php' ), strlen( 'class-' ) ); $name = str_replace( ' ', '_', ucwords( str_replace( '-', ' ', $name ) ) ); return __NAMESPACE__ . '\\Cron\\' . $name; } }