acquire_generation_lock() ) { PRAutoBlogger_Logger::instance()->info( 'Daily generation skipped: already running (lock held).', 'scheduler' ); return; } try { ( new PRAutoBlogger_Pipeline_Runner() )->run(); } catch ( \Exception $e ) { PRAutoBlogger_Logger::instance()->error( 'Daily generation FAILED: ' . $e->getMessage(), 'scheduler' ); } finally { $this->release_generation_lock(); } } /** * Handle the metrics collection cron event. * Side effects: API calls to GA4, database writes to ab_content_scores. */ public function on_collect_metrics(): void { try { ( new PRAutoBlogger_Metrics_Collector() )->collect_all(); } catch ( \Exception $e ) { PRAutoBlogger_Logger::instance()->error( 'Metrics collection FAILED: ' . $e->getMessage(), 'metrics' ); } } /** * AJAX handler: trigger manual generation. * Verifies nonce/capability, then runs the pipeline with the same DB lock. * * Side effects: API calls, database writes, WordPress post creation. */ public function on_ajax_generate_now(): void { check_ajax_referer( 'prautoblogger_generate_now', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 ); return; } // LLM calls take 30-60+ seconds; extend PHP limit (may be blocked by host). // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged @set_time_limit( 300 ); $force = isset( $_POST['force'] ) && '1' === $_POST['force']; if ( $force ) { $this->release_generation_lock(); } if ( ! $this->acquire_generation_lock() ) { wp_send_json_error( [ 'message' => __( 'A generation run is already in progress. Pass force=1 to clear the lock.', 'prautoblogger' ) ] ); return; } try { // Manual runs skip dedup so "Generate Now" always produces content. $results = ( new PRAutoBlogger_Pipeline_Runner() ) ->set_skip_dedup( true ) ->run(); wp_send_json_success( $results ); } catch ( \Exception $e ) { PRAutoBlogger_Logger::instance()->error( 'Manual generation FAILED: ' . $e->getMessage(), 'pipeline' ); wp_send_json_error( [ 'message' => $e->getMessage() ] ); } finally { $this->release_generation_lock(); } } /** AJAX handler: test API connections (OpenRouter, Reddit). */ public function on_ajax_test_connection(): void { check_ajax_referer( 'prautoblogger_test_connection', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 ); return; } $service = isset( $_POST['service'] ) ? sanitize_text_field( wp_unslash( $_POST['service'] ) ) : ''; $results = []; if ( 'openrouter' === $service || 'all' === $service ) { $results['openrouter'] = ( new PRAutoBlogger_OpenRouter_Provider() )->validate_credentials_detailed(); } if ( 'reddit' === $service || 'all' === $service ) { $reddit = new PRAutoBlogger_Reddit_Provider(); $is_ok = $reddit->validate_credentials(); $results['reddit'] = $is_ok ? [ 'status' => 'ok', 'message' => __( 'Reddit source available (RSS primary, .json fallback).', 'prautoblogger' ) ] : [ 'status' => 'error', 'message' => __( 'Reddit sources unreachable (both RSS and .json failed).', 'prautoblogger' ) ]; } wp_send_json_success( $results ); } /** * Lazy-load the OpenRouter model registry singleton. * Config injected here — registry class has no knowledge of PRAUTOBLOGGER_* constants. * * @return PRAutoBlogger_OpenRouter_Model_Registry */ public function get_model_registry(): PRAutoBlogger_OpenRouter_Model_Registry { if ( null === $this->model_registry ) { $this->model_registry = new PRAutoBlogger_OpenRouter_Model_Registry( 'prautoblogger_openrouter_model_registry', 'prautoblogger_openrouter_model_registry_cache' ); } return $this->model_registry; } /** * Acquire a database-level atomic mutex for generation. * Uses INSERT IGNORE — option_name UNIQUE index guarantees single-writer. * Expired locks (>1 hour) are cleaned up first. * * @return bool True if lock acquired. */ public function acquire_generation_lock(): bool { global $wpdb; $lock_name = 'prautoblogger_generation_lock'; // Clean up expired locks to prevent permanent deadlock. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND CAST(option_value AS UNSIGNED) < %d", $lock_name, time() - HOUR_IN_SECONDS ) ); // Atomic insert — UNIQUE constraint guarantees only one process succeeds. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $result = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, %s)", $lock_name, (string) time(), 'no' ) ); return $result > 0; } /** Release the generation mutex. */ public function release_generation_lock(): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s", 'prautoblogger_generation_lock' ) ); } }