queue = $queue; $this->settings = $settings; } /** * Process the queue: fetch batch, send to API, handle response. */ public function process_queue() { try { $this->settings->reload(); if ( ! $this->can_send() ) { return false; } if ( ! $this->acquire_lock() ) { return false; } $result = $this->send_batch(); $this->release_lock(); return $result; } catch ( \Throwable $e ) { $this->release_lock(); return false; } } private function can_send() { if ( ! $this->settings->get( 'enabled' ) ) { return false; } if ( ! $this->settings->has_domain_key() ) { return false; } if ( $this->is_circuit_breaker_open() ) { return false; } return true; } private function acquire_lock( $timeout = 30 ) { $lock = get_transient( $this->lock_transient ); if ( $lock !== false ) { return false; } set_transient( $this->lock_transient, time(), $timeout ); return true; } private function release_lock() { delete_transient( $this->lock_transient ); } private function send_batch() { $batch_size = $this->settings->get( 'batch_size', 500 ); $batch = $this->queue->fetch_batch( $batch_size ); if ( empty( $batch ) ) { return true; // Nothing to send. } // Stop well before the 30s sending lock (and a typical PHP execution // limit) so a long batch can't be killed mid-flight with sent-but-not- // deleted rows. Rows we don't reach stay leased by fetch_batch() and are // picked up on the next run. $deadline = time() + (int) apply_filters( 'aiadvisors_send_time_budget', 20 ); $sent_ok = 0; $fail_ids = array(); // Iterate rows directly so each queue id stays paired with its own // payload. (Filtering undecodable rows into a parallel array and mapping // results back by position deletes the wrong rows and re-sends others.) foreach ( $batch as $row ) { if ( time() >= $deadline ) { break; } $id = (int) $row['id']; $decoded = json_decode( $row['payload'], true ); // Undecodable payload can never be sent — drop just this row. if ( ! is_array( $decoded ) ) { $this->queue->delete_batch( array( $id ) ); continue; } $body = wp_json_encode( $decoded ); if ( $body === false ) { $this->queue->delete_batch( array( $id ) ); continue; } $response = $this->send_request( $body ); if ( is_wp_error( $response ) ) { $this->record_error( 'Network error: ' . $response->get_error_message() ); $this->increment_circuit_breaker_failures(); $fail_ids[] = $id; break; // Stop batch on network error. } $code = wp_remote_retrieve_response_code( $response ); if ( 200 === $code ) { // Delete as soon as the platform accepts it (not after the loop) // so a mid-batch timeout can't re-send accepted rows. $this->queue->delete_batch( array( $id ) ); $this->reset_circuit_breaker(); ++$sent_ok; } elseif ( in_array( $code, array( 401, 403 ), true ) ) { // Auth failure isn't this row's fault: pause via the breaker and // leave the row queued (leased) without charging it an attempt. $this->record_error( "Authentication failed (HTTP $code)" ); $this->trip_circuit_breaker( 'Authentication failed' ); break; } elseif ( 429 === $code || $code >= 500 ) { // Rate-limited or server error: keep the data and back off. $this->record_error( "Retryable response (HTTP $code)" ); $this->increment_circuit_breaker_failures(); $fail_ids[] = $id; break; } else { // Other 4xx: this specific payload is rejected as malformed. Drop // it so it can't wedge the queue, but don't trip the breaker. $this->record_error( "Rejected (HTTP $code)" ); $this->queue->delete_batch( array( $id ) ); } } if ( $sent_ok > 0 ) { set_transient( $this->last_send_key, time(), 3600 ); } if ( ! empty( $fail_ids ) ) { $this->queue->mark_failed( $fail_ids ); } return empty( $fail_ids ); } private function send_request( $body ) { $endpoint = $this->settings->get( 'endpoint', AIADVISORS_API_ENDPOINT ); $headers = array( 'Content-Type' => 'application/json' ); // Also send the domain key in an Authorization header (it remains in the // JSON body for backward compatibility), matching the /api/wordpress/* // endpoints. $domain_key = $this->settings->get_domain_key(); if ( ! empty( $domain_key ) ) { $headers['Authorization'] = 'Bearer ' . $domain_key; } return wp_remote_post( $endpoint, array( 'headers' => $headers, 'body' => $body, 'timeout' => 5, 'blocking' => true, 'sslverify' => true, 'user-agent' => 'AI-Advisors WordPress Plugin/' . AIADVISORS_VERSION, ) ); } // --- Circuit Breaker --- private function is_circuit_breaker_open() { $breaker = get_transient( $this->breaker_transient ); return $breaker !== false && isset( $breaker['status'] ) && $breaker['status'] === 'open'; } private function trip_circuit_breaker( $reason = '' ) { $cooldown = $this->settings->get( 'circuit_breaker_cooldown', 1800 ); set_transient( $this->breaker_transient, array( 'status' => 'open', 'reason' => $reason, 'opened_at' => time(), ), $cooldown ); } private function increment_circuit_breaker_failures() { $breaker = get_transient( $this->breaker_transient ); if ( $breaker === false ) { $breaker = array( 'failures' => 0 ); } $breaker['failures'] = ( $breaker['failures'] ?? 0 ) + 1; $threshold = $this->settings->get( 'circuit_breaker_threshold', 3 ); if ( $breaker['failures'] >= $threshold ) { $this->trip_circuit_breaker( "Failed $threshold consecutive attempts" ); } else { set_transient( $this->breaker_transient, $breaker, 300 ); } } private function reset_circuit_breaker() { delete_transient( $this->breaker_transient ); } private function record_error( $message ) { set_transient( $this->last_error_key, array( 'message' => $message, 'time' => time(), ), 3600 ); } // --- Status --- /** * Get current sender status for admin display. */ public function get_status() { $last_send = get_transient( $this->last_send_key ); $last_error = get_transient( $this->last_error_key ); $breaker = get_transient( $this->breaker_transient ); $status = array( 'queue_size' => $this->queue->get_queue_size(), 'last_send' => $last_send ? wp_date( 'Y-m-d H:i:s', $last_send ) : __( 'Never', 'ai-advisors' ), 'last_error' => $last_error ? $last_error['message'] : null, 'last_error_time' => $last_error ? wp_date( 'Y-m-d H:i:s', $last_error['time'] ) : null, 'circuit_breaker' => 'closed', ); if ( $breaker !== false && isset( $breaker['status'] ) && $breaker['status'] === 'open' ) { $status['circuit_breaker'] = 'open'; $status['circuit_breaker_reason'] = $breaker['reason'] ?? 'Unknown'; } $oldest_age = $this->queue->get_oldest_entry_age(); if ( $oldest_age > 0 ) { $status['oldest_entry_age'] = human_time_diff( time() - $oldest_age, time() ) . ' ago'; } return $status; } /** * Send a test event directly (bypasses queue for immediate feedback). */ public function send_test_event() { if ( ! $this->settings->has_domain_key() ) { return array( 'success' => false, 'message' => __( 'Domain key not configured.', 'ai-advisors' ), ); } $test_data = array( 'domain_key' => $this->settings->get_domain_key(), 'url' => '/aiadvisors-test-event', 'user_agent' => 'AI-Advisors-TestBot/1.0', 'timestamp' => gmdate( 'c' ), 'ip' => Privacy::get_client_ip(), 'status_code' => 200, 'is_test' => true, 'source' => 'wordpress_plugin', 'plugin_version' => AIADVISORS_VERSION, ); $body = wp_json_encode( $test_data ); $response = $this->send_request( $body ); if ( is_wp_error( $response ) ) { return array( 'success' => false, 'message' => 'Network error: ' . $response->get_error_message(), ); } $code = wp_remote_retrieve_response_code( $response ); if ( $code === 200 ) { return array( 'success' => true, 'message' => __( 'Test event sent successfully!', 'ai-advisors' ), ); } return array( 'success' => false, 'message' => sprintf( 'Server responded with HTTP %d', $code ), ); } }