From 84e38d1db366707ef146883296e47986d3bdb34e Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Fri, 26 Jun 2026 21:08:37 +0530 Subject: [PATCH 1/3] feat: use Action Scheduler for background jobs when available Route all background jobs (post processing, deletion, updates and Qdrant migration) through Action Scheduler when another plugin on the site provides it, falling back to WP-Cron otherwise. Adds a thin Scheduler helper that detects Action Scheduler at call time, so there is no new dependency and the existing WP-Cron behaviour is preserved when it is unavailable. --- inc/API.php | 2 +- inc/DB_Table.php | 12 +- inc/Main.php | 2 +- inc/Qdrant_API.php | 4 +- inc/Scheduler.php | 106 ++++++++++++++++++ phpstan.neon | 1 + .../action-scheduler.stubs.php | 73 ++++++++++++ 7 files changed, 189 insertions(+), 11 deletions(-) create mode 100644 inc/Scheduler.php create mode 100644 tests/php/static-analysis-stubs/action-scheduler.stubs.php diff --git a/inc/API.php b/inc/API.php index 7c6ce109..9269be05 100644 --- a/inc/API.php +++ b/inc/API.php @@ -726,7 +726,7 @@ public function qdrant_deactivate() { $over_limit = $this->table->get_posts_over_limit(); if ( ! empty( $over_limit ) ) { - wp_schedule_single_event( time(), 'hyve_delete_posts', [ $over_limit ] ); + Scheduler::enqueue_async( 'hyve_delete_posts', [ $over_limit ] ); } $this->table->update_storage( 'WordPress', 'Qdrant' ); diff --git a/inc/DB_Table.php b/inc/DB_Table.php index 7703a690..4da6ed32 100644 --- a/inc/DB_Table.php +++ b/inc/DB_Table.php @@ -73,9 +73,7 @@ public function __construct() { add_action( 'hyve_delete_posts', [ $this, 'delete_posts' ], 10, 1 ); add_action( 'hyve_update_posts', [ $this, 'update_posts' ] ); - if ( ! wp_next_scheduled( 'hyve_update_posts' ) ) { - wp_schedule_event( time(), 'hourly', 'hyve_update_posts' ); - } + Scheduler::ensure_recurring( 'hyve_update_posts', HOUR_IN_SECONDS, 'hourly' ); if ( ! $this->table_exists() || version_compare( $this->version, get_option( $this->table_name . '_db_version' ), '>' ) ) { $this->create_table(); @@ -543,7 +541,7 @@ public function process_post( $id ) { $embeddings = $openai->create_embeddings( $stripped ); if ( is_wp_error( $embeddings ) || ! $embeddings ) { - wp_schedule_single_event( time() + 60, 'hyve_process_post', [ $id ] ); + Scheduler::schedule_single( time() + 60, 'hyve_process_post', [ $id ] ); return; } @@ -570,7 +568,7 @@ public function process_post( $id ) { } if ( is_wp_error( $success ) ) { - wp_schedule_single_event( time() + 60, 'hyve_process_post', [ $id ] ); + Scheduler::schedule_single( time() + 60, 'hyve_process_post', [ $id ] ); return; } } @@ -631,7 +629,7 @@ public function update_posts() { $this->add_post( $post_id, 'update' ); } - wp_schedule_single_event( time() + 60, 'hyve_update_posts' ); + Scheduler::schedule_single( time() + 60, 'hyve_update_posts' ); } /** @@ -662,7 +660,7 @@ public function delete_posts( array $posts ): void { $has_more = count( $posts ) > 20; if ( $has_more ) { - wp_schedule_single_event( time() + 10, 'hyve_delete_posts', [ array_slice( $posts, 20 ) ] ); + Scheduler::schedule_single( time() + 10, 'hyve_delete_posts', [ array_slice( $posts, 20 ) ] ); } } diff --git a/inc/Main.php b/inc/Main.php index 55f2784b..c598c3b4 100644 --- a/inc/Main.php +++ b/inc/Main.php @@ -553,7 +553,7 @@ public function update_meta( $post_id, $post, $update ) { delete_post_meta( $post_id, '_hyve_moderation_failed' ); delete_post_meta( $post_id, '_hyve_moderation_review' ); - wp_schedule_single_event( time(), 'hyve_update_posts' ); + Scheduler::enqueue_async( 'hyve_update_posts' ); } /** diff --git a/inc/Qdrant_API.php b/inc/Qdrant_API.php index 9d56dd24..052d7425 100644 --- a/inc/Qdrant_API.php +++ b/inc/Qdrant_API.php @@ -123,7 +123,7 @@ public function init() { ] ); - wp_schedule_single_event( time(), 'hyve_lite_migrate_data' ); + Scheduler::enqueue_async( 'hyve_lite_migrate_data' ); } return true; @@ -425,7 +425,7 @@ public function migrate_data() { update_option( 'hyve_qdrant_migration', $migration_status ); if ( $has_more ) { - wp_schedule_single_event( time() + 10, 'hyve_lite_migrate_data' ); + Scheduler::schedule_single( time() + 10, 'hyve_lite_migrate_data' ); } } diff --git a/inc/Scheduler.php b/inc/Scheduler.php new file mode 100644 index 00000000..21ccfb5c --- /dev/null +++ b/inc/Scheduler.php @@ -0,0 +1,106 @@ + $args Arguments to pass to the hook. + * + * @return int|bool Action ID when using Action Scheduler, otherwise the WP-Cron result. + */ + public static function enqueue_async( $hook, $args = [] ) { + if ( function_exists( 'as_enqueue_async_action' ) ) { + return as_enqueue_async_action( $hook, $args, self::GROUP ); + } + + return wp_schedule_single_event( time(), $hook, $args ); + } + + /** + * Schedule a job to run once at a given time. + * + * Replaces `wp_schedule_single_event( time() + $delay, ... )`. + * + * @since 1.3.4 + * + * @param int $timestamp Unix timestamp when the job should run. + * @param string $hook The hook to fire. + * @param list $args Arguments to pass to the hook. + * + * @return int|bool Action ID when using Action Scheduler, otherwise the WP-Cron result. + */ + public static function schedule_single( $timestamp, $hook, $args = [] ) { + if ( function_exists( 'as_schedule_single_action' ) ) { + return as_schedule_single_action( $timestamp, $hook, $args, self::GROUP ); + } + + return wp_schedule_single_event( $timestamp, $hook, $args ); + } + + /** + * Ensure a recurring job is scheduled exactly once. + * + * Replaces the `wp_next_scheduled()` guard around `wp_schedule_event()`. + * + * Self-heals if Action Scheduler later appears or disappears: when it is + * available, any legacy WP-Cron entry is removed and the job runs on Action + * Scheduler; otherwise it runs on WP-Cron. + * + * @since 1.3.4 + * + * @param string $hook The hook to fire. + * @param int $interval_seconds Interval between runs, in seconds (Action Scheduler). + * @param string $recurrence WP-Cron schedule name used for the fallback (e.g. `hourly`). + * @param list $args Arguments to pass to the hook. + * + * @return void + */ + public static function ensure_recurring( $hook, $interval_seconds, $recurrence, $args = [] ) { + if ( function_exists( 'as_has_scheduled_action' ) && function_exists( 'as_schedule_recurring_action' ) ) { + if ( wp_next_scheduled( $hook, $args ) ) { + wp_clear_scheduled_hook( $hook, $args ); + } + + if ( ! as_has_scheduled_action( $hook, ! empty( $args ) ? $args : null, self::GROUP ) ) { + as_schedule_recurring_action( time(), $interval_seconds, $hook, $args, self::GROUP ); + } + + return; + } + + if ( ! wp_next_scheduled( $hook, $args ) ) { + wp_schedule_event( time(), $recurrence, $hook, $args ); + } + } +} diff --git a/phpstan.neon b/phpstan.neon index 0d0bd407..762c9ecd 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,5 +4,6 @@ parameters: - %currentWorkingDirectory%/inc bootstrapFiles: - %currentWorkingDirectory%/tests/php/static-analysis-stubs/hyve-lite.php + - %currentWorkingDirectory%/tests/php/static-analysis-stubs/action-scheduler.stubs.php includes: - %currentWorkingDirectory%/vendor/szepeviktor/phpstan-wordpress/extension.neon \ No newline at end of file diff --git a/tests/php/static-analysis-stubs/action-scheduler.stubs.php b/tests/php/static-analysis-stubs/action-scheduler.stubs.php new file mode 100644 index 00000000..5c43733d --- /dev/null +++ b/tests/php/static-analysis-stubs/action-scheduler.stubs.php @@ -0,0 +1,73 @@ + $args + * @param string $group + * @param bool $unique + * @param int $priority + * + * @return int + */ + function as_enqueue_async_action( $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) { + return 0; + } +} + +if ( ! function_exists( 'as_schedule_single_action' ) ) { + /** + * @param int $timestamp + * @param string $hook + * @param array $args + * @param string $group + * @param bool $unique + * @param int $priority + * + * @return int + */ + function as_schedule_single_action( $timestamp, $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) { + return 0; + } +} + +if ( ! function_exists( 'as_schedule_recurring_action' ) ) { + /** + * @param int $timestamp + * @param int $interval_in_seconds + * @param string $hook + * @param array $args + * @param string $group + * @param bool $unique + * @param int $priority + * + * @return int + */ + function as_schedule_recurring_action( $timestamp, $interval_in_seconds, $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) { + return 0; + } +} + +if ( ! function_exists( 'as_has_scheduled_action' ) ) { + /** + * @param string $hook + * @param array|null $args + * @param string $group + * + * @return bool + */ + function as_has_scheduled_action( $hook, $args = null, $group = '' ) { + return false; + } +} From 39c27241f02b1fd98f605b6e0f504e17b38b9b9c Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Fri, 10 Jul 2026 20:05:22 +0530 Subject: [PATCH 2/3] fix: defer Action Scheduler calls until its data store is initialized The as_* functions no-op with a doing_it_wrong notice before the data store is set up on init, so ensure_recurring() running from the DB_Table constructor cleared the WP-Cron entry without booking its Action Scheduler replacement, losing the recurring job. Defer the migration to action_scheduler_init and fall back to WP-Cron for one-off jobs enqueued too early. --- inc/Scheduler.php | 36 +++++++++++++++++-- .../action-scheduler.stubs.php | 13 +++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/inc/Scheduler.php b/inc/Scheduler.php index 21ccfb5c..ba6af9d5 100644 --- a/inc/Scheduler.php +++ b/inc/Scheduler.php @@ -27,6 +27,21 @@ class Scheduler { */ const GROUP = 'hyve'; + /** + * Whether Action Scheduler is loaded and its data store is initialized. + * + * The `as_*` functions no-op with a `_doing_it_wrong` notice when called + * before the data store is set up on `init`, so scheduling must either + * wait for that or fall back to WP-Cron. + * + * @since 1.3.4 + * + * @return bool + */ + private static function is_ready() { + return class_exists( 'ActionScheduler', false ) && \ActionScheduler::is_initialized(); + } + /** * Schedule a job to run as soon as possible. * @@ -40,7 +55,7 @@ class Scheduler { * @return int|bool Action ID when using Action Scheduler, otherwise the WP-Cron result. */ public static function enqueue_async( $hook, $args = [] ) { - if ( function_exists( 'as_enqueue_async_action' ) ) { + if ( function_exists( 'as_enqueue_async_action' ) && self::is_ready() ) { return as_enqueue_async_action( $hook, $args, self::GROUP ); } @@ -61,7 +76,7 @@ public static function enqueue_async( $hook, $args = [] ) { * @return int|bool Action ID when using Action Scheduler, otherwise the WP-Cron result. */ public static function schedule_single( $timestamp, $hook, $args = [] ) { - if ( function_exists( 'as_schedule_single_action' ) ) { + if ( function_exists( 'as_schedule_single_action' ) && self::is_ready() ) { return as_schedule_single_action( $timestamp, $hook, $args, self::GROUP ); } @@ -87,7 +102,22 @@ public static function schedule_single( $timestamp, $hook, $args = [] ) { * @return void */ public static function ensure_recurring( $hook, $interval_seconds, $recurrence, $args = [] ) { - if ( function_exists( 'as_has_scheduled_action' ) && function_exists( 'as_schedule_recurring_action' ) ) { + $has_as = function_exists( 'as_has_scheduled_action' ) && function_exists( 'as_schedule_recurring_action' ); + + if ( $has_as && ! self::is_ready() ) { + // Called before `init`: retry once the data store is up, so the + // WP-Cron entry is only cleared when its replacement can be booked. + add_action( + 'action_scheduler_init', + static function () use ( $hook, $interval_seconds, $recurrence, $args ) { + self::ensure_recurring( $hook, $interval_seconds, $recurrence, $args ); + } + ); + + return; + } + + if ( $has_as ) { if ( wp_next_scheduled( $hook, $args ) ) { wp_clear_scheduled_hook( $hook, $args ); } diff --git a/tests/php/static-analysis-stubs/action-scheduler.stubs.php b/tests/php/static-analysis-stubs/action-scheduler.stubs.php index 5c43733d..5acd30a5 100644 --- a/tests/php/static-analysis-stubs/action-scheduler.stubs.php +++ b/tests/php/static-analysis-stubs/action-scheduler.stubs.php @@ -71,3 +71,16 @@ function as_has_scheduled_action( $hook, $args = null, $group = '' ) { return false; } } + +if ( ! class_exists( 'ActionScheduler' ) ) { + class ActionScheduler { + /** + * @param string|null $function_name + * + * @return bool + */ + public static function is_initialized( $function_name = null ) { + return false; + } + } +} From d1e3f85e994d396d986a35c2bb402d224ca58bf4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:39:10 +0000 Subject: [PATCH 3/3] fix: resolve merge conflicts with development branch in DB_Table.php Co-authored-by: HardeepAsrani <2649903+HardeepAsrani@users.noreply.github.com> --- inc/DB_Table.php | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/inc/DB_Table.php b/inc/DB_Table.php index b677c7e5..5720974d 100644 --- a/inc/DB_Table.php +++ b/inc/DB_Table.php @@ -700,17 +700,12 @@ public function process_post( $id, $allow_retry = true ) { $embeddings = $openai->create_embeddings( $stripped ); if ( is_wp_error( $embeddings ) || ! $embeddings ) { -<<<<<<< HEAD - Scheduler::schedule_single( time() + 60, 'hyve_process_post', [ $id ] ); - return; -======= $error = is_wp_error( $embeddings ) ? $embeddings : new \WP_Error( 'unknown_error', __( 'An unexpected error occurred while indexing this content.', 'hyve-lite' ) ); $this->handle_processing_failure( $id, (int) $post->post_id, $error, $allow_retry ); return $error; ->>>>>>> origin/development } $embeddings = reset( $embeddings ); @@ -736,13 +731,8 @@ public function process_post( $id, $allow_retry = true ) { } if ( is_wp_error( $success ) ) { -<<<<<<< HEAD - Scheduler::schedule_single( time() + 60, 'hyve_process_post', [ $id ] ); - return; -======= $this->handle_processing_failure( $id, (int) $post->post_id, $success, $allow_retry ); return $success; ->>>>>>> origin/development } } @@ -793,7 +783,7 @@ private function handle_processing_failure( $id, $post_id, $error, $allow_retry if ( $will_retry ) { set_transient( $transient, $attempts, DAY_IN_SECONDS ); - wp_schedule_single_event( time() + ( MINUTE_IN_SECONDS * $attempts ), 'hyve_process_post', [ $id ] ); + Scheduler::schedule_single( time() + ( MINUTE_IN_SECONDS * $attempts ), 'hyve_process_post', [ $id ] ); return; }