From 2a63224f4f09cacf0ce7ce994842d44d6acca42c Mon Sep 17 00:00:00 2001 From: Tom Stark Date: Thu, 16 Jul 2026 11:40:05 +0200 Subject: [PATCH] fix: defer the analytics flush schedule check to init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Action Scheduler's data store only initializes on init priority 1, but the dispatcher's ensure_scheduled() ran synchronously at plugins_loaded. Called that early, as_has_scheduled_action() returns false and as_schedule_recurring_action() returns 0 without scheduling anything — and since Action Scheduler's functions exist, the WP-Cron fallback was never reached either. On any site with Action Scheduler installed the hourly flush was never scheduled at all, so buffered analytics events accumulated in the queue table indefinitely and never reached the relay. register() now hooks ensure_scheduled() to init instead of calling it inline (the plugin boots at plugins_loaded, which always precedes init, including on cron requests). --- src/class-analytics-dispatcher.php | 11 +++++-- tests/AnalyticsDispatcherTest.php | 48 ++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/class-analytics-dispatcher.php b/src/class-analytics-dispatcher.php index cf95f70..92aa0c2 100644 --- a/src/class-analytics-dispatcher.php +++ b/src/class-analytics-dispatcher.php @@ -129,7 +129,10 @@ public function register(): void { self::log_debug( 'Analytics schema install error: ' . $e->getMessage() ); } - $this->ensure_scheduled(); + // Deferred to init: Action Scheduler's data store is only usable + // from init priority 1 onward, and register() runs at plugins_loaded + // — as_*() calls made that early silently schedule nothing. + add_action( 'init', array( $this, 'ensure_scheduled' ) ); } } @@ -159,9 +162,13 @@ public static function clear_scheduled(): void { * Ensure the hourly flush is scheduled exactly once, preferring Action * Scheduler and adapting when it appears or disappears. * + * Runs on init (hooked by {@see register()}) because Action Scheduler's + * data store initializes on init priority 1; called earlier, its API + * functions return without scheduling. + * * @return void */ - protected function ensure_scheduled(): void { + public function ensure_scheduled(): void { try { if ( $this->action_scheduler_available() ) { // Migrate a stale WP-Cron recurrence so both backends never fire. diff --git a/tests/AnalyticsDispatcherTest.php b/tests/AnalyticsDispatcherTest.php index 3723773..2484bba 100644 --- a/tests/AnalyticsDispatcherTest.php +++ b/tests/AnalyticsDispatcherTest.php @@ -73,6 +73,19 @@ 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() ); } + /** + * Fire every callback register() deferred to init, as WordPress would. + */ + private function fire_init_callbacks(): void { + global $wp_test_actions; + + foreach ( $wp_test_actions as $action ) { + if ( 'init' === $action['hook'] ) { + call_user_func( $action['callback'] ); + } + } + } + /** * Build a dispatcher that reports Action Scheduler as unavailable. */ @@ -330,12 +343,38 @@ public function test_flush_tolerates_partial_rejection_response(): void { $this->assertSame( array(), $table->rows ); } + public function test_register_defers_schedule_check_to_init(): void { + global $wp_test_doing_cron, $wp_test_actions, $wp_test_as_recurring_calls, $wp_test_recurring_events; + + $wp_test_doing_cron = true; + + $this->make_dispatcher()->register(); + + // register() runs at plugins_loaded, before Action Scheduler's data + // store initializes (init priority 1) — as_*() calls made that early + // silently no-op, so no scheduling API may be touched yet. + $this->assertSame( array(), $wp_test_as_recurring_calls, 'No AS call at registration time.' ); + $this->assertSame( array(), $wp_test_recurring_events, 'No WP-Cron schedule at registration time.' ); + + $init_hooks = array_values( + array_filter( $wp_test_actions, static fn ( array $a ): bool => 'init' === $a['hook'] ) + ); + $this->assertCount( 1, $init_hooks, 'The schedule check must be deferred to init.' ); + + // Firing the deferred callback performs the actual scheduling. + call_user_func( $init_hooks[0]['callback'] ); + + $this->assertCount( 1, $wp_test_as_recurring_calls ); + $this->assertSame( self::FLUSH_HOOK, $wp_test_as_recurring_calls[0]['hook'] ); + } + 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->fire_init_callbacks(); $this->assertCount( 1, $wp_test_as_recurring_calls ); $call = $wp_test_as_recurring_calls[0]; @@ -352,6 +391,7 @@ public function test_register_skips_scheduling_when_as_action_exists(): void { $wp_test_as_has_scheduled = true; $this->make_dispatcher()->register(); + $this->fire_init_callbacks(); $this->assertSame( array(), $wp_test_as_recurring_calls ); } @@ -362,6 +402,7 @@ public function test_register_falls_back_to_wp_cron_recurring(): void { $wp_test_doing_cron = true; $this->make_dispatcher_without_action_scheduler( $this->make_fake_table() )->register(); + $this->fire_init_callbacks(); $this->assertCount( 1, $wp_test_recurring_events ); $this->assertSame( self::FLUSH_HOOK, $wp_test_recurring_events[0]['hook'] ); @@ -375,6 +416,7 @@ public function test_register_skips_wp_cron_when_already_scheduled(): void { $wp_test_next_scheduled = time() + 100; $this->make_dispatcher_without_action_scheduler( $this->make_fake_table() )->register(); + $this->fire_init_callbacks(); $this->assertSame( array(), $wp_test_recurring_events ); } @@ -386,6 +428,7 @@ public function test_register_migrates_wp_cron_schedule_to_action_scheduler(): v $wp_test_next_scheduled = time() + 100; $this->make_dispatcher()->register(); + $this->fire_init_callbacks(); // The stale WP-Cron recurrence is cleared so both backends never fire. $this->assertCount( 1, $wp_test_cleared_hooks ); @@ -416,6 +459,7 @@ public function install(): void { }; $this->make_dispatcher( $table )->register(); + $this->fire_init_callbacks(); $this->assertCount( 1, $wp_test_as_recurring_calls, 'Scheduling still proceeds after install failure.' ); } @@ -430,5 +474,9 @@ public function test_register_does_no_schedule_work_on_front_end(): void { $this->assertSame( 0, $table->install_calls ); $this->assertSame( array(), $wp_test_as_recurring_calls ); $this->assertSame( array(), $wp_test_recurring_events ); + + global $wp_test_actions; + $init_hooks = array_filter( $wp_test_actions, static fn ( array $a ): bool => 'init' === $a['hook'] ); + $this->assertSame( array(), $init_hooks, 'No deferred schedule check on the front end.' ); } }