Files
prautoblogger/includes/class-executor.php
peptiderepo 650d8df325 Fix false error: detect completed pipeline at 90s instead of 300s
The status endpoint waited 300 seconds before checking if the pipeline
had produced output. On Hostinger, PHP's max_execution_time kills the
cron process after ~120s. The pipeline publishes the post before images,
so the post exists but the transient never transitions to 'complete'.
The user sees "Generating…" for 5 minutes, then gives up.

Now the status endpoint proactively checks for recently-created posts
after just 90 seconds. If found, it reports success immediately. The
300-second hard-fail threshold is still there for genuinely dead runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 16:03:01 +07:00

292 lines
9.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Cron/AJAX handlers for content generation, metrics collection, model registry.
*
* Triggered by: PRAutoBlogger hook registration.
* Dependencies: Pipeline_Runner, Metrics_Collector, Generation_Lock, Logger.
*
* @see class-prautoblogger.php — Hook wiring.
* @see core/class-pipeline-runner.php — Actual generation pipeline.
* @see class-generation-lock.php — DB mutex.
*/
class PRAutoBlogger_Executor {
/** @var PRAutoBlogger_OpenRouter_Model_Registry|null Lazy-loaded singleton. */
private ?PRAutoBlogger_OpenRouter_Model_Registry $model_registry = null;
/** Transient key for background generation status. */
private const STATUS_TRANSIENT = 'prautoblogger_generation_status';
/** How long to keep the result available for polling (seconds). */
private const STATUS_TTL = 600;
/**
* Handle the daily generation cron event.
* Uses an atomic DB mutex to prevent overlapping runs.
*
* Side effects: API calls, database writes, WordPress post creation.
*/
public function on_daily_generation(): void {
// Refresh model registry before lock — stuck lock shouldn't block refresh.
do_action( 'prautoblogger_refresh_model_registry', false );
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
PRAutoBlogger_Logger::instance()->info( 'Daily generation skipped: already running (lock held).', 'scheduler' );
return;
}
try {
( new PRAutoBlogger_Pipeline_Runner() )->run();
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Daily generation %s: %s', get_class( $e ), $e->getMessage() ),
'scheduler'
);
} finally {
PRAutoBlogger_Generation_Lock::release();
}
}
/**
* 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 ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Metrics collection %s: %s', get_class( $e ), $e->getMessage() ),
'metrics'
);
}
}
/**
* AJAX handler: kick off manual generation as a background cron job.
*
* Returns immediately so the browser never hits Hostinger's 120-second
* connection timeout. The frontend polls on_ajax_generation_status()
* every few seconds to get progress and final results.
*
* Side effects: schedules a WP-Cron event, writes a status transient.
*/
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;
}
$force = isset( $_POST['force'] ) && '1' === $_POST['force'];
if ( $force ) {
PRAutoBlogger_Generation_Lock::release();
delete_transient( self::STATUS_TRANSIENT );
}
// Check if a run is already in progress.
$current = get_transient( self::STATUS_TRANSIENT );
if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) {
wp_send_json_success( [
'background' => true,
'message' => __( 'Generation already in progress.', 'prautoblogger' ),
] );
return;
}
// Write initial "running" status for the frontend to poll.
set_transient( self::STATUS_TRANSIENT, [
'status' => 'running',
'stage' => __( 'Starting generation...', 'prautoblogger' ),
'started' => time(),
], self::STATUS_TTL );
// Schedule immediate one-shot cron event. WordPress will fire this
// on the next page load (or via wp-cron.php) in a separate PHP process
// that is not subject to the 120-second connection timeout.
if ( ! wp_next_scheduled( 'prautoblogger_manual_generation' ) ) {
wp_schedule_single_event( time(), 'prautoblogger_manual_generation' );
}
// Spawn the cron immediately via a non-blocking loopback request
// so we don't depend on the next visitor to trigger it.
spawn_cron();
// Direct non-blocking loopback to wp-cron.php — ensures the event
// fires even if DISABLE_WP_CRON is true or spawn_cron() no-ops.
wp_remote_post(
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
[
'timeout' => 0.01,
'blocking' => false,
'sslverify' => false,
]
);
wp_send_json_success( [
'background' => true,
'message' => __( 'Generation started in background. Polling for status...', 'prautoblogger' ),
] );
}
/** Cron handler: runs pipeline in background, writes results to transient. */
public function on_manual_generation(): void {
// Keep PHP running even after LiteSpeed closes the HTTP connection.
// On Hostinger shared hosting, the web server kills HTTP connections
// at 120 seconds, but ignore_user_abort lets the PHP process continue.
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@ignore_user_abort( true );
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( 300 );
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
set_transient( self::STATUS_TRANSIENT, [
'status' => 'error',
'message' => __( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' ),
], self::STATUS_TTL );
return;
}
try {
$this->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) );
$results = ( new PRAutoBlogger_Pipeline_Runner() )
->set_skip_dedup( true )
->run();
set_transient( self::STATUS_TRANSIENT, [
'status' => 'complete',
'generated' => $results['generated'],
'published' => $results['published'],
'rejected' => $results['rejected'],
'cost' => $results['cost'],
], self::STATUS_TTL );
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Manual generation %s: %s', get_class( $e ), $e->getMessage() ),
'pipeline'
);
set_transient( self::STATUS_TRANSIENT, [
'status' => 'error',
'message' => $e->getMessage(),
], self::STATUS_TTL );
} finally {
PRAutoBlogger_Generation_Lock::release();
}
}
/** AJAX: return generation status for frontend polling. Recovers orphaned runs. */
public function on_ajax_generation_status(): void {
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
return;
}
$status = get_transient( self::STATUS_TRANSIENT );
if ( ! is_array( $status ) ) {
wp_send_json_success( [ 'status' => 'idle' ] );
return;
}
// Fallback: detect orphaned "running" state when PHP process was killed.
// On Hostinger shared hosting, set_time_limit() is often ignored and
// PHP kills the cron process at max_execution_time (120–180s). The
// pipeline creates the post first and images last, so the post exists
// even when the process died during image generation.
if ( 'running' === $status['status'] ) {
$started = $status['started'] ?? 0;
$elapsed = time() - $started;
// After 10 minutes, give up unconditionally.
if ( $elapsed > self::STATUS_TTL ) {
delete_transient( self::STATUS_TRANSIENT );
wp_send_json_success( [
'status' => 'error',
'message' => __( 'Generation timed out. Check the Activity Log for details.', 'prautoblogger' ),
] );
return;
}
// After 90 seconds, proactively check if the pipeline already
// produced a post. This catches the common case where the cron
// process was killed by max_execution_time after publishing but
// before updating the transient. Without this, the user stares
// at "Generating…" for 5+ minutes despite the work being done.
if ( $elapsed > 90 ) {
$recent = get_posts( [
'post_type' => 'post',
'post_status' => [ 'publish', 'draft' ],
'posts_per_page' => 1,
'date_query' => [ [ 'after' => gmdate( 'Y-m-d H:i:s', $started ) ] ],
'meta_key' => '_prautoblogger_run_id',
'orderby' => 'date',
'order' => 'DESC',
] );
if ( ! empty( $recent ) ) {
delete_transient( self::STATUS_TRANSIENT );
PRAutoBlogger_Generation_Lock::release();
wp_send_json_success( [
'status' => 'complete',
'generated' => 1,
'published' => 1,
'rejected' => 0,
'cost' => 0.0,
'note' => __( 'Generation completed (status recovered).', 'prautoblogger' ),
] );
return;
}
// No post found yet — only declare failure after 5 minutes.
// The pipeline genuinely needs 2–4 min for article + images.
if ( $elapsed > 300 ) {
delete_transient( self::STATUS_TRANSIENT );
PRAutoBlogger_Generation_Lock::release();
wp_send_json_success( [
'status' => 'error',
'message' => __( 'Generation process ended without producing content. Check Activity Log.', 'prautoblogger' ),
] );
return;
}
}
}
wp_send_json_success( $status );
}
/**
* Helper: update the generation stage for frontend polling.
*
* @param string $stage Human-readable stage description.
*/
private function update_generation_stage( string $stage ): void {
$current = get_transient( self::STATUS_TRANSIENT );
if ( is_array( $current ) ) {
$current['stage'] = $stage;
set_transient( self::STATUS_TRANSIENT, $current, self::STATUS_TTL );
}
}
/**
* 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;
}
}