get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} p JOIN {$wpdb->postmeta} ms ON ms.post_id = p.ID AND ms.meta_key = %s LEFT JOIN {$wpdb->postmeta} mt ON mt.post_id = p.ID AND mt.meta_key = %s WHERE p.post_status = 'publish' AND mt.post_id IS NULL", '_ai_search_embedding_v2', AI_Search::META_KEY_TITLE_V2 ) ); } /** Posts with a dual pair stored. */ public static function count_done() { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta} WHERE meta_key = %s", AI_Search::META_KEY_TITLE_V2 ) ); } /** Total posts with a single-vector embedding (eligible for dual). */ public static function count_total() { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta} WHERE meta_key = %s", '_ai_search_embedding_v2' ) ); } public static function progress() { $total = self::count_total(); $done = self::count_done(); return [ 'total' => $total, 'done' => $done, 'pending' => max( 0, $total - $done ), 'percent' => $total > 0 ? (int) floor( $done / $total * 100 ) : 0, ]; } /* ==================================================================== * Scheduling * ================================================================== */ /** * Keep the daily cron scheduled while dual is on AND there's work to do. * Unschedule once the backfill is complete OR the merchant turns dual off. * Called on every plugins_loaded via AI_Search::register_hooks. */ public static function ensure_scheduled() { if ( ! AI_Search::is_dual_enabled() ) { self::unschedule(); return; } if ( self::count_pending() === 0 ) { self::unschedule(); return; } if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_event( time() + 60, 'daily', self::CRON_HOOK ); } } public static function unschedule() { $ts = wp_next_scheduled( self::CRON_HOOK ); if ( $ts ) wp_unschedule_event( $ts, self::CRON_HOOK ); } /* ==================================================================== * Workers * ================================================================== */ /** * Daily cron handler. Takes one soft lock then runs batches until the * CRON_BUDGET_SECONDS cap is hit, there's nothing left to do, or the * provider returns an error that would fan out to every post in the * batch (quota exceeded, auth failure, service down). */ public static function cron_tick() { if ( ! AI_Search::is_dual_enabled() ) return; if ( get_transient( self::CRON_LOCK_TRANSIENT ) ) return; set_transient( self::CRON_LOCK_TRANSIENT, 1, 60 ); @set_time_limit( 60 ); $started = microtime( true ); while ( ( microtime( true ) - $started ) < self::CRON_BUDGET_SECONDS ) { $batch_results = self::run_one_batch(); if ( ! $batch_results['processed'] ) break; // nothing more if ( $batch_results['fatal'] ) break; // quota / auth / 5xx } delete_transient( self::CRON_LOCK_TRANSIENT ); if ( self::count_pending() === 0 ) { self::unschedule(); set_transient( self::DONE_NOTICE, 1, 7 * DAY_IN_SECONDS ); } } /** * One batch of BATCH_SIZE posts through the dual-write path. Returns * ['processed' => int, 'errors' => int, 'fatal' => bool]. 'fatal' is * set when every post in the batch failed with the same provider error * code — a signal the caller should stop retrying instead of hammering * the service for the same reason. */ public static function run_one_batch() { $ids = self::next_batch_ids(); if ( empty( $ids ) ) return [ 'processed' => 0, 'errors' => 0, 'fatal' => false ]; $plugin = AI_Search::get_instance(); $results = $plugin->generate_embeddings_batch_dual( $ids ); $errors = 0; $fatal_codes = [ 'quota_exceeded', 'invalid_token', 'no_token', 'service_unavailable', 'provider_error' ]; $seen_fatal = 0; foreach ( $results as $r ) { if ( is_wp_error( $r ) ) { $errors++; if ( in_array( $r->get_error_code(), $fatal_codes, true ) ) $seen_fatal++; } } return [ 'processed' => count( $results ), 'errors' => $errors, 'fatal' => $errors > 0 && $seen_fatal === count( $results ), // all-fail with fatal code ]; } /** * Next BATCH_SIZE post ids that have single-vector v2 but no dual pair. */ private static function next_batch_ids() { global $wpdb; return array_map( 'intval', $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p JOIN {$wpdb->postmeta} ms ON ms.post_id = p.ID AND ms.meta_key = %s LEFT JOIN {$wpdb->postmeta} mt ON mt.post_id = p.ID AND mt.meta_key = %s WHERE p.post_status = 'publish' AND mt.post_id IS NULL LIMIT %d", '_ai_search_embedding_v2', AI_Search::META_KEY_TITLE_V2, self::BATCH_SIZE ) ) ); } /* ==================================================================== * AJAX — Run Next Batch button on the settings UI * ================================================================== */ public static function ajax_batch() { check_ajax_referer( 'ai_search_dual_backfill' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => 'forbidden' ], 403 ); } $res = self::run_one_batch(); $progress = self::progress(); if ( $progress['pending'] === 0 ) { self::unschedule(); set_transient( self::DONE_NOTICE, 1, 7 * DAY_IN_SECONDS ); } wp_send_json_success( array_merge( $res, $progress ) ); } /* ==================================================================== * Admin notice — one-shot "all done" message after backfill finishes * ================================================================== */ public static function maybe_render_done_notice() { if ( ! current_user_can( 'manage_options' ) ) return; if ( ! get_transient( self::DONE_NOTICE ) ) return; delete_transient( self::DONE_NOTICE ); echo '

' . esc_html__( 'AI Search: dual-vector backfill is complete. Semantic ranking now runs on both the title and body vectors.', 'ai-search' ) . '

'; } /* ==================================================================== * Clean-up (invoked only on explicit request — never automatically) * ================================================================== */ public static function purge_dual_postmeta() { global $wpdb; foreach ( [ AI_Search::META_KEY_TITLE_V2, AI_Search::META_KEY_BODY_V2 ] as $key ) { $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s", $key ) ); } } }