links = $links; $this->scanner = $scanner; $this->checker = $checker; } /** * Registers hooks. * * @return void */ public function hooks() { add_filter( 'cron_schedules', array( __CLASS__, 'cron_schedules' ) ); // phpcs:ignore WordPress.WP.CronInterval.CronSchedulesInterval -- Five minutes; each run is time-boxed and locked. add_action( self::HOOK, array( $this, 'run' ) ); add_action( self::CATCHUP, array( $this, 'run_catchup' ) ); add_action( 'init', array( __CLASS__, 'ensure_scheduled' ), 20 ); } /** * Adds the five-minute schedule. * * @param array $schedules Schedules. * @return array */ public static function cron_schedules( $schedules ) { $schedules[ self::SCHEDULE ] = array( 'interval' => 5 * MINUTE_IN_SECONDS, 'display' => __( 'Every five minutes (LinkTender)', 'linktender' ), ); return $schedules; } /** * Whether Action Scheduler is available. * * @return bool */ public static function use_action_scheduler() { $loaded = function_exists( 'as_schedule_recurring_action' ) && function_exists( 'as_schedule_single_action' ) && function_exists( 'as_has_scheduled_action' ) && function_exists( 'as_unschedule_all_actions' ); $available = $loaded && did_action( 'action_scheduler_init' ); /** * Filters whether Action Scheduler is used instead of WP-Cron. * * @param bool $available Whether Action Scheduler is loaded. */ return $loaded && (bool) apply_filters( 'linktender_use_action_scheduler', $available ); } /** * Schedules the recurring run. * * @return void */ public static function schedule() { if ( self::use_action_scheduler() ) { wp_clear_scheduled_hook( self::HOOK ); if ( ! as_has_scheduled_action( self::HOOK, array(), self::AS_GROUP ) ) { as_schedule_recurring_action( time() + 30, 5 * MINUTE_IN_SECONDS, self::HOOK, array(), self::AS_GROUP ); } return; } if ( ! wp_next_scheduled( self::HOOK ) ) { add_filter( 'cron_schedules', array( __CLASS__, 'cron_schedules' ) ); // phpcs:ignore WordPress.WP.CronInterval.CronSchedulesInterval -- See hooks(). wp_schedule_event( time() + 30, self::SCHEDULE, self::HOOK ); } } /** * Keeps the schedule in place (also moves to Action Scheduler once it loads). * * @return void */ public static function ensure_scheduled() { if ( ! wp_doing_cron() && ! is_admin() ) { return; } if ( ! LinkTender_Install::checking_started() ) { // Nothing runs in the background until an administrator starts checking. return; } if ( self::use_action_scheduler() ) { if ( wp_next_scheduled( self::HOOK ) || ! as_has_scheduled_action( self::HOOK, array(), self::AS_GROUP ) ) { self::schedule(); } } elseif ( ! wp_next_scheduled( self::HOOK ) ) { self::schedule(); } } /** * Removes scheduled runs. * * @return void */ public static function unschedule() { wp_clear_scheduled_hook( self::HOOK ); wp_clear_scheduled_hook( self::CATCHUP ); if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( self::HOOK, array(), self::AS_GROUP ); as_unschedule_all_actions( self::CATCHUP, array(), self::AS_GROUP ); } delete_option( self::LOCK ); } /** * Takes the run lock. * * @param int $ttl Seconds. * @return bool */ private function lock( $ttl ) { if ( add_option( self::LOCK, time() + $ttl, '', false ) ) { return true; } $expires = (int) get_option( self::LOCK, 0 ); if ( $expires < time() ) { delete_option( self::LOCK ); return (bool) add_option( self::LOCK, time() + $ttl, '', false ); } return false; } /** * Releases the run lock. * * @return void */ private function unlock() { delete_option( self::LOCK ); } /** * One time-boxed batch: parse pending content, then check due links. * * @param bool $force Run even when paused (manual "Run now"). * @return array Summary with parsed/checked counts. */ public function run( $force = false ) { $summary = array( 'checked' => 0, 'scan' => 'idle', 'locked' => false, ); if ( ! LinkTender_Install::checking_started() ) { // No outgoing requests before an administrator has started checking. $summary['scan'] = 'not_started'; return $summary; } if ( LinkTender_Settings::get( 'paused' ) && true !== $force ) { $summary['scan'] = 'paused'; return $summary; } LinkTender_Install::maybe_upgrade(); $budget = (int) LinkTender_Settings::get( 'time_budget', 20 ); if ( ! $this->lock( $budget + 60 ) ) { $summary['locked'] = true; return $summary; } $start = microtime( true ); $deadline = $start + $budget; // Content that changed during front-end requests (visitor comments, for example). $this->scanner->process_pending(); // Weekly safety rescan, in case content changed outside WordPress hooks. $state = LinkTender_Scanner::state(); if ( ! LinkTender_Scanner::is_running() && $state['finished'] && $state['finished'] < time() - WEEK_IN_SECONDS ) { LinkTender_Scanner::request_full_scan(); } if ( LinkTender_Scanner::is_running() ) { // Content parsing may use at most half the budget so checks keep moving. $done = $this->scanner->run( $start + $budget / 2 ); $summary['scan'] = $done ? 'finished' : 'running'; } $summary['checked'] = $this->check_due( $deadline ); update_option( self::LAST_RUN, array( 'time' => time(), 'checked' => $summary['checked'], 'scan' => $summary['scan'], 'seconds' => round( microtime( true ) - $start, 1 ), ), false ); $this->unlock(); delete_transient( 'linktender_broken_count' ); if ( true !== $force ) { $this->maybe_catch_up( $summary ); } /** * Fires after each background batch. * * @param array $summary Summary. */ do_action( 'linktender_batch_complete', $summary ); return $summary; } /** * Follow-up batch while there is a backlog (same as a regular run). * * @return void */ public function run_catchup() { $this->run(); } /** * While a scan is running or links are still due, schedules one extra batch a minute * from now instead of waiting for the regular five-minute run. Each batch stays time-boxed * and locked, so this only shortens the gap between batches. * * @param array $summary Batch summary. * @return void */ private function maybe_catch_up( array $summary ) { /** * Filters the pause before a follow-up batch while there is a backlog. Return 0 to disable. * * @param int $seconds Seconds. */ $delay = (int) apply_filters( 'linktender_catchup_delay', MINUTE_IN_SECONDS ); if ( $delay <= 0 ) { return; } $backlog = 'running' === $summary['scan'] || ( $summary['checked'] > 0 && $this->links->due( 1 ) ); if ( ! $backlog ) { return; } if ( self::use_action_scheduler() ) { if ( ! as_has_scheduled_action( self::CATCHUP, array(), self::AS_GROUP ) ) { as_schedule_single_action( time() + $delay, self::CATCHUP, array(), self::AS_GROUP ); } } elseif ( ! wp_next_scheduled( self::CATCHUP ) ) { wp_schedule_single_event( time() + $delay, self::CATCHUP ); } } /** * Checks due links until the deadline or batch size is reached. * * Links are checked in small parallel groups. A group never holds two links on the same * host, each host gets at most a few requests per batch, and requests to one host are * spaced by the "host_delay" setting. * * @param float $deadline microtime deadline. * @return int Links checked. */ private function check_due( $deadline ) { $settings = LinkTender_Settings::all(); $batch = (int) $settings['batch_size']; $delay = (int) $settings['host_delay']; $timeout = (int) $settings['timeout']; $concurrency = self::concurrency(); $per_host = array(); $last_hit = array(); $checked = 0; /** * Filters how many links on the same host are checked per batch. * * @param int $max Max requests per host per batch. */ $max_per_host = (int) apply_filters( 'linktender_max_per_host', 5 ); // Drop ignored and excluded links, and keep the per-host cap. When one busy host fills the // first window of due links, look further (up to three windows) for links on other hosts. $todo = array(); $seen = array(); for ( $window = 0; $window < 3; $window++ ) { if ( count( $todo ) >= $batch ) { break; } $full = array(); foreach ( $per_host as $host => $count ) { if ( $count >= $max_per_host ) { $full[] = (string) $host; } } $candidates = 0 === $window ? $this->links->due( $batch * 3 ) : $this->links->due( $batch * 3, $full ); $added = 0; foreach ( $candidates as $link ) { if ( count( $todo ) >= $batch ) { break; } if ( isset( $seen[ $link->id ] ) ) { continue; } $seen[ $link->id ] = true; if ( LinkTender_Settings::is_host_ignored( $link->host ) || LinkTender_Settings::is_url_excluded( $link->url ) || LinkTender_Links::is_admin_or_login( $link->url ) ) { $this->links->update( $link->id, array( 'next_check' => $this->gmdate_in( (int) $settings['recheck_hours'] * HOUR_IN_SECONDS ) ) ); continue; } $host = $link->host; if ( isset( $per_host[ $host ] ) && $per_host[ $host ] >= $max_per_host ) { continue; } $per_host[ $host ] = isset( $per_host[ $host ] ) ? $per_host[ $host ] + 1 : 1; $todo[] = $link; ++$added; } if ( count( $candidates ) < $batch * 3 || ( 0 === $added && $window > 0 ) ) { break; // Nothing more is due. } } unset( $candidates ); while ( $todo ) { if ( microtime( true ) + $timeout > $deadline ) { break; } // Next group: distinct hosts, up to the concurrency limit, oldest-due first. Hosts that // do not need a pause go first; a host that does is only picked to fill the group. $group = array(); $hosts = array(); $now = microtime( true ); foreach ( array( true, false ) as $ready_only ) { foreach ( $todo as $index => $link ) { if ( count( $group ) >= $concurrency ) { break 2; } if ( isset( $hosts[ $link->host ] ) ) { continue; } if ( $ready_only && $delay && isset( $last_hit[ $link->host ] ) && $last_hit[ $link->host ] + $delay > $now ) { continue; } $hosts[ $link->host ] = true; $group[ $index ] = $link; } if ( $group ) { break; } } // Space out requests to the same host. if ( $delay ) { $wait = 0; foreach ( array_keys( $hosts ) as $host ) { if ( isset( $last_hit[ $host ] ) ) { $wait = max( $wait, $last_hit[ $host ] + $delay - microtime( true ) ); } } if ( $wait > 0 ) { usleep( (int) ( $wait * 1000000 ) ); } } $urls = array(); foreach ( $group as $index => $link ) { $urls[ $index ] = $link->url; unset( $todo[ $index ] ); } $results = 1 === count( $urls ) ? array( key( $urls ) => $this->checker->check( reset( $urls ) ) ) : $this->checker->check_many( $urls ); $offline = false; foreach ( $group as $index => $link ) { $last_hit[ $link->host ] = microtime( true ); $this->apply_result( $link, $results[ $index ] ); ++$checked; if ( LinkTender_Checker::KIND_OFFLINE === $results[ $index ]['kind'] && 'offline' === $results[ $index ]['error_code'] ) { $offline = true; } } if ( $offline ) { // No point checking more links while this server cannot reach the internet. break; } } return $checked; } /** * How many links are checked at the same time (1 = one after another). * * @return int */ public static function concurrency() { /** * Filters how many links are checked in parallel. Requests in one group always go to different hosts. * * @param int $concurrency Parallel requests (1 to 10). */ $concurrency = (int) apply_filters( 'linktender_concurrency', (int) LinkTender_Settings::get( 'concurrency', 4 ) ); return max( 1, min( 10, $concurrency ) ); } /** * Retry delays (seconds after the previous failure) before a temporary failure counts as broken. * The defaults add up to 24 hours: 2h, 8h, 14h. * * @return int[] */ public static function retry_intervals() { /** * Filters retry delays for failing links. * * @param int[] $intervals Seconds. */ return array_values( (array) apply_filters( 'linktender_retry_intervals', array( 2 * HOUR_IN_SECONDS, 8 * HOUR_IN_SECONDS, 14 * HOUR_IN_SECONDS ) ) ); } /** * Stores a check result and decides the status and next check time. * * @param object $link Link row. * @param array $result Checker result. * @return void */ public function apply_result( $link, array $result ) { $recheck = (int) LinkTender_Settings::get( 'recheck_hours', 72 ) * HOUR_IN_SECONDS; $intervals = self::retry_intervals(); $now = current_time( 'mysql', true ); $previous = $link->status; $fails = (int) $link->fail_count; $data = array( 'http_code' => (int) $result['http_code'], 'error_code' => substr( (string) $result['error_code'], 0, 30 ), 'message' => mb_substr( wp_strip_all_tags( (string) $result['message'] ), 0, 250 ), 'final_url' => $result['final_url'] !== $link->url ? (string) $result['final_url'] : null, 'redirect_count' => min( 255, (int) $result['redirect_count'] ), 'response_ms' => (int) $result['response_ms'], 'check_count' => (int) $link->check_count + 1, 'last_checked' => $now, ); if ( LinkTender_Checker::KIND_OFFLINE === $result['kind'] ) { // Our own connection failed, not the link: keep what we knew and try again later. $data = array( 'next_check' => $this->gmdate_in( 'loopback' === $result['error_code'] ? 6 * HOUR_IN_SECONDS : HOUR_IN_SECONDS ), ); if ( ! in_array( $previous, array( 'ok', 'redirect', 'broken', 'unverified' ), true ) ) { $data['status'] = 'unverified'; $data['http_code'] = 0; $data['error_code'] = substr( (string) $result['error_code'], 0, 30 ); $data['message'] = mb_substr( wp_strip_all_tags( (string) $result['message'] ), 0, 250 ); $data['last_checked'] = $now; $data['check_count'] = (int) $link->check_count + 1; } } switch ( $result['kind'] ) { case LinkTender_Checker::KIND_OFFLINE: break; case LinkTender_Checker::KIND_OK: case LinkTender_Checker::KIND_REDIRECT: $data['status'] = $result['kind']; $data['fail_count'] = 0; $data['first_failure'] = null; $data['next_check'] = $this->gmdate_in( $recheck ); break; case LinkTender_Checker::KIND_BLOCKED: // Never "broken": we could not see the page, so we do not know. $data['status'] = 'unverified'; $data['fail_count'] = 0; $data['first_failure'] = null; $data['next_check'] = $this->gmdate_in( max( DAY_IN_SECONDS, $recheck ) ); break; case LinkTender_Checker::KIND_BROKEN: // A clear 404/410 is shown as broken at once, then re-confirmed on the retry schedule. ++$fails; $data['status'] = 'broken'; $data['fail_count'] = $fails; $data['first_failure'] = $link->first_failure ? $link->first_failure : $now; $data['next_check'] = $this->gmdate_in( isset( $intervals[ $fails - 1 ] ) ? $intervals[ $fails - 1 ] : $recheck ); break; default: // Temporary failure: retry over 24 hours before calling it broken. ++$fails; $data['fail_count'] = $fails; $data['first_failure'] = $link->first_failure ? $link->first_failure : $now; if ( $fails > count( $intervals ) ) { $data['status'] = 'broken'; $data['next_check'] = $this->gmdate_in( $recheck ); } else { // Keep a known-good status while retrying; new links show as unverified. $data['status'] = in_array( $previous, array( 'ok', 'redirect', 'broken' ), true ) ? $previous : 'unverified'; $data['next_check'] = $this->gmdate_in( $intervals[ $fails - 1 ] ); /* translators: 1: error message, 2: retry number, 3: total retries. */ $data['message'] = mb_substr( sprintf( __( '%1$s (retry %2$d of %3$d scheduled)', 'linktender' ), $data['message'], $fails, count( $intervals ) ), 0, 250 ); } } /** * Filters the row update before a check result is saved. * * @param array $data Columns to update. * @param object $link Link row before the check. * @param array $result Checker result. */ $data = apply_filters( 'linktender_link_update', $data, $link, $result ); $this->links->update( $link->id, $data ); /** * Fires after a link was checked. * * @param object $link Link row before the check. * @param array $data Saved columns. * @param array $result Checker result. */ do_action( 'linktender_link_checked', $link, $data, $result ); if ( isset( $data['status'] ) && $data['status'] !== $previous ) { /** * Fires when a link changes status (e.g. ok to broken). Useful for email alerts. * * @param int $link_id Link ID. * @param string $status New status. * @param string $previous Old status. */ do_action( 'linktender_link_status_changed', (int) $link->id, $data['status'], $previous ); } } /** * GMT MySQL datetime N seconds from now. * * @param int $seconds Seconds. * @return string */ private function gmdate_in( $seconds ) { return gmdate( 'Y-m-d H:i:s', time() + (int) $seconds ); } }