runs = $runs; $this->tasks = $tasks; $this->findings = $findings; $this->crawl_pages = $crawl_pages; $this->crawler = $crawler; $this->analyzers = $analyzers; $this->checkers = $checkers; $this->registry = $registry; $this->coordinator = $coordinator; $this->transaction = $transaction; } public function run_one( string $run_uuid ): array { $run = $this->runs->get_by_uuid( $run_uuid ); if ( null === $run ) { throw new StorageException( 'run_not_found' ); } if ( RunStatus::RUNNING !== $run->status() ) { return $this->result( 'idle', $run->status(), null ); } $lease = $this->tasks->acquire_next( $run->id(), 60 ); if ( null === $lease ) { $settled = $this->coordinator->settle( $run_uuid ); return $this->result( 'idle', $settled->status(), null ); } $is_analyzer = in_array( $lease->check_id(), $this->analyzers->ids(), true ); if ( ! $is_analyzer && null === $this->checkers->get( $lease->check_id() ) ) { $this->tasks->fail( $lease, 'checker_capability_denied' ); $settled = $this->coordinator->settle( $run_uuid ); return $this->result( 'failed', $settled->status(), $lease->check_id() ); } $started = microtime( true ); $memory_start = memory_get_usage( true ); try { $this->transaction->begin(); if ( ! $is_analyzer ) { $state = $this->run_local_checker( $run->id(), $lease, $started, $memory_start ); } elseif ( self::CRAWL_TASK_ID === $lease->check_id() ) { $state = $this->run_crawl_chunk( $run->id(), $lease, $started, $memory_start ); } else { $state = $this->run_analyzer( $run->id(), $lease, $started, $memory_start ); } $this->transaction->commit(); } catch ( BudgetExceeded $exception ) { $this->transaction->rollback(); $this->crawl_pages->clear_pending( $run->id(), 'run_budget_exceeded' ); $this->tasks->fail( $lease, 'checker_budget_exceeded' ); $settled = $this->coordinator->settle( $run_uuid ); return $this->result( 'failed', $settled->status(), $lease->check_id() ); } catch ( Throwable $exception ) { $this->transaction->rollback(); $this->crawl_pages->clear_pending( $run->id(), 'run_failed' ); $this->tasks->fail( $lease, 'checker_failed' ); $settled = $this->coordinator->settle( $run_uuid ); return $this->result( 'failed', $settled->status(), $lease->check_id() ); } $settled = $this->coordinator->settle( $run_uuid ); return $this->result( $state, $settled->status(), $lease->check_id() ); } private function run_local_checker( int $run_id, TaskLease $lease, float $started, int $memory_start ): string { $checker = $this->checkers->get( $lease->check_id() ); if ( null === $checker ) { throw new StorageException( 'checker_unavailable' ); } $budget = $this->registry->local_budget_for( $lease->check_id() ); $finding = $checker->execute( $this->timestamp() ); $encoded = wp_json_encode( $finding, JSON_UNESCAPED_SLASHES ); $item_count = isset( $finding['coverage']['observed'] ) ? max( 1, (int) $finding['coverage']['observed'] ) : 1; if ( false === $encoded ) { throw new StorageException( 'finding_encode_failed' ); } $usage = $this->measured_usage( array( 'milliseconds' => 0, 'items' => $item_count, 'requests' => 0, 'bytes' => strlen( $encoded ), ), $started, $memory_start ); foreach ( $usage as $dimension => $amount ) { $budget = $budget->consume( $dimension, $amount ); } $this->runs->consume_budget( $run_id, $usage ); $this->findings->save( $run_id, $finding ); if ( ! $this->tasks->complete( $lease, array( 'finding_count' => 1 ) ) ) { throw new StorageException( 'lease_expired_before_completion' ); } return 'completed'; } private function run_crawl_chunk( int $run_id, TaskLease $lease, float $started, int $memory_start ): string { $run_budget = $this->runs->budget( $run_id ); $remaining = $run_budget->remaining( 'requests' ); $analysis_reserve = $this->registry->same_site_analysis_reserve( count( $this->analyzers->ids() ) ); if ( $run_budget->remaining( 'milliseconds' ) <= $analysis_reserve + self::CRAWL_CHUNK_TIME_GUARD_MS ) { // Finalize the bounded crawl as partial instead of allowing one more // slow request to consume the time reserved for local conclusions. $remaining = 0; } $chunk = $this->crawler->process_one( $run_id, $lease->cursor(), $remaining ); $usage = $this->measured_usage( $chunk->usage(), $started, $memory_start ); if ( 'active' === $chunk->state() ) { $this->runs->consume_budget( $run_id, $usage ); if ( ! $this->tasks->checkpoint_progress( $lease, $chunk->cursor() ) ) { throw new StorageException( 'lease_expired_before_checkpoint' ); } return 'progress'; } if ( 'budget_exhausted' === $chunk->state() ) { $this->crawl_pages->clear_pending( $run_id, 'crawl_budget_exhausted' ); } $snapshot = $this->snapshot( $run_id ); $finding = $this->analyzers->analyze( self::CRAWL_TASK_ID, $snapshot, $this->timestamp() ); $usage['items'] += count( $snapshot ); $encoded = wp_json_encode( $finding, JSON_UNESCAPED_SLASHES ); if ( false === $encoded ) { throw new StorageException( 'finding_encode_failed' ); } $usage['bytes'] = max( $usage['bytes'], strlen( $encoded ) ); $this->runs->consume_budget( $run_id, $usage ); $this->findings->save( $run_id, $finding ); if ( ! $this->tasks->complete( $lease, array( 'finding_count' => 1, 'crawl' => $chunk->cursor(), ) ) ) { throw new StorageException( 'lease_expired_before_completion' ); } return 'completed'; } private function run_analyzer( int $run_id, TaskLease $lease, float $started, int $memory_start ): string { $snapshot = $this->snapshot( $run_id ); $finding = $this->analyzers->analyze( $lease->check_id(), $snapshot, $this->timestamp() ); $encoded = wp_json_encode( $finding, JSON_UNESCAPED_SLASHES ); if ( false === $encoded ) { throw new StorageException( 'finding_encode_failed' ); } $usage = $this->measured_usage( array( 'milliseconds' => 0, 'items' => count( $snapshot ), 'requests' => 0, 'bytes' => strlen( $encoded ), ), $started, $memory_start ); $this->runs->consume_budget( $run_id, $usage ); $this->findings->save( $run_id, $finding ); if ( ! $this->tasks->complete( $lease, array( 'finding_count' => 1 ) ) ) { throw new StorageException( 'lease_expired_before_completion' ); } return 'completed'; } /** * @return list,http_status:int|null,content_class:string,error_code:string|null}> */ private function snapshot( int $run_id ): array { $snapshot = array(); for ( $offset = 0; $offset < 500; $offset += 100 ) { $rows = $this->crawl_pages->snapshot( $run_id, $offset, 100 ); $snapshot = array_merge( $snapshot, $rows ); if ( count( $rows ) < 100 ) { break; } } return $snapshot; } /** * @param array{milliseconds:int,items:int,requests:int,bytes:int} $usage Declared chunk usage. * @return array{milliseconds:int,items:int,requests:int,bytes:int} */ private function measured_usage( array $usage, float $started, int $memory_start ): array { $usage['milliseconds'] = max( $usage['milliseconds'], (int) ceil( ( microtime( true ) - $started ) * 1000 ) ); $usage['bytes'] = max( $usage['bytes'], max( 0, memory_get_usage( true ) - $memory_start ) ); return $usage; } private function timestamp(): string { return mysql_to_rfc3339( current_time( 'mysql', true ) ); } /** * @return array{state:string,run_status:string,check_id:string|null} */ private function result( string $state, string $run_status, ?string $check_id ): array { return array( 'state' => $state, 'run_status' => $run_status, 'check_id' => $check_id, ); } }