> $schedules */ public static function cron_schedules( array $schedules ): array { $schedules[ self::MINUTE_SCHEDULE ] = array( 'interval' => MINUTE_IN_SECONDS, 'display' => __( 'Every minute (AI Narration)', 'ai-narration' ), ); return $schedules; } public function maybe_schedule_heartbeat(): void { self::schedule_heartbeat(); } /** Ensure the recurring fallback exists during plugin activation. */ public static function activate(): void { add_filter( 'cron_schedules', array( self::class, 'cron_schedules' ) ); self::schedule_heartbeat(); } public function record_heartbeat(): void { self::record_health( array( 'last_heartbeat' => time(), ) ); } /** Record a completed provider step for administrator diagnostics. */ public static function record_worker_activity(): void { self::record_health( array( 'last_worker_activity' => time(), ) ); } /** * Dispatch one authenticated, non-blocking local wake-up. * * Multiple queue operations in the same PHP request collapse to one kick. * The delay is deliberately capped because longer waits belong to cron. */ public static function kick( int $delay_seconds = 1 ): void { if ( self::$dispatched_in_request || self::is_kick_request() ) { return; } $delay_seconds = max( 0, min( 10, $delay_seconds ) ); $existing = get_option( self::PENDING_OPTION, array() ); // A non-blocking HTTP request can be accepted without its handler ever // starting. Keep this delivery lease short so a missed local wake-up // cannot suppress every replacement kick until the minute fallback. if ( is_array( $existing ) && ! empty( $existing['created'] ) && absint( $existing['created'] ) >= time() - self::PENDING_LEASE_SECONDS ) { self::$dispatched_in_request = true; return; } if ( false !== $existing ) { delete_option( self::PENDING_OPTION ); } self::dispatch_kick( $delay_seconds, 0 ); } /** Create one authenticated local wake-up request. */ private static function dispatch_kick( int $delay_seconds, int $attempt ): void { $token = strtolower( wp_generate_password( 40, false, false ) ); $pending = array( 'token' => $token, 'created' => time(), 'attempt' => max( 0, min( self::MAX_KICK_RETRIES, $attempt ) ), ); if ( ! add_option( self::PENDING_OPTION, $pending, '', false ) ) { return; } self::$dispatched_in_request = true; self::record_health( array( 'last_kick_attempt' => time(), 'last_kick_error' => '', ) ); $response = wp_remote_post( admin_url( 'admin-ajax.php' ), array( 'timeout' => 0.01, 'blocking' => false, // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook. 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), 'body' => array( 'action' => self::AJAX_ACTION, 'token' => $token, 'delay' => $delay_seconds, 'attempt' => $pending['attempt'], ), ) ); if ( is_wp_error( $response ) ) { self::delete_pending_token( $token ); self::record_health( array( 'last_kick_error' => sanitize_text_field( $response->get_error_code() ), ) ); } } /** * Accept the one-time local token, wait for the current cron request to * release its global lock, then launch WordPress cron without blocking. */ public function handle_kick(): void { // This public loopback endpoint authenticates with a one-time server token rather than a user nonce. // phpcs:disable WordPress.Security.NonceVerification.Missing $token = isset( $_POST['token'] ) && is_scalar( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : ''; $delay = isset( $_POST['delay'] ) && is_scalar( $_POST['delay'] ) ? absint( $_POST['delay'] ) : 1; $attempt = isset( $_POST['attempt'] ) && is_scalar( $_POST['attempt'] ) ? absint( $_POST['attempt'] ) : 0; // phpcs:enable WordPress.Security.NonceVerification.Missing $delay = max( 0, min( 10, $delay ) ); $attempt = max( 0, min( self::MAX_KICK_RETRIES, $attempt ) ); $pending = get_option( self::PENDING_OPTION, array() ); $expected = is_array( $pending ) ? sanitize_text_field( (string) ( $pending['token'] ?? '' ) ) : ''; $expected_attempt = is_array( $pending ) ? absint( $pending['attempt'] ?? 0 ) : 0; if ( '' === $token || '' === $expected || ! hash_equals( $expected, $token ) || $attempt !== $expected_attempt ) { status_header( 403 ); wp_die(); } delete_option( self::PENDING_OPTION ); ignore_user_abort( true ); if ( $delay > 0 ) { usleep( $delay * 1000000 ); } $deadline = microtime( true ) + 10; while ( false !== get_transient( 'doing_cron' ) && microtime( true ) < $deadline ) { usleep( 250000 ); } if ( false !== get_transient( 'doing_cron' ) ) { self::record_health( array( 'last_kick_accepted' => time(), 'last_kick_error' => 'cron_locked', ) ); if ( $attempt < self::MAX_KICK_RETRIES ) { self::dispatch_kick( self::KICK_RETRY_DELAY, $attempt + 1 ); } wp_die(); } $cron_url = add_query_arg( 'doing_wp_cron', sprintf( '%.22F', microtime( true ) ), site_url( 'wp-cron.php' ) ); $response = wp_remote_post( $cron_url, array( 'timeout' => 0.01, 'blocking' => false, // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook. 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), ) ); self::record_health( array( 'last_kick_accepted' => time(), 'last_cron_dispatch' => is_wp_error( $response ) ? 0 : time(), 'last_kick_error' => is_wp_error( $response ) ? sanitize_text_field( $response->get_error_code() ) : '', ) ); wp_die(); } /** @return array{last_heartbeat:int,last_worker_activity:int,last_kick_attempt:int,last_kick_accepted:int,last_cron_dispatch:int,last_kick_error:string} */ public static function health(): array { $health = get_option( self::HEALTH_OPTION, array() ); $health = is_array( $health ) ? $health : array(); return array( 'last_heartbeat' => absint( $health['last_heartbeat'] ?? 0 ), 'last_worker_activity' => absint( $health['last_worker_activity'] ?? 0 ), 'last_kick_attempt' => absint( $health['last_kick_attempt'] ?? 0 ), 'last_kick_accepted' => absint( $health['last_kick_accepted'] ?? 0 ), 'last_cron_dispatch' => absint( $health['last_cron_dispatch'] ?? 0 ), 'last_kick_error' => sanitize_key( (string) ( $health['last_kick_error'] ?? '' ) ), ); } public static function fallback_scheduled(): bool { return false !== wp_next_scheduled( self::HEARTBEAT_HOOK ); } /** @return array{active:int,expired:int} */ public static function worker_lock_health(): array { global $wpdb; // A wildcard query is required to enumerate the plugin's expiring lock options; each option value is then read through get_option(). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $option_names = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( self::WORKER_LOCK_PREFIX ) . '%' ) ); $active = 0; $expired = 0; foreach ( $option_names as $option_name ) { $lease = get_option( (string) $option_name, false ); if ( is_array( $lease ) && absint( $lease['expires'] ?? 0 ) > time() ) { $active++; continue; } if ( false !== $lease && self::delete_option_if_unchanged( (string) $option_name, $lease ) ) { $expired++; } } return array( 'active' => $active, 'expired' => $expired ); } /** * Claim an expiring worker lease with WordPress's atomic option insert. * * Transient get-then-set locking permits two PHP processes to observe an * empty lock before either writes it. add_option() instead relies on the * unique option name, so only one process can acquire a live lease. */ public static function acquire_worker_lock( string $scope, int $object_id, int $ttl ): string { $option_name = self::worker_lock_option_name( $scope, $object_id ); $now = time(); $existing = get_option( $option_name, false ); if ( is_array( $existing ) && absint( $existing['expires'] ?? 0 ) > $now ) { return ''; } if ( false !== $existing ) { if ( ! self::delete_option_if_unchanged( $option_name, $existing ) ) { return ''; } } $token = strtolower( wp_generate_password( 40, false, false ) ); $lease = array( 'token' => $token, 'expires' => $now + max( MINUTE_IN_SECONDS, $ttl ), ); return add_option( $option_name, $lease, '', false ) ? $token : ''; } /** Claim the single provider-call slot used by narration generation. */ public static function acquire_provider_lock( int $ttl ): string { if ( self::$provider_call_claimed_in_request ) { return ''; } $token = self::acquire_worker_lock( 'provider', 0, $ttl ); if ( '' !== $token ) { self::$provider_call_claimed_in_request = true; } return $token; } /** Release only the worker lease owned by the supplied token. */ public static function release_worker_lock( string $scope, int $object_id, string $token ): void { if ( '' === $token ) { return; } $option_name = self::worker_lock_option_name( $scope, $object_id ); $current = get_option( $option_name, false ); $current_token = is_array( $current ) ? sanitize_text_field( (string) ( $current['token'] ?? '' ) ) : ''; if ( '' !== $current_token && hash_equals( $token, $current_token ) ) { delete_option( $option_name ); } } /** Report a live worker lease and discard its expired option record. */ public static function worker_lock_active( string $scope, int $object_id ): bool { $option_name = self::worker_lock_option_name( $scope, $object_id ); $current = get_option( $option_name, false ); if ( is_array( $current ) && absint( $current['expires'] ?? 0 ) > time() ) { return true; } if ( false !== $current ) { self::delete_option_if_unchanged( $option_name, $current ); } return false; } /** Delete only the exact expired lease observed by this request. */ private static function delete_option_if_unchanged( string $option_name, $observed ): bool { global $wpdb; $deleted = $wpdb->delete( $wpdb->options, array( 'option_name' => $option_name, 'option_value' => maybe_serialize( $observed ), ), array( '%s', '%s' ) ); wp_cache_delete( $option_name, 'options' ); return 1 === $deleted; } public static function deactivate(): void { foreach ( array( self::HEARTBEAT_HOOK, 'ai_narration_generate_cache_chunk', 'ai_narration_cache_watchdog', 'ai_narration_schedule_cache_post', ) as $hook ) { wp_clear_scheduled_hook( $hook ); } delete_option( self::PENDING_OPTION ); delete_option( self::HEALTH_OPTION ); delete_transient( 'ai_narration_cache_recovery_scan' ); global $wpdb; // A wildcard query is required to enumerate only this plugin's lock options during deactivation cleanup. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $lock_options = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( self::WORKER_LOCK_PREFIX ) . '%' ) ); foreach ( $lock_options as $lock_option ) { delete_option( (string) $lock_option ); } } private static function schedule_heartbeat(): void { if ( ! wp_next_scheduled( self::HEARTBEAT_HOOK ) ) { wp_schedule_event( time() + MINUTE_IN_SECONDS, self::MINUTE_SCHEDULE, self::HEARTBEAT_HOOK ); } } private static function is_kick_request(): bool { // Read-only routing inspection; no state is changed before the one-time token is validated in handle_kick(). // phpcs:ignore WordPress.Security.NonceVerification.Recommended $action = isset( $_REQUEST['action'] ) && is_scalar( $_REQUEST['action'] ) ? sanitize_key( (string) wp_unslash( $_REQUEST['action'] ) ) : ''; return wp_doing_ajax() && self::AJAX_ACTION === $action; } private static function worker_lock_option_name( string $scope, int $object_id ): string { $scope = sanitize_key( $scope ); return self::WORKER_LOCK_PREFIX . ( '' !== $scope ? $scope : 'job' ) . '_' . absint( $object_id ); } private static function delete_pending_token( string $token ): void { $pending = get_option( self::PENDING_OPTION, array() ); $expected = is_array( $pending ) ? sanitize_text_field( (string) ( $pending['token'] ?? '' ) ) : ''; if ( '' !== $expected && hash_equals( $expected, $token ) ) { delete_option( self::PENDING_OPTION ); } } /** @param array $changes */ private static function record_health( array $changes ): void { $current = get_option( self::HEALTH_OPTION, array() ); $current = is_array( $current ) ? $current : array(); update_option( self::HEALTH_OPTION, array_merge( $current, $changes ), false ); } }