add( $job, 0 ); } /** * Put a failed job back, to be tried again later. * * @param Job $job Job carrying its incremented attempt count. * @param int $delay_s Seconds to wait. * @return bool */ public function retry( Job $job, int $delay_s ): bool { return $this->add( $job, max( 0, $delay_s ) ); } /** * How many jobs are waiting. * * @return int */ public function size(): int { return count( $this->read() ); } /** * Drop every pending job. * * @return int */ public function purge(): int { $size = $this->size(); delete_option( self::OPTION ); wp_clear_scheduled_hook( self::CRON_HOOK ); return $size; } /** * Name shown on the settings screen. * * @return string */ public function label(): string { return __( 'WP-Cron', 'adpix-analytics' ); } /** * Take the jobs that are due now. * * @param int $limit Maximum jobs to return. * @return Job[] */ public function claim( int $limit ): array { $this->lock(); $entries = $this->read(); $now = time(); $claimed = array(); $keep = array(); foreach ( $entries as $entry ) { $run_at = isset( $entry['run_at'] ) ? (int) $entry['run_at'] : 0; $job = Job::from_array( $entry ); if ( null === $job ) { continue; // Unreadable leftover: dropping it is the only way it ever stops being retried. } if ( count( $claimed ) < $limit && $run_at <= $now ) { $claimed[] = $job; } else { $keep[] = $entry; } } $this->write( $keep ); $this->unlock(); if ( array() !== $keep ) { $this->ensure_cron( 60 ); } return $claimed; } /** * Append a job and make sure something will come for it. * * @param Job $job Job. * @param int $delay_s Seconds to wait. * @return bool */ private function add( Job $job, int $delay_s ): bool { $this->lock(); $entries = $this->read(); if ( count( $entries ) >= self::MAX_JOBS ) { $this->unlock(); // Refusing the newest job rather than evicting the oldest is the deliberate choice: the // oldest is the one closest to being delivered, and the caller is told so it can log a loss // rather than the plugin silently discarding an order. return false; } $entries[] = array_merge( $job->to_array(), array( 'run_at' => time() + $delay_s ) ); $stored = $this->write( $entries ); $this->unlock(); $this->ensure_cron( $delay_s ); return $stored; } /** * Read the pending entries. * * @return array> */ private function read(): array { $stored = get_option( self::OPTION, array() ); if ( ! is_array( $stored ) ) { return array(); } return array_values( array_filter( $stored, 'is_array' ) ); } /** * Write the pending entries. * * @param array> $entries Entries. * @return bool */ private function write( array $entries ): bool { if ( array() === $entries ) { delete_option( self::OPTION ); return true; } return update_option( self::OPTION, array_values( $entries ), false ); } /** * Make sure a drain is scheduled. * * @param int $delay_s Seconds until the job is due. * @return void */ private function ensure_cron( int $delay_s ): void { if ( false !== wp_next_scheduled( self::CRON_HOOK ) ) { return; } // WP-Cron only runs when the site is visited, so this is the earliest the job could run, not a // promise about when it will. wp_schedule_single_event( time() + max( 30, $delay_s ), self::CRON_HOOK ); } /** * Wait briefly for exclusive access. * * Best effort by design. A transient is not a real mutex, and pretending otherwise would be worse * than acknowledging it: the lock closes most of the read-modify-write window, and the cap and the * collector's own idempotency cover what it does not. * * @return void */ private function lock(): void { for ( $attempt = 0; $attempt < 5; $attempt++ ) { if ( false === get_transient( self::LOCK ) ) { set_transient( self::LOCK, 1, 10 ); return; } usleep( 50000 ); } } /** * Release the lock. * * @return void */ private function unlock(): void { delete_transient( self::LOCK ); } }