links = $links ?? new LinkRepository(); $this->http = $http ?? new HttpChecker(); $this->limiter = $limiter ?? new RateLimiter( $this->links ); $this->throttle_factory = $throttle_factory ?? static function ( int $lane ): HostThrottle { return new HostThrottle( $lane ); }; } /** * Register hooks. */ public function register(): void { if ( Jobs::is_background_context() ) { add_action( 'action_scheduler_init', array( $this, 'ensure_tick' ) ); } add_action( self::HOOK_TICK, array( $this, 'tick' ) ); add_action( self::HOOK_LANE, array( $this, 'handle_lane' ) ); add_action( 'unbreak_links_added', array( $this, 'tick' ) ); add_action( 'unbreak_extraction_done', array( $this, 'tick' ) ); } /** * Kill switch state. Re-read during a batch so pausing takes effect mid-batch. * * @phpstan-impure */ public static function is_paused(): bool { return (bool) get_option( self::PAUSED, false ); } /** * Pause or resume all checking. * * @param bool $paused New state. */ public static function set_paused( bool $paused ): void { update_option( self::PAUSED, $paused ? 1 : 0, true ); if ( $paused ) { Jobs::cancel( self::HOOK_LANE ); return; } ( new self() )->tick(); } /** * Number of lanes (1-4). */ public static function lanes(): int { return max( 1, min( 4, (int) Settings::get( 'concurrency' ) ) ); } /** * Host buckets owned by a lane. * * @param int $lane Lane index. * @param int $lanes Lane count. * @return int[] */ public static function buckets_for_lane( int $lane, int $lanes ): array { $buckets = array(); for ( $b = 0; $b < Url::HOST_BUCKETS; $b++ ) { if ( $b % $lanes === $lane ) { $buckets[] = $b; } } return $buckets; } /** * Make sure the recurring safety-net tick exists (cheap check, runs when Action Scheduler is ready). */ public function ensure_tick(): void { if ( ! function_exists( 'as_has_scheduled_action' ) || as_has_scheduled_action( self::HOOK_TICK, array(), Jobs::GROUP ) ) { return; } as_schedule_recurring_action( Clock::now() + 60, self::TICK_INTERVAL, self::HOOK_TICK, array(), Jobs::GROUP ); } /** * Enqueue every lane that has work. Does nothing while paused or when nothing is due. */ public function tick(): void { if ( self::is_paused() || ! $this->links->has_due( Clock::now() ) ) { return; } $lanes = self::lanes(); for ( $lane = 0; $lane < $lanes; $lane++ ) { Jobs::enqueue_unique( self::HOOK_LANE, array( $lane ) ); } } /** * Action Scheduler callback for a lane batch. * * @param int $lane Lane index. */ public function handle_lane( $lane = 0 ): void { $this->run_lane( (int) $lane ); } /** * Run one batch for a lane. * * @param int $lane Lane index. * @return int Number of URLs checked. */ public function run_lane( $lane = 0 ): int { $lane = (int) $lane; $lanes = self::lanes(); if ( self::is_paused() || $lane >= $lanes ) { return 0; } $budget = $this->limiter->lane_budget( $lane, $lanes, self::BATCH_URLS ); if ( $budget <= 0 ) { return 0; // Hourly limit reached; the recurring tick resumes later. } $candidates = $this->links->due( self::buckets_for_lane( $lane, $lanes ), $budget * 3, Clock::now() ); if ( empty( $candidates ) ) { return 0; } // Admin/auth URLs indexed before they were excluded are dropped, never requested. $admin = array_filter( $candidates, static function ( $link ): bool { return Url::is_wp_admin( (string) $link->url ); } ); if ( ! empty( $admin ) ) { $this->links->delete( array_map( 'intval', wp_list_pluck( $admin, 'id' ) ) ); $candidates = array_diff_key( $candidates, $admin ); } $throttle = ( $this->throttle_factory )( $lane ); $started = microtime( true ); $checked = 0; $queue = self::interleave_hosts( $candidates ); foreach ( $queue as $link ) { if ( $checked >= $budget || self::is_paused() ) { break; } $elapsed = microtime( true ) - $started; $wait = $throttle->wait_time( (string) $link->host ); // Leave room for one full request (timeout) within the batch budget. if ( $elapsed + $wait > self::BATCH_SECS - 1 ) { break; } $throttle->wait( (string) $link->host ); $this->check_link( $link, $throttle ); ++$checked; if ( microtime( true ) - $started >= self::BATCH_SECS ) { break; } } $throttle->save(); if ( ! self::is_paused() && $checked > 0 && $this->limiter->remaining() > 0 && ! empty( $this->links->due( self::buckets_for_lane( $lane, $lanes ), 1, Clock::now() ) ) ) { Jobs::enqueue_unique( self::HOOK_LANE, array( $lane ) ); } return $checked; } /** * Check one link now and store the outcome. * * @param object $link Row with id, url, host, is_internal, status, fail_count, first_failure_at. * @param HostThrottle|null $throttle Throttle to record the request in. * @return array{status:string,reason:string} Status "removed" when the URL is an admin/auth URL (deleted, not requested). */ public function check_link( object $link, ?HostThrottle $throttle = null ): array { $url = (string) $link->url; if ( Url::is_wp_admin( $url ) ) { $this->links->delete( array( (int) $link->id ) ); return array( 'status' => 'removed', 'reason' => __( 'WordPress admin and login URLs are not checked.', 'unbreak' ), ); } $response = $this->internal_fast_path( $link ); if ( null === $response ) { $response = $this->http->check( $url ); if ( null !== $throttle ) { $throttle->record( (string) $link->host ); } } $raw = Classifier::classify( $response, $url ); $now = Clock::now(); $state = StatusPolicy::apply( array( 'status' => (string) $link->status, 'fail_count' => (int) $link->fail_count, 'first_failure_at' => Clock::from_mysql( $link->first_failure_at ), 'last_result' => isset( $link->last_result ) ? (string) $link->last_result : null, ), $raw['status'], $now, (int) Settings::get( 'check_interval_days' ) ); $message = '' !== $response->error_message ? $response->error_message : $raw['reason']; $fields = array( 'status' => $state['status'], 'last_result' => $state['last_result'], 'http_code' => null !== $response->code ? (int) $response->code : null, 'final_url' => $response->redirect_count > 0 ? $response->final_url : null, 'redirect_count' => min( 255, $response->redirect_count ), 'error_message' => '' !== $message ? substr( $message, 0, 255 ) : null, 'response_ms' => $response->response_ms, 'fail_count' => $state['fail_count'], 'first_failure_at' => null !== $state['first_failure_at'] ? Clock::to_mysql( $state['first_failure_at'] ) : null, 'last_checked_at' => Clock::to_mysql( $now ), 'next_check_at' => Clock::to_mysql( $state['next_check_at'] ), ); if ( $state['status'] !== (string) $link->status ) { $fields['status_changed_at'] = Clock::to_mysql( $now ); } $this->links->save_result( (int) $link->id, $fields ); /** * Fires after a link was checked. * * @param int $link_id Link ID. * @param string $status New stored status. * @param string $raw Raw classification of this check. */ do_action( 'unbreak_link_checked', (int) $link->id, $state['status'], $raw['status'] ); return array( 'status' => $state['status'], 'reason' => $raw['reason'], ); } /** * Optional fast path: internal links to published posts are OK without a request. * * @param object $link Link row. */ private function internal_fast_path( object $link ): ?Response { if ( ! Settings::get( 'internal_fast_path' ) || ! (int) $link->is_internal ) { return null; } $post_id = url_to_postid( (string) $link->url ); if ( $post_id > 0 && 'publish' === get_post_status( $post_id ) ) { return Response::verified( (string) $link->url, __( 'Verified internally (published post)', 'unbreak' ) ); } return null; } /** * Reorder candidates round-robin by host so one slow host does not consume the batch. * * @param array $links Candidates (oldest first). * @return array */ public static function interleave_hosts( array $links ): array { $by_host = array(); foreach ( $links as $link ) { $by_host[ (string) $link->host ][] = $link; } $out = array(); while ( ! empty( $by_host ) ) { foreach ( $by_host as $host => $queue ) { $out[] = array_shift( $by_host[ $host ] ); if ( empty( $by_host[ $host ] ) ) { unset( $by_host[ $host ] ); } } } return $out; } }