prefix . // 'aiadvisors_queue' ). The table name is prefix-derived (never user input) and // every value passes through $wpdb->prepare() or an (int) cast; a table // identifier cannot be a bound placeholder, so it must be interpolated. Plugin // Check runs the raw WordPress standard (ignoring phpcs.xml.dist), so silence // the expected own-table notices for this file. // phpcs:disable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders -- Own custom table; see note above. class Queue { private $table_name; private $wpdb; private $max_attempts = 6; public function __construct() { global $wpdb; $this->wpdb = $wpdb; $this->table_name = $wpdb->prefix . 'aiadvisors_queue'; } /** * Insert a log entry into the queue. * * @param string $payload JSON-encoded log data. * @return int|false Insert ID on success, false on failure. */ public function insert( $payload ) { try { if ( ! is_string( $payload ) ) { $payload = wp_json_encode( $payload ); } // Enforce queue size limit. $queue_size = $this->get_queue_size(); $max_size = get_option( 'aiadvisors_settings' )['max_queue_size'] ?? 100000; if ( $queue_size >= $max_size ) { return false; } $result = $this->wpdb->insert( $this->table_name, array( 'payload' => $payload, 'created_at' => current_time( 'mysql', true ), 'attempts' => 0, 'next_attempt_at' => current_time( 'mysql', true ), ), array( '%s', '%s', '%d', '%s' ) ); return false === $result ? false : $this->wpdb->insert_id; } catch ( \Throwable $e ) { return false; } } /** * Fetch a batch of entries ready for sending. * * Leases the fetched rows (pushes their next_attempt_at a couple of minutes * into the future) so a concurrent cron run won't pick up the same rows. * Attempt counts are NOT bumped here — only rows that are actually sent and * fail get an attempt charged, in mark_failed(). * * @param int $limit Max entries to fetch. * @return array Array of rows with id, payload, attempts. */ public function fetch_batch( $limit = 500 ) { try { $now = current_time( 'mysql', true ); $table = esc_sql( $this->table_name ); $sql = $this->wpdb->prepare( "SELECT id, payload, attempts FROM `$table` WHERE attempts < %d AND (next_attempt_at IS NULL OR next_attempt_at <= %s) ORDER BY id ASC LIMIT %d", $this->max_attempts, $now, $limit ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $results = $this->wpdb->get_results( $sql, ARRAY_A ); if ( empty( $results ) ) { return array(); } // Lease the fetched rows: push next_attempt_at into the future so a // concurrent run (or this one after its 30s lock expires) will not // grab the same rows. Do NOT increment attempts here — a row that is // fetched but never actually sent (the sender stops the batch on the // first network/5xx error) must not burn a retry. attempts is bumped // only for rows we truly attempt, in mark_failed(). UTC_TIMESTAMP() // matches the UTC clock (current_time('mysql', true)) used for // created_at/next_attempt_at everywhere else; MySQL NOW() would use // the DB server's local zone and desync the whole backoff schedule. $ids = array_column( $results, 'id' ); $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); $lease_minutes = (int) apply_filters( 'aiadvisors_queue_lease_minutes', 2 ); $lease_values = array_merge( array( $lease_minutes ), $ids ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $update_sql = $this->wpdb->prepare( "UPDATE `$table` SET next_attempt_at = DATE_ADD(UTC_TIMESTAMP(), INTERVAL %d MINUTE) WHERE id IN ($placeholders)", ...$lease_values ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $this->wpdb->query( $update_sql ); return $results; } catch ( \Throwable $e ) { return array(); } } /** * Delete successfully sent entries. * * @param array $ids Entry IDs to delete. */ public function delete_batch( $ids ) { if ( empty( $ids ) ) { return; } try { $ids = array_map( 'intval', (array) $ids ); $table = esc_sql( $this->table_name ); $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $sql = $this->wpdb->prepare( "DELETE FROM `$table` WHERE id IN ($placeholders)", ...$ids ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $this->wpdb->query( $sql ); } catch ( \Throwable $e ) { // Silently handle. } } /** * Mark entries as failed: charge one attempt and push next_attempt_at out. * * This is the only place attempts is incremented, so a row's retry budget * is spent only on sends we actually made. * * @param array $ids Entry IDs. * @param int|null $delay_minutes Custom delay in minutes (null = exponential backoff). */ public function mark_failed( $ids, $delay_minutes = null ) { if ( empty( $ids ) ) { return; } try { $ids = array_map( 'intval', (array) $ids ); $table = esc_sql( $this->table_name ); $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); if ( $delay_minutes === null ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $sql = $this->wpdb->prepare( "UPDATE `$table` SET attempts = attempts + 1, next_attempt_at = DATE_ADD(UTC_TIMESTAMP(), INTERVAL POW(2, LEAST(attempts, 8)) MINUTE) WHERE id IN ($placeholders)", ...$ids ); } else { $prepare_values = array_merge( array( $delay_minutes ), $ids ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $sql = $this->wpdb->prepare( "UPDATE `$table` SET attempts = attempts + 1, next_attempt_at = DATE_ADD(UTC_TIMESTAMP(), INTERVAL %d MINUTE) WHERE id IN ($placeholders)", ...$prepare_values ); } // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $this->wpdb->query( $sql ); } catch ( \Throwable $e ) { // Silently handle. } } /** * Remove old entries and entries that exceeded max attempts. */ public function cleanup_old_entries() { try { $settings = get_option( 'aiadvisors_settings', array() ); $retention_days = $settings['retention_days'] ?? 7; $cutoff = gmdate( 'Y-m-d H:i:s', strtotime( "-{$retention_days} days" ) ); $table = esc_sql( $this->table_name ); // Delete old entries. $sql = $this->wpdb->prepare( "DELETE FROM `$table` WHERE created_at < %s", $cutoff ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $this->wpdb->query( $sql ); // Delete entries that exceeded max attempts. $sql = $this->wpdb->prepare( "DELETE FROM `$table` WHERE attempts >= %d", $this->max_attempts ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $this->wpdb->query( $sql ); } catch ( \Throwable $e ) { // Silently handle. } } /** * Get current queue size. */ public function get_queue_size() { try { $table = esc_sql( $this->table_name ); return (int) $this->wpdb->get_var( "SELECT COUNT(*) FROM `$table`" ); } catch ( \Throwable $e ) { return 0; } } /** * Get age of oldest entry in seconds. */ public function get_oldest_entry_age() { try { $table = esc_sql( $this->table_name ); $oldest = $this->wpdb->get_var( "SELECT MIN(created_at) FROM `$table`" ); if ( $oldest ) { return time() - strtotime( $oldest ); } } catch ( \Throwable $e ) { // Silently handle. } return 0; } /** * Clear all entries (used for testing/reset). */ public function clear_all() { try { $table = esc_sql( $this->table_name ); $this->wpdb->query( "TRUNCATE TABLE `$table`" ); } catch ( \Throwable $e ) { // Silently handle. } } }