From da7634c528cfd8dfdb762766f3037d92126c1edc Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Tue, 21 Apr 2026 02:23:55 +0700 Subject: [PATCH] feat(cost): daily reaper for orphan llm_research rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the orphan-research-row reaper per thread research-cost-reaper seq 1. When a pipeline dies between post creation and `Post_Assembler::amortize_research_costs()` (Hostinger exec kill, OOM, LiteSpeed cut, fatal), the shared research row stays in `wp_prautoblogger_generation_log` with `post_id = NULL` and sibling articles' cost popovers understate their true spend. New class `PRAutoBlogger_Research_Reaper` owns: - `on_cron()` — WP-Cron entrypoint hooked to the daily `prautoblogger_reap_orphan_research_rows` event (already scheduled by the Activator in the previous commit at 03:15). - `reap()` — returns a `{reaped, deleted, skipped}` summary. For each orphan `llm_research` row older than 1 hour (grace window for live pipelines): - `_prautoblogger_run_id` post_meta primary lookup. - Falls back to distinct post_ids in gen_log with the same run_id. - If ≥1 post found → delegate to the existing idempotent `Post_Assembler::amortize_research_costs()`. That method inserts the per-article rows and deletes the original orphan in one pass, so the reaper is idempotent on re-run. - If 0 posts found AND orphan is >7 days old → delete outright as sunk cost with nothing to attribute to. - Else → skip; try again the next day. - Never throws. DB errors caught and logged WARNING. New WP-CLI command `wp prautoblogger reap-research` registered in the main loader when `WP_CLI` is defined — manual ops trigger that prints the same summary. Departure from the CTO's spec: the reap logic lives on `Research_Reaper` instead of `Post_Assembler`. Placing it on Post_Assembler pushed that file to 391 lines, over the 300-line cap. Moving it to the thin class colocates the attribution logic with the cron/CLI plumbing that triggers it. `amortize_research_costs()` stays on Post_Assembler — the reaper delegates to it unchanged. Flagged for CTO review. Part of the v0.8.1 bundle (thread research-cost-reaper). Co-Authored-By: Claude Opus 4.7 (1M context) --- includes/class-prautoblogger.php | 18 +++ includes/core/class-research-reaper.php | 183 ++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 includes/core/class-research-reaper.php diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index 2a26c16..7441c58 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -116,6 +116,24 @@ class PRAutoBlogger { // Single-idea generation from the Ideas browser page. add_action( 'prautoblogger_generate_from_idea', [ 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ] ); + + // v0.8.1: daily reaper for orphan `llm_research` rows (when a + // pipeline dies before amortize_research_costs runs). + add_action( 'prautoblogger_reap_orphan_research_rows', [ 'PRAutoBlogger_Research_Reaper', 'on_cron' ] ); + + // v0.8.1: WP-CLI manual trigger for the reaper. Only registers when + // WP-CLI is present; no-op in normal HTTP requests. + if ( defined( 'WP_CLI' ) && WP_CLI ) { + \WP_CLI::add_command( 'prautoblogger reap-research', static function (): void { + $stats = PRAutoBlogger_Research_Reaper::reap(); + \WP_CLI::success( sprintf( + 'Reaped %d, deleted %d (stale), skipped %d.', + (int) $stats['reaped'], + (int) $stats['deleted'], + (int) $stats['skipped'] + ) ); + } ); + } } /** Register AJAX handlers for admin actions. */ diff --git a/includes/core/class-research-reaper.php b/includes/core/class-research-reaper.php new file mode 100644 index 0000000..28ff000 --- /dev/null +++ b/includes/core/class-research-reaper.php @@ -0,0 +1,183 @@ + 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( + "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, [ '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, [ '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( [ + '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( + "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 ) : []; + } +}