module = $module; $this->checks = $checks; } /** * Rows per statement. * * @since 1.0.0 * @return int */ public static function batch(): int { /** * Filter how many rows one Database clean-up statement deletes. Clamped to 50–5000; * checks that delete through an API use a smaller batch of their own, scaled the same way. * * @since 1.0.0 * @param int $rows Rows (default 500). */ return max( 50, min( 5000, (int) apply_filters( 'apwc_database_cleanup_batch', self::DEFAULT_BATCH ) ) ); } /** * Seconds one step keeps working. * * @since 1.0.0 * @return int */ public static function seconds(): int { /** * Filter how long (seconds) one Database clean-up scan or clean step keeps working before * it answers (the page then sends the next step). Clamped to 1–20. * * @since 1.0.0 * @param int $seconds Time budget (default 5). */ return max( 1, min( 20, (int) apply_filters( 'apwc_database_cleanup_step_seconds', 5 ) ) ); } /** * The last database error of this request ('' for none). * * @return string */ private static function db_error(): string { global $wpdb; return (string) $wpdb->last_error; } /** * Forget the last database error before a step. * * @return void */ private static function reset_db_error(): void { global $wpdb; $wpdb->last_error = ''; } /* ------------------------------------------------------------------ run state */ /** * The site's clean run, or null. * * @since 1.0.0 * @return array|null */ public static function run(): ?array { $run = get_option( self::RUN ); if ( ! is_array( $run ) || ! is_string( $run['token'] ?? null ) || ! is_string( $run['check'] ?? null ) ) { return null; } $run = array_merge( array( 'user' => 0, 'users' => array(), 'threshold' => null, 'state' => array(), 'deleted' => 0, 'skipped' => 0, 'notes' => array(), 'estimate' => 0, 'started' => 0, 'updated' => 0, 'status' => 'running', ), $run ); $run['users'] = array_values( array_unique( array_map( 'intval', array_filter( (array) $run['users'], 'is_numeric' ) ) ) ); $run['state'] = is_array( $run['state'] ) ? $run['state'] : array(); $run['notes'] = is_array( $run['notes'] ) ? $run['notes'] : array(); foreach ( array( 'user', 'deleted', 'skipped', 'estimate', 'started', 'updated' ) as $key ) { $run[ $key ] = is_numeric( $run[ $key ] ) ? (int) $run[ $key ] : 0; } return $run; } /** * Save the run. * * @param array $run Run. * @return void */ private static function save_run( array $run ): void { $run['updated'] = time(); update_option( self::RUN, $run, false ); // Adds it (not autoloaded) when missing. } /** * A 409/400/503 error. * * @param string $code Code. * @param string $message Message. * @param int $status HTTP status. * @param array $data Extra data. * @return WP_Error */ private static function error( string $code, string $message, int $status = 409, array $data = array() ): WP_Error { return new WP_Error( $code, $message, array_merge( array( 'status' => $status ), $data ) ); } /** * 409 while another step holds the lock. * * @return WP_Error */ private static function busy(): WP_Error { return self::error( 'apwc_dbc_busy', __( 'Another clean-up step is running. Trying again…', 'admin-powerups-for-woocommerce' ) ); } /* ------------------------------------------------------------------ scan */ /** * One bounded scan step. With no token a new scan starts (of `$ids`, or every check); with a * token the scan carries on. Read-only. * * @since 1.0.0 * @param string[] $ids Check IDs to scan ([] = all). * @param string $token Scan token from the last step, or ''. * @return array|WP_Error */ public function scan_step( array $ids, string $token ) { $lock = Lock::acquire( self::LOCK, 120 ); if ( '' === $lock ) { return self::busy(); } try { return $this->scan_locked( $ids, $token ); } finally { Lock::release( self::LOCK, $lock ); } } /** * Scan step under the lock. * * @param string[] $ids Check IDs. * @param string $token Token. * @return array|WP_Error */ private function scan_locked( array $ids, string $token ) { $results = Results::get(); $all = $this->checks->all(); if ( '' === $token ) { $full = ! $ids; $ids = $full ? array_keys( $all ) : array_values( array_intersect( array_keys( $all ), $ids ) ); // Checks above the user's level are left out, and their stored result is kept as it // was: a per-user reason never goes into the site-wide results. $ids = array_values( array_filter( $ids, fn( $id ) => DevGuard::can( $this->module, Checks::risk( $all[ $id ] ) ) ) ); $token = bin2hex( random_bytes( 16 ) ); Sizes::flush(); $results = array_merge( $results, array( 'token' => $token, 'started' => time(), 'full' => $full, 'queue' => $ids, 'sizes' => Sizes::read(), ) ); foreach ( $ids as $id ) { $check = $all[ $id ]; $reason = $check->unavailable(); $results['checks'][ $id ] = array( 'count' => 0, 'done' => '' !== $reason, 'state' => array(), 'threshold' => $check->threshold(), 'unavailable' => $reason, 'scanned_at' => '' !== $reason ? time() : 0, 'bytes' => 0, 'recheck' => false, ); } if ( in_array( 'as-complete', $ids, true ) ) { $results['failed'] = self::failed_actions(); } } elseif ( ! hash_equals( $results['token'], $token ) ) { return self::error( 'apwc_dbc_replaced', __( 'A newer scan started. This one stopped.', 'admin-powerups-for-woocommerce' ) ); } $start = microtime( true ); $budget = (float) self::seconds(); $current = ''; foreach ( $results['queue'] as $id ) { $entry = $results['checks'][ $id ] ?? null; if ( ! $entry || $entry['done'] ) { continue; } $check = $all[ $id ] ?? null; if ( ! $check ) { $results['checks'][ $id ] = array_merge( $entry, array( 'done' => true, 'unavailable' => __( 'This check is no longer available.', 'admin-powerups-for-woocommerce' ), 'scanned_at' => time(), ) ); continue; } $left = $budget - ( microtime( true ) - $start ); if ( $left <= 0 ) { break; } $current = $id; self::reset_db_error(); try { $counted = $check->count( $entry['state'], $left ); } catch ( \Throwable $e ) { unset( $e ); $counted = null; } if ( null === $counted || '' !== self::db_error() ) { // Never a misleading 0: say it couldn't be counted. $results['checks'][ $id ] = array_merge( $entry, array( 'count' => 0, 'done' => true, 'state' => array(), 'unavailable' => __( 'Couldn’t be counted: the database returned an error. Scan again.', 'admin-powerups-for-woocommerce' ), 'scanned_at' => time(), ) ); continue; } $entry = array_merge( $entry, array( 'count' => (int) $counted['count'], 'state' => (array) $counted['state'], 'done' => (bool) $counted['done'], ) ); if ( $entry['done'] ) { $tables = $check->tables(); $entry['sample_from'] = Results::sample_from( $entry['state'] ); $entry['state'] = array(); $entry['scanned_at'] = time(); $entry['bytes'] = $tables && $entry['count'] > 0 ? Sizes::bytes( $tables[0], $entry['count'] ) : 0; } $results['checks'][ $id ] = $entry; if ( ! $entry['done'] ) { break; // Budget spent inside this check. } } $queued = $results['queue']; $pending = array_filter( $queued, static fn( $id ) => isset( $results['checks'][ $id ] ) && ! $results['checks'][ $id ]['done'] ); $done = ! $pending; if ( $done ) { if ( $results['full'] ) { $results['finished'] = time(); // "Checked … ago" only moves for a scan of every check. } $results['queue'] = array(); } Results::save( $results ); if ( $done ) { /** * Fires after a Database clean-up scan finished. * * @since 1.0.0 * @param array $results Scan results (see Results::get()). */ do_action( 'apwc_database_cleanup_scanned', Results::get() ); } $counts = array(); foreach ( $queued as $id ) { if ( isset( $results['checks'][ $id ] ) ) { $counts[ $id ] = array( 'count' => '' === $results['checks'][ $id ]['unavailable'] ? $results['checks'][ $id ]['count'] : 0, 'done' => $results['checks'][ $id ]['done'], ); } } $check = '' !== $current ? ( $all[ $current ] ?? null ) : null; return array( 'done' => $done, 'token' => $token, 'current' => $check ? $check->label() : '', 'scanned' => count( $queued ) - count( $pending ), 'total' => count( $queued ), 'results' => $counts, 'finished' => $results['finished'], // Changes whenever a step moved on (the page's stall check compares answers). 'position' => '' !== $current ? $current . ':' . substr( md5( (string) wp_json_encode( $results['checks'][ $current ]['state'] ?? array() ) ), 0, 12 ) : '', ); } /** * Failed scheduled actions (one indexed count), or null when Action Scheduler can't be read. * * @return int|null */ private static function failed_actions(): ?int { global $wpdb; if ( '' !== ActionHistory::unavailable() ) { return null; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one indexed count per scan. return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE status = %s', ActionHistory::tables()['actions'], 'failed' ) ); } /* ------------------------------------------------------------------ clean */ /** * One bounded clean step of a check. No token starts a run; a token continues it. * * @since 1.0.0 * @param Check $check Check. * @param string $token Run token, or ''. * @param bool $backup Whether "I have a recent backup" is ticked. * @return array|WP_Error */ public function clean_step( Check $check, string $token, bool $backup ) { $lock = Lock::acquire( self::LOCK, 120 ); if ( '' === $lock ) { return self::busy(); } try { return $this->clean_locked( $check, $token, $backup ); } finally { Lock::release( self::LOCK, $lock ); } } /** * Clean step under the lock. * * @param Check $check Check. * @param string $token Token. * @param bool $backup Backup confirmed. * @return array|WP_Error */ private function clean_locked( Check $check, string $token, bool $backup ) { $id = $check->id(); $run = self::run(); if ( Checks::needs_backup( $check ) && ! $backup ) { return self::error( 'apwc_dbc_backup', __( 'Confirm you have a recent database backup first.', 'admin-powerups-for-woocommerce' ), 400 ); } if ( '' !== $token ) { if ( ! $run || ! hash_equals( (string) $run['token'], $token ) || $run['check'] !== $id ) { return self::error( 'apwc_dbc_replaced', __( 'This clean-up was replaced or discarded. Reload the page.', 'admin-powerups-for-woocommerce' ) ); } } else { if ( $run ) { $other = $this->checks->get( (string) $run['check'] ); return self::error( 'apwc_dbc_run_pending', sprintf( /* translators: %s: check name. */ __( '“%s” was stopped part-way. Continue or discard it first.', 'admin-powerups-for-woocommerce' ), $other ? $other->label() : (string) $run['check'] ), 409, array( 'check' => (string) $run['check'] ) ); } $entry = Results::for_check( $id ); $stale = Results::stale_reason( $check, $entry ); if ( '' !== $stale ) { return self::error( 'apwc_dbc_stale', $stale ); } if ( 0 === (int) $entry['count'] ) { return self::error( 'apwc_dbc_nothing', __( 'Nothing to clean.', 'admin-powerups-for-woocommerce' ) ); } $run = array( 'token' => bin2hex( random_bytes( 16 ) ), 'check' => $id, 'user' => get_current_user_id(), 'users' => array(), 'threshold' => $check->threshold(), 'state' => array(), 'deleted' => 0, 'skipped' => 0, 'notes' => array(), 'estimate' => (int) $entry['count'], 'started' => time(), 'status' => 'running', ); } // Everyone who runs a step is recorded (the history lists them). $run['users'] = array_values( array_unique( array_merge( (array) $run['users'], array( get_current_user_id() ) ) ) ); $reason = $check->unavailable(); if ( '' !== $reason ) { $run['status'] = 'stopped'; self::save_run( $run ); return self::error( 'apwc_dbc_unavailable', $reason ); } if ( $check->threshold() !== $run['threshold'] ) { $run['status'] = 'stopped'; self::save_run( $run ); return self::error( 'apwc_dbc_stale', __( 'The age setting changed during the clean-up. Discard it and scan again.', 'admin-powerups-for-woocommerce' ) ); } $run['status'] = 'running'; self::reset_db_error(); try { $step = $check->clean( (array) $run['state'], self::batch(), (float) self::seconds() ); } catch ( \Throwable $e ) { unset( $e ); $run['status'] = 'stopped'; self::save_run( $run ); return self::error( 'apwc_dbc_failed', __( 'The clean-up step failed. Try again later.', 'admin-powerups-for-woocommerce' ), 500 ); } $run = self::add_step( $run, $step ); if ( '' !== self::db_error() ) { $run['status'] = 'stopped'; self::save_run( $run ); return self::error( 'apwc_dbc_db_busy', __( 'The database is busy. Try again later; what was cleaned so far stays cleaned.', 'admin-powerups-for-woocommerce' ), 503, array( 'deleted' => (int) $step['deleted'] ) ); } /** * Fires after one Database clean-up step deleted rows (or none). * * @since 1.0.0 * @param string $check_id Check ID. * @param int $deleted Rows deleted in this step. */ do_action( 'apwc_database_cleanup_step', $id, (int) $step['deleted'] ); $done = (bool) $step['done']; if ( $done ) { delete_option( self::RUN ); $this->finish( $check, $run, 'done', 'manual' ); $this->after_clean( $check, (int) $run['deleted'] ); } else { self::save_run( $run ); } return array( 'deleted' => (int) $step['deleted'], 'total' => (int) $run['deleted'], 'skipped' => (int) $run['skipped'], 'left_estimate' => $run['estimate'] >= 0 ? max( 0, (int) $run['estimate'] - (int) $run['deleted'] ) : null, 'estimate' => (int) $run['estimate'], 'done' => $done, 'token' => $done ? '' : (string) $run['token'], 'notes' => $run['notes'], // Changes whenever a step moved on (the page's stall check compares answers). 'position' => substr( md5( (string) wp_json_encode( $run['state'] ) ), 0, 12 ), ); } /** * Add a step's result to a run (or a weekly cycle). * * @param array $run Run. * @param array $step Step result. * @return array */ private static function add_step( array $run, array $step ): array { $run['deleted'] = (int) ( $run['deleted'] ?? 0 ) + (int) ( $step['deleted'] ?? 0 ); $run['skipped'] = (int) ( $run['skipped'] ?? 0 ) + (int) ( $step['skipped'] ?? 0 ); $notes = is_array( $run['notes'] ?? null ) ? $run['notes'] : array(); foreach ( (array) ( $step['notes'] ?? array() ) as $note => $count ) { $notes[ (string) $note ] = (int) ( $notes[ (string) $note ] ?? 0 ) + (int) $count; } $run['notes'] = $notes; $run['state'] = (array) ( $step['state'] ?? array() ); return $run; } /** * Refresh a check's result after a clean: fast checks are counted again now, chunked ones * are marked "scan again to confirm". * * @param Check $check Check. * @param int $deleted Rows deleted by the run. * @return void */ private function after_clean( Check $check, int $deleted ): void { $entry = Results::for_check( $check->id() ); if ( ! $entry ) { return; } if ( ! $check->slow() ) { self::reset_db_error(); $counted = $check->count( array(), (float) self::seconds() ); if ( $counted['done'] && '' === self::db_error() ) { $tables = $check->tables(); Results::update_check( $check->id(), array( 'count' => (int) $counted['count'], 'done' => true, 'state' => array(), 'scanned_at' => time(), 'recheck' => false, 'bytes' => $tables && $counted['count'] > 0 ? Sizes::bytes( $tables[0], (int) $counted['count'] ) : 0, 'sample_from' => Results::sample_from( (array) $counted['state'] ), ) ); return; } } $count = max( 0, (int) $entry['count'] - $deleted ); Results::update_check( $check->id(), array( 'count' => $entry['count'] < 0 ? -1 : $count, 'recheck' => true, ) ); } /** * Write the history entry of a finished, stopped or failed run and fire the action. * * @param Check $check Check. * @param array $run Run. * @param string $status done | stopped | failed. * @param string $source manual | scheduled. * @param int $discarded_by User who discarded the run (0: none). * @return void */ private function finish( Check $check, array $run, string $status, string $source, int $discarded_by = 0 ): void { $tables = $check->tables(); History::add( array( 'check' => $check->id(), 'label' => $check->label(), 'risk' => Checks::risk( $check ), 'threshold' => $run['threshold'] ?? null, 'deleted' => (int) ( $run['deleted'] ?? 0 ), 'skipped' => (int) ( $run['skipped'] ?? 0 ), 'notes' => (array) ( $run['notes'] ?? array() ), 'bytes' => $tables ? Sizes::bytes( $tables[0], (int) ( $run['deleted'] ?? 0 ) ) : 0, 'user' => (int) ( $run['user'] ?? 0 ), 'users' => array_values( array_filter( array_map( 'intval', (array) ( $run['users'] ?? array() ) ) ) ), 'discarded_by' => $discarded_by, 'source' => $source, 'started' => (int) ( $run['started'] ?? 0 ), 'finished' => time(), 'status' => $status, ) ); /** * Fires when a Database clean-up run of a check ends. * * @since 1.0.0 * @param string $check_id Check ID. * @param int $total_deleted Rows deleted by the whole run. * @param string $status `done`, `stopped` (discarded part-way) or `failed` (the * weekly clean-up gave up on it). * @param string $source `manual` or `scheduled`. */ do_action( 'apwc_database_cleanup_finished', $check->id(), (int) ( $run['deleted'] ?? 0 ), $status, $source ); } /** * Discard the stopped run (nothing is undone; the history notes what it deleted and who * discarded it). * * @since 1.0.0 * @return array|WP_Error 409 busy while a step runs, 403 without the run's * level and object capability. */ public function discard() { $lock = Lock::acquire( self::LOCK, 120 ); if ( '' === $lock ) { return self::busy(); } try { $run = self::run(); if ( ! $run ) { delete_option( self::RUN ); // Nothing usable (or corrupt): nothing to keep. return array( 'check' => '' ); } // Checked here too, under the lock: the Data section and a run started meanwhile go through this. $check = $this->checks->get( (string) $run['check'] ); $level = $check ? Checks::risk( $check ) : DevGuard::ADVANCED; $cap = $check ? $check->object_cap() : ''; if ( ! DevGuard::can( $this->module, $level ) || ( '' !== $cap && ! current_user_can( $cap ) ) ) { return self::error( 'apwc_dbc_forbidden', __( 'Only someone who can clean this check can discard its clean-up.', 'admin-powerups-for-woocommerce' ), 403 ); } delete_option( self::RUN ); if ( $check ) { $this->finish( $check, $run, 'stopped', 'manual', get_current_user_id() ); if ( (int) $run['deleted'] > 0 ) { $this->after_clean( $check, (int) $run['deleted'] ); } } return array( 'check' => (string) $run['check'] ); } finally { Lock::release( self::LOCK, $lock ); } } /* ------------------------------------------------------------------ weekly schedule */ /** * Check IDs the schedule may clean now: selected, core, Safe and available. * * @since 1.0.0 * @return string[] */ public function scheduled_ids(): array { $selected = Str::scalars( (array) $this->module->setting( 'scheduled_checks', array() ) ); $out = array(); foreach ( array_keys( $this->checks->schedulable() ) as $id ) { $check = $this->checks->get( $id ); if ( ! in_array( $id, $selected, true ) || ! $check || ! $this->checks->is_core( $check ) || DevGuard::SAFE !== Checks::risk( $check ) ) { continue; } $out[] = $id; } return $out; } /** * A new weekly cycle. * * @return array */ private function new_cycle(): array { return array_merge( self::cycle_template(), array( 'queue' => $this->scheduled_ids() ) ); } /** * Keys of a weekly cycle with empty values. * * @return array */ private static function cycle_template(): array { return array_merge( array( 'queue' => array(), 'retry' => array(), 'attempts' => array(), ), self::fresh_cycle_totals() ); } /** * A stored cycle with the right types (another version, a manual edit or an import may have * stored anything): check IDs as strings, attempts as ints, totals as ints. Null when there * is no usable queue. * * @param mixed $stored Stored option. * @return array|null */ private static function normalize_cycle( $stored ): ?array { if ( ! is_array( $stored ) || ! is_array( $stored['queue'] ?? null ) ) { return null; } $ids = static fn( $items ) => array_values( array_unique( array_filter( is_array( $items ) ? $items : array(), static fn( $id ) => is_string( $id ) && (bool) preg_match( '/^' . Checks::ID_PATTERN . '$/', $id ) ) ) ); $attempts = array(); foreach ( is_array( $stored['attempts'] ?? null ) ? $stored['attempts'] : array() as $id => $n ) { if ( is_string( $id ) && is_numeric( $n ) ) { $attempts[ $id ] = (int) $n; } } $notes = array(); foreach ( is_array( $stored['notes'] ?? null ) ? $stored['notes'] : array() as $key => $n ) { if ( is_string( $key ) && is_numeric( $n ) ) { $notes[ $key ] = (int) $n; } } return array( 'queue' => $ids( $stored['queue'] ), 'retry' => $ids( $stored['retry'] ?? array() ), 'attempts' => $attempts, 'state' => is_array( $stored['state'] ?? null ) ? $stored['state'] : array(), 'deleted' => is_numeric( $stored['deleted'] ?? null ) ? (int) $stored['deleted'] : 0, 'skipped' => is_numeric( $stored['skipped'] ?? null ) ? (int) $stored['skipped'] : 0, 'notes' => $notes, 'started' => is_numeric( $stored['started'] ?? null ) ? (int) $stored['started'] : time(), ); } /** * The weekly job (and its follow-ups): clean the selected Safe checks within the budget, * then queue one follow-up (a minute later) while work is left. Does nothing while the * schedule is off. A check that hits a database error or stops making progress is written * to the history as failed and retried once, 15 minutes after the rest. * * @since 1.0.0 * @param bool $follow_up True for a follow-up job (carries on; never starts a new cycle). * @return void */ public function run_scheduled( bool $follow_up = false ): void { if ( ! $this->module->setting( 'schedule', false ) ) { delete_option( self::SCHEDULE ); return; } $lock = Lock::acquire( self::LOCK, 120 ); if ( '' === $lock ) { if ( ! $follow_up ) { // Keep this week: the follow-up starts the cycle (add_option never replaces one in progress). add_option( self::SCHEDULE, array( 'start' => true ), '', false ); } Module::queue_follow_up( 5 * MINUTE_IN_SECONDS ); return; } try { $stored = get_option( self::SCHEDULE ); $cycle = self::normalize_cycle( $stored ); if ( is_array( $stored ) && ! empty( $stored['start'] ) ) { $cycle = $this->new_cycle(); // Started while the lock was busy. } elseif ( null === $cycle ) { if ( false !== $stored ) { delete_option( self::SCHEDULE ); // Unusable: repaired by starting over next week. } if ( $follow_up ) { return; } $cycle = $this->new_cycle(); } $allowed = $this->scheduled_ids(); $run = self::run(); $start = microtime( true ); $budget = (float) self::seconds(); while ( $cycle['queue'] && microtime( true ) - $start < $budget ) { $id = (string) $cycle['queue'][0]; $check = $this->checks->get( $id ); // Checked again every time: only core Safe checks that are selected and available. if ( ! $check || ! in_array( $id, $allowed, true ) || '' !== $check->unavailable() || ( $run && $run['check'] === $id ) ) { array_shift( $cycle['queue'] ); $cycle = array_merge( $cycle, self::fresh_cycle_totals() ); continue; } $before = md5( (string) wp_json_encode( $cycle['state'] ) ); self::reset_db_error(); $failed = false; try { $step = $check->clean( (array) $cycle['state'], self::batch(), $budget - ( microtime( true ) - $start ) ); } catch ( \Throwable $e ) { unset( $e ); $failed = true; $step = self::no_step( array( 'error' => 1 ) ); } if ( '' !== self::db_error() ) { $failed = true; $step['notes']['error'] = (int) ( $step['notes']['error'] ?? 0 ) + 1; } // No progress: not done, nothing deleted or skipped, no notes, no progress flag, same state. $stuck = ! $step['done'] && 0 === (int) $step['deleted'] && 0 === (int) $step['skipped'] && empty( $step['notes'] ) && empty( $step['progress'] ) && md5( (string) wp_json_encode( (array) $step['state'] ) ) === $before; if ( $stuck ) { $failed = true; $step['notes']['no_progress'] = 1; } $cycle = self::add_step( $cycle, $step ); do_action( 'apwc_database_cleanup_step', $id, (int) $step['deleted'] ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- documented in clean_locked(). if ( $failed ) { $this->finish_scheduled( $check, $cycle, 'failed' ); array_shift( $cycle['queue'] ); if ( (int) ( $cycle['attempts'][ $id ] ?? 0 ) < 1 ) { $cycle['attempts'][ $id ] = 1; $cycle['retry'][] = $id; } $cycle = array_merge( $cycle, self::fresh_cycle_totals() ); break; // A database in trouble: don't go on in this job. } if ( ! $step['done'] ) { break; } if ( $cycle['deleted'] > 0 || $cycle['skipped'] > 0 ) { $this->finish_scheduled( $check, $cycle, 'done' ); } array_shift( $cycle['queue'] ); $cycle = array_merge( $cycle, self::fresh_cycle_totals() ); } $delay = self::FOLLOW_UP_DELAY; if ( ! $cycle['queue'] && $cycle['retry'] ) { $cycle['queue'] = array_values( array_unique( $cycle['retry'] ) ); $cycle['retry'] = array(); $delay = self::RETRY_DELAY; } if ( ! $cycle['queue'] ) { delete_option( self::SCHEDULE ); return; } update_option( self::SCHEDULE, $cycle, false ); Module::queue_follow_up( $delay ); } finally { Lock::release( self::LOCK, $lock ); } } /** * History entry of a check the weekly job finished or gave up on, and its scan result * marked "scan again". * * @param Check $check Check. * @param array $cycle Cycle totals. * @param string $status done | failed. * @return void */ private function finish_scheduled( Check $check, array $cycle, string $status ): void { $this->finish( $check, array( 'threshold' => $check->threshold(), 'deleted' => (int) $cycle['deleted'], 'skipped' => (int) $cycle['skipped'], 'notes' => (array) $cycle['notes'], 'user' => 0, 'started' => (int) $cycle['started'], ), $status, 'scheduled' ); $entry = Results::for_check( $check->id() ); if ( $entry && (int) $cycle['deleted'] > 0 ) { Results::update_check( $check->id(), array( 'count' => $entry['count'] < 0 ? -1 : max( 0, (int) $entry['count'] - (int) $cycle['deleted'] ), 'recheck' => true, ) ); } } /** * An empty step result. * * @param array $notes Notes. * @return array{deleted: int, skipped: int, notes: array, done: bool, state: array} */ private static function no_step( array $notes = array() ): array { return array( 'deleted' => 0, 'skipped' => 0, 'notes' => $notes, 'done' => false, 'state' => array(), ); } /** * Per-check totals reset when the schedule moves on to the next check. * * @return array{state: array, deleted: int, skipped: int, notes: array, started: int} */ private static function fresh_cycle_totals(): array { return array( 'state' => array(), 'deleted' => 0, 'skipped' => 0, 'notes' => array(), 'started' => time(), ); } }