From c9b65f90c10af816edb8db386c5d09cef36c6b1e Mon Sep 17 00:00:00 2001 From: Tom Stark Date: Mon, 13 Jul 2026 13:59:55 +0200 Subject: [PATCH 1/9] feat: add analytics queue table for batched event buffering --- src/class-analytics-queue-table.php | 147 ++++++++++++++++++++++++++++ tests/AnalyticsQueueTableTest.php | 116 ++++++++++++++++++++++ tests/phpstan-bootstrap.php | 4 + tests/wp-stubs.php | 73 +++++++++++++- 4 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 src/class-analytics-queue-table.php create mode 100644 tests/AnalyticsQueueTableTest.php diff --git a/src/class-analytics-queue-table.php b/src/class-analytics-queue-table.php new file mode 100644 index 0000000..af6eb78 --- /dev/null +++ b/src/class-analytics-queue-table.php @@ -0,0 +1,147 @@ +prefix . 'supertab_connect_analytics_queue'; + } + + /** + * Create or upgrade the table. Idempotent: no-op when the recorded schema + * version is current. Runs dbDelta otherwise. + * + * @return void + */ + public function install(): void { + if ( self::DB_VERSION === get_option( self::VERSION_OPTION ) ) { + return; + } + + global $wpdb; + + if ( ! function_exists( 'dbDelta' ) ) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + } + + $charset_collate = $wpdb->get_charset_collate(); + + // dbDelta is whitespace-sensitive: two spaces after PRIMARY KEY. + $sql = 'CREATE TABLE ' . $this->name() . " ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + payload LONGTEXT NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id) +) {$charset_collate};"; + + dbDelta( $sql ); + + update_option( self::VERSION_OPTION, self::DB_VERSION, false ); + } + + /** + * Insert one serialized event payload. + * + * @param string $payload JSON-encoded analytics event. + * @return bool True when the row was written. + */ + public function insert( string $payload ): bool { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Write to the plugin's own queue table; no core API covers it. + $result = $wpdb->insert( + $this->name(), + array( + 'payload' => $payload, + 'created_at' => gmdate( 'Y-m-d H:i:s' ), + ), + array( '%s', '%s' ) + ); + + return false !== $result; + } + + /** + * Current number of buffered rows. + * + * @return int + */ + public function count(): int { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Live queue-size check on the plugin's own table; name from $wpdb->prefix. + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$this->name()}" ); + } + + /** + * Claim up to $limit oldest rows: select them, delete them, return their + * payloads. Deliver-once semantics — once claimed, rows are gone whether or + * not the subsequent send succeeds. + * + * @param int $limit Maximum rows to claim. + * @return list JSON payload strings, oldest first. + */ + public function claim_batch( int $limit ): array { + global $wpdb; + + $table = $this->name(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Queue drain on the plugin's own table; name from $wpdb->prefix. + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT id, payload FROM {$table} ORDER BY id ASC LIMIT %d", $limit ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix. + ARRAY_A + ); + + if ( empty( $rows ) ) { + return array(); + } + + $ids = implode( ',', array_map( 'intval', array_column( $rows, 'id' ) ) ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- IDs are intval()-sanitized; plugin's own table. + $wpdb->query( "DELETE FROM {$table} WHERE id IN ({$ids})" ); + + return array_column( $rows, 'payload' ); + } +} diff --git a/tests/AnalyticsQueueTableTest.php b/tests/AnalyticsQueueTableTest.php new file mode 100644 index 0000000..e88ab51 --- /dev/null +++ b/tests/AnalyticsQueueTableTest.php @@ -0,0 +1,116 @@ +assertSame( 'wp_supertab_connect_analytics_queue', ( new Analytics_Queue_Table() )->name() ); + } + + public function test_install_runs_dbdelta_and_stores_version(): void { + global $wp_test_dbdelta_queries; + + ( new Analytics_Queue_Table() )->install(); + + $this->assertCount( 1, $wp_test_dbdelta_queries ); + $sql = $wp_test_dbdelta_queries[0]; + $this->assertStringContainsString( 'CREATE TABLE wp_supertab_connect_analytics_queue', $sql ); + $this->assertStringContainsString( 'id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT', $sql ); + $this->assertStringContainsString( 'payload LONGTEXT NOT NULL', $sql ); + $this->assertStringContainsString( 'created_at DATETIME NOT NULL', $sql ); + // dbDelta requires exactly two spaces after PRIMARY KEY. + $this->assertStringContainsString( 'PRIMARY KEY (id)', $sql ); + $this->assertSame( Analytics_Queue_Table::DB_VERSION, get_option( 'supertab_connect_db_version' ) ); + } + + public function test_install_skips_when_version_current(): void { + global $wp_test_dbdelta_queries; + + update_option( 'supertab_connect_db_version', Analytics_Queue_Table::DB_VERSION ); + + ( new Analytics_Queue_Table() )->install(); + + $this->assertCount( 0, $wp_test_dbdelta_queries ); + } + + public function test_insert_writes_payload_row(): void { + global $wpdb; + + $result = ( new Analytics_Queue_Table() )->insert( '{"request_id":"req-1"}' ); + + $this->assertTrue( $result ); + $this->assertCount( 1, $wpdb->insert_calls ); + $call = $wpdb->insert_calls[0]; + $this->assertSame( 'wp_supertab_connect_analytics_queue', $call['table'] ); + $this->assertSame( '{"request_id":"req-1"}', $call['data']['payload'] ); + $this->assertMatchesRegularExpression( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $call['data']['created_at'] ); + $this->assertSame( array( '%s', '%s' ), $call['format'] ); + } + + public function test_insert_returns_false_on_db_error(): void { + global $wpdb; + + $wpdb->insert_result = false; + + $this->assertFalse( ( new Analytics_Queue_Table() )->insert( '{}' ) ); + } + + public function test_count_returns_row_count(): void { + global $wpdb; + + $wpdb->var_result = '42'; + + $this->assertSame( 42, ( new Analytics_Queue_Table() )->count() ); + } + + public function test_claim_batch_returns_empty_without_delete(): void { + global $wpdb; + + $wpdb->results_queue = array( array() ); + + $this->assertSame( array(), ( new Analytics_Queue_Table() )->claim_batch( 500 ) ); + // Only the SELECT ran — no DELETE. + $this->assertCount( 1, $wpdb->queries ); + $this->assertStringContainsString( 'SELECT', $wpdb->queries[0] ); + } + + public function test_claim_batch_selects_deletes_and_returns_payloads(): void { + global $wpdb; + + $wpdb->results_queue = array( + array( + array( 'id' => '1', 'payload' => '{"a":1}' ), + array( 'id' => '2', 'payload' => '{"b":2}' ), + ), + ); + + $payloads = ( new Analytics_Queue_Table() )->claim_batch( 500 ); + + $this->assertSame( array( '{"a":1}', '{"b":2}' ), $payloads ); + $this->assertCount( 2, $wpdb->queries ); + $this->assertStringContainsString( 'ORDER BY id ASC', $wpdb->queries[0] ); + $this->assertStringContainsString( 'LIMIT 500', $wpdb->queries[0] ); + $this->assertSame( 'DELETE FROM wp_supertab_connect_analytics_queue WHERE id IN (1,2)', $wpdb->queries[1] ); + } +} diff --git a/tests/phpstan-bootstrap.php b/tests/phpstan-bootstrap.php index ce69653..c058548 100644 --- a/tests/phpstan-bootstrap.php +++ b/tests/phpstan-bootstrap.php @@ -15,6 +15,10 @@ define( 'SUPERTAB_CONNECT_ENVIRONMENT', 'sbx' ); define( 'SUPERTAB_CONNECT_API_BASE_URL', 'https://api-connect.sbx.supertab.co' ); +if ( ! defined( 'ABSPATH' ) ) { + define( 'ABSPATH', dirname( __DIR__ ) . '/' ); +} + if ( ! function_exists( 'as_enqueue_async_action' ) ) { /** * Signature-only stub of Action Scheduler's async enqueue function. diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php index 0d6282d..2b2fde6 100644 --- a/tests/wp-stubs.php +++ b/tests/wp-stubs.php @@ -30,6 +30,10 @@ define( 'HOUR_IN_SECONDS', 60 * MINUTE_IN_SECONDS ); } +if ( ! defined( 'ARRAY_A' ) ) { + define( 'ARRAY_A', 'ARRAY_A' ); +} + /* |-------------------------------------------------------------------------- | Plugin Constants @@ -88,11 +92,62 @@ $wp_test_as_enqueue_calls = []; $wp_test_as_unschedule_calls = []; +global $wp_test_dbdelta_queries; + +$wp_test_dbdelta_queries = []; + +/** + * Minimal wpdb spy. Records calls; returns configurable canned results. + */ +class WP_Test_Wpdb { + public string $prefix = 'wp_'; + public array $insert_calls = []; + public array $queries = []; + /** @var int|false */ + public $insert_result = 1; + /** Shifted once per get_results() call. */ + public array $results_queue = []; + public string $var_result = '0'; + + public function insert( string $table, array $data, $format = null ) { + $this->insert_calls[] = [ 'table' => $table, 'data' => $data, 'format' => $format ]; + return $this->insert_result; + } + + public function get_var( string $query ) { + $this->queries[] = $query; + return $this->var_result; + } + + public function get_results( string $query, string $output = 'OBJECT' ) { + $this->queries[] = $query; + return array_shift( $this->results_queue ) ?? []; + } + + public function query( string $query ) { + $this->queries[] = $query; + return 0; + } + + public function prepare( string $query, ...$args ): string { + // Replace %s with quoted placeholders, then vsprintf handles both %s and %d + $query = str_replace( '%s', "'%s'", $query ); + return vsprintf( $query, $args ); + } + + public function get_charset_collate(): string { + return 'DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci'; + } +} + +global $wpdb; +$wpdb = new WP_Test_Wpdb(); + /** * Reset all in-memory stores. Call in setUp()/tearDown(). */ function wp_stubs_reset(): void { - global $wp_test_options, $wp_test_transients, $wp_test_headers_sent, $wp_test_status_code, $wp_test_http_calls, $wp_test_scheduled_events, $wp_test_cleared_hooks, $wp_test_unscheduled_hooks, $wp_test_schedule_result, $wp_test_doing_cron, $wp_test_as_enqueue_calls, $wp_test_as_unschedule_calls; + global $wp_test_options, $wp_test_transients, $wp_test_headers_sent, $wp_test_status_code, $wp_test_http_calls, $wp_test_scheduled_events, $wp_test_cleared_hooks, $wp_test_unscheduled_hooks, $wp_test_schedule_result, $wp_test_doing_cron, $wp_test_as_enqueue_calls, $wp_test_as_unschedule_calls, $wp_test_dbdelta_queries, $wpdb; $wp_test_options = []; $wp_test_transients = []; $wp_test_headers_sent = []; @@ -105,6 +160,8 @@ function wp_stubs_reset(): void { $wp_test_doing_cron = false; $wp_test_as_enqueue_calls = []; $wp_test_as_unschedule_calls = []; + $wp_test_dbdelta_queries = []; + $wpdb = new WP_Test_Wpdb(); } /* @@ -304,3 +361,17 @@ function as_unschedule_all_actions( string $hook, array $args = [], string $grou $wp_test_as_unschedule_calls[] = [ 'hook' => $hook, 'args' => $args, 'group' => $group ]; } } + +/* +|-------------------------------------------------------------------------- +| Database Upgrade Stubs +|-------------------------------------------------------------------------- +*/ + +if ( ! function_exists( 'dbDelta' ) ) { + function dbDelta( $queries = '', bool $execute = true ): array { + global $wp_test_dbdelta_queries; + $wp_test_dbdelta_queries[] = $queries; + return []; + } +} From f558ec5f4ff7137cf186c7c72b76b1170c4a3959 Mon Sep 17 00:00:00 2001 From: Tom Stark Date: Mon, 13 Jul 2026 14:07:15 +0200 Subject: [PATCH 2/9] feat: buffer analytics events in the queue table instead of per-event jobs --- src/class-analytics-dispatcher.php | 138 +++++++++++++++-------------- src/class-plugin.php | 2 +- tests/AnalyticsDispatcherTest.php | 115 +++++++++++++++--------- tests/wp-stubs.php | 6 ++ 4 files changed, 151 insertions(+), 110 deletions(-) diff --git a/src/class-analytics-dispatcher.php b/src/class-analytics-dispatcher.php index a580aa2..bc5c4fa 100644 --- a/src/class-analytics-dispatcher.php +++ b/src/class-analytics-dispatcher.php @@ -1,6 +1,7 @@ settings = $settings; $this->http_client = $http_client; + $this->table = $table; } /** - * Register the queued-job handler. + * Register job handlers. * * Must run in every request context (admin, front-end, cron) so the queue * runner can dispatch wherever it executes. @@ -71,66 +94,66 @@ public function __construct( Settings $settings, HttpClientInterface $http_clien * @return void */ public function register(): void { - add_action( self::HOOK, array( $this, 'dispatch' ) ); + add_action( self::LEGACY_HOOK, array( $this, 'dispatch' ) ); } /** * Clear any pending queued work. Called on plugin deactivation. * - * Clearing is args-agnostic: it removes every pending event/action for - * {@see self::HOOK} regardless of the payload it was scheduled with. The - * hook is unique to this plugin, so clearing by hook alone is safe. Note - * that exact-args clearing (e.g. {@see wp_clear_scheduled_hook()} with no - * args) would match nothing, since every event is scheduled with a - * non-empty payload. + * Clears both the recurring flush hook and the legacy per-event hook, in + * both backends, args-agnostically. * * @return void */ public static function clear_scheduled(): void { try { - wp_unschedule_hook( self::HOOK ); + foreach ( array( self::FLUSH_HOOK, self::LEGACY_HOOK ) as $hook ) { + wp_unschedule_hook( $hook ); - if ( function_exists( 'as_unschedule_all_actions' ) ) { - call_user_func( 'as_unschedule_all_actions', self::HOOK ); + if ( function_exists( 'as_unschedule_all_actions' ) ) { + call_user_func( 'as_unschedule_all_actions', $hook ); + } } } catch ( \Throwable $e ) { - if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { - // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intentional error logging for analytics clear_scheduled failures. - error_log( '[Supertab Connect] Analytics clear_scheduled error: ' . $e->getMessage() ); - } + self::log_debug( 'Analytics clear_scheduled error: ' . $e->getMessage() ); } } /** - * Enqueue a serialized analytics event for off-request delivery. + * Buffer a serialized analytics event for the next hourly batch flush. * - * Prefers Action Scheduler (near-real-time async loopback); falls back to - * WP-Cron; and, only if scheduling fails outright, emits inline as a - * best-effort last resort. + * Fail-open: on a full buffer the event is dropped; on any insert/encode + * failure (e.g. missing table) delivery falls back to the inline + * single-event path so the event still has a chance to arrive. * * @param array $event_data Serialized {@see AnalyticsEvent}. * @return void */ public function enqueue( array $event_data ): void { - if ( $this->action_scheduler_available() ) { - $this->enqueue_async( $event_data ); - return; - } + try { + if ( $this->table->count() >= self::MAX_BUFFER_ROWS ) { + self::log_debug( 'Analytics buffer full; dropping event.' ); + return; + } + + $payload = wp_json_encode( $event_data ); - if ( $this->enqueue_cron( $event_data ) ) { - return; + if ( false !== $payload && $this->table->insert( $payload ) ) { + return; + } + } catch ( \Throwable $e ) { + self::log_debug( 'Analytics enqueue error: ' . $e->getMessage() ); } $this->dispatch( $event_data ); } /** - * Deliver one event to the relay. + * Deliver one event to the relay, inline. * - * Serves as both the queued job handler and the inline last-resort path. - * Fail-open: rehydration ({@see AnalyticsEvent::fromArray()}) and delivery - * are wrapped so a malformed payload can never throw into the queue runner - * or the visitor request; {@see HttpAnalyticsTransport} additionally + * Serves as the legacy queued-job handler and the buffering last-resort + * path. Fail-open: rehydration and delivery are wrapped so a malformed + * payload can never throw; {@see HttpAnalyticsTransport} additionally * swallows transport errors. * * @param array $event_data Serialized {@see AnalyticsEvent}. @@ -147,39 +170,20 @@ public function dispatch( array $event_data ): void { $transport->emit( AnalyticsEvent::fromArray( $event_data ) ); } catch ( \Throwable $e ) { - if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { - // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intentional error logging for analytics dispatch failures. - error_log( '[Supertab Connect] Analytics dispatch error: ' . $e->getMessage() ); - } + self::log_debug( 'Analytics dispatch error: ' . $e->getMessage() ); } } /** - * Whether Action Scheduler is available on this site. - * - * @return bool - */ - protected function action_scheduler_available(): bool { - return function_exists( 'as_enqueue_async_action' ); - } - - /** - * Enqueue via Action Scheduler's async action (runs ASAP in a loopback). + * Log a message when WP_DEBUG is on. * - * @param array $event_data Serialized event. + * @param string $message Message to log. * @return void */ - protected function enqueue_async( array $event_data ): void { - call_user_func( 'as_enqueue_async_action', self::HOOK, array( $event_data ), self::GROUP ); - } - - /** - * Enqueue via WP-Cron as a single event at the earliest tick. - * - * @param array $event_data Serialized event. - * @return bool True if the event was scheduled. - */ - protected function enqueue_cron( array $event_data ): bool { - return false !== wp_schedule_single_event( time(), self::HOOK, array( $event_data ) ); + private static function log_debug( string $message ): void { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intentional debug logging; analytics is fail-open. + error_log( '[Supertab Connect] ' . $message ); + } } } diff --git a/src/class-plugin.php b/src/class-plugin.php index aa21890..0a56dab 100644 --- a/src/class-plugin.php +++ b/src/class-plugin.php @@ -83,7 +83,7 @@ public function init(): void { $dispatcher = null; if ( $analytics_enabled && self::should_use_wp_queue() ) { - $dispatcher = new Analytics_Dispatcher( $settings, $http_client ); + $dispatcher = new Analytics_Dispatcher( $settings, $http_client, new Analytics_Queue_Table() ); $dispatcher->register(); } diff --git a/tests/AnalyticsDispatcherTest.php b/tests/AnalyticsDispatcherTest.php index 0037f47..6ea0fbb 100644 --- a/tests/AnalyticsDispatcherTest.php +++ b/tests/AnalyticsDispatcherTest.php @@ -1,6 +1,6 @@ */ + public array $rows = array(); + public bool $insert_ok = true; + /** Overrides count() when >= 0. */ + public int $fixed_count = -1; + /** @var list */ + public array $claim_calls = array(); + public int $install_calls = 0; + + public function install(): void { + ++$this->install_calls; + } - /** - * Build a dispatcher that reports Action Scheduler as unavailable. - */ - private function make_dispatcher_without_action_scheduler(): Analytics_Dispatcher { - return new class( new Settings(), new WP_Http_Client() ) extends Analytics_Dispatcher { - protected function action_scheduler_available(): bool { - return false; + public function insert( string $payload ): bool { + if ( ! $this->insert_ok ) { + return false; + } + $this->rows[] = $payload; + return true; + } + + public function count(): int { + return $this->fixed_count >= 0 ? $this->fixed_count : count( $this->rows ); + } + + public function claim_batch( int $limit ): array { + $this->claim_calls[] = $limit; + return array_splice( $this->rows, 0, $limit ); } }; } - public function test_enqueue_uses_action_scheduler_when_available(): void { - global $wp_test_as_enqueue_calls, $wp_test_scheduled_events; + private function make_dispatcher( ?Analytics_Queue_Table $table = null ): Analytics_Dispatcher { + return new Analytics_Dispatcher( new Settings(), new WP_Http_Client(), $table ?? $this->make_fake_table() ); + } + + public function test_enqueue_buffers_event_as_json_row(): void { + global $wp_test_http_calls; - $this->make_dispatcher()->enqueue( array( 'request_id' => 'req-async' ) ); + $table = $this->make_fake_table(); - $this->assertCount( 1, $wp_test_as_enqueue_calls ); - $this->assertSame( self::HOOK, $wp_test_as_enqueue_calls[0]['hook'] ); - $this->assertSame( array( array( 'request_id' => 'req-async' ) ), $wp_test_as_enqueue_calls[0]['args'] ); - $this->assertSame( 'supertab-connect', $wp_test_as_enqueue_calls[0]['group'] ); - $this->assertSame( array(), $wp_test_scheduled_events ); + $this->make_dispatcher( $table )->enqueue( array( 'request_id' => 'req-1' ) ); + + $this->assertSame( array( '{"request_id":"req-1"}' ), $table->rows ); + $this->assertSame( array(), $wp_test_http_calls, 'Buffering must not trigger an HTTP call.' ); } - public function test_enqueue_falls_back_to_wp_cron_when_action_scheduler_absent(): void { - global $wp_test_scheduled_events, $wp_test_http_calls; + public function test_enqueue_drops_event_when_buffer_full(): void { + global $wp_test_http_calls; + + $table = $this->make_fake_table(); + $table->fixed_count = 10000; - $this->make_dispatcher_without_action_scheduler()->enqueue( array( 'request_id' => 'req-cron' ) ); + $this->make_dispatcher( $table )->enqueue( array( 'request_id' => 'req-overflow' ) ); - $this->assertCount( 1, $wp_test_scheduled_events ); - $this->assertSame( self::HOOK, $wp_test_scheduled_events[0]['hook'] ); - $this->assertSame( array( array( 'request_id' => 'req-cron' ) ), $wp_test_scheduled_events[0]['args'] ); - $this->assertSame( array(), $wp_test_http_calls ); + $this->assertSame( array(), $table->rows, 'Event must be dropped at the row cap.' ); + $this->assertSame( array(), $wp_test_http_calls, 'A capped buffer must not fall back to inline delivery.' ); } - public function test_enqueue_emits_inline_when_scheduling_fails(): void { - global $wp_test_schedule_result, $wp_test_http_calls; + public function test_enqueue_falls_back_inline_when_insert_fails(): void { + global $wp_test_http_calls; update_option( 'supertab_connect_merchant_api_key', 'key-inline' ); - $wp_test_schedule_result = false; - $this->make_dispatcher_without_action_scheduler()->enqueue( array( 'request_id' => 'req-inline' ) ); + $table = $this->make_fake_table(); + $table->insert_ok = false; + + $this->make_dispatcher( $table )->enqueue( array( 'request_id' => 'req-9' ) ); $this->assertCount( 1, $wp_test_http_calls ); - $this->assertSame( 'POST', $wp_test_http_calls[0]['method'] ); + $call = $wp_test_http_calls[0]; + $this->assertSame( 'POST', $call['method'] ); + $this->assertSame( SUPERTAB_CONNECT_API_BASE_URL . '/ingest/events', $call['url'] ); + + $body = json_decode( $call['args']['body'], true ); + $this->assertSame( 'req-9', $body['request_id'] ); } public function test_dispatch_posts_classified_event_to_relay(): void { @@ -122,20 +156,17 @@ public function test_dispatch_swallows_malformed_event_data(): void { $this->assertSame( array(), $wp_test_http_calls, 'No relay POST should occur when rehydration fails.' ); } - public function test_clear_scheduled_clears_wp_cron_and_action_scheduler(): void { + public function test_clear_scheduled_clears_both_hooks_in_both_backends(): void { global $wp_test_unscheduled_hooks, $wp_test_as_unschedule_calls; Analytics_Dispatcher::clear_scheduled(); - // Must clear WP-Cron args-agnostically via wp_unschedule_hook(), which - // removes every event for the hook regardless of scheduled payload. - $this->assertCount( 1, $wp_test_unscheduled_hooks ); - $this->assertSame( self::HOOK, $wp_test_unscheduled_hooks[0] ); + $this->assertSame( array( self::FLUSH_HOOK, self::LEGACY_HOOK ), $wp_test_unscheduled_hooks ); - // Must call Action Scheduler with hook only (empty args, empty group) - // so it hits the bulk cancel-by-hook path rather than exact-args match. - $this->assertCount( 1, $wp_test_as_unschedule_calls ); - $this->assertSame( self::HOOK, $wp_test_as_unschedule_calls[0]['hook'] ); + $this->assertCount( 2, $wp_test_as_unschedule_calls ); + $this->assertSame( self::FLUSH_HOOK, $wp_test_as_unschedule_calls[0]['hook'] ); + $this->assertSame( self::LEGACY_HOOK, $wp_test_as_unschedule_calls[1]['hook'] ); + // Hook-only clearing (empty args/group) hits the bulk cancel-by-hook path. $this->assertSame( array(), $wp_test_as_unschedule_calls[0]['args'] ); $this->assertSame( '', $wp_test_as_unschedule_calls[0]['group'] ); } diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php index 2b2fde6..d739c7a 100644 --- a/tests/wp-stubs.php +++ b/tests/wp-stubs.php @@ -292,6 +292,12 @@ function esc_html( string $text ): string { } } +if ( ! function_exists( 'wp_json_encode' ) ) { + function wp_json_encode( $data, int $options = 0, int $depth = 512 ) { + return json_encode( $data, $options, $depth ); + } +} + /* |-------------------------------------------------------------------------- | Hooks (no-op stubs) From 3546dcfb527d03843927f783a77deb8daace9a90 Mon Sep 17 00:00:00 2001 From: Tom Stark Date: Mon, 13 Jul 2026 14:12:26 +0200 Subject: [PATCH 3/9] feat: drain analytics buffer in hourly batch POSTs to /ingest/events --- src/class-analytics-dispatcher.php | 100 +++++++++++++++++++++++++++++ tests/AnalyticsDispatcherTest.php | 100 +++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) diff --git a/src/class-analytics-dispatcher.php b/src/class-analytics-dispatcher.php index bc5c4fa..ffccbfb 100644 --- a/src/class-analytics-dispatcher.php +++ b/src/class-analytics-dispatcher.php @@ -15,6 +15,7 @@ } use Supertab\Connect\Analytics\AnalyticsEvent; +use Supertab\Connect\Analytics\AnalyticsTransportInterface; use Supertab\Connect\Analytics\HttpAnalyticsTransport; use Supertab\Connect\Http\HttpClientInterface; @@ -43,6 +44,20 @@ class Analytics_Dispatcher { */ public const LEGACY_HOOK = 'supertab_connect_emit_analytics'; + /** + * Maximum events per batch POST (API limit: 500/request). + * + * @var int + */ + private const BATCH_SIZE = 500; + + /** + * Maximum batches per flush run, bounding job runtime. + * + * @var int + */ + private const MAX_BATCHES_PER_RUN = 10; + /** * Row cap: when the buffer holds this many rows (broken cron), new events * are dropped rather than growing the table unboundedly. @@ -148,6 +163,91 @@ public function enqueue( array $event_data ): void { $this->dispatch( $event_data ); } + /** + * Drain the buffer: claim up to BATCH_SIZE rows at a time and POST each + * batch as a JSON array, for at most MAX_BATCHES_PER_RUN batches. + * + * Deliver-once: rows are deleted at claim time, so a failed POST drops + * those events (debug-logged). Malformed rows are skipped. Fail-open + * throughout — this is the FLUSH_HOOK job handler and must never throw + * into the queue runner. + * + * @return void + */ + public function flush(): void { + try { + for ( $i = 0; $i < self::MAX_BATCHES_PER_RUN; $i++ ) { + $payloads = $this->table->claim_batch( self::BATCH_SIZE ); + + if ( array() === $payloads ) { + return; + } + + $events = array(); + foreach ( $payloads as $payload ) { + $event = json_decode( $payload, true ); + + if ( is_array( $event ) ) { + $events[] = $event; + } else { + self::log_debug( 'Skipping malformed buffered analytics event.' ); + } + } + + if ( array() !== $events ) { + $this->post_batch( $events ); + } + + if ( count( $payloads ) < self::BATCH_SIZE ) { + return; + } + } + } catch ( \Throwable $e ) { + self::log_debug( 'Analytics flush error: ' . $e->getMessage() ); + } + } + + /** + * POST one batch of events to the relay as a JSON array. + * + * Best-effort: non-2xx responses, rejected events, and transport errors + * are debug-logged only — never retried or re-buffered. + * + * @param array> $events Decoded event payloads. + * @return void + */ + private function post_batch( array $events ): void { + try { + $body = wp_json_encode( array_values( $events ) ); + + if ( false === $body ) { + self::log_debug( 'Failed to encode analytics batch.' ); + return; + } + + $response = $this->http_client->post( + rtrim( SUPERTAB_CONNECT_API_BASE_URL, '/' ) . AnalyticsTransportInterface::ANALYTICS_EVENTS_PATH, + $body, + array( + 'Authorization' => 'Bearer ' . $this->settings->get_merchant_api_key(), + 'Content-Type' => 'application/json', + ) + ); + + if ( $response['statusCode'] < 200 || $response['statusCode'] >= 300 ) { + self::log_debug( 'Analytics batch POST returned ' . $response['statusCode'] . '; ' . count( $events ) . ' events dropped.' ); + return; + } + + $decoded = json_decode( $response['body'], true ); + if ( is_array( $decoded ) && ( $decoded['rejected_count'] ?? 0 ) > 0 ) { + self::log_debug( 'Analytics batch partially rejected: ' . $decoded['rejected_count'] . ' events dropped server-side.' ); + } + } catch ( \Throwable $e ) { + self::log_debug( 'Analytics batch POST error: ' . $e->getMessage() . '; ' . count( $events ) . ' events dropped.' ); + } + } + /** * Deliver one event to the relay, inline. * diff --git a/tests/AnalyticsDispatcherTest.php b/tests/AnalyticsDispatcherTest.php index 6ea0fbb..db5c16a 100644 --- a/tests/AnalyticsDispatcherTest.php +++ b/tests/AnalyticsDispatcherTest.php @@ -170,4 +170,104 @@ public function test_clear_scheduled_clears_both_hooks_in_both_backends(): void $this->assertSame( array(), $wp_test_as_unschedule_calls[0]['args'] ); $this->assertSame( '', $wp_test_as_unschedule_calls[0]['group'] ); } + + public function test_flush_without_rows_makes_no_request(): void { + global $wp_test_http_calls; + + $table = $this->make_fake_table(); + + $this->make_dispatcher( $table )->flush(); + + $this->assertSame( array(), $wp_test_http_calls ); + $this->assertSame( array( 500 ), $table->claim_calls, 'Exactly one cheap claim on an empty buffer.' ); + } + + public function test_flush_posts_batch_array_with_auth(): void { + global $wp_test_http_calls; + + update_option( 'supertab_connect_merchant_api_key', 'key-batch' ); + + $table = $this->make_fake_table(); + $table->rows = array( '{"request_id":"req-1"}', '{"request_id":"req-2"}' ); + + $this->make_dispatcher( $table )->flush(); + + $this->assertCount( 1, $wp_test_http_calls ); + $call = $wp_test_http_calls[0]; + $this->assertSame( 'POST', $call['method'] ); + $this->assertSame( SUPERTAB_CONNECT_API_BASE_URL . '/ingest/events', $call['url'] ); + $this->assertSame( 'Bearer key-batch', $call['args']['headers']['Authorization'] ); + $this->assertSame( 'application/json', $call['args']['headers']['Content-Type'] ); + + $body = json_decode( $call['args']['body'], true ); + $this->assertSame( + array( + array( 'request_id' => 'req-1' ), + array( 'request_id' => 'req-2' ), + ), + $body, + 'Body must be a JSON array of event objects.' + ); + + $this->assertSame( array(), $table->rows, 'Claimed rows are deleted.' ); + } + + public function test_flush_skips_malformed_rows(): void { + global $wp_test_http_calls; + + $table = $this->make_fake_table(); + $table->rows = array( 'not-json', '{"request_id":"req-ok"}' ); + + $this->make_dispatcher( $table )->flush(); + + $this->assertCount( 1, $wp_test_http_calls ); + $body = json_decode( $wp_test_http_calls[0]['args']['body'], true ); + $this->assertSame( array( array( 'request_id' => 'req-ok' ) ), $body ); + } + + public function test_flush_posts_nothing_when_all_rows_malformed(): void { + global $wp_test_http_calls; + + $table = $this->make_fake_table(); + $table->rows = array( 'nope', '[]broken' ); + + $this->make_dispatcher( $table )->flush(); + + $this->assertSame( array(), $wp_test_http_calls ); + $this->assertSame( array(), $table->rows, 'Malformed rows are still consumed.' ); + } + + public function test_flush_stops_after_max_batches(): void { + global $wp_test_http_calls; + + $table = $this->make_fake_table(); + for ( $i = 0; $i < 5500; $i++ ) { + $table->rows[] = '{"request_id":"req-' . $i . '"}'; + } + + $this->make_dispatcher( $table )->flush(); + + $this->assertCount( 10, $wp_test_http_calls, '10 batches of 500, then stop.' ); + $this->assertSame( array_fill( 0, 10, 500 ), $table->claim_calls ); + $this->assertCount( 500, $table->rows, 'Remainder waits for the next run.' ); + } + + public function test_flush_swallows_http_errors(): void { + $table = $this->make_fake_table(); + $table->rows = array( '{"request_id":"req-x"}' ); + + $throwing_client = new class() implements \Supertab\Connect\Http\HttpClientInterface { + public function get( string $url, array $headers = array() ): array { + throw new \RuntimeException( 'boom' ); + } + public function post( string $url, string $body, array $headers = array() ): array { + throw new \RuntimeException( 'boom' ); + } + }; + + $dispatcher = new Analytics_Dispatcher( new Settings(), $throwing_client, $table ); + $dispatcher->flush(); + + $this->assertSame( array(), $table->rows, 'Deliver-once: rows are gone even when the POST fails.' ); + } } From 29d39463a1b5dc220d73521780df18c41e418ef6 Mon Sep 17 00:00:00 2001 From: Tom Stark Date: Mon, 13 Jul 2026 14:18:22 +0200 Subject: [PATCH 4/9] test: cover non-2xx and partial-rejection responses in analytics flush Add $wp_test_http_response global to wp-stubs.php for response override, enabling tests of Analytics_Dispatcher::post_batch() branches: - non-2xx status handling (no retry, events dropped) - 2xx with rejected_count > 0 (partial rejection tolerated) All 83 tests pass. --- tests/AnalyticsDispatcherTest.php | 34 +++++++++++++++++++++++++++++++ tests/wp-stubs.php | 10 +++++---- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/tests/AnalyticsDispatcherTest.php b/tests/AnalyticsDispatcherTest.php index db5c16a..0ebd6d4 100644 --- a/tests/AnalyticsDispatcherTest.php +++ b/tests/AnalyticsDispatcherTest.php @@ -270,4 +270,38 @@ public function post( string $url, string $body, array $headers = array() ): arr $this->assertSame( array(), $table->rows, 'Deliver-once: rows are gone even when the POST fails.' ); } + + public function test_flush_drops_batch_without_retry_on_non_2xx(): void { + global $wp_test_http_calls, $wp_test_http_response; + + $wp_test_http_response = array( + 'response' => array( 'code' => 500 ), + 'body' => '{}', + ); + + $table = $this->make_fake_table(); + $table->rows = array( '{"request_id":"req-fail"}' ); + + $this->make_dispatcher( $table )->flush(); + + $this->assertCount( 1, $wp_test_http_calls, 'Exactly one POST — no retry on non-2xx.' ); + $this->assertSame( array(), $table->rows, 'Deliver-once: rows stay consumed on non-2xx.' ); + } + + public function test_flush_tolerates_partial_rejection_response(): void { + global $wp_test_http_calls, $wp_test_http_response; + + $wp_test_http_response = array( + 'response' => array( 'code' => 200 ), + 'body' => '{"accepted_count":1,"rejected_count":1,"message":"partial"}', + ); + + $table = $this->make_fake_table(); + $table->rows = array( '{"request_id":"req-a"}', '{"request_id":"req-b"}' ); + + $this->make_dispatcher( $table )->flush(); + + $this->assertCount( 1, $wp_test_http_calls, 'Rejected events are never re-sent.' ); + $this->assertSame( array(), $table->rows ); + } } diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php index d739c7a..977c484 100644 --- a/tests/wp-stubs.php +++ b/tests/wp-stubs.php @@ -74,13 +74,14 @@ | */ -global $wp_test_options, $wp_test_transients, $wp_test_headers_sent, $wp_test_status_code, $wp_test_http_calls; +global $wp_test_options, $wp_test_transients, $wp_test_headers_sent, $wp_test_status_code, $wp_test_http_calls, $wp_test_http_response; $wp_test_options = []; $wp_test_transients = []; $wp_test_headers_sent = []; $wp_test_status_code = 200; $wp_test_http_calls = []; +$wp_test_http_response = null; global $wp_test_scheduled_events, $wp_test_cleared_hooks, $wp_test_unscheduled_hooks, $wp_test_schedule_result, $wp_test_doing_cron, $wp_test_as_enqueue_calls, $wp_test_as_unschedule_calls; @@ -147,12 +148,13 @@ public function get_charset_collate(): string { * Reset all in-memory stores. Call in setUp()/tearDown(). */ function wp_stubs_reset(): void { - global $wp_test_options, $wp_test_transients, $wp_test_headers_sent, $wp_test_status_code, $wp_test_http_calls, $wp_test_scheduled_events, $wp_test_cleared_hooks, $wp_test_unscheduled_hooks, $wp_test_schedule_result, $wp_test_doing_cron, $wp_test_as_enqueue_calls, $wp_test_as_unschedule_calls, $wp_test_dbdelta_queries, $wpdb; + global $wp_test_options, $wp_test_transients, $wp_test_headers_sent, $wp_test_status_code, $wp_test_http_calls, $wp_test_http_response, $wp_test_scheduled_events, $wp_test_cleared_hooks, $wp_test_unscheduled_hooks, $wp_test_schedule_result, $wp_test_doing_cron, $wp_test_as_enqueue_calls, $wp_test_as_unschedule_calls, $wp_test_dbdelta_queries, $wpdb; $wp_test_options = []; $wp_test_transients = []; $wp_test_headers_sent = []; $wp_test_status_code = 200; $wp_test_http_calls = []; + $wp_test_http_response = null; $wp_test_scheduled_events = []; $wp_test_cleared_hooks = []; $wp_test_unscheduled_hooks = []; @@ -248,9 +250,9 @@ function status_header( int $code ): void { if ( ! function_exists( 'wp_remote_post' ) ) { function wp_remote_post( string $url, array $args = [] ) { - global $wp_test_http_calls; + global $wp_test_http_calls, $wp_test_http_response; $wp_test_http_calls[] = [ 'method' => 'POST', 'url' => $url, 'args' => $args ]; - return [ 'response' => [ 'code' => 200 ], 'body' => '{}' ]; + return $wp_test_http_response ?? [ 'response' => [ 'code' => 200 ], 'body' => '{}' ]; } } From 040a3529ac02362417dff94cbace7032b57e5685 Mon Sep 17 00:00:00 2001 From: Tom Stark Date: Mon, 13 Jul 2026 14:22:18 +0200 Subject: [PATCH 5/9] feat: schedule hourly analytics flush via Action Scheduler with WP-Cron fallback --- src/class-analytics-dispatcher.php | 60 +++++++++++++++++- tests/AnalyticsDispatcherTest.php | 97 ++++++++++++++++++++++++++++++ tests/phpstan-bootstrap.php | 38 ++++++++++++ tests/wp-stubs.php | 87 +++++++++++++++++++++------ 4 files changed, 262 insertions(+), 20 deletions(-) diff --git a/src/class-analytics-dispatcher.php b/src/class-analytics-dispatcher.php index ffccbfb..a73c120 100644 --- a/src/class-analytics-dispatcher.php +++ b/src/class-analytics-dispatcher.php @@ -44,6 +44,13 @@ class Analytics_Dispatcher { */ public const LEGACY_HOOK = 'supertab_connect_emit_analytics'; + /** + * Action Scheduler group. + * + * @var string + */ + private const GROUP = 'supertab-connect'; + /** * Maximum events per batch POST (API limit: 500/request). * @@ -101,15 +108,24 @@ public function __construct( Settings $settings, HttpClientInterface $http_clien } /** - * Register job handlers. + * Register job handlers and (in admin/cron contexts) self-heal the schema + * and the hourly schedule. * - * Must run in every request context (admin, front-end, cron) so the queue - * runner can dispatch wherever it executes. + * Handlers must be registered in every request context (admin, front-end, + * cron) so the queue runner can dispatch wherever it executes. Schema + * install and schedule checks are restricted to admin/cron requests to + * keep front-end requests free of extra queries. * * @return void */ public function register(): void { + add_action( self::FLUSH_HOOK, array( $this, 'flush' ) ); add_action( self::LEGACY_HOOK, array( $this, 'dispatch' ) ); + + if ( is_admin() || wp_doing_cron() ) { + $this->table->install(); + $this->ensure_scheduled(); + } } /** @@ -134,6 +150,44 @@ public static function clear_scheduled(): void { } } + /** + * Ensure the hourly flush is scheduled exactly once, preferring Action + * Scheduler and adapting when it appears or disappears. + * + * @return void + */ + protected function ensure_scheduled(): void { + try { + if ( $this->action_scheduler_available() ) { + // Migrate a stale WP-Cron recurrence so both backends never fire. + if ( false !== wp_next_scheduled( self::FLUSH_HOOK ) ) { + wp_clear_scheduled_hook( self::FLUSH_HOOK ); + } + + if ( ! call_user_func( 'as_has_scheduled_action', self::FLUSH_HOOK ) ) { + call_user_func( 'as_schedule_recurring_action', time() + HOUR_IN_SECONDS, HOUR_IN_SECONDS, self::FLUSH_HOOK, array(), self::GROUP ); + } + + return; + } + + if ( false === wp_next_scheduled( self::FLUSH_HOOK ) ) { + wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', self::FLUSH_HOOK ); + } + } catch ( \Throwable $e ) { + self::log_debug( 'Analytics ensure_scheduled error: ' . $e->getMessage() ); + } + } + + /** + * Whether Action Scheduler's recurring API is available on this site. + * + * @return bool + */ + protected function action_scheduler_available(): bool { + return function_exists( 'as_schedule_recurring_action' ) && function_exists( 'as_has_scheduled_action' ); + } + /** * Buffer a serialized analytics event for the next hourly batch flush. * diff --git a/tests/AnalyticsDispatcherTest.php b/tests/AnalyticsDispatcherTest.php index 0ebd6d4..d85b5e9 100644 --- a/tests/AnalyticsDispatcherTest.php +++ b/tests/AnalyticsDispatcherTest.php @@ -74,6 +74,17 @@ private function make_dispatcher( ?Analytics_Queue_Table $table = null ): Analyt return new Analytics_Dispatcher( new Settings(), new WP_Http_Client(), $table ?? $this->make_fake_table() ); } + /** + * Build a dispatcher that reports Action Scheduler as unavailable. + */ + private function make_dispatcher_without_action_scheduler( Analytics_Queue_Table $table ): Analytics_Dispatcher { + return new class( new Settings(), new WP_Http_Client(), $table ) extends Analytics_Dispatcher { + protected function action_scheduler_available(): bool { + return false; + } + }; + } + public function test_enqueue_buffers_event_as_json_row(): void { global $wp_test_http_calls; @@ -304,4 +315,90 @@ public function test_flush_tolerates_partial_rejection_response(): void { $this->assertCount( 1, $wp_test_http_calls, 'Rejected events are never re-sent.' ); $this->assertSame( array(), $table->rows ); } + + public function test_register_schedules_recurring_via_action_scheduler_in_cron_context(): void { + global $wp_test_doing_cron, $wp_test_as_recurring_calls, $wp_test_recurring_events; + + $wp_test_doing_cron = true; + + $this->make_dispatcher()->register(); + + $this->assertCount( 1, $wp_test_as_recurring_calls ); + $call = $wp_test_as_recurring_calls[0]; + $this->assertSame( self::FLUSH_HOOK, $call['hook'] ); + $this->assertSame( HOUR_IN_SECONDS, $call['interval'] ); + $this->assertSame( 'supertab-connect', $call['group'] ); + $this->assertSame( array(), $wp_test_recurring_events, 'No duplicate WP-Cron schedule.' ); + } + + public function test_register_skips_scheduling_when_as_action_exists(): void { + global $wp_test_doing_cron, $wp_test_as_has_scheduled, $wp_test_as_recurring_calls; + + $wp_test_doing_cron = true; + $wp_test_as_has_scheduled = true; + + $this->make_dispatcher()->register(); + + $this->assertSame( array(), $wp_test_as_recurring_calls ); + } + + public function test_register_falls_back_to_wp_cron_recurring(): void { + global $wp_test_doing_cron, $wp_test_recurring_events; + + $wp_test_doing_cron = true; + + $this->make_dispatcher_without_action_scheduler( $this->make_fake_table() )->register(); + + $this->assertCount( 1, $wp_test_recurring_events ); + $this->assertSame( self::FLUSH_HOOK, $wp_test_recurring_events[0]['hook'] ); + $this->assertSame( 'hourly', $wp_test_recurring_events[0]['recurrence'] ); + } + + public function test_register_skips_wp_cron_when_already_scheduled(): void { + global $wp_test_doing_cron, $wp_test_next_scheduled, $wp_test_recurring_events; + + $wp_test_doing_cron = true; + $wp_test_next_scheduled = time() + 100; + + $this->make_dispatcher_without_action_scheduler( $this->make_fake_table() )->register(); + + $this->assertSame( array(), $wp_test_recurring_events ); + } + + public function test_register_migrates_wp_cron_schedule_to_action_scheduler(): void { + global $wp_test_doing_cron, $wp_test_next_scheduled, $wp_test_cleared_hooks, $wp_test_as_recurring_calls; + + $wp_test_doing_cron = true; + $wp_test_next_scheduled = time() + 100; + + $this->make_dispatcher()->register(); + + // The stale WP-Cron recurrence is cleared so both backends never fire. + $this->assertCount( 1, $wp_test_cleared_hooks ); + $this->assertSame( self::FLUSH_HOOK, $wp_test_cleared_hooks[0]['hook'] ); + $this->assertCount( 1, $wp_test_as_recurring_calls ); + } + + public function test_register_installs_table_in_admin_context(): void { + global $wp_test_is_admin; + + $wp_test_is_admin = true; + $table = $this->make_fake_table(); + + $this->make_dispatcher( $table )->register(); + + $this->assertSame( 1, $table->install_calls ); + } + + public function test_register_does_no_schedule_work_on_front_end(): void { + global $wp_test_as_recurring_calls, $wp_test_recurring_events; + + $table = $this->make_fake_table(); + + $this->make_dispatcher( $table )->register(); + + $this->assertSame( 0, $table->install_calls ); + $this->assertSame( array(), $wp_test_as_recurring_calls ); + $this->assertSame( array(), $wp_test_recurring_events ); + } } diff --git a/tests/phpstan-bootstrap.php b/tests/phpstan-bootstrap.php index c058548..408ee16 100644 --- a/tests/phpstan-bootstrap.php +++ b/tests/phpstan-bootstrap.php @@ -61,3 +61,41 @@ function as_enqueue_async_action( string $hook, array $args = array(), string $g function as_unschedule_all_actions( string $hook = '', array $args = array(), string $group = '' ): void { } } + +if ( ! function_exists( 'as_schedule_recurring_action' ) ) { + /** + * Signature-only stub of Action Scheduler's recurring-schedule function. + * + * Real implementation is provided at runtime by the Action Scheduler + * library when active. Declared here purely so PHPStan can type-check the + * `call_user_func( 'as_schedule_recurring_action', ... )` call site. + * + * @param int $timestamp First run timestamp. + * @param int $interval_in_seconds Recurrence interval. + * @param string $hook Action hook to trigger. + * @param array $args Arguments to pass to the hook. + * @param string $group Action group. + * @return int Action ID. + */ + function as_schedule_recurring_action( int $timestamp, int $interval_in_seconds, string $hook, array $args = array(), string $group = '' ): int { + return 0; + } +} + +if ( ! function_exists( 'as_has_scheduled_action' ) ) { + /** + * Signature-only stub of Action Scheduler's pending-action check. + * + * Real implementation is provided at runtime by the Action Scheduler + * library when active. Declared here purely so PHPStan can type-check the + * `call_user_func( 'as_has_scheduled_action', ... )` call site. + * + * @param string $hook Action hook to check. + * @param array|null $args Arguments matching the scheduled action. + * @param string $group Action group. + * @return bool + */ + function as_has_scheduled_action( string $hook, ?array $args = null, string $group = '' ): bool { + return false; + } +} diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php index 977c484..e06879a 100644 --- a/tests/wp-stubs.php +++ b/tests/wp-stubs.php @@ -83,7 +83,7 @@ $wp_test_http_calls = []; $wp_test_http_response = null; -global $wp_test_scheduled_events, $wp_test_cleared_hooks, $wp_test_unscheduled_hooks, $wp_test_schedule_result, $wp_test_doing_cron, $wp_test_as_enqueue_calls, $wp_test_as_unschedule_calls; +global $wp_test_scheduled_events, $wp_test_cleared_hooks, $wp_test_unscheduled_hooks, $wp_test_schedule_result, $wp_test_doing_cron, $wp_test_as_enqueue_calls, $wp_test_as_unschedule_calls, $wp_test_recurring_events, $wp_test_next_scheduled, $wp_test_as_recurring_calls, $wp_test_as_has_scheduled, $wp_test_is_admin; $wp_test_scheduled_events = []; $wp_test_cleared_hooks = []; @@ -92,6 +92,11 @@ $wp_test_doing_cron = false; $wp_test_as_enqueue_calls = []; $wp_test_as_unschedule_calls = []; +$wp_test_recurring_events = []; +$wp_test_next_scheduled = false; +$wp_test_as_recurring_calls = []; +$wp_test_as_has_scheduled = false; +$wp_test_is_admin = false; global $wp_test_dbdelta_queries; @@ -148,22 +153,27 @@ public function get_charset_collate(): string { * Reset all in-memory stores. Call in setUp()/tearDown(). */ function wp_stubs_reset(): void { - global $wp_test_options, $wp_test_transients, $wp_test_headers_sent, $wp_test_status_code, $wp_test_http_calls, $wp_test_http_response, $wp_test_scheduled_events, $wp_test_cleared_hooks, $wp_test_unscheduled_hooks, $wp_test_schedule_result, $wp_test_doing_cron, $wp_test_as_enqueue_calls, $wp_test_as_unschedule_calls, $wp_test_dbdelta_queries, $wpdb; - $wp_test_options = []; - $wp_test_transients = []; - $wp_test_headers_sent = []; - $wp_test_status_code = 200; - $wp_test_http_calls = []; - $wp_test_http_response = null; - $wp_test_scheduled_events = []; - $wp_test_cleared_hooks = []; - $wp_test_unscheduled_hooks = []; - $wp_test_schedule_result = true; - $wp_test_doing_cron = false; - $wp_test_as_enqueue_calls = []; - $wp_test_as_unschedule_calls = []; - $wp_test_dbdelta_queries = []; - $wpdb = new WP_Test_Wpdb(); + global $wp_test_options, $wp_test_transients, $wp_test_headers_sent, $wp_test_status_code, $wp_test_http_calls, $wp_test_http_response, $wp_test_scheduled_events, $wp_test_cleared_hooks, $wp_test_unscheduled_hooks, $wp_test_schedule_result, $wp_test_doing_cron, $wp_test_as_enqueue_calls, $wp_test_as_unschedule_calls, $wp_test_recurring_events, $wp_test_next_scheduled, $wp_test_as_recurring_calls, $wp_test_as_has_scheduled, $wp_test_is_admin, $wp_test_dbdelta_queries, $wpdb; + $wp_test_options = []; + $wp_test_transients = []; + $wp_test_headers_sent = []; + $wp_test_status_code = 200; + $wp_test_http_calls = []; + $wp_test_http_response = null; + $wp_test_scheduled_events = []; + $wp_test_cleared_hooks = []; + $wp_test_unscheduled_hooks = []; + $wp_test_schedule_result = true; + $wp_test_doing_cron = false; + $wp_test_as_enqueue_calls = []; + $wp_test_as_unschedule_calls = []; + $wp_test_recurring_events = []; + $wp_test_next_scheduled = false; + $wp_test_as_recurring_calls = []; + $wp_test_as_has_scheduled = false; + $wp_test_is_admin = false; + $wp_test_dbdelta_queries = []; + $wpdb = new WP_Test_Wpdb(); } /* @@ -370,6 +380,49 @@ function as_unschedule_all_actions( string $hook, array $args = [], string $grou } } +if ( ! function_exists( 'wp_schedule_event' ) ) { + function wp_schedule_event( int $timestamp, string $recurrence, string $hook, array $args = [], bool $wp_error = false ) { + global $wp_test_recurring_events, $wp_test_schedule_result; + $wp_test_recurring_events[] = [ 'timestamp' => $timestamp, 'recurrence' => $recurrence, 'hook' => $hook, 'args' => $args ]; + return $wp_test_schedule_result; + } +} + +if ( ! function_exists( 'wp_next_scheduled' ) ) { + function wp_next_scheduled( string $hook, array $args = [] ) { + global $wp_test_next_scheduled; + return $wp_test_next_scheduled; + } +} + +if ( ! function_exists( 'as_schedule_recurring_action' ) ) { + function as_schedule_recurring_action( int $timestamp, int $interval_in_seconds, string $hook, array $args = [], string $group = '' ): int { + global $wp_test_as_recurring_calls; + $wp_test_as_recurring_calls[] = [ 'timestamp' => $timestamp, 'interval' => $interval_in_seconds, 'hook' => $hook, 'args' => $args, 'group' => $group ]; + return count( $wp_test_as_recurring_calls ); + } +} + +if ( ! function_exists( 'as_has_scheduled_action' ) ) { + function as_has_scheduled_action( string $hook, $args = null, string $group = '' ): bool { + global $wp_test_as_has_scheduled; + return (bool) $wp_test_as_has_scheduled; + } +} + +/* +|-------------------------------------------------------------------------- +| Hooks +|-------------------------------------------------------------------------- +*/ + +if ( ! function_exists( 'is_admin' ) ) { + function is_admin(): bool { + global $wp_test_is_admin; + return (bool) $wp_test_is_admin; + } +} + /* |-------------------------------------------------------------------------- | Database Upgrade Stubs From 4e74429eafd4a0114e28200ab9f995b2317f0ecd Mon Sep 17 00:00:00 2001 From: Tom Stark Date: Mon, 13 Jul 2026 14:27:30 +0200 Subject: [PATCH 6/9] feat: provision analytics queue table on activation, drop on uninstall --- supertab-connect.php | 3 ++- uninstall.php | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/supertab-connect.php b/supertab-connect.php index 933d530..50a91b5 100644 --- a/supertab-connect.php +++ b/supertab-connect.php @@ -44,11 +44,12 @@ require_once __DIR__ . '/vendor/autoload.php'; } -// Set activation flag for redirect. +// Set activation flag for redirect and provision the analytics queue table. register_activation_hook( __FILE__, static function (): void { set_transient( 'supertab_connect_activating', true, 30 ); + ( new Supertab_Connect\Analytics_Queue_Table() )->install(); } ); diff --git a/uninstall.php b/uninstall.php index b6a796f..bd315e9 100644 --- a/uninstall.php +++ b/uninstall.php @@ -23,3 +23,9 @@ // Remove transients. delete_transient( 'supertab_connect_activating' ); delete_transient( 'supertab_connect_license_xml' ); + +// Remove the analytics queue table and its schema-version option. +global $wpdb; +// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.NoCaching -- Uninstall cleanup of the plugin's own custom table. +$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}supertab_connect_analytics_queue" ); +delete_option( 'supertab_connect_db_version' ); From 8e21b062c5cd220fe5a3d79515779502d4b3b721 Mon Sep 17 00:00:00 2001 From: Tom Stark Date: Mon, 13 Jul 2026 14:47:13 +0200 Subject: [PATCH 7/9] fix: fail-open schema install in cron context; broaden analytics test coverage - Wrap table install in register() with try-catch to prevent DB errors from fataling cron runs - Add test verifying scheduling proceeds after install failure - Track autoload parameter in update_option stub for option assertions - Add assertion that schema version option has autoload=false - Add boundary test for exact 500-item batch claim behavior - Extend wp_remote_get stub with same response override as wp_remote_post - Update HTTP stubs comment to document response override capability --- src/class-analytics-dispatcher.php | 7 ++++++- tests/AnalyticsDispatcherTest.php | 31 ++++++++++++++++++++++++++++++ tests/AnalyticsQueueTableTest.php | 3 ++- tests/wp-stubs.php | 19 ++++++++++-------- 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/class-analytics-dispatcher.php b/src/class-analytics-dispatcher.php index a73c120..443677c 100644 --- a/src/class-analytics-dispatcher.php +++ b/src/class-analytics-dispatcher.php @@ -123,7 +123,12 @@ public function register(): void { add_action( self::LEGACY_HOOK, array( $this, 'dispatch' ) ); if ( is_admin() || wp_doing_cron() ) { - $this->table->install(); + try { + $this->table->install(); + } catch ( \Throwable $e ) { + self::log_debug( 'Analytics schema install error: ' . $e->getMessage() ); + } + $this->ensure_scheduled(); } } diff --git a/tests/AnalyticsDispatcherTest.php b/tests/AnalyticsDispatcherTest.php index d85b5e9..3151e96 100644 --- a/tests/AnalyticsDispatcherTest.php +++ b/tests/AnalyticsDispatcherTest.php @@ -263,6 +263,21 @@ public function test_flush_stops_after_max_batches(): void { $this->assertCount( 500, $table->rows, 'Remainder waits for the next run.' ); } + public function test_flush_makes_second_empty_claim_on_exact_batch_boundary(): void { + global $wp_test_http_calls; + + $table = $this->make_fake_table(); + for ( $i = 0; $i < 500; $i++ ) { + $table->rows[] = '{"request_id":"req-' . $i . '"}'; + } + + $this->make_dispatcher( $table )->flush(); + + $this->assertCount( 1, $wp_test_http_calls, 'One full batch POSTed.' ); + $this->assertSame( array( 500, 500 ), $table->claim_calls, 'A full batch triggers one more (empty) claim.' ); + $this->assertSame( array(), $table->rows ); + } + public function test_flush_swallows_http_errors(): void { $table = $this->make_fake_table(); $table->rows = array( '{"request_id":"req-x"}' ); @@ -390,6 +405,22 @@ public function test_register_installs_table_in_admin_context(): void { $this->assertSame( 1, $table->install_calls ); } + public function test_register_swallows_install_failure(): void { + global $wp_test_doing_cron, $wp_test_as_recurring_calls; + + $wp_test_doing_cron = true; + + $table = new class() extends Analytics_Queue_Table { + public function install(): void { + throw new \RuntimeException( 'db down' ); + } + }; + + $this->make_dispatcher( $table )->register(); + + $this->assertCount( 1, $wp_test_as_recurring_calls, 'Scheduling still proceeds after install failure.' ); + } + public function test_register_does_no_schedule_work_on_front_end(): void { global $wp_test_as_recurring_calls, $wp_test_recurring_events; diff --git a/tests/AnalyticsQueueTableTest.php b/tests/AnalyticsQueueTableTest.php index e88ab51..e4194a1 100644 --- a/tests/AnalyticsQueueTableTest.php +++ b/tests/AnalyticsQueueTableTest.php @@ -29,7 +29,7 @@ public function test_name_uses_wpdb_prefix(): void { } public function test_install_runs_dbdelta_and_stores_version(): void { - global $wp_test_dbdelta_queries; + global $wp_test_dbdelta_queries, $wp_test_option_autoload; ( new Analytics_Queue_Table() )->install(); @@ -42,6 +42,7 @@ public function test_install_runs_dbdelta_and_stores_version(): void { // dbDelta requires exactly two spaces after PRIMARY KEY. $this->assertStringContainsString( 'PRIMARY KEY (id)', $sql ); $this->assertSame( Analytics_Queue_Table::DB_VERSION, get_option( 'supertab_connect_db_version' ) ); + $this->assertFalse( $wp_test_option_autoload['supertab_connect_db_version'], 'Schema version option must not autoload.' ); } public function test_install_skips_when_version_current(): void { diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php index e06879a..3861ad2 100644 --- a/tests/wp-stubs.php +++ b/tests/wp-stubs.php @@ -74,7 +74,7 @@ | */ -global $wp_test_options, $wp_test_transients, $wp_test_headers_sent, $wp_test_status_code, $wp_test_http_calls, $wp_test_http_response; +global $wp_test_options, $wp_test_transients, $wp_test_headers_sent, $wp_test_status_code, $wp_test_http_calls, $wp_test_http_response, $wp_test_option_autoload; $wp_test_options = []; $wp_test_transients = []; @@ -82,6 +82,7 @@ $wp_test_status_code = 200; $wp_test_http_calls = []; $wp_test_http_response = null; +$wp_test_option_autoload = []; global $wp_test_scheduled_events, $wp_test_cleared_hooks, $wp_test_unscheduled_hooks, $wp_test_schedule_result, $wp_test_doing_cron, $wp_test_as_enqueue_calls, $wp_test_as_unschedule_calls, $wp_test_recurring_events, $wp_test_next_scheduled, $wp_test_as_recurring_calls, $wp_test_as_has_scheduled, $wp_test_is_admin; @@ -153,13 +154,14 @@ public function get_charset_collate(): string { * Reset all in-memory stores. Call in setUp()/tearDown(). */ function wp_stubs_reset(): void { - global $wp_test_options, $wp_test_transients, $wp_test_headers_sent, $wp_test_status_code, $wp_test_http_calls, $wp_test_http_response, $wp_test_scheduled_events, $wp_test_cleared_hooks, $wp_test_unscheduled_hooks, $wp_test_schedule_result, $wp_test_doing_cron, $wp_test_as_enqueue_calls, $wp_test_as_unschedule_calls, $wp_test_recurring_events, $wp_test_next_scheduled, $wp_test_as_recurring_calls, $wp_test_as_has_scheduled, $wp_test_is_admin, $wp_test_dbdelta_queries, $wpdb; + global $wp_test_options, $wp_test_transients, $wp_test_headers_sent, $wp_test_status_code, $wp_test_http_calls, $wp_test_http_response, $wp_test_option_autoload, $wp_test_scheduled_events, $wp_test_cleared_hooks, $wp_test_unscheduled_hooks, $wp_test_schedule_result, $wp_test_doing_cron, $wp_test_as_enqueue_calls, $wp_test_as_unschedule_calls, $wp_test_recurring_events, $wp_test_next_scheduled, $wp_test_as_recurring_calls, $wp_test_as_has_scheduled, $wp_test_is_admin, $wp_test_dbdelta_queries, $wpdb; $wp_test_options = []; $wp_test_transients = []; $wp_test_headers_sent = []; $wp_test_status_code = 200; $wp_test_http_calls = []; $wp_test_http_response = null; + $wp_test_option_autoload = []; $wp_test_scheduled_events = []; $wp_test_cleared_hooks = []; $wp_test_unscheduled_hooks = []; @@ -191,8 +193,9 @@ function get_option( string $option, $default = false ) { if ( ! function_exists( 'update_option' ) ) { function update_option( string $option, $value, $autoload = null ): bool { - global $wp_test_options; - $wp_test_options[ $option ] = $value; + global $wp_test_options, $wp_test_option_autoload; + $wp_test_options[ $option ] = $value; + $wp_test_option_autoload[ $option ] = $autoload; return true; } } @@ -253,8 +256,8 @@ function status_header( int $code ): void { |-------------------------------------------------------------------------- | | Capture each outbound request into $wp_test_http_calls so tests can assert -| on the URL and args (headers, user-agent, body). Always returns a canned -| 200 response. +| on the URL and args (headers, user-agent, body). Default response is a canned +| 200, overridable per-test via $wp_test_http_response. | */ @@ -268,9 +271,9 @@ function wp_remote_post( string $url, array $args = [] ) { if ( ! function_exists( 'wp_remote_get' ) ) { function wp_remote_get( string $url, array $args = [] ) { - global $wp_test_http_calls; + global $wp_test_http_calls, $wp_test_http_response; $wp_test_http_calls[] = [ 'method' => 'GET', 'url' => $url, 'args' => $args ]; - return [ 'response' => [ 'code' => 200 ], 'body' => 'ok' ]; + return $wp_test_http_response ?? [ 'response' => [ 'code' => 200 ], 'body' => 'ok' ]; } } From 06e8c667d723110fb1ec07789a0176127532b7c8 Mon Sep 17 00:00:00 2001 From: Tom Stark Date: Tue, 14 Jul 2026 15:25:08 +0200 Subject: [PATCH 8/9] fix: make claim_batch atomic with a FOR UPDATE transaction Overlapping flush runners (WP-Cron and Action Scheduler firing together during a backend migration) could both SELECT the same rows before either DELETE ran, double-delivering a batch. Row locks make the second claimer wait and see the rows already gone. --- src/class-analytics-queue-table.php | 44 +++++++++++++++++++++-------- tests/AnalyticsQueueTableTest.php | 19 ++++++++----- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/src/class-analytics-queue-table.php b/src/class-analytics-queue-table.php index af6eb78..2fbd55e 100644 --- a/src/class-analytics-queue-table.php +++ b/src/class-analytics-queue-table.php @@ -119,29 +119,49 @@ public function count(): int { * payloads. Deliver-once semantics — once claimed, rows are gone whether or * not the subsequent send succeeds. * + * The claim runs in a transaction with FOR UPDATE row locks so overlapping + * flush runners (e.g. WP-Cron and Action Scheduler firing together during + * a backend migration) block on the locked rows instead of both claiming — + * and double-delivering — the same batch. + * * @param int $limit Maximum rows to claim. * @return list JSON payload strings, oldest first. + * @throws \Throwable Rethrows any claim-query failure after rolling back. */ public function claim_batch( int $limit ): array { global $wpdb; $table = $this->name(); - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Queue drain on the plugin's own table; name from $wpdb->prefix. - $rows = $wpdb->get_results( - $wpdb->prepare( "SELECT id, payload FROM {$table} ORDER BY id ASC LIMIT %d", $limit ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix. - ARRAY_A - ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Claim must be atomic across SELECT and DELETE. + $wpdb->query( 'START TRANSACTION' ); - if ( empty( $rows ) ) { - return array(); - } + try { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Queue drain on the plugin's own table; name from $wpdb->prefix. + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT id, payload FROM {$table} ORDER BY id ASC LIMIT %d FOR UPDATE", $limit ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix. + ARRAY_A + ); - $ids = implode( ',', array_map( 'intval', array_column( $rows, 'id' ) ) ); + if ( empty( $rows ) ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Close the claim transaction. + $wpdb->query( 'COMMIT' ); + return array(); + } - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- IDs are intval()-sanitized; plugin's own table. - $wpdb->query( "DELETE FROM {$table} WHERE id IN ({$ids})" ); + $ids = implode( ',', array_map( 'intval', array_column( $rows, 'id' ) ) ); - return array_column( $rows, 'payload' ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- IDs are intval()-sanitized; plugin's own table. + $wpdb->query( "DELETE FROM {$table} WHERE id IN ({$ids})" ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Close the claim transaction. + $wpdb->query( 'COMMIT' ); + + return array_column( $rows, 'payload' ); + } catch ( \Throwable $e ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Release locks; caller handles the error. + $wpdb->query( 'ROLLBACK' ); + throw $e; + } } } diff --git a/tests/AnalyticsQueueTableTest.php b/tests/AnalyticsQueueTableTest.php index e4194a1..a893adc 100644 --- a/tests/AnalyticsQueueTableTest.php +++ b/tests/AnalyticsQueueTableTest.php @@ -91,9 +91,11 @@ public function test_claim_batch_returns_empty_without_delete(): void { $wpdb->results_queue = array( array() ); $this->assertSame( array(), ( new Analytics_Queue_Table() )->claim_batch( 500 ) ); - // Only the SELECT ran — no DELETE. - $this->assertCount( 1, $wpdb->queries ); - $this->assertStringContainsString( 'SELECT', $wpdb->queries[0] ); + // Transaction opened and committed around the SELECT — no DELETE. + $this->assertSame( 'START TRANSACTION', $wpdb->queries[0] ); + $this->assertStringContainsString( 'SELECT', $wpdb->queries[1] ); + $this->assertSame( 'COMMIT', $wpdb->queries[2] ); + $this->assertCount( 3, $wpdb->queries ); } public function test_claim_batch_selects_deletes_and_returns_payloads(): void { @@ -109,9 +111,12 @@ public function test_claim_batch_selects_deletes_and_returns_payloads(): void { $payloads = ( new Analytics_Queue_Table() )->claim_batch( 500 ); $this->assertSame( array( '{"a":1}', '{"b":2}' ), $payloads ); - $this->assertCount( 2, $wpdb->queries ); - $this->assertStringContainsString( 'ORDER BY id ASC', $wpdb->queries[0] ); - $this->assertStringContainsString( 'LIMIT 500', $wpdb->queries[0] ); - $this->assertSame( 'DELETE FROM wp_supertab_connect_analytics_queue WHERE id IN (1,2)', $wpdb->queries[1] ); + $this->assertCount( 4, $wpdb->queries ); + $this->assertSame( 'START TRANSACTION', $wpdb->queries[0] ); + $this->assertStringContainsString( 'ORDER BY id ASC', $wpdb->queries[1] ); + $this->assertStringContainsString( 'LIMIT 500', $wpdb->queries[1] ); + $this->assertStringContainsString( 'FOR UPDATE', $wpdb->queries[1] ); + $this->assertSame( 'DELETE FROM wp_supertab_connect_analytics_queue WHERE id IN (1,2)', $wpdb->queries[2] ); + $this->assertSame( 'COMMIT', $wpdb->queries[3] ); } } From df127b9c5af7269096500d129b76732da019db63 Mon Sep 17 00:00:00 2001 From: Tom Stark Date: Tue, 14 Jul 2026 15:26:34 +0200 Subject: [PATCH 9/9] perf: replace per-event COUNT(*) cap check with O(1) id-span probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enqueue() ran SELECT COUNT(*) on every buffered event — an index scan of up to 10k rows per classified bot request. Rows are inserted with ascending ids and only ever deleted oldest-first, so MAX(id) - MIN(id) + 1 bounds the live row count with two O(1) index lookups. Auto-increment gaps can only trip the cap early, which is the fail-open direction. --- src/class-analytics-dispatcher.php | 2 +- src/class-analytics-queue-table.php | 19 ++++++++++++++----- tests/AnalyticsDispatcherTest.php | 11 +++++------ tests/AnalyticsQueueTableTest.php | 24 +++++++++++++++++++++--- tests/wp-stubs.php | 5 +++-- 5 files changed, 44 insertions(+), 17 deletions(-) diff --git a/src/class-analytics-dispatcher.php b/src/class-analytics-dispatcher.php index 443677c..cf95f70 100644 --- a/src/class-analytics-dispatcher.php +++ b/src/class-analytics-dispatcher.php @@ -205,7 +205,7 @@ protected function action_scheduler_available(): bool { */ public function enqueue( array $event_data ): void { try { - if ( $this->table->count() >= self::MAX_BUFFER_ROWS ) { + if ( $this->table->is_full( self::MAX_BUFFER_ROWS ) ) { self::log_debug( 'Analytics buffer full; dropping event.' ); return; } diff --git a/src/class-analytics-queue-table.php b/src/class-analytics-queue-table.php index 2fbd55e..be29663 100644 --- a/src/class-analytics-queue-table.php +++ b/src/class-analytics-queue-table.php @@ -103,15 +103,24 @@ public function insert( string $payload ): bool { } /** - * Current number of buffered rows. + * Whether the buffer has reached $max_rows. * - * @return int + * Uses the id span (MAX - MIN + 1) rather than COUNT(*): rows are inserted + * with ascending ids and only ever deleted oldest-first, so the span bounds + * the row count, and MIN/MAX are O(1) index lookups where COUNT(*) scans + * the index on every buffered event. Auto-increment gaps can only inflate + * the span, making the cap trip early — the fail-open direction. + * + * @param int $max_rows Row cap. + * @return bool True when at or above the cap. */ - public function count(): int { + public function is_full( int $max_rows ): bool { global $wpdb; - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Live queue-size check on the plugin's own table; name from $wpdb->prefix. - return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$this->name()}" ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Live capacity check on the plugin's own table; name from $wpdb->prefix. + $span = $wpdb->get_var( "SELECT MAX(id) - MIN(id) + 1 FROM {$this->name()}" ); + + return null !== $span && (int) $span >= $max_rows; } /** diff --git a/tests/AnalyticsDispatcherTest.php b/tests/AnalyticsDispatcherTest.php index 3151e96..3723773 100644 --- a/tests/AnalyticsDispatcherTest.php +++ b/tests/AnalyticsDispatcherTest.php @@ -41,8 +41,7 @@ private function make_fake_table(): Analytics_Queue_Table { /** @var list */ public array $rows = array(); public bool $insert_ok = true; - /** Overrides count() when >= 0. */ - public int $fixed_count = -1; + public bool $full = false; /** @var list */ public array $claim_calls = array(); public int $install_calls = 0; @@ -59,8 +58,8 @@ public function insert( string $payload ): bool { return true; } - public function count(): int { - return $this->fixed_count >= 0 ? $this->fixed_count : count( $this->rows ); + public function is_full( int $max_rows ): bool { + return $this->full; } public function claim_batch( int $limit ): array { @@ -99,8 +98,8 @@ public function test_enqueue_buffers_event_as_json_row(): void { public function test_enqueue_drops_event_when_buffer_full(): void { global $wp_test_http_calls; - $table = $this->make_fake_table(); - $table->fixed_count = 10000; + $table = $this->make_fake_table(); + $table->full = true; $this->make_dispatcher( $table )->enqueue( array( 'request_id' => 'req-overflow' ) ); diff --git a/tests/AnalyticsQueueTableTest.php b/tests/AnalyticsQueueTableTest.php index a893adc..86c92bb 100644 --- a/tests/AnalyticsQueueTableTest.php +++ b/tests/AnalyticsQueueTableTest.php @@ -77,12 +77,30 @@ public function test_insert_returns_false_on_db_error(): void { $this->assertFalse( ( new Analytics_Queue_Table() )->insert( '{}' ) ); } - public function test_count_returns_row_count(): void { + public function test_is_full_at_id_span_cap(): void { global $wpdb; - $wpdb->var_result = '42'; + $wpdb->var_result = '10000'; - $this->assertSame( 42, ( new Analytics_Queue_Table() )->count() ); + $this->assertTrue( ( new Analytics_Queue_Table() )->is_full( 10000 ) ); + // O(1) MIN/MAX span check, not a COUNT(*) scan. + $this->assertStringContainsString( 'MAX(id) - MIN(id) + 1', $wpdb->queries[0] ); + } + + public function test_is_not_full_below_id_span_cap(): void { + global $wpdb; + + $wpdb->var_result = '9999'; + + $this->assertFalse( ( new Analytics_Queue_Table() )->is_full( 10000 ) ); + } + + public function test_is_not_full_when_empty(): void { + global $wpdb; + + $wpdb->var_result = null; + + $this->assertFalse( ( new Analytics_Queue_Table() )->is_full( 10000 ) ); } public function test_claim_batch_returns_empty_without_delete(): void { diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php index fda9e8e..ebeac06 100644 --- a/tests/wp-stubs.php +++ b/tests/wp-stubs.php @@ -115,14 +115,15 @@ class WP_Test_Wpdb { public $insert_result = 1; /** Shifted once per get_results() call. */ public array $results_queue = []; - public string $var_result = '0'; + /** @var string|null */ + public $var_result = '0'; public function insert( string $table, array $data, $format = null ) { $this->insert_calls[] = [ 'table' => $table, 'data' => $data, 'format' => $format ]; return $this->insert_result; } - public function get_var( string $query ) { + public function get_var( string $query ): ?string { $this->queries[] = $query; return $this->var_result; }