*/ private array $tables; public function __construct( wpdb $database ) { $this->database = $database; $this->tables = Schema::tables( $database ); } /** * Clear completed task cursors and remove a bounded set of expired runs. * * @return array{task_cursors:int,runs:int} */ public function cleanup_expired( int $batch_size = 25 ): array { if ( $batch_size < 1 || $batch_size > 100 ) { throw new InvalidArgumentException( 'invalid_cleanup_batch' ); } $task_cutoff = gmdate( 'Y-m-d H:i:s', time() - ( self::TASK_CURSOR_RETENTION_HOURS * HOUR_IN_SECONDS ) ); // Table name is selected from the compiled Schema allowlist. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $task_result = $this->database->query( $this->database->prepare( "UPDATE {$this->tables['tasks']} SET cursor_json = %s, lock_hash = NULL, lock_expires_at = NULL WHERE status = %s AND updated_at < %s AND cursor_json <> %s LIMIT %d", '{}', TaskStatus::COMPLETED, $task_cutoff, '{}', $batch_size ) ); if ( false === $task_result ) { throw new StorageException( 'task_cleanup_failed' ); } $run_ids = $this->expired_run_ids( $batch_size ); foreach ( $run_ids as $run_id ) { $this->delete_run_by_id( $run_id ); } return array( 'task_cursors' => $task_result, 'runs' => count( $run_ids ), ); } public function delete_run( string $uuid ): bool { if ( ! wp_is_uuid( $uuid, 4 ) ) { return false; } // Table name is selected from the compiled Schema allowlist. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $run_id = $this->database->get_var( $this->database->prepare( "SELECT id FROM {$this->tables['runs']} WHERE run_uuid = %s LIMIT 1", $uuid ) ); if ( null === $run_id ) { return false; } $this->delete_run_by_id( (int) $run_id ); return true; } /** * @return list */ private function expired_run_ids( int $batch_size ): array { $failed_cutoff = gmdate( 'Y-m-d H:i:s', time() - ( self::FAILED_RETENTION_DAYS * DAY_IN_SECONDS ) ); // Both branches select only IDs from the compiled runs table. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $ids = $this->database->get_col( $this->database->prepare( "(SELECT id FROM {$this->tables['runs']} WHERE status IN (%s, %s) AND updated_at < %s ORDER BY id ASC LIMIT %d) UNION (SELECT id FROM {$this->tables['runs']} WHERE status = %s ORDER BY completed_at DESC, id DESC LIMIT 18446744073709551615 OFFSET %d) LIMIT %d", RunStatus::FAILED, RunStatus::CANCELLED, $failed_cutoff, $batch_size, RunStatus::COMPLETED, self::DEFAULT_REPORT_LIMIT, $batch_size ) ); return array_values( array_map( 'intval', $ids ) ); } private function delete_run_by_id( int $run_id ): void { if ( $run_id < 1 ) { throw new InvalidArgumentException( 'invalid_run_id' ); } // All statements target the compiled plugin-owned tables only. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $this->database->query( 'START TRANSACTION' ); $statements = array( $this->database->prepare( "DELETE FROM {$this->tables['code_symbols']} WHERE run_id = %d", $run_id ), $this->database->prepare( "DELETE FROM {$this->tables['code_files']} WHERE run_id = %d", $run_id ), $this->database->prepare( "DELETE FROM {$this->tables['crawl_pages']} WHERE run_id = %d", $run_id ), $this->database->prepare( "DELETE FROM {$this->tables['decisions']} WHERE finding_id IN (SELECT id FROM {$this->tables['findings']} WHERE run_id = %d)", $run_id ), $this->database->prepare( "DELETE FROM {$this->tables['findings']} WHERE run_id = %d", $run_id ), $this->database->prepare( "DELETE FROM {$this->tables['assertions']} WHERE run_id = %d", $run_id ), $this->database->prepare( "DELETE FROM {$this->tables['operations']} WHERE run_id = %d", $run_id ), $this->database->prepare( "DELETE FROM {$this->tables['tasks']} WHERE run_id = %d", $run_id ), $this->database->prepare( "DELETE FROM {$this->tables['runs']} WHERE id = %d", $run_id ), ); foreach ( $statements as $statement ) { if ( false === $this->database->query( $statement ) ) { $this->database->query( 'ROLLBACK' ); throw new StorageException( 'run_delete_failed' ); } } $this->database->query( 'COMMIT' ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared } }