mixed, 'done' => bool ). State lives in one non-autoloaded * option per job, so a job resumes where it stopped after a timeout or crash. */ class Jobs { const HOOK = 'orbitstem_seo_run_jobs'; const LOCK = 'orbitstem_seo_jobs_lock'; const STATE_PREFIX = 'orbitstem_seo_job_'; const MAX_ERRORS = 3; /** * Registered handlers. * * @var array */ private $handlers = array(); /** * Hooks the cron runner and lets modules register handlers. */ public function register_hooks() { add_action( self::HOOK, array( $this, 'tick' ) ); add_action( 'init', function () { /** * Register job handlers with $jobs->register(). * * @param Jobs $jobs Job runner. */ do_action( 'orbitstem_seo_register_jobs', $this ); } ); } /** * Registers a job handler. * * @param string $name Job name (sanitized key). * @param callable $callback Handler. * @param int $batch_size Items per batch. */ public function register( $name, callable $callback, $batch_size = 50 ) { $this->handlers[ sanitize_key( $name ) ] = array( 'callback' => $callback, 'batch_size' => max( 1, (int) $batch_size ), ); } /** * Starts a job unless it is already running. * * @param string $name Job name. * @return bool Whether a new run was started. */ public function start( $name ) { $name = sanitize_key( $name ); if ( ! isset( $this->handlers[ $name ] ) ) { return false; } $state = $this->status( $name ); if ( $state && 'running' === $state['status'] ) { return false; } $this->save( $name, array( 'status' => 'running', 'cursor' => null, 'batches' => 0, 'errors' => 0, 'last_error' => '', 'started' => time(), 'updated' => time(), ) ); $this->schedule(); return true; } /** * Current state of a job, or null when it never ran. * * @param string $name Job name. * @return array|null */ public function status( $name ) { $state = get_option( self::STATE_PREFIX . sanitize_key( $name ), null ); return is_array( $state ) ? $state : null; } /** * Cancels a running job. * * @param string $name Job name. */ public function cancel( $name ) { $state = $this->status( $name ); if ( $state && 'running' === $state['status'] ) { $state['status'] = 'cancelled'; $state['updated'] = time(); $this->save( sanitize_key( $name ), $state ); } } /** * Processes running jobs until the time budget is spent. Safe to call * concurrently: only the request holding the lock does work. * * @param float $budget Seconds of work allowed in this call. */ public function tick( $budget = 20.0 ) { if ( ! Lock::acquire( self::LOCK, 300 ) ) { return; } $deadline = microtime( true ) + (float) $budget; $still_running = false; try { foreach ( $this->handlers as $name => $handler ) { $state = $this->status( $name ); if ( ! $state || 'running' !== $state['status'] ) { continue; } while ( 'running' === $state['status'] && microtime( true ) < $deadline ) { $state = $this->run_batch( $state, $handler ); $this->save( $name, $state ); } if ( 'running' === $state['status'] ) { $still_running = true; } } } finally { Lock::release( self::LOCK ); } if ( $still_running ) { $this->schedule(); } } /** * Runs one batch and returns the new state. * * @param array $state Job state. * @param array $handler Handler definition. * @return array */ private function run_batch( array $state, array $handler ) { try { $result = call_user_func( $handler['callback'], $state['cursor'], $handler['batch_size'] ); } catch ( \Throwable $e ) { $result = $e; } if ( is_array( $result ) && array_key_exists( 'done', $result ) ) { $state['cursor'] = isset( $result['cursor'] ) ? $result['cursor'] : null; ++$state['batches']; $state['errors'] = 0; if ( $result['done'] ) { $state['status'] = 'complete'; } } else { ++$state['errors']; $state['last_error'] = $result instanceof \Throwable ? $result->getMessage() : 'Job handler returned an invalid result.'; if ( $state['errors'] >= self::MAX_ERRORS ) { $state['status'] = 'failed'; } } $state['updated'] = time(); return $state; } /** * Persists job state (never autoloaded). * * @param string $name Job name. * @param array $state State. */ private function save( $name, array $state ) { update_option( self::STATE_PREFIX . $name, $state, false ); } /** * Schedules the next cron tick if none is queued. */ private function schedule() { if ( ! wp_next_scheduled( self::HOOK ) ) { wp_schedule_single_event( time() + 30, self::HOOK ); } } }