60, 'normal' => 20, 'fast' => 5, ); /** * @var JobRunner */ private JobRunner $runner; /** * @var JobStateRepository */ private JobStateRepository $repository; /** * @var JobLease */ private JobLease $lease; /** * Returns the current unix time. * * @var callable(): int */ private $clock; /** * Builds the time budget for a batch. * * @var callable(): Deadline */ private $deadline_factory; /** * @since 1.14.0 * * @param JobRunner $runner Job runner. * @param JobStateRepository $repository Job storage. * @param JobLease $lease Ownership lease. * @param callable|null $clock Optional. Returns the current unix time. * @param callable|null $deadline_factory Optional. Builds a batch deadline. */ public function __construct( JobRunner $runner, JobStateRepository $repository, JobLease $lease, ?callable $clock = null, ?callable $deadline_factory = null ) { $this->runner = $runner; $this->repository = $repository; $this->lease = $lease; $this->clock = $clock ?? static fn(): int => time(); $this->deadline_factory = $deadline_factory ?? static fn(): Deadline => Deadline::from_php_limit(); } /** * Hooks the cron runner and the admin-side rescue. * * @since 1.14.0 * * @return void */ public function register(): void { add_action( self::CRON_HOOK, array( $this, 'run' ) ); add_action( 'admin_init', array( $this, 'maybe_resume' ) ); } /** * Starts a fresh job and queues its first batch immediately. * * @since 1.14.0 */ public function start(): JobState { $state = $this->runner->start(); $this->schedule_next( 0 ); update_option( self::STARTED_OPTION, 1, false ); return $state; } /** * Seconds an interactive batch may take. * * A batch started by someone pressing a button runs inside their request, * so the full cron budget would leave the browser waiting a minute and a * half for a click. Short enough to answer quickly, long enough to move * the counter visibly. * * @since 1.16.1 */ public const INTERACTIVE_BUDGET_SECONDS = 15; /** * Runs one batch now and reports whether it moved. * * Scheduling a cron event is not a substitute for this on a site with * `DISABLE_WP_CRON` set: nothing there fires the event until an external * cron does, so a queued batch is indistinguishable from a dead one. * Pressing a button has to do the work, not ask for it to be done later. * * @since 1.16.1 * * @return bool True when this call ran a batch; false when another runner * already holds the lease. */ public function run_now(): bool { // Its own microtime clock rather than the scheduler's: Deadline // measures a budget in fractions of a second, and the scheduler's // whole-second clock would round a 15-second budget unpredictably. $clock = static fn(): float => microtime( true ); return null !== $this->run_with( new Deadline( $clock() + self::INTERACTIVE_BUDGET_SECONDS, $clock, self::INTERACTIVE_BUDGET_SECONDS ) ); } /** * Runs one batch, then queues the next if there is more to do. * * @since 1.14.0 * * @return void */ public function run(): void { $this->run_with( ( $this->deadline_factory )() ); } /** * Runs one batch under the given deadline and queues the next if needed. * * @since 1.16.1 * * @param Deadline $deadline Batch deadline. * * @return JobState|null The state after the batch, or null when another * runner holds the lease. */ private function run_with( Deadline $deadline ): ?JobState { $owner = wp_generate_password( 12, false ); $state = $this->runner->run_batch( $deadline, $owner ); // A null result means another runner holds the lease. That runner // will queue the next batch; doing it here as well would multiply // runners rather than advance the job. if ( null === $state || 'running' !== $state->status() ) { return $state; } $this->schedule_next( $this->gap_seconds() ); return $state; } /** * Requeues a job that claims to be running but is held by nobody, and * reconciles the stored job with the stages that exist now — reopening a * finished job when an update has added a stage with work to do. * * Runs on every admin page load, so it does the cheap check first: if an * event is already queued there is nothing to rescue and the job record is * never read. * * @since 1.14.0 * @since 1.18.1 Reopens a finished job for stages added since it ran. * @since 1.18.4 Starts the first index on a site that has never had one. * * @return void */ public function maybe_resume(): void { if ( wp_next_scheduled( self::CRON_HOOK ) ) { return; } // A new site used to show empty reports until someone found the // Start button. A site with a stored job indexed before this flag // existed, so it is only recorded, not scanned again. if ( ! get_option( self::STARTED_OPTION ) ) { if ( null === $this->repository->load() ) { $this->start(); return; } update_option( self::STARTED_OPTION, 1, false ); } if ( $this->runner->reconcile_stages() ) { $this->schedule_next( 0 ); return; } $state = $this->repository->load(); if ( null === $state || ! $this->lease->is_stale( $state ) ) { return; } // A running job with an expired lease is one whose batch died without // reaching any cleanup. Nothing else will restart it. $this->schedule_next( 0 ); } /** * Queues one batch $gap_seconds from now. * * @since 1.14.0 * * @param int $gap_seconds Seconds to wait. * * @return void */ public function schedule_next( int $gap_seconds ): void { wp_schedule_single_event( ( $this->clock )() + max( 0, $gap_seconds ), self::CRON_HOOK ); } /** * Seconds to wait between batches, from the stored intensity. * * An unrecognised stored value falls back to `normal` rather than to zero: * zero would silently turn the throttle off, which is the opposite of what * a site with an unreadable setting wants. * * @since 1.14.0 */ public function gap_seconds(): int { $intensity = (string) get_option( self::INTENSITY_OPTION, 'normal' ); return self::GAPS[ $intensity ] ?? self::GAPS['normal']; } }