0, 'deleted' => 0, 'skipped' => 0, ); try { global $wpdb; $log_table = $wpdb->prefix . 'prautoblogger_generation_log'; $grace = gmdate( 'Y-m-d H:i:s', time() - self::GRACE_WINDOW_SECONDS ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $orphans = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT id, run_id, created_at FROM {$log_table} WHERE stage = 'llm_research' AND post_id IS NULL AND created_at < %s", $grace ) ); if ( ! is_array( $orphans ) ) { return $stats; } $stale_cutoff = time() - self::STALE_WINDOW_SECONDS; foreach ( $orphans as $orphan ) { self::handle_orphan( $orphan, $stale_cutoff, $log_table, $stats ); } } catch ( \Throwable $e ) { PRAutoBlogger_Logger::instance()->warning( 'Reaper encountered DB error: ' . $e->getMessage(), 'cost-tracker' ); } return $stats; } /** * Route one orphan row: amortize if siblings exist, delete if stale, * otherwise skip and try again next run. * * @param object $orphan DB row with id, run_id, created_at. * @param int $stale_cutoff Unix timestamp — orphans older than this delete. * @param string $log_table Fully-qualified gen_log table name. * @param array $stats Counters, mutated by reference. * @return void */ private static function handle_orphan( object $orphan, int $stale_cutoff, string $log_table, array &$stats ): void { global $wpdb; $run_id = (string) ( $orphan->run_id ?? '' ); $created_ts = strtotime( (string) ( $orphan->created_at ?? '' ) ); $is_stale = false !== $created_ts && $created_ts < $stale_cutoff; if ( '' === $run_id ) { if ( $is_stale ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->delete( $log_table, array( 'id' => (int) $orphan->id ) ); ++$stats['deleted']; } else { ++$stats['skipped']; } return; } $post_ids = self::find_posts_for_run_id( $run_id ); if ( count( $post_ids ) >= 1 ) { PRAutoBlogger_Post_Assembler::amortize_research_costs( $run_id ); PRAutoBlogger_Logger::instance()->info( sprintf( 'Reaped research cost across %d articles for run %s.', count( $post_ids ), $run_id ), 'cost-tracker' ); ++$stats['reaped']; return; } if ( $is_stale ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->delete( $log_table, array( 'id' => (int) $orphan->id ) ); PRAutoBlogger_Logger::instance()->info( sprintf( 'Deleted 7-day-stale orphan research row for run %s — no articles to attribute.', $run_id ), 'cost-tracker' ); ++$stats['deleted']; return; } ++$stats['skipped']; } /** * Return distinct post IDs for a given run_id. Prefers post_meta; * falls back to gen_log rows for legacy posts. * * @param string $run_id Pipeline run UUID. * @return int[] Distinct post IDs. */ private static function find_posts_for_run_id( string $run_id ): array { $meta_ids = get_posts( array( 'post_type' => 'any', 'post_status' => 'any', 'meta_key' => '_prautoblogger_run_id', 'meta_value' => $run_id, 'fields' => 'ids', 'posts_per_page' => -1, 'suppress_filters' => true, ) ); if ( is_array( $meta_ids ) && ! empty( $meta_ids ) ) { return array_map( 'intval', $meta_ids ); } global $wpdb; $log_table = $wpdb->prefix . 'prautoblogger_generation_log'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $ids = $wpdb->get_col( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT DISTINCT post_id FROM {$log_table} WHERE run_id = %s AND post_id IS NOT NULL", $run_id ) ); return is_array( $ids ) ? array_map( 'intval', $ids ) : array(); } }