From 8bfc4bff838396f4b4663a3e74ae865fd2b17ec9 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 13 Jul 2026 10:03:17 -0400 Subject: [PATCH 1/8] fix: prune expired orphaned Action Scheduler logs --- .../Tasks/Retention/RetentionCleanup.php | 81 +++++++++++++++++++ ...ention-action-scheduler-batching-smoke.php | 50 ++++++++++-- 2 files changed, 123 insertions(+), 8 deletions(-) diff --git a/inc/Engine/AI/System/Tasks/Retention/RetentionCleanup.php b/inc/Engine/AI/System/Tasks/Retention/RetentionCleanup.php index 7e15a0160..47e4ae733 100644 --- a/inc/Engine/AI/System/Tasks/Retention/RetentionCleanup.php +++ b/inc/Engine/AI/System/Tasks/Retention/RetentionCleanup.php @@ -693,6 +693,7 @@ public static function countActionSchedulerBreakdown(): array { $actions_count = 0; $logs_count = 0; + $global_cutoff = gmdate( 'Y-m-d H:i:s', time() - (int) round( self::actionSchedulerMaxAgeDays() * DAY_IN_SECONDS ) ); foreach ( self::actionSchedulerCleanupWindows() as $window ) { $cutoff = $window['cutoff']; @@ -753,6 +754,18 @@ public static function countActionSchedulerBreakdown(): array { } } + // The custom tables do not enforce a foreign key, so native Action + // Scheduler cleanup can leave expired logs after deleting actions. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $logs_count += (int) $wpdb->get_var( + $wpdb->prepare( + 'SELECT COUNT(*) FROM %i l LEFT JOIN %i a ON l.action_id = a.action_id WHERE l.action_id != 0 AND a.action_id IS NULL AND l.log_date_gmt < %s', + $logs_table, + $actions_table, + $global_cutoff + ) + ); + return array( 'actions' => $actions_count, 'logs' => $logs_count, @@ -775,6 +788,7 @@ public static function cleanupActionSchedulerActions(): array { $hit_limit = false; $logs_deleted = 0; $actions_deleted = 0; + $orphan_logs_deleted = 0; foreach ( self::actionSchedulerCleanupWindows() as $window ) { $cutoff = $window['cutoff']; @@ -806,6 +820,18 @@ public static function cleanupActionSchedulerActions(): array { ); } + $orphan_logs_deleted = self::deleteOrphanActionSchedulerLogsBatched( + $logs_table, + $actions_table, + gmdate( 'Y-m-d H:i:s', time() - (int) round( self::actionSchedulerMaxAgeDays() * DAY_IN_SECONDS ) ), + $batch_size, + $max_iterations, + $deadline, + $iterations_used, + $hit_limit + ); + $logs_deleted += $orphan_logs_deleted; + // Hard row-count ceiling per high-churn hook. Age windows above can // leave the table unbounded when generation outruns the cleanup // cadence; this backstop deletes the OLDEST completed/terminal rows for @@ -842,6 +868,7 @@ public static function cleanupActionSchedulerActions(): array { array( 'actions_deleted' => $actions_deleted, 'logs_deleted' => $logs_deleted, + 'orphan_logs_deleted' => $orphan_logs_deleted, 'ceiling_actions_deleted' => $ceiling['actions_deleted'], 'ceiling_logs_deleted' => $ceiling['logs_deleted'], 'max_age_days' => $max_age_days, @@ -863,6 +890,7 @@ public static function cleanupActionSchedulerActions(): array { 'deleted' => $total_deleted, 'actions_deleted' => $actions_deleted, 'logs_deleted' => $logs_deleted, + 'orphan_logs_deleted' => $orphan_logs_deleted, 'ceiling_actions_deleted' => $ceiling['actions_deleted'], 'ceiling_logs_deleted' => $ceiling['logs_deleted'], 'max_age_days' => $max_age_days, @@ -1077,6 +1105,59 @@ private static function deleteActionSchedulerLogsBatched( return $deleted; } + /** + * Delete expired logs whose actions were already removed by another cleanup. + * + * @param string $logs_table Logs table name. + * @param string $actions_table Actions table name. + * @param string $cutoff GMT cutoff datetime. + * @param int $batch_size Rows per batch. + * @param int $max_iterations Hard iteration ceiling (shared budget). + * @param float $deadline Wall-clock deadline (microtime float). + * @param int $iterations_used Shared iteration counter (by reference). + * @param bool $hit_limit Set true when a budget cap trips (by reference). + * @return int Total rows deleted. + */ + private static function deleteOrphanActionSchedulerLogsBatched( + string $logs_table, + string $actions_table, + string $cutoff, + int $batch_size, + int $max_iterations, + float $deadline, + int &$iterations_used, + bool &$hit_limit + ): int { + global $wpdb; + + $deleted = 0; + + do { + if ( $iterations_used >= $max_iterations || microtime( true ) >= $deadline ) { + $hit_limit = true; + break; + } + ++$iterations_used; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $affected = $wpdb->query( + $wpdb->prepare( + 'DELETE FROM %i WHERE log_id IN ( SELECT log_id FROM ( SELECT l.log_id FROM %i l LEFT JOIN %i a ON l.action_id = a.action_id WHERE l.action_id != 0 AND a.action_id IS NULL AND l.log_date_gmt < %s LIMIT %d ) AS tmp )', + $logs_table, + $logs_table, + $actions_table, + $cutoff, + $batch_size + ) + ); + + $affected = false !== $affected ? (int) $affected : 0; + $deleted += $affected; + } while ( $affected > 0 ); + + return $deleted; + } + /** * Batched delete of Action Scheduler action rows by id-subquery. * diff --git a/tests/retention-action-scheduler-batching-smoke.php b/tests/retention-action-scheduler-batching-smoke.php index bddf512cc..5cf44eb4c 100644 --- a/tests/retention-action-scheduler-batching-smoke.php +++ b/tests/retention-action-scheduler-batching-smoke.php @@ -148,7 +148,7 @@ function assert_batching( string $name, bool $condition, string $detail = '' ): /** @var array */ public array $actions = array(); - /** @var array */ + /** @var array */ public array $logs = array(); public int $delete_queries = 0; @@ -194,6 +194,10 @@ public function get_var( $prepared ) { $cutoff = $this->extract_cutoff( $args ); $hook = $this->extract_hook( $sql, $args ); + if ( str_contains( $sql, 'LEFT JOIN' ) ) { + return count( $this->matching_orphan_logs( $cutoff ) ); + } + if ( str_contains( $sql, 'FROM %i l' ) || str_contains( $sql, 'log_id' ) ) { return count( $this->matching_logs( $cutoff, $hook ) ); } @@ -217,7 +221,9 @@ public function query( $prepared ): int { $this->batch_sizes[] = $limit; if ( str_contains( $sql, 'log_id IN' ) ) { - $matching = array_slice( $this->matching_logs( $cutoff, $hook ), 0, $limit, true ); + $matching = str_contains( $sql, 'LEFT JOIN' ) + ? array_slice( $this->matching_orphan_logs( $cutoff ), 0, $limit, true ) + : array_slice( $this->matching_logs( $cutoff, $hook ), 0, $limit, true ); foreach ( array_keys( $matching ) as $log_id ) { unset( $this->logs[ $log_id ] ); } @@ -286,6 +292,17 @@ private function matching_logs( string $cutoff, ?string $hook ): array { } return $out; } + + private function matching_orphan_logs( string $cutoff ): array { + $out = array(); + foreach ( $this->logs as $id => $row ) { + if ( 0 === $row['action_id'] || isset( $this->actions[ $row['action_id'] ] ) || $row['log_date_gmt'] >= $cutoff ) { + continue; + } + $out[ $id ] = $row; + } + return $out; + } }; // Seed enough rows to force the batching loop to iterate past the 1000-row @@ -308,8 +325,9 @@ private function matching_logs( string $cutoff, ?string $hook ): array { 'last_attempt_gmt' => $seven_h, ); $fake_wpdb->logs[ $lid ] = array( - 'log_id' => $lid, - 'action_id' => $aid, + 'log_id' => $lid, + 'action_id' => $aid, + 'log_date_gmt' => $seven_h, ); ++$aid; ++$lid; @@ -332,6 +350,17 @@ private function matching_logs( string $cutoff, ?string $hook ): array { ); ++$aid; } + $fake_wpdb->logs[ $lid ] = array( + 'log_id' => $lid, + 'action_id' => 999999, + 'log_date_gmt' => $eight_day, + ); + ++$lid; + $fake_wpdb->logs[ $lid ] = array( + 'log_id' => $lid, + 'action_id' => 999998, + 'log_date_gmt' => $fresh, + ); $GLOBALS['wpdb'] = $fake_wpdb; @@ -347,8 +376,8 @@ private function matching_logs( string $cutoff, ?string $hook ): array { && 0 === $fake_wpdb->delete_queries ); assert_batching( - 'count covers per-hook + global eligible rows (2500 logs + 2530 actions)', - 5030 === $count_total, + 'count includes expired orphan logs (2501 logs + 2530 actions)', + 5031 === $count_total, "got {$count_total}" ); @@ -401,14 +430,19 @@ private function matching_logs( string $cutoff, ?string $hook ): array { ) ); assert_batching( - 'result reports per-table deletion counts + batch metadata', + 'result reports per-table deletion counts, including the orphan log, and batch metadata', 2530 === $result['actions_deleted'] - && 2500 === $result['logs_deleted'] + && 2501 === $result['logs_deleted'] + && 1 === $result['orphan_logs_deleted'] && 1000 === $result['batch_size'] && isset( $result['iterations'] ) && false === $result['hit_limit'], "actions={$result['actions_deleted']} logs={$result['logs_deleted']}" ); + assert_batching( + 'expired orphan logs are removed while fresh orphan logs survive', + ! isset( $fake_wpdb->logs[ 2501 ] ) && isset( $fake_wpdb->logs[ 2502 ] ) + ); // OPTIMIZE is opt-in: default off => no OPTIMIZE call. assert_batching( From 8fb2bdd600284781c97cd008493947322b8fe345 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 13 Jul 2026 10:05:03 -0400 Subject: [PATCH 2/8] Revert "fix: prune expired orphaned Action Scheduler logs" This reverts commit 8bfc4bff838396f4b4663a3e74ae865fd2b17ec9. --- .../Tasks/Retention/RetentionCleanup.php | 81 ------------------- ...ention-action-scheduler-batching-smoke.php | 50 ++---------- 2 files changed, 8 insertions(+), 123 deletions(-) diff --git a/inc/Engine/AI/System/Tasks/Retention/RetentionCleanup.php b/inc/Engine/AI/System/Tasks/Retention/RetentionCleanup.php index 47e4ae733..7e15a0160 100644 --- a/inc/Engine/AI/System/Tasks/Retention/RetentionCleanup.php +++ b/inc/Engine/AI/System/Tasks/Retention/RetentionCleanup.php @@ -693,7 +693,6 @@ public static function countActionSchedulerBreakdown(): array { $actions_count = 0; $logs_count = 0; - $global_cutoff = gmdate( 'Y-m-d H:i:s', time() - (int) round( self::actionSchedulerMaxAgeDays() * DAY_IN_SECONDS ) ); foreach ( self::actionSchedulerCleanupWindows() as $window ) { $cutoff = $window['cutoff']; @@ -754,18 +753,6 @@ public static function countActionSchedulerBreakdown(): array { } } - // The custom tables do not enforce a foreign key, so native Action - // Scheduler cleanup can leave expired logs after deleting actions. - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $logs_count += (int) $wpdb->get_var( - $wpdb->prepare( - 'SELECT COUNT(*) FROM %i l LEFT JOIN %i a ON l.action_id = a.action_id WHERE l.action_id != 0 AND a.action_id IS NULL AND l.log_date_gmt < %s', - $logs_table, - $actions_table, - $global_cutoff - ) - ); - return array( 'actions' => $actions_count, 'logs' => $logs_count, @@ -788,7 +775,6 @@ public static function cleanupActionSchedulerActions(): array { $hit_limit = false; $logs_deleted = 0; $actions_deleted = 0; - $orphan_logs_deleted = 0; foreach ( self::actionSchedulerCleanupWindows() as $window ) { $cutoff = $window['cutoff']; @@ -820,18 +806,6 @@ public static function cleanupActionSchedulerActions(): array { ); } - $orphan_logs_deleted = self::deleteOrphanActionSchedulerLogsBatched( - $logs_table, - $actions_table, - gmdate( 'Y-m-d H:i:s', time() - (int) round( self::actionSchedulerMaxAgeDays() * DAY_IN_SECONDS ) ), - $batch_size, - $max_iterations, - $deadline, - $iterations_used, - $hit_limit - ); - $logs_deleted += $orphan_logs_deleted; - // Hard row-count ceiling per high-churn hook. Age windows above can // leave the table unbounded when generation outruns the cleanup // cadence; this backstop deletes the OLDEST completed/terminal rows for @@ -868,7 +842,6 @@ public static function cleanupActionSchedulerActions(): array { array( 'actions_deleted' => $actions_deleted, 'logs_deleted' => $logs_deleted, - 'orphan_logs_deleted' => $orphan_logs_deleted, 'ceiling_actions_deleted' => $ceiling['actions_deleted'], 'ceiling_logs_deleted' => $ceiling['logs_deleted'], 'max_age_days' => $max_age_days, @@ -890,7 +863,6 @@ public static function cleanupActionSchedulerActions(): array { 'deleted' => $total_deleted, 'actions_deleted' => $actions_deleted, 'logs_deleted' => $logs_deleted, - 'orphan_logs_deleted' => $orphan_logs_deleted, 'ceiling_actions_deleted' => $ceiling['actions_deleted'], 'ceiling_logs_deleted' => $ceiling['logs_deleted'], 'max_age_days' => $max_age_days, @@ -1105,59 +1077,6 @@ private static function deleteActionSchedulerLogsBatched( return $deleted; } - /** - * Delete expired logs whose actions were already removed by another cleanup. - * - * @param string $logs_table Logs table name. - * @param string $actions_table Actions table name. - * @param string $cutoff GMT cutoff datetime. - * @param int $batch_size Rows per batch. - * @param int $max_iterations Hard iteration ceiling (shared budget). - * @param float $deadline Wall-clock deadline (microtime float). - * @param int $iterations_used Shared iteration counter (by reference). - * @param bool $hit_limit Set true when a budget cap trips (by reference). - * @return int Total rows deleted. - */ - private static function deleteOrphanActionSchedulerLogsBatched( - string $logs_table, - string $actions_table, - string $cutoff, - int $batch_size, - int $max_iterations, - float $deadline, - int &$iterations_used, - bool &$hit_limit - ): int { - global $wpdb; - - $deleted = 0; - - do { - if ( $iterations_used >= $max_iterations || microtime( true ) >= $deadline ) { - $hit_limit = true; - break; - } - ++$iterations_used; - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $affected = $wpdb->query( - $wpdb->prepare( - 'DELETE FROM %i WHERE log_id IN ( SELECT log_id FROM ( SELECT l.log_id FROM %i l LEFT JOIN %i a ON l.action_id = a.action_id WHERE l.action_id != 0 AND a.action_id IS NULL AND l.log_date_gmt < %s LIMIT %d ) AS tmp )', - $logs_table, - $logs_table, - $actions_table, - $cutoff, - $batch_size - ) - ); - - $affected = false !== $affected ? (int) $affected : 0; - $deleted += $affected; - } while ( $affected > 0 ); - - return $deleted; - } - /** * Batched delete of Action Scheduler action rows by id-subquery. * diff --git a/tests/retention-action-scheduler-batching-smoke.php b/tests/retention-action-scheduler-batching-smoke.php index 5cf44eb4c..bddf512cc 100644 --- a/tests/retention-action-scheduler-batching-smoke.php +++ b/tests/retention-action-scheduler-batching-smoke.php @@ -148,7 +148,7 @@ function assert_batching( string $name, bool $condition, string $detail = '' ): /** @var array */ public array $actions = array(); - /** @var array */ + /** @var array */ public array $logs = array(); public int $delete_queries = 0; @@ -194,10 +194,6 @@ public function get_var( $prepared ) { $cutoff = $this->extract_cutoff( $args ); $hook = $this->extract_hook( $sql, $args ); - if ( str_contains( $sql, 'LEFT JOIN' ) ) { - return count( $this->matching_orphan_logs( $cutoff ) ); - } - if ( str_contains( $sql, 'FROM %i l' ) || str_contains( $sql, 'log_id' ) ) { return count( $this->matching_logs( $cutoff, $hook ) ); } @@ -221,9 +217,7 @@ public function query( $prepared ): int { $this->batch_sizes[] = $limit; if ( str_contains( $sql, 'log_id IN' ) ) { - $matching = str_contains( $sql, 'LEFT JOIN' ) - ? array_slice( $this->matching_orphan_logs( $cutoff ), 0, $limit, true ) - : array_slice( $this->matching_logs( $cutoff, $hook ), 0, $limit, true ); + $matching = array_slice( $this->matching_logs( $cutoff, $hook ), 0, $limit, true ); foreach ( array_keys( $matching ) as $log_id ) { unset( $this->logs[ $log_id ] ); } @@ -292,17 +286,6 @@ private function matching_logs( string $cutoff, ?string $hook ): array { } return $out; } - - private function matching_orphan_logs( string $cutoff ): array { - $out = array(); - foreach ( $this->logs as $id => $row ) { - if ( 0 === $row['action_id'] || isset( $this->actions[ $row['action_id'] ] ) || $row['log_date_gmt'] >= $cutoff ) { - continue; - } - $out[ $id ] = $row; - } - return $out; - } }; // Seed enough rows to force the batching loop to iterate past the 1000-row @@ -325,9 +308,8 @@ private function matching_orphan_logs( string $cutoff ): array { 'last_attempt_gmt' => $seven_h, ); $fake_wpdb->logs[ $lid ] = array( - 'log_id' => $lid, - 'action_id' => $aid, - 'log_date_gmt' => $seven_h, + 'log_id' => $lid, + 'action_id' => $aid, ); ++$aid; ++$lid; @@ -350,17 +332,6 @@ private function matching_orphan_logs( string $cutoff ): array { ); ++$aid; } - $fake_wpdb->logs[ $lid ] = array( - 'log_id' => $lid, - 'action_id' => 999999, - 'log_date_gmt' => $eight_day, - ); - ++$lid; - $fake_wpdb->logs[ $lid ] = array( - 'log_id' => $lid, - 'action_id' => 999998, - 'log_date_gmt' => $fresh, - ); $GLOBALS['wpdb'] = $fake_wpdb; @@ -376,8 +347,8 @@ private function matching_orphan_logs( string $cutoff ): array { && 0 === $fake_wpdb->delete_queries ); assert_batching( - 'count includes expired orphan logs (2501 logs + 2530 actions)', - 5031 === $count_total, + 'count covers per-hook + global eligible rows (2500 logs + 2530 actions)', + 5030 === $count_total, "got {$count_total}" ); @@ -430,19 +401,14 @@ private function matching_orphan_logs( string $cutoff ): array { ) ); assert_batching( - 'result reports per-table deletion counts, including the orphan log, and batch metadata', + 'result reports per-table deletion counts + batch metadata', 2530 === $result['actions_deleted'] - && 2501 === $result['logs_deleted'] - && 1 === $result['orphan_logs_deleted'] + && 2500 === $result['logs_deleted'] && 1000 === $result['batch_size'] && isset( $result['iterations'] ) && false === $result['hit_limit'], "actions={$result['actions_deleted']} logs={$result['logs_deleted']}" ); - assert_batching( - 'expired orphan logs are removed while fresh orphan logs survive', - ! isset( $fake_wpdb->logs[ 2501 ] ) && isset( $fake_wpdb->logs[ 2502 ] ) - ); // OPTIMIZE is opt-in: default off => no OPTIMIZE call. assert_batching( From 5829d7d5ba1396462cbbc6488db343e32367c78b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 13 Jul 2026 10:49:24 -0400 Subject: [PATCH 3/8] fix: reconcile recurring Action Scheduler actions safely --- .../AI/System/SystemAgentServiceProvider.php | 3 + inc/Engine/Tasks/RecurringScheduler.php | 18 ++++-- .../recurring-scheduler-idempotency-smoke.php | 40 ++++++++++++- ...ention-action-scheduler-batching-smoke.php | 60 +++++++++++++++++-- 4 files changed, 109 insertions(+), 12 deletions(-) diff --git a/inc/Engine/AI/System/SystemAgentServiceProvider.php b/inc/Engine/AI/System/SystemAgentServiceProvider.php index 3ae406d1a..fb587bb1f 100644 --- a/inc/Engine/AI/System/SystemAgentServiceProvider.php +++ b/inc/Engine/AI/System/SystemAgentServiceProvider.php @@ -59,7 +59,10 @@ public function __construct() { $this->registerBuiltInSchedules(); $this->initializeRegistry(); $this->registerActionSchedulerHooks(); + // Bootstrap immediately after AS initializes, then let AS's native daily + // recurring-ensure action repair schedules that are later interrupted. add_action( 'action_scheduler_init', array( $this, 'manageRecurringTaskSchedules' ) ); + add_action( 'action_scheduler_ensure_recurring_actions', array( $this, 'manageRecurringTaskSchedules' ) ); WakeBriefingTask::registerStalenessGuard(); } diff --git a/inc/Engine/Tasks/RecurringScheduler.php b/inc/Engine/Tasks/RecurringScheduler.php index c10cef8da..0ecba0f80 100644 --- a/inc/Engine/Tasks/RecurringScheduler.php +++ b/inc/Engine/Tasks/RecurringScheduler.php @@ -235,7 +235,7 @@ public static function ensureSchedule( // single chain now instead of preserving the duplicates. if ( self::countMatchingPendingActions( $hook, $args, $group ) > 1 ) { self::unschedule( $hook, $args, $group ); - as_schedule_recurring_action( $first_run_time, $interval_seconds, $hook, $args, $group ); + as_schedule_recurring_action( $first_run_time, $interval_seconds, $hook, $args, $group, true ); if ( ! self::isScheduled( $hook, $args, $group ) ) { return self::error( @@ -263,9 +263,13 @@ public static function ensureSchedule( ); } - self::unschedule( $hook, $args, $group ); + if ( self::getPendingAction( $hook, $args, $group ) ) { + self::unschedule( $hook, $args, $group ); + } - as_schedule_recurring_action( $first_run_time, $interval_seconds, $hook, $args, $group ); + // A reconciliation can race another request during activation, upgrades, + // or cron. Let Action Scheduler atomically preserve the first chain. + as_schedule_recurring_action( $first_run_time, $interval_seconds, $hook, $args, $group, true ); // Verify persistence. AS can silently drop actions when its tables // aren't ready (e.g. CLI context during plugin activation). @@ -676,7 +680,7 @@ private static function scheduleCron( string $hook, array $args, string $cron_ex // single chain instead of preserving the duplicates. if ( self::countMatchingPendingActions( $hook, $args, $group ) > 1 ) { self::unschedule( $hook, $args, $group ); - $action_id = as_schedule_cron_action( time(), $cron_expression, $hook, $args, $group ); + $action_id = as_schedule_cron_action( time(), $cron_expression, $hook, $args, $group, true ); if ( ! self::isScheduled( $hook, $args, $group ) ) { return self::error( @@ -703,9 +707,11 @@ private static function scheduleCron( string $hook, array $args, string $cron_ex ); } - self::unschedule( $hook, $args, $group ); + if ( self::getPendingAction( $hook, $args, $group ) ) { + self::unschedule( $hook, $args, $group ); + } - $action_id = as_schedule_cron_action( time(), $cron_expression, $hook, $args, $group ); + $action_id = as_schedule_cron_action( time(), $cron_expression, $hook, $args, $group, true ); if ( ! self::isScheduled( $hook, $args, $group ) ) { return self::error( diff --git a/tests/recurring-scheduler-idempotency-smoke.php b/tests/recurring-scheduler-idempotency-smoke.php index 1506796ab..64fe39927 100644 --- a/tests/recurring-scheduler-idempotency-smoke.php +++ b/tests/recurring-scheduler-idempotency-smoke.php @@ -147,6 +147,9 @@ public function get_schedule(): DmRecurringSchedulerFakeSchedule { $GLOBALS['datamachine_rs_scheduled_recurring'] = 0; $GLOBALS['datamachine_rs_scheduled_cron'] = 0; $GLOBALS['datamachine_rs_unscheduled'] = 0; +$GLOBALS['datamachine_rs_recurring_unique'] = array(); +$GLOBALS['datamachine_rs_cron_unique'] = array(); +$GLOBALS['datamachine_rs_schedule_race'] = false; function datamachine_rs_key( string $hook, array $args, string $group ): string { return $group . '|' . $hook . '|' . serialize( $args ); @@ -177,6 +180,9 @@ function datamachine_rs_reset(): void { $GLOBALS['datamachine_rs_scheduled_recurring'] = 0; $GLOBALS['datamachine_rs_scheduled_cron'] = 0; $GLOBALS['datamachine_rs_unscheduled'] = 0; + $GLOBALS['datamachine_rs_recurring_unique'] = array(); + $GLOBALS['datamachine_rs_cron_unique'] = array(); + $GLOBALS['datamachine_rs_schedule_race'] = false; ActionScheduler::$initialized = true; } @@ -220,14 +226,24 @@ function as_schedule_single_action( int $timestamp, string $hook, array $args = return 101; } -function as_schedule_recurring_action( int $timestamp, int $interval, string $hook, array $args = array(), string $group = '' ): int { +function as_schedule_recurring_action( int $timestamp, int $interval, string $hook, array $args = array(), string $group = '', bool $unique = false ): int { ++$GLOBALS['datamachine_rs_scheduled_recurring']; + $GLOBALS['datamachine_rs_recurring_unique'][] = $unique; + if ( $GLOBALS['datamachine_rs_schedule_race'] ) { + $GLOBALS['datamachine_rs_schedule_race'] = false; + datamachine_rs_seed_action( $hook, $args, $group, new DmRecurringSchedulerFakeSchedule( true, $interval, $timestamp ) ); + return 0; + } + if ( $unique && datamachine_rs_pending_count( $hook, $args, $group ) > 0 ) { + return 0; + } datamachine_rs_seed_action( $hook, $args, $group, new DmRecurringSchedulerFakeSchedule( true, $interval, $timestamp ) ); return 202; } -function as_schedule_cron_action( int $timestamp, string $expression, string $hook, array $args = array(), string $group = '' ): int { +function as_schedule_cron_action( int $timestamp, string $expression, string $hook, array $args = array(), string $group = '', bool $unique = false ): int { ++$GLOBALS['datamachine_rs_scheduled_cron']; + $GLOBALS['datamachine_rs_cron_unique'][] = $unique; datamachine_rs_seed_action( $hook, $args, $group, new DmRecurringSchedulerFakeSchedule( true, $expression, $timestamp ) ); return 303; } @@ -371,4 +387,24 @@ function datamachine_assert_schedule_result( $result ): array { datamachine_assert( 1 === datamachine_rs_counter( 'datamachine_rs_scheduled_cron' ), 'exactly one cron chain recreated' ); datamachine_assert( 1 === datamachine_rs_pending_count( 'datamachine_cron', array(), RecurringScheduler::GROUP ), 'cron collapsed to exactly one pending action' ); +echo "\n[14] concurrent recurring reconciliation creates exactly one unique chain (#2892)\n"; +datamachine_rs_reset(); +$GLOBALS['datamachine_rs_schedule_race'] = true; +$result = datamachine_assert_schedule_result( RecurringScheduler::ensureSchedule( 'datamachine_recurring_retention_as_actions', array(), 'hourly' ) ); +datamachine_assert( 1 === datamachine_rs_pending_count( 'datamachine_recurring_retention_as_actions', array(), RecurringScheduler::GROUP ), 'concurrent reconciliation preserves exactly one pending chain' ); +datamachine_assert( array( true ) === $GLOBALS['datamachine_rs_recurring_unique'], 'recurring reconciliation requests Action Scheduler uniqueness' ); +datamachine_assert( 0 === datamachine_rs_counter( 'datamachine_rs_unscheduled' ), 'initial reconciliation does not cancel a concurrent healthy chain' ); + +echo "\n[15] all recurring and cron replacement paths request Action Scheduler uniqueness (#2892)\n"; +datamachine_rs_reset(); +datamachine_assert_schedule_result( RecurringScheduler::ensureSchedule( 'datamachine_recurring_retention_as_actions', array(), 'hourly' ) ); +datamachine_assert( array( true ) === $GLOBALS['datamachine_rs_recurring_unique'], 'missing enabled recurrence is restored as a unique action' ); +datamachine_rs_reset(); +datamachine_assert_schedule_result( RecurringScheduler::ensureSchedule( 'datamachine_cron', array(), 'cron', array( 'cron_expression' => '0 0 * * *' ) ) ); +datamachine_assert( array( true ) === $GLOBALS['datamachine_rs_cron_unique'], 'cron replacement is also unique' ); + +echo "\n[16] Action Scheduler native recurring-ensure hook repairs interrupted schedules (#2892)\n"; +$provider_source = file_get_contents( __DIR__ . '/../inc/Engine/AI/System/SystemAgentServiceProvider.php' ) ?: ''; +datamachine_assert( str_contains( $provider_source, "add_action( 'action_scheduler_ensure_recurring_actions', array( \$this, 'manageRecurringTaskSchedules' ) );" ), 'provider registers reconciliation on Action Scheduler native recurring-ensure hook' ); + echo "\nAll recurring scheduler idempotency assertions passed.\n"; diff --git a/tests/retention-action-scheduler-batching-smoke.php b/tests/retention-action-scheduler-batching-smoke.php index bddf512cc..198cfbbdd 100644 --- a/tests/retention-action-scheduler-batching-smoke.php +++ b/tests/retention-action-scheduler-batching-smoke.php @@ -332,6 +332,47 @@ private function matching_logs( string $cutoff, ?string $hook ): array { ); ++$aid; } + $expired_recurring_action_id = $aid; + $expired_recurring_log_id = $lid; + $fake_wpdb->actions[ $aid ] = array( + 'action_id' => $aid, + 'hook' => 'datamachine_recurring_retention_as_actions', + 'status' => 'canceled', + 'last_attempt_gmt' => $eight_day, + ); + $fake_wpdb->logs[ $lid ] = array( + 'log_id' => $lid, + 'action_id' => $aid, + ); + ++$aid; + ++$lid; + + $recent_recurring_action_id = $aid; + $recent_recurring_log_id = $lid; + $fake_wpdb->actions[ $aid ] = array( + 'action_id' => $aid, + 'hook' => 'datamachine_recurring_retention_as_actions', + 'status' => 'canceled', + 'last_attempt_gmt' => $fresh, + ); + $fake_wpdb->logs[ $lid ] = array( + 'log_id' => $lid, + 'action_id' => $aid, + ); + ++$aid; + ++$lid; + + $non_datamachine_log_id = $lid; + $fake_wpdb->actions[ $aid ] = array( + 'action_id' => $aid, + 'hook' => 'woocommerce_background_task', + 'status' => 'pending', + 'last_attempt_gmt' => $eight_day, + ); + $fake_wpdb->logs[ $lid ] = array( + 'log_id' => $lid, + 'action_id' => $aid, + ); $GLOBALS['wpdb'] = $fake_wpdb; @@ -347,8 +388,8 @@ private function matching_logs( string $cutoff, ?string $hook ): array { && 0 === $fake_wpdb->delete_queries ); assert_batching( - 'count covers per-hook + global eligible rows (2500 logs + 2530 actions)', - 5030 === $count_total, + 'count includes expired logs attached to canceled Data Machine recurrences (2501 logs + 2531 actions)', + 5032 === $count_total, "got {$count_total}" ); @@ -402,13 +443,24 @@ private function matching_logs( string $cutoff, ?string $hook ): array { ); assert_batching( 'result reports per-table deletion counts + batch metadata', - 2530 === $result['actions_deleted'] - && 2500 === $result['logs_deleted'] + 2531 === $result['actions_deleted'] + && 2501 === $result['logs_deleted'] && 1000 === $result['batch_size'] && isset( $result['iterations'] ) && false === $result['hit_limit'], "actions={$result['actions_deleted']} logs={$result['logs_deleted']}" ); + assert_batching( + 'expired canceled Data Machine recurrence and its log are deleted', + ! isset( $fake_wpdb->actions[ $expired_recurring_action_id ] ) + && ! isset( $fake_wpdb->logs[ $expired_recurring_log_id ] ) + ); + assert_batching( + 'recent canceled and non-Data-Machine logs are preserved', + isset( $fake_wpdb->actions[ $recent_recurring_action_id ] ) + && isset( $fake_wpdb->logs[ $recent_recurring_log_id ] ) + && isset( $fake_wpdb->logs[ $non_datamachine_log_id ] ) + ); // OPTIMIZE is opt-in: default off => no OPTIMIZE call. assert_batching( From d112d3c7a31271244b23e56249ffe29a35b75d4c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 13 Jul 2026 11:10:33 -0400 Subject: [PATCH 4/8] test: fix image template smoke bootstrap (#2896) --- ...lities-image-template-load-order-smoke.php | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/tests/abilities-image-template-load-order-smoke.php b/tests/abilities-image-template-load-order-smoke.php index 46cda31e0..40f113de2 100644 --- a/tests/abilities-image-template-load-order-smoke.php +++ b/tests/abilities-image-template-load-order-smoke.php @@ -7,9 +7,9 @@ * * Two kinds of assertions: * - * 1. Source-string assertions — `data-machine.php` must call - * `ImageTemplateAbilities::ensure_registered()` UNCONDITIONALLY at - * file include time, NOT only inside the gated runtime function. + * 1. Source-string assertions — `data-machine.php` must declare + * `ImageTemplateAbilities::ensure_registered()` in the lightweight + * ability manifest registered at file include time. * * 2. Behavioral assertions — `ImageTemplateAbilities::ensure_registered()` * must handle all three timing states defensively, matching the @@ -50,22 +50,20 @@ } $assert( - 'data-machine.php calls ImageTemplateAbilities::ensure_registered() unconditionally at file load', - str_contains( $bootstrap, 'Register `datamachine/render-image-template` and' ) - && str_contains( $bootstrap, "require_once __DIR__ . '/inc/Abilities/Media/ImageTemplateAbilities.php';\n\\DataMachine\\Abilities\\Media\\ImageTemplateAbilities::ensure_registered();" ) + 'data-machine.php declares ImageTemplateAbilities::ensure_registered() in the lightweight manifest', + str_contains( $bootstrap, "'file' => __DIR__ . '/inc/Abilities/Media/ImageTemplateAbilities.php'," ) + && str_contains( $bootstrap, "'class' => \\DataMachine\\Abilities\\Media\\ImageTemplateAbilities::class," ) + && str_contains( $bootstrap, "'method' => 'ensure_registered'," ) ); $assert( - 'unconditional call site is OUTSIDE datamachine_run_datamachine_plugin()', - // The image-template registration must sit at file scope, after the - // unconditional `AbilityCategories::ensure_registered()` call (which other - // unconditional ability registrations — e.g. AgentAbilities — may follow). + 'lightweight manifest registration is unconditional at file load', (bool) preg_match( '/^\\\\DataMachine\\\\Abilities\\\\AbilityCategories::ensure_registered\(\);\s*\n/m', $bootstrap ) && (bool) preg_match( - '/^\/\*\*\s*\n\s*\*\s*Register `datamachine\/render-image-template`/m', + '/^\\\\DataMachine\\\\Abilities\\\\AbilityManifest::register\( datamachine_lightweight_ability_manifest\(\) \);\s*$/m', $bootstrap ) ); @@ -180,7 +178,9 @@ function wp_register_ability( $name, $args ) { } } -// Stub PermissionHelper which the ability definitions reference. +// Load or stub every collaborator used while building ability definitions. +require_once $plugin_root . '/inc/Abilities/AbilityRegistration.php'; + if ( ! class_exists( 'DataMachine\\Abilities\\PermissionHelper' ) ) { eval( 'namespace DataMachine\\Abilities; @@ -190,6 +190,16 @@ public static function can_manage(): bool { return true; } ); } +$assert( + 'bootstrap loads AbilityRegistration used to build registration definitions', + class_exists( 'DataMachine\\Abilities\\AbilityRegistration', false ) +); + +$assert( + 'bootstrap loads PermissionHelper referenced by registration definitions', + class_exists( 'DataMachine\\Abilities\\PermissionHelper', false ) +); + require_once $plugin_root . '/inc/Abilities/Media/ImageTemplateAbilities.php'; $reset = static function (): void { @@ -214,6 +224,8 @@ public static function can_manage(): bool { return true; } 'state 1: when doing_action fires, abilities register immediately via wp_register_ability()', isset( $GLOBALS['datamachine_2290_state']->registered['datamachine/render-image-template'] ) && isset( $GLOBALS['datamachine_2290_state']->registered['datamachine/list-image-templates'] ) + && $GLOBALS['datamachine_2290_state']->registered['datamachine/render-image-template']['execute_callback'] instanceof Closure + && $GLOBALS['datamachine_2290_state']->registered['datamachine/list-image-templates']['execute_callback'] instanceof Closure && empty( $GLOBALS['datamachine_2290_state']->hooked ) ); From 2fa8b0131c1668b803d48833eec4224475fabbb1 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 13 Jul 2026 10:03:17 -0400 Subject: [PATCH 5/8] fix: prune expired orphaned Action Scheduler logs --- .../Tasks/Retention/RetentionCleanup.php | 81 +++++++++++++++++++ ...ention-action-scheduler-batching-smoke.php | 50 ++++++++++-- 2 files changed, 123 insertions(+), 8 deletions(-) diff --git a/inc/Engine/AI/System/Tasks/Retention/RetentionCleanup.php b/inc/Engine/AI/System/Tasks/Retention/RetentionCleanup.php index 7e15a0160..47e4ae733 100644 --- a/inc/Engine/AI/System/Tasks/Retention/RetentionCleanup.php +++ b/inc/Engine/AI/System/Tasks/Retention/RetentionCleanup.php @@ -693,6 +693,7 @@ public static function countActionSchedulerBreakdown(): array { $actions_count = 0; $logs_count = 0; + $global_cutoff = gmdate( 'Y-m-d H:i:s', time() - (int) round( self::actionSchedulerMaxAgeDays() * DAY_IN_SECONDS ) ); foreach ( self::actionSchedulerCleanupWindows() as $window ) { $cutoff = $window['cutoff']; @@ -753,6 +754,18 @@ public static function countActionSchedulerBreakdown(): array { } } + // The custom tables do not enforce a foreign key, so native Action + // Scheduler cleanup can leave expired logs after deleting actions. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $logs_count += (int) $wpdb->get_var( + $wpdb->prepare( + 'SELECT COUNT(*) FROM %i l LEFT JOIN %i a ON l.action_id = a.action_id WHERE l.action_id != 0 AND a.action_id IS NULL AND l.log_date_gmt < %s', + $logs_table, + $actions_table, + $global_cutoff + ) + ); + return array( 'actions' => $actions_count, 'logs' => $logs_count, @@ -775,6 +788,7 @@ public static function cleanupActionSchedulerActions(): array { $hit_limit = false; $logs_deleted = 0; $actions_deleted = 0; + $orphan_logs_deleted = 0; foreach ( self::actionSchedulerCleanupWindows() as $window ) { $cutoff = $window['cutoff']; @@ -806,6 +820,18 @@ public static function cleanupActionSchedulerActions(): array { ); } + $orphan_logs_deleted = self::deleteOrphanActionSchedulerLogsBatched( + $logs_table, + $actions_table, + gmdate( 'Y-m-d H:i:s', time() - (int) round( self::actionSchedulerMaxAgeDays() * DAY_IN_SECONDS ) ), + $batch_size, + $max_iterations, + $deadline, + $iterations_used, + $hit_limit + ); + $logs_deleted += $orphan_logs_deleted; + // Hard row-count ceiling per high-churn hook. Age windows above can // leave the table unbounded when generation outruns the cleanup // cadence; this backstop deletes the OLDEST completed/terminal rows for @@ -842,6 +868,7 @@ public static function cleanupActionSchedulerActions(): array { array( 'actions_deleted' => $actions_deleted, 'logs_deleted' => $logs_deleted, + 'orphan_logs_deleted' => $orphan_logs_deleted, 'ceiling_actions_deleted' => $ceiling['actions_deleted'], 'ceiling_logs_deleted' => $ceiling['logs_deleted'], 'max_age_days' => $max_age_days, @@ -863,6 +890,7 @@ public static function cleanupActionSchedulerActions(): array { 'deleted' => $total_deleted, 'actions_deleted' => $actions_deleted, 'logs_deleted' => $logs_deleted, + 'orphan_logs_deleted' => $orphan_logs_deleted, 'ceiling_actions_deleted' => $ceiling['actions_deleted'], 'ceiling_logs_deleted' => $ceiling['logs_deleted'], 'max_age_days' => $max_age_days, @@ -1077,6 +1105,59 @@ private static function deleteActionSchedulerLogsBatched( return $deleted; } + /** + * Delete expired logs whose actions were already removed by another cleanup. + * + * @param string $logs_table Logs table name. + * @param string $actions_table Actions table name. + * @param string $cutoff GMT cutoff datetime. + * @param int $batch_size Rows per batch. + * @param int $max_iterations Hard iteration ceiling (shared budget). + * @param float $deadline Wall-clock deadline (microtime float). + * @param int $iterations_used Shared iteration counter (by reference). + * @param bool $hit_limit Set true when a budget cap trips (by reference). + * @return int Total rows deleted. + */ + private static function deleteOrphanActionSchedulerLogsBatched( + string $logs_table, + string $actions_table, + string $cutoff, + int $batch_size, + int $max_iterations, + float $deadline, + int &$iterations_used, + bool &$hit_limit + ): int { + global $wpdb; + + $deleted = 0; + + do { + if ( $iterations_used >= $max_iterations || microtime( true ) >= $deadline ) { + $hit_limit = true; + break; + } + ++$iterations_used; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $affected = $wpdb->query( + $wpdb->prepare( + 'DELETE FROM %i WHERE log_id IN ( SELECT log_id FROM ( SELECT l.log_id FROM %i l LEFT JOIN %i a ON l.action_id = a.action_id WHERE l.action_id != 0 AND a.action_id IS NULL AND l.log_date_gmt < %s LIMIT %d ) AS tmp )', + $logs_table, + $logs_table, + $actions_table, + $cutoff, + $batch_size + ) + ); + + $affected = false !== $affected ? (int) $affected : 0; + $deleted += $affected; + } while ( $affected > 0 ); + + return $deleted; + } + /** * Batched delete of Action Scheduler action rows by id-subquery. * diff --git a/tests/retention-action-scheduler-batching-smoke.php b/tests/retention-action-scheduler-batching-smoke.php index bddf512cc..5cf44eb4c 100644 --- a/tests/retention-action-scheduler-batching-smoke.php +++ b/tests/retention-action-scheduler-batching-smoke.php @@ -148,7 +148,7 @@ function assert_batching( string $name, bool $condition, string $detail = '' ): /** @var array */ public array $actions = array(); - /** @var array */ + /** @var array */ public array $logs = array(); public int $delete_queries = 0; @@ -194,6 +194,10 @@ public function get_var( $prepared ) { $cutoff = $this->extract_cutoff( $args ); $hook = $this->extract_hook( $sql, $args ); + if ( str_contains( $sql, 'LEFT JOIN' ) ) { + return count( $this->matching_orphan_logs( $cutoff ) ); + } + if ( str_contains( $sql, 'FROM %i l' ) || str_contains( $sql, 'log_id' ) ) { return count( $this->matching_logs( $cutoff, $hook ) ); } @@ -217,7 +221,9 @@ public function query( $prepared ): int { $this->batch_sizes[] = $limit; if ( str_contains( $sql, 'log_id IN' ) ) { - $matching = array_slice( $this->matching_logs( $cutoff, $hook ), 0, $limit, true ); + $matching = str_contains( $sql, 'LEFT JOIN' ) + ? array_slice( $this->matching_orphan_logs( $cutoff ), 0, $limit, true ) + : array_slice( $this->matching_logs( $cutoff, $hook ), 0, $limit, true ); foreach ( array_keys( $matching ) as $log_id ) { unset( $this->logs[ $log_id ] ); } @@ -286,6 +292,17 @@ private function matching_logs( string $cutoff, ?string $hook ): array { } return $out; } + + private function matching_orphan_logs( string $cutoff ): array { + $out = array(); + foreach ( $this->logs as $id => $row ) { + if ( 0 === $row['action_id'] || isset( $this->actions[ $row['action_id'] ] ) || $row['log_date_gmt'] >= $cutoff ) { + continue; + } + $out[ $id ] = $row; + } + return $out; + } }; // Seed enough rows to force the batching loop to iterate past the 1000-row @@ -308,8 +325,9 @@ private function matching_logs( string $cutoff, ?string $hook ): array { 'last_attempt_gmt' => $seven_h, ); $fake_wpdb->logs[ $lid ] = array( - 'log_id' => $lid, - 'action_id' => $aid, + 'log_id' => $lid, + 'action_id' => $aid, + 'log_date_gmt' => $seven_h, ); ++$aid; ++$lid; @@ -332,6 +350,17 @@ private function matching_logs( string $cutoff, ?string $hook ): array { ); ++$aid; } + $fake_wpdb->logs[ $lid ] = array( + 'log_id' => $lid, + 'action_id' => 999999, + 'log_date_gmt' => $eight_day, + ); + ++$lid; + $fake_wpdb->logs[ $lid ] = array( + 'log_id' => $lid, + 'action_id' => 999998, + 'log_date_gmt' => $fresh, + ); $GLOBALS['wpdb'] = $fake_wpdb; @@ -347,8 +376,8 @@ private function matching_logs( string $cutoff, ?string $hook ): array { && 0 === $fake_wpdb->delete_queries ); assert_batching( - 'count covers per-hook + global eligible rows (2500 logs + 2530 actions)', - 5030 === $count_total, + 'count includes expired orphan logs (2501 logs + 2530 actions)', + 5031 === $count_total, "got {$count_total}" ); @@ -401,14 +430,19 @@ private function matching_logs( string $cutoff, ?string $hook ): array { ) ); assert_batching( - 'result reports per-table deletion counts + batch metadata', + 'result reports per-table deletion counts, including the orphan log, and batch metadata', 2530 === $result['actions_deleted'] - && 2500 === $result['logs_deleted'] + && 2501 === $result['logs_deleted'] + && 1 === $result['orphan_logs_deleted'] && 1000 === $result['batch_size'] && isset( $result['iterations'] ) && false === $result['hit_limit'], "actions={$result['actions_deleted']} logs={$result['logs_deleted']}" ); + assert_batching( + 'expired orphan logs are removed while fresh orphan logs survive', + ! isset( $fake_wpdb->logs[ 2501 ] ) && isset( $fake_wpdb->logs[ 2502 ] ) + ); // OPTIMIZE is opt-in: default off => no OPTIMIZE call. assert_batching( From a5f01813296eb25e6e5560a798177622d9bed45d Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 13 Jul 2026 10:05:03 -0400 Subject: [PATCH 6/8] Revert "fix: prune expired orphaned Action Scheduler logs" This reverts commit 8bfc4bff838396f4b4663a3e74ae865fd2b17ec9. --- .../Tasks/Retention/RetentionCleanup.php | 81 ------------------- ...ention-action-scheduler-batching-smoke.php | 50 ++---------- 2 files changed, 8 insertions(+), 123 deletions(-) diff --git a/inc/Engine/AI/System/Tasks/Retention/RetentionCleanup.php b/inc/Engine/AI/System/Tasks/Retention/RetentionCleanup.php index 47e4ae733..7e15a0160 100644 --- a/inc/Engine/AI/System/Tasks/Retention/RetentionCleanup.php +++ b/inc/Engine/AI/System/Tasks/Retention/RetentionCleanup.php @@ -693,7 +693,6 @@ public static function countActionSchedulerBreakdown(): array { $actions_count = 0; $logs_count = 0; - $global_cutoff = gmdate( 'Y-m-d H:i:s', time() - (int) round( self::actionSchedulerMaxAgeDays() * DAY_IN_SECONDS ) ); foreach ( self::actionSchedulerCleanupWindows() as $window ) { $cutoff = $window['cutoff']; @@ -754,18 +753,6 @@ public static function countActionSchedulerBreakdown(): array { } } - // The custom tables do not enforce a foreign key, so native Action - // Scheduler cleanup can leave expired logs after deleting actions. - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $logs_count += (int) $wpdb->get_var( - $wpdb->prepare( - 'SELECT COUNT(*) FROM %i l LEFT JOIN %i a ON l.action_id = a.action_id WHERE l.action_id != 0 AND a.action_id IS NULL AND l.log_date_gmt < %s', - $logs_table, - $actions_table, - $global_cutoff - ) - ); - return array( 'actions' => $actions_count, 'logs' => $logs_count, @@ -788,7 +775,6 @@ public static function cleanupActionSchedulerActions(): array { $hit_limit = false; $logs_deleted = 0; $actions_deleted = 0; - $orphan_logs_deleted = 0; foreach ( self::actionSchedulerCleanupWindows() as $window ) { $cutoff = $window['cutoff']; @@ -820,18 +806,6 @@ public static function cleanupActionSchedulerActions(): array { ); } - $orphan_logs_deleted = self::deleteOrphanActionSchedulerLogsBatched( - $logs_table, - $actions_table, - gmdate( 'Y-m-d H:i:s', time() - (int) round( self::actionSchedulerMaxAgeDays() * DAY_IN_SECONDS ) ), - $batch_size, - $max_iterations, - $deadline, - $iterations_used, - $hit_limit - ); - $logs_deleted += $orphan_logs_deleted; - // Hard row-count ceiling per high-churn hook. Age windows above can // leave the table unbounded when generation outruns the cleanup // cadence; this backstop deletes the OLDEST completed/terminal rows for @@ -868,7 +842,6 @@ public static function cleanupActionSchedulerActions(): array { array( 'actions_deleted' => $actions_deleted, 'logs_deleted' => $logs_deleted, - 'orphan_logs_deleted' => $orphan_logs_deleted, 'ceiling_actions_deleted' => $ceiling['actions_deleted'], 'ceiling_logs_deleted' => $ceiling['logs_deleted'], 'max_age_days' => $max_age_days, @@ -890,7 +863,6 @@ public static function cleanupActionSchedulerActions(): array { 'deleted' => $total_deleted, 'actions_deleted' => $actions_deleted, 'logs_deleted' => $logs_deleted, - 'orphan_logs_deleted' => $orphan_logs_deleted, 'ceiling_actions_deleted' => $ceiling['actions_deleted'], 'ceiling_logs_deleted' => $ceiling['logs_deleted'], 'max_age_days' => $max_age_days, @@ -1105,59 +1077,6 @@ private static function deleteActionSchedulerLogsBatched( return $deleted; } - /** - * Delete expired logs whose actions were already removed by another cleanup. - * - * @param string $logs_table Logs table name. - * @param string $actions_table Actions table name. - * @param string $cutoff GMT cutoff datetime. - * @param int $batch_size Rows per batch. - * @param int $max_iterations Hard iteration ceiling (shared budget). - * @param float $deadline Wall-clock deadline (microtime float). - * @param int $iterations_used Shared iteration counter (by reference). - * @param bool $hit_limit Set true when a budget cap trips (by reference). - * @return int Total rows deleted. - */ - private static function deleteOrphanActionSchedulerLogsBatched( - string $logs_table, - string $actions_table, - string $cutoff, - int $batch_size, - int $max_iterations, - float $deadline, - int &$iterations_used, - bool &$hit_limit - ): int { - global $wpdb; - - $deleted = 0; - - do { - if ( $iterations_used >= $max_iterations || microtime( true ) >= $deadline ) { - $hit_limit = true; - break; - } - ++$iterations_used; - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $affected = $wpdb->query( - $wpdb->prepare( - 'DELETE FROM %i WHERE log_id IN ( SELECT log_id FROM ( SELECT l.log_id FROM %i l LEFT JOIN %i a ON l.action_id = a.action_id WHERE l.action_id != 0 AND a.action_id IS NULL AND l.log_date_gmt < %s LIMIT %d ) AS tmp )', - $logs_table, - $logs_table, - $actions_table, - $cutoff, - $batch_size - ) - ); - - $affected = false !== $affected ? (int) $affected : 0; - $deleted += $affected; - } while ( $affected > 0 ); - - return $deleted; - } - /** * Batched delete of Action Scheduler action rows by id-subquery. * diff --git a/tests/retention-action-scheduler-batching-smoke.php b/tests/retention-action-scheduler-batching-smoke.php index 5cf44eb4c..bddf512cc 100644 --- a/tests/retention-action-scheduler-batching-smoke.php +++ b/tests/retention-action-scheduler-batching-smoke.php @@ -148,7 +148,7 @@ function assert_batching( string $name, bool $condition, string $detail = '' ): /** @var array */ public array $actions = array(); - /** @var array */ + /** @var array */ public array $logs = array(); public int $delete_queries = 0; @@ -194,10 +194,6 @@ public function get_var( $prepared ) { $cutoff = $this->extract_cutoff( $args ); $hook = $this->extract_hook( $sql, $args ); - if ( str_contains( $sql, 'LEFT JOIN' ) ) { - return count( $this->matching_orphan_logs( $cutoff ) ); - } - if ( str_contains( $sql, 'FROM %i l' ) || str_contains( $sql, 'log_id' ) ) { return count( $this->matching_logs( $cutoff, $hook ) ); } @@ -221,9 +217,7 @@ public function query( $prepared ): int { $this->batch_sizes[] = $limit; if ( str_contains( $sql, 'log_id IN' ) ) { - $matching = str_contains( $sql, 'LEFT JOIN' ) - ? array_slice( $this->matching_orphan_logs( $cutoff ), 0, $limit, true ) - : array_slice( $this->matching_logs( $cutoff, $hook ), 0, $limit, true ); + $matching = array_slice( $this->matching_logs( $cutoff, $hook ), 0, $limit, true ); foreach ( array_keys( $matching ) as $log_id ) { unset( $this->logs[ $log_id ] ); } @@ -292,17 +286,6 @@ private function matching_logs( string $cutoff, ?string $hook ): array { } return $out; } - - private function matching_orphan_logs( string $cutoff ): array { - $out = array(); - foreach ( $this->logs as $id => $row ) { - if ( 0 === $row['action_id'] || isset( $this->actions[ $row['action_id'] ] ) || $row['log_date_gmt'] >= $cutoff ) { - continue; - } - $out[ $id ] = $row; - } - return $out; - } }; // Seed enough rows to force the batching loop to iterate past the 1000-row @@ -325,9 +308,8 @@ private function matching_orphan_logs( string $cutoff ): array { 'last_attempt_gmt' => $seven_h, ); $fake_wpdb->logs[ $lid ] = array( - 'log_id' => $lid, - 'action_id' => $aid, - 'log_date_gmt' => $seven_h, + 'log_id' => $lid, + 'action_id' => $aid, ); ++$aid; ++$lid; @@ -350,17 +332,6 @@ private function matching_orphan_logs( string $cutoff ): array { ); ++$aid; } - $fake_wpdb->logs[ $lid ] = array( - 'log_id' => $lid, - 'action_id' => 999999, - 'log_date_gmt' => $eight_day, - ); - ++$lid; - $fake_wpdb->logs[ $lid ] = array( - 'log_id' => $lid, - 'action_id' => 999998, - 'log_date_gmt' => $fresh, - ); $GLOBALS['wpdb'] = $fake_wpdb; @@ -376,8 +347,8 @@ private function matching_orphan_logs( string $cutoff ): array { && 0 === $fake_wpdb->delete_queries ); assert_batching( - 'count includes expired orphan logs (2501 logs + 2530 actions)', - 5031 === $count_total, + 'count covers per-hook + global eligible rows (2500 logs + 2530 actions)', + 5030 === $count_total, "got {$count_total}" ); @@ -430,19 +401,14 @@ private function matching_orphan_logs( string $cutoff ): array { ) ); assert_batching( - 'result reports per-table deletion counts, including the orphan log, and batch metadata', + 'result reports per-table deletion counts + batch metadata', 2530 === $result['actions_deleted'] - && 2501 === $result['logs_deleted'] - && 1 === $result['orphan_logs_deleted'] + && 2500 === $result['logs_deleted'] && 1000 === $result['batch_size'] && isset( $result['iterations'] ) && false === $result['hit_limit'], "actions={$result['actions_deleted']} logs={$result['logs_deleted']}" ); - assert_batching( - 'expired orphan logs are removed while fresh orphan logs survive', - ! isset( $fake_wpdb->logs[ 2501 ] ) && isset( $fake_wpdb->logs[ 2502 ] ) - ); // OPTIMIZE is opt-in: default off => no OPTIMIZE call. assert_batching( From c045d6fdd1727ef8839666daa03503f83949b870 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 13 Jul 2026 10:49:24 -0400 Subject: [PATCH 7/8] fix: reconcile recurring Action Scheduler actions safely --- .../AI/System/SystemAgentServiceProvider.php | 3 + inc/Engine/Tasks/RecurringScheduler.php | 18 ++++-- .../recurring-scheduler-idempotency-smoke.php | 40 ++++++++++++- ...ention-action-scheduler-batching-smoke.php | 60 +++++++++++++++++-- 4 files changed, 109 insertions(+), 12 deletions(-) diff --git a/inc/Engine/AI/System/SystemAgentServiceProvider.php b/inc/Engine/AI/System/SystemAgentServiceProvider.php index 3ae406d1a..fb587bb1f 100644 --- a/inc/Engine/AI/System/SystemAgentServiceProvider.php +++ b/inc/Engine/AI/System/SystemAgentServiceProvider.php @@ -59,7 +59,10 @@ public function __construct() { $this->registerBuiltInSchedules(); $this->initializeRegistry(); $this->registerActionSchedulerHooks(); + // Bootstrap immediately after AS initializes, then let AS's native daily + // recurring-ensure action repair schedules that are later interrupted. add_action( 'action_scheduler_init', array( $this, 'manageRecurringTaskSchedules' ) ); + add_action( 'action_scheduler_ensure_recurring_actions', array( $this, 'manageRecurringTaskSchedules' ) ); WakeBriefingTask::registerStalenessGuard(); } diff --git a/inc/Engine/Tasks/RecurringScheduler.php b/inc/Engine/Tasks/RecurringScheduler.php index c10cef8da..0ecba0f80 100644 --- a/inc/Engine/Tasks/RecurringScheduler.php +++ b/inc/Engine/Tasks/RecurringScheduler.php @@ -235,7 +235,7 @@ public static function ensureSchedule( // single chain now instead of preserving the duplicates. if ( self::countMatchingPendingActions( $hook, $args, $group ) > 1 ) { self::unschedule( $hook, $args, $group ); - as_schedule_recurring_action( $first_run_time, $interval_seconds, $hook, $args, $group ); + as_schedule_recurring_action( $first_run_time, $interval_seconds, $hook, $args, $group, true ); if ( ! self::isScheduled( $hook, $args, $group ) ) { return self::error( @@ -263,9 +263,13 @@ public static function ensureSchedule( ); } - self::unschedule( $hook, $args, $group ); + if ( self::getPendingAction( $hook, $args, $group ) ) { + self::unschedule( $hook, $args, $group ); + } - as_schedule_recurring_action( $first_run_time, $interval_seconds, $hook, $args, $group ); + // A reconciliation can race another request during activation, upgrades, + // or cron. Let Action Scheduler atomically preserve the first chain. + as_schedule_recurring_action( $first_run_time, $interval_seconds, $hook, $args, $group, true ); // Verify persistence. AS can silently drop actions when its tables // aren't ready (e.g. CLI context during plugin activation). @@ -676,7 +680,7 @@ private static function scheduleCron( string $hook, array $args, string $cron_ex // single chain instead of preserving the duplicates. if ( self::countMatchingPendingActions( $hook, $args, $group ) > 1 ) { self::unschedule( $hook, $args, $group ); - $action_id = as_schedule_cron_action( time(), $cron_expression, $hook, $args, $group ); + $action_id = as_schedule_cron_action( time(), $cron_expression, $hook, $args, $group, true ); if ( ! self::isScheduled( $hook, $args, $group ) ) { return self::error( @@ -703,9 +707,11 @@ private static function scheduleCron( string $hook, array $args, string $cron_ex ); } - self::unschedule( $hook, $args, $group ); + if ( self::getPendingAction( $hook, $args, $group ) ) { + self::unschedule( $hook, $args, $group ); + } - $action_id = as_schedule_cron_action( time(), $cron_expression, $hook, $args, $group ); + $action_id = as_schedule_cron_action( time(), $cron_expression, $hook, $args, $group, true ); if ( ! self::isScheduled( $hook, $args, $group ) ) { return self::error( diff --git a/tests/recurring-scheduler-idempotency-smoke.php b/tests/recurring-scheduler-idempotency-smoke.php index 1506796ab..64fe39927 100644 --- a/tests/recurring-scheduler-idempotency-smoke.php +++ b/tests/recurring-scheduler-idempotency-smoke.php @@ -147,6 +147,9 @@ public function get_schedule(): DmRecurringSchedulerFakeSchedule { $GLOBALS['datamachine_rs_scheduled_recurring'] = 0; $GLOBALS['datamachine_rs_scheduled_cron'] = 0; $GLOBALS['datamachine_rs_unscheduled'] = 0; +$GLOBALS['datamachine_rs_recurring_unique'] = array(); +$GLOBALS['datamachine_rs_cron_unique'] = array(); +$GLOBALS['datamachine_rs_schedule_race'] = false; function datamachine_rs_key( string $hook, array $args, string $group ): string { return $group . '|' . $hook . '|' . serialize( $args ); @@ -177,6 +180,9 @@ function datamachine_rs_reset(): void { $GLOBALS['datamachine_rs_scheduled_recurring'] = 0; $GLOBALS['datamachine_rs_scheduled_cron'] = 0; $GLOBALS['datamachine_rs_unscheduled'] = 0; + $GLOBALS['datamachine_rs_recurring_unique'] = array(); + $GLOBALS['datamachine_rs_cron_unique'] = array(); + $GLOBALS['datamachine_rs_schedule_race'] = false; ActionScheduler::$initialized = true; } @@ -220,14 +226,24 @@ function as_schedule_single_action( int $timestamp, string $hook, array $args = return 101; } -function as_schedule_recurring_action( int $timestamp, int $interval, string $hook, array $args = array(), string $group = '' ): int { +function as_schedule_recurring_action( int $timestamp, int $interval, string $hook, array $args = array(), string $group = '', bool $unique = false ): int { ++$GLOBALS['datamachine_rs_scheduled_recurring']; + $GLOBALS['datamachine_rs_recurring_unique'][] = $unique; + if ( $GLOBALS['datamachine_rs_schedule_race'] ) { + $GLOBALS['datamachine_rs_schedule_race'] = false; + datamachine_rs_seed_action( $hook, $args, $group, new DmRecurringSchedulerFakeSchedule( true, $interval, $timestamp ) ); + return 0; + } + if ( $unique && datamachine_rs_pending_count( $hook, $args, $group ) > 0 ) { + return 0; + } datamachine_rs_seed_action( $hook, $args, $group, new DmRecurringSchedulerFakeSchedule( true, $interval, $timestamp ) ); return 202; } -function as_schedule_cron_action( int $timestamp, string $expression, string $hook, array $args = array(), string $group = '' ): int { +function as_schedule_cron_action( int $timestamp, string $expression, string $hook, array $args = array(), string $group = '', bool $unique = false ): int { ++$GLOBALS['datamachine_rs_scheduled_cron']; + $GLOBALS['datamachine_rs_cron_unique'][] = $unique; datamachine_rs_seed_action( $hook, $args, $group, new DmRecurringSchedulerFakeSchedule( true, $expression, $timestamp ) ); return 303; } @@ -371,4 +387,24 @@ function datamachine_assert_schedule_result( $result ): array { datamachine_assert( 1 === datamachine_rs_counter( 'datamachine_rs_scheduled_cron' ), 'exactly one cron chain recreated' ); datamachine_assert( 1 === datamachine_rs_pending_count( 'datamachine_cron', array(), RecurringScheduler::GROUP ), 'cron collapsed to exactly one pending action' ); +echo "\n[14] concurrent recurring reconciliation creates exactly one unique chain (#2892)\n"; +datamachine_rs_reset(); +$GLOBALS['datamachine_rs_schedule_race'] = true; +$result = datamachine_assert_schedule_result( RecurringScheduler::ensureSchedule( 'datamachine_recurring_retention_as_actions', array(), 'hourly' ) ); +datamachine_assert( 1 === datamachine_rs_pending_count( 'datamachine_recurring_retention_as_actions', array(), RecurringScheduler::GROUP ), 'concurrent reconciliation preserves exactly one pending chain' ); +datamachine_assert( array( true ) === $GLOBALS['datamachine_rs_recurring_unique'], 'recurring reconciliation requests Action Scheduler uniqueness' ); +datamachine_assert( 0 === datamachine_rs_counter( 'datamachine_rs_unscheduled' ), 'initial reconciliation does not cancel a concurrent healthy chain' ); + +echo "\n[15] all recurring and cron replacement paths request Action Scheduler uniqueness (#2892)\n"; +datamachine_rs_reset(); +datamachine_assert_schedule_result( RecurringScheduler::ensureSchedule( 'datamachine_recurring_retention_as_actions', array(), 'hourly' ) ); +datamachine_assert( array( true ) === $GLOBALS['datamachine_rs_recurring_unique'], 'missing enabled recurrence is restored as a unique action' ); +datamachine_rs_reset(); +datamachine_assert_schedule_result( RecurringScheduler::ensureSchedule( 'datamachine_cron', array(), 'cron', array( 'cron_expression' => '0 0 * * *' ) ) ); +datamachine_assert( array( true ) === $GLOBALS['datamachine_rs_cron_unique'], 'cron replacement is also unique' ); + +echo "\n[16] Action Scheduler native recurring-ensure hook repairs interrupted schedules (#2892)\n"; +$provider_source = file_get_contents( __DIR__ . '/../inc/Engine/AI/System/SystemAgentServiceProvider.php' ) ?: ''; +datamachine_assert( str_contains( $provider_source, "add_action( 'action_scheduler_ensure_recurring_actions', array( \$this, 'manageRecurringTaskSchedules' ) );" ), 'provider registers reconciliation on Action Scheduler native recurring-ensure hook' ); + echo "\nAll recurring scheduler idempotency assertions passed.\n"; diff --git a/tests/retention-action-scheduler-batching-smoke.php b/tests/retention-action-scheduler-batching-smoke.php index bddf512cc..198cfbbdd 100644 --- a/tests/retention-action-scheduler-batching-smoke.php +++ b/tests/retention-action-scheduler-batching-smoke.php @@ -332,6 +332,47 @@ private function matching_logs( string $cutoff, ?string $hook ): array { ); ++$aid; } + $expired_recurring_action_id = $aid; + $expired_recurring_log_id = $lid; + $fake_wpdb->actions[ $aid ] = array( + 'action_id' => $aid, + 'hook' => 'datamachine_recurring_retention_as_actions', + 'status' => 'canceled', + 'last_attempt_gmt' => $eight_day, + ); + $fake_wpdb->logs[ $lid ] = array( + 'log_id' => $lid, + 'action_id' => $aid, + ); + ++$aid; + ++$lid; + + $recent_recurring_action_id = $aid; + $recent_recurring_log_id = $lid; + $fake_wpdb->actions[ $aid ] = array( + 'action_id' => $aid, + 'hook' => 'datamachine_recurring_retention_as_actions', + 'status' => 'canceled', + 'last_attempt_gmt' => $fresh, + ); + $fake_wpdb->logs[ $lid ] = array( + 'log_id' => $lid, + 'action_id' => $aid, + ); + ++$aid; + ++$lid; + + $non_datamachine_log_id = $lid; + $fake_wpdb->actions[ $aid ] = array( + 'action_id' => $aid, + 'hook' => 'woocommerce_background_task', + 'status' => 'pending', + 'last_attempt_gmt' => $eight_day, + ); + $fake_wpdb->logs[ $lid ] = array( + 'log_id' => $lid, + 'action_id' => $aid, + ); $GLOBALS['wpdb'] = $fake_wpdb; @@ -347,8 +388,8 @@ private function matching_logs( string $cutoff, ?string $hook ): array { && 0 === $fake_wpdb->delete_queries ); assert_batching( - 'count covers per-hook + global eligible rows (2500 logs + 2530 actions)', - 5030 === $count_total, + 'count includes expired logs attached to canceled Data Machine recurrences (2501 logs + 2531 actions)', + 5032 === $count_total, "got {$count_total}" ); @@ -402,13 +443,24 @@ private function matching_logs( string $cutoff, ?string $hook ): array { ); assert_batching( 'result reports per-table deletion counts + batch metadata', - 2530 === $result['actions_deleted'] - && 2500 === $result['logs_deleted'] + 2531 === $result['actions_deleted'] + && 2501 === $result['logs_deleted'] && 1000 === $result['batch_size'] && isset( $result['iterations'] ) && false === $result['hit_limit'], "actions={$result['actions_deleted']} logs={$result['logs_deleted']}" ); + assert_batching( + 'expired canceled Data Machine recurrence and its log are deleted', + ! isset( $fake_wpdb->actions[ $expired_recurring_action_id ] ) + && ! isset( $fake_wpdb->logs[ $expired_recurring_log_id ] ) + ); + assert_batching( + 'recent canceled and non-Data-Machine logs are preserved', + isset( $fake_wpdb->actions[ $recent_recurring_action_id ] ) + && isset( $fake_wpdb->logs[ $recent_recurring_log_id ] ) + && isset( $fake_wpdb->logs[ $non_datamachine_log_id ] ) + ); // OPTIMIZE is opt-in: default off => no OPTIMIZE call. assert_batching( From 0f9bcae15f91cadcac5847b3e30082752dd58459 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 13 Jul 2026 17:13:18 -0400 Subject: [PATCH 8/8] Fix recurring schedules with distinct arguments --- inc/Engine/Tasks/RecurringScheduler.php | 15 +++++++++++---- tests/recurring-scheduler-idempotency-smoke.php | 8 ++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/inc/Engine/Tasks/RecurringScheduler.php b/inc/Engine/Tasks/RecurringScheduler.php index 0ecba0f80..c1d2006c4 100644 --- a/inc/Engine/Tasks/RecurringScheduler.php +++ b/inc/Engine/Tasks/RecurringScheduler.php @@ -228,6 +228,9 @@ public static function ensureSchedule( : 0; $first_run_time = time() + $stagger_offset; } + // Action Scheduler unique actions are keyed by hook and group, not args. + // Preserve independent schedules that share a hook but have distinct args. + $unique = empty( $args ); if ( empty( $options['force_reschedule'] ) && self::hasMatchingRecurringAction( $hook, $args, $group, (int) $interval_seconds ) ) { // Self-healing dedup: if a previous call already created MORE THAN @@ -235,7 +238,7 @@ public static function ensureSchedule( // single chain now instead of preserving the duplicates. if ( self::countMatchingPendingActions( $hook, $args, $group ) > 1 ) { self::unschedule( $hook, $args, $group ); - as_schedule_recurring_action( $first_run_time, $interval_seconds, $hook, $args, $group, true ); + as_schedule_recurring_action( $first_run_time, $interval_seconds, $hook, $args, $group, $unique ); if ( ! self::isScheduled( $hook, $args, $group ) ) { return self::error( @@ -269,7 +272,7 @@ public static function ensureSchedule( // A reconciliation can race another request during activation, upgrades, // or cron. Let Action Scheduler atomically preserve the first chain. - as_schedule_recurring_action( $first_run_time, $interval_seconds, $hook, $args, $group, true ); + as_schedule_recurring_action( $first_run_time, $interval_seconds, $hook, $args, $group, $unique ); // Verify persistence. AS can silently drop actions when its tables // aren't ready (e.g. CLI context during plugin activation). @@ -675,12 +678,16 @@ private static function scheduleCron( string $hook, array $args, string $cron_ex ); } + // Action Scheduler unique actions are keyed by hook and group, not args. + // Preserve independent schedules that share a hook but have distinct args. + $unique = empty( $args ); + if ( ! $force_reschedule && self::hasMatchingCronAction( $hook, $args, $group, $cron_expression ) ) { // Self-healing dedup: collapse an already-duplicated signature to a // single chain instead of preserving the duplicates. if ( self::countMatchingPendingActions( $hook, $args, $group ) > 1 ) { self::unschedule( $hook, $args, $group ); - $action_id = as_schedule_cron_action( time(), $cron_expression, $hook, $args, $group, true ); + $action_id = as_schedule_cron_action( time(), $cron_expression, $hook, $args, $group, $unique ); if ( ! self::isScheduled( $hook, $args, $group ) ) { return self::error( @@ -711,7 +718,7 @@ private static function scheduleCron( string $hook, array $args, string $cron_ex self::unschedule( $hook, $args, $group ); } - $action_id = as_schedule_cron_action( time(), $cron_expression, $hook, $args, $group, true ); + $action_id = as_schedule_cron_action( time(), $cron_expression, $hook, $args, $group, $unique ); if ( ! self::isScheduled( $hook, $args, $group ) ) { return self::error( diff --git a/tests/recurring-scheduler-idempotency-smoke.php b/tests/recurring-scheduler-idempotency-smoke.php index 64fe39927..fa65488a1 100644 --- a/tests/recurring-scheduler-idempotency-smoke.php +++ b/tests/recurring-scheduler-idempotency-smoke.php @@ -407,4 +407,12 @@ function datamachine_assert_schedule_result( $result ): array { $provider_source = file_get_contents( __DIR__ . '/../inc/Engine/AI/System/SystemAgentServiceProvider.php' ) ?: ''; datamachine_assert( str_contains( $provider_source, "add_action( 'action_scheduler_ensure_recurring_actions', array( \$this, 'manageRecurringTaskSchedules' ) );" ), 'provider registers reconciliation on Action Scheduler native recurring-ensure hook' ); +echo "\n[17] distinct argument tuples sharing a hook remain independently schedulable\n"; +datamachine_rs_reset(); +datamachine_assert_schedule_result( RecurringScheduler::ensureSchedule( 'datamachine_per_flow', array( 'flow_id' => 1 ), 'hourly' ) ); +datamachine_assert_schedule_result( RecurringScheduler::ensureSchedule( 'datamachine_per_flow', array( 'flow_id' => 2 ), 'hourly' ) ); +datamachine_assert( array( false, false ) === $GLOBALS['datamachine_rs_recurring_unique'], 'argument-bearing schedules do not use hook/group-only Action Scheduler uniqueness' ); +datamachine_assert( 1 === datamachine_rs_pending_count( 'datamachine_per_flow', array( 'flow_id' => 1 ), RecurringScheduler::GROUP ), 'first argument tuple has its own pending chain' ); +datamachine_assert( 1 === datamachine_rs_pending_count( 'datamachine_per_flow', array( 'flow_id' => 2 ), RecurringScheduler::GROUP ), 'second argument tuple has its own pending chain' ); + echo "\nAll recurring scheduler idempotency assertions passed.\n";