From a5731edeb1dd82d15f25fcf699a3ab3b10cd8a7b Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 10:28:24 +0530 Subject: [PATCH 01/25] feat(purge): add Action Scheduler constants for auto-purge migration Refs XWPENG-28 --- classes/class-admin.php | 28 ++++++++++++++++++++++++++++ tests/phpunit/test-class-admin.php | 7 +++++++ 2 files changed, 35 insertions(+) diff --git a/classes/class-admin.php b/classes/class-admin.php index dd685ff84..f025a8fa2 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -25,6 +25,34 @@ class Admin { */ const ASYNC_DELETION_ACTION = 'stream_erase_large_records_action'; + /** + * Recurring Action Scheduler action that drives the TTL-based auto-purge. + * + * @const string + */ + const AUTO_PURGE_ACTION = 'stream_auto_purge_action'; + + /** + * Async batch worker scheduled by the recurring auto-purge action. + * + * @const string + */ + const AUTO_PURGE_BATCH_ACTION = 'stream_auto_purge_batch_action'; + + /** + * Terminal action that runs the orphan-meta reaper once per chain. + * + * @const string + */ + const AUTO_PURGE_REAPER_ACTION = 'stream_auto_purge_reaper_action'; + + /** + * Action Scheduler group string for all auto-purge actions. + * + * @const string + */ + const AUTO_PURGE_GROUP = 'stream-auto-purge'; + /** * Holds Instance of plugin object * diff --git a/tests/phpunit/test-class-admin.php b/tests/phpunit/test-class-admin.php index 7b4aae629..effedff41 100644 --- a/tests/phpunit/test-class-admin.php +++ b/tests/phpunit/test-class-admin.php @@ -606,4 +606,11 @@ private function dummy_meta_data( $stream_id ) { 'meta_value' => 'false', ); } + + public function test_auto_purge_action_constants_exist() { + $this->assertSame( 'stream_auto_purge_action', \WP_Stream\Admin::AUTO_PURGE_ACTION ); + $this->assertSame( 'stream_auto_purge_batch_action', \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + $this->assertSame( 'stream_auto_purge_reaper_action', \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + $this->assertSame( 'stream-auto-purge', \WP_Stream\Admin::AUTO_PURGE_GROUP ); + } } From f53d64e7f89e5d6552c7fab6af20dae2303f94ba Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 10:31:41 +0530 Subject: [PATCH 02/25] feat(purge): wire Admin to AS-based auto-purge callbacks Adds stub method bodies so the action registration resolves; the real implementations land in subsequent commits. Refs XWPENG-28 --- classes/class-admin.php | 44 ++++++++++++++++++++++++++---- tests/phpunit/test-class-admin.php | 21 ++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/classes/class-admin.php b/classes/class-admin.php index f025a8fa2..e854a5fdc 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -229,14 +229,21 @@ public function __construct( $plugin ) { ) ); - // Auto purge setup. + // Auto purge setup (Action Scheduler). add_action( 'wp_loaded', array( $this, 'purge_schedule_setup' ) ); add_action( - 'wp_stream_auto_purge', - array( - $this, - 'purge_scheduled_action', - ) + self::AUTO_PURGE_ACTION, + array( $this, 'purge_scheduled_action' ) + ); + add_action( + self::AUTO_PURGE_BATCH_ACTION, + array( $this, 'auto_purge_batch' ), + 10, + 2 + ); + add_action( + self::AUTO_PURGE_REAPER_ACTION, + array( $this, 'auto_purge_reaper' ) ); // Ajax users list. @@ -913,6 +920,31 @@ public function purge_scheduled_action() { ); } + /** + * Async Action Scheduler callback: delete one batch of records eligible + * under the snapshotted UTC cutoff, then chain the next batch. + * + * Stub implementation; real body is added in a later task. + * + * @param string $cutoff MySQL DATETIME string in UTC. + * @param int $blog_id Blog to scope to, or 0 for all blogs. + * @return void + */ + public function auto_purge_batch( $cutoff, $blog_id = 0 ) { + unset( $cutoff, $blog_id ); + } + + /** + * Terminal Action Scheduler callback for the auto-purge chain. + * + * Stub implementation; real body is added in a later task. + * + * @return void + */ + public function auto_purge_reaper() { + // Filled in by a later task. + } + /** * Returns the admin action links. * diff --git a/tests/phpunit/test-class-admin.php b/tests/phpunit/test-class-admin.php index effedff41..7156e3e17 100644 --- a/tests/phpunit/test-class-admin.php +++ b/tests/phpunit/test-class-admin.php @@ -613,4 +613,25 @@ public function test_auto_purge_action_constants_exist() { $this->assertSame( 'stream_auto_purge_reaper_action', \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); $this->assertSame( 'stream-auto-purge', \WP_Stream\Admin::AUTO_PURGE_GROUP ); } + + public function test_register_hooks_auto_purge_action_scheduler_callbacks() { + // The Admin instance is constructed by the test bootstrap, so register() + // has already run. Just assert the actions are wired up. + $this->assertNotFalse( + has_action( \WP_Stream\Admin::AUTO_PURGE_ACTION, array( $this->admin, 'purge_scheduled_action' ) ), + 'Recurring auto-purge AS callback should be registered' + ); + $this->assertNotFalse( + has_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, array( $this->admin, 'auto_purge_batch' ) ), + 'Auto-purge batch worker should be registered' + ); + $this->assertNotFalse( + has_action( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION, array( $this->admin, 'auto_purge_reaper' ) ), + 'Auto-purge reaper should be registered' + ); + $this->assertFalse( + has_action( 'wp_stream_auto_purge', array( $this->admin, 'purge_scheduled_action' ) ), + 'Legacy wp_stream_auto_purge hook should no longer dispatch to purge_scheduled_action directly' + ); + } } From 42a92edcf71c5e43717c2bc5e6d63c38bc8d5927 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 10:33:30 +0530 Subject: [PATCH 03/25] feat(purge): schedule auto-purge via Action Scheduler Replaces the legacy twicedaily WP-Cron event with a recurring AS action scheduled at 12h intervals. Clears any pre-existing legacy event on upgrade so the two cannot double-fire. Idempotent: re-running the setup while a recurring action is pending is a no-op. Refs XWPENG-28 --- classes/class-admin.php | 23 +++++++++++++++++-- tests/phpunit/test-class-admin.php | 36 +++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/classes/class-admin.php b/classes/class-admin.php index e854a5fdc..299e77fc9 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -849,8 +849,27 @@ public static function get_blog_record_table_size( $blog_id = null ): int { * @return void */ public function purge_schedule_setup() { - if ( ! wp_next_scheduled( 'wp_stream_auto_purge' ) ) { - wp_schedule_event( time(), 'twicedaily', 'wp_stream_auto_purge' ); + // Clear the legacy WP-Cron event scheduled by Stream <= 4.1.x so it + // cannot double-fire alongside the new AS recurring action. + if ( wp_next_scheduled( 'wp_stream_auto_purge' ) ) { + wp_clear_scheduled_hook( 'wp_stream_auto_purge' ); + } + + if ( ! function_exists( 'as_schedule_recurring_action' ) ) { + // Action Scheduler not yet loaded (e.g. very early hook); bail. + // Plugin::__construct() loads it before init, so this should be unreachable. + return; + } + + if ( false === as_next_scheduled_action( self::AUTO_PURGE_ACTION ) ) { + // 12 hours == old `twicedaily` interval. + as_schedule_recurring_action( + time(), + 12 * HOUR_IN_SECONDS, + self::AUTO_PURGE_ACTION, + array(), + self::AUTO_PURGE_GROUP + ); } } diff --git a/tests/phpunit/test-class-admin.php b/tests/phpunit/test-class-admin.php index 7156e3e17..fb6f85f5a 100644 --- a/tests/phpunit/test-class-admin.php +++ b/tests/phpunit/test-class-admin.php @@ -374,11 +374,41 @@ public function test_wp_ajax_reset_large_records_blog() { remove_filter( 'wp_stream_is_network_activated', '__return_false' ); } - public function test_purge_schedule_setup() { + public function test_purge_schedule_setup_uses_action_scheduler_and_unschedules_wp_cron() { + // Simulate a pre-existing legacy WP-Cron event from older Stream versions. wp_clear_scheduled_hook( 'wp_stream_auto_purge' ); - $this->assertFalse( wp_next_scheduled( 'wp_stream_auto_purge' ) ); - $this->admin->purge_schedule_setup(); + wp_schedule_event( time(), 'twicedaily', 'wp_stream_auto_purge' ); $this->assertNotFalse( wp_next_scheduled( 'wp_stream_auto_purge' ) ); + + // Make sure AS has no purge actions queued. + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_ACTION ); + } + + $this->admin->purge_schedule_setup(); + + // Legacy WP-Cron event is gone. + $this->assertFalse( + wp_next_scheduled( 'wp_stream_auto_purge' ), + 'Legacy wp_stream_auto_purge WP-Cron event should be cleared' + ); + + // Recurring AS action is scheduled. + $this->assertNotFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_ACTION ), + 'Recurring AS auto-purge action should be scheduled' + ); + + // Idempotent: calling it again must not schedule a second recurring action. + $this->admin->purge_schedule_setup(); + $ids = as_get_scheduled_actions( + array( + 'hook' => \WP_Stream\Admin::AUTO_PURGE_ACTION, + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ), + 'ids' + ); + $this->assertCount( 1, $ids, 'purge_schedule_setup() must be idempotent' ); } public function test_purge_scheduled_action() { From 1acd83293c778a978b69310b5a9812d2b4fe7489 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 10:35:38 +0530 Subject: [PATCH 04/25] feat(purge): replace inline DELETE with AS chain enqueue The recurring action now snapshots the TTL cutoff in UTC, fires wp_stream_auto_purge for back-compat, applies an overlap guard, and enqueues the first batch into the auto-purge chain. Multisite scoping (per-site activation) is encoded as blog_id; 0 means 'all blogs'. Defaults are merged into the options array via wp_parse_args so a partially-saved option still gets the missing keys. Refs XWPENG-28 --- classes/class-admin.php | 54 +++++---- tests/phpunit/test-class-admin.php | 179 ++++++++++++++++++++++++----- 2 files changed, 186 insertions(+), 47 deletions(-) diff --git a/classes/class-admin.php b/classes/class-admin.php index 299e77fc9..06586739c 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -895,7 +895,15 @@ private function delete_orphaned_meta() { * @return void */ public function purge_scheduled_action() { - global $wpdb; + /** + * Fires once per auto-purge cycle, before any deletion is enqueued. + * + * Preserved for backward compatibility with consumers that hooked the + * legacy WP-Cron event of the same name in Stream <= 4.1.x. + * + * @since 1.0.0 + */ + do_action( 'wp_stream_auto_purge' ); // Don't purge when in Network Admin unless Stream is network activated. if ( @@ -908,34 +916,40 @@ public function purge_scheduled_action() { $defaults = $this->plugin->settings->get_defaults(); if ( $this->plugin->is_multisite_network_activated() ) { - $options = (array) get_site_option( 'wp_stream_network', $defaults ); + $options = wp_parse_args( (array) get_site_option( 'wp_stream_network', array() ), $defaults ); } else { - $options = (array) get_option( 'wp_stream', $defaults ); + $options = wp_parse_args( (array) get_option( 'wp_stream', array() ), $defaults ); } - if ( ! empty( $options['general_keep_records_indefinitely'] ) || ! isset( $options['general_records_ttl'] ) ) { + if ( ! empty( $options['general_keep_records_indefinitely'] ) || empty( $options['general_records_ttl'] ) ) { return; } - $days = $options['general_records_ttl']; - $timezone = new DateTimeZone( 'UTC' ); - $date = new DateTime( 'now', $timezone ); - - $date->sub( DateInterval::createFromDateString( "$days days" ) ); + // Overlap guard: if a previous chain is still draining, don't stack a new one. + if ( + function_exists( 'as_has_scheduled_action' ) + && as_has_scheduled_action( self::AUTO_PURGE_BATCH_ACTION ) + ) { + return; + } - $where = $wpdb->prepare( ' AND `stream`.`created` < %s', $date->format( 'Y-m-d H:i:s' ) ); + // Snapshot the UTC cutoff once per recurring tick. Each batch in this + // chain operates against this fixed cutoff so the chain is finite. + $days = (int) $options['general_records_ttl']; + $cutoff = ( new DateTime( 'now', new DateTimeZone( 'UTC' ) ) ) + ->sub( DateInterval::createFromDateString( $days . ' days' ) ) + ->format( 'Y-m-d H:i:s' ); - // Multisite but NOT network activated, only purge the current blog. - if ( $this->plugin->is_multisite_not_network_activated() ) { - $where .= $wpdb->prepare( ' AND `blog_id` = %d', get_current_blog_id() ); - } + // blog_id = 0 means "all blogs" (network-activated path). + $blog_id = $this->plugin->is_multisite_not_network_activated() ? (int) get_current_blog_id() : 0; - $wpdb->query( - "DELETE `stream`, `meta` - FROM {$wpdb->stream} AS `stream` - LEFT JOIN {$wpdb->streammeta} AS `meta` - ON `meta`.`record_id` = `stream`.`ID` - WHERE 1=1 {$where};", // @codingStandardsIgnoreLine $where already prepared + as_enqueue_async_action( + self::AUTO_PURGE_BATCH_ACTION, + array( + 'cutoff' => $cutoff, + 'blog_id' => $blog_id, + ), + self::AUTO_PURGE_GROUP ); } diff --git a/tests/phpunit/test-class-admin.php b/tests/phpunit/test-class-admin.php index fb6f85f5a..5bd148177 100644 --- a/tests/phpunit/test-class-admin.php +++ b/tests/phpunit/test-class-admin.php @@ -411,43 +411,126 @@ public function test_purge_schedule_setup_uses_action_scheduler_and_unschedules_ $this->assertCount( 1, $ids, 'purge_schedule_setup() must be idempotent' ); } - public function test_purge_scheduled_action() { - // Set the TTL to one day + public function test_purge_scheduled_action_fires_bc_filter() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + } + + $hits = 0; + $listener = function () use ( &$hits ) { + ++$hits; + }; + add_action( 'wp_stream_auto_purge', $listener ); + + // Make sure something is eligible so we exercise the full code path. + $this->seed_aged_records( 1, 5 ); + $this->set_records_ttl( 1 ); + + $this->admin->purge_scheduled_action(); + + remove_action( 'wp_stream_auto_purge', $listener ); + $this->assertSame( 1, $hits, 'wp_stream_auto_purge action must fire exactly once per recurring tick' ); + } + + public function test_purge_scheduled_action_enqueues_first_batch_with_snapshotted_cutoff() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + } + + $this->seed_aged_records( 1, 5 ); + $this->set_records_ttl( 1 ); + + $this->admin->purge_scheduled_action(); + + $scheduled = as_get_scheduled_actions( + array( + 'hook' => \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ) + ); + $this->assertNotEmpty( $scheduled, 'A first batch must be enqueued when records are eligible' ); + + $action = array_shift( $scheduled ); + $args = $action->get_args(); + $this->assertArrayHasKey( 'cutoff', $args ); + $this->assertArrayHasKey( 'blog_id', $args ); + $this->assertMatchesRegularExpression( + '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', + $args['cutoff'], + 'Cutoff must be a MySQL DATETIME string' + ); + } + + public function test_purge_scheduled_action_respects_keep_indefinitely() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + } + $this->seed_aged_records( 1, 5 ); + if ( is_multisite() && is_plugin_active_for_network( $this->plugin->locations['plugin'] ) ) { - $options = (array) get_site_option( 'wp_stream_network', array() ); - $options['general_records_ttl'] = '1'; - update_site_option( 'wp_stream_network', $options ); + update_site_option( 'wp_stream_network', array( 'general_keep_records_indefinitely' => 1 ) ); } else { - $options = (array) get_option( 'wp_stream', array() ); - $options['general_records_ttl'] = '1'; - update_option( 'wp_stream', $options ); + update_option( 'wp_stream', array( 'general_keep_records_indefinitely' => 1 ) ); } - global $wpdb; + $this->admin->purge_scheduled_action(); - // Create (two day old) dummy records - $stream_data = $this->dummy_stream_data(); - $stream_data['created'] = gmdate( 'Y-m-d h:i:s', strtotime( '2 days ago' ) ); - $wpdb->insert( $wpdb->stream, $stream_data ); - $stream_id = $wpdb->insert_id; - $this->assertNotFalse( $stream_id ); + $this->assertFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), + 'No batch must be enqueued when keep-records-indefinitely is on' + ); + } - // Create dummy meta - $meta_data = $this->dummy_meta_data( $stream_id ); - $wpdb->insert( $wpdb->streammeta, $meta_data ); - $meta_id = $wpdb->insert_id; - $this->assertNotFalse( $meta_id ); + public function test_purge_scheduled_action_applies_defaults_when_option_missing() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + } + // Drop the option entirely. + if ( is_multisite() && is_plugin_active_for_network( $this->plugin->locations['plugin'] ) ) { + delete_site_option( 'wp_stream_network' ); + } else { + delete_option( 'wp_stream' ); + } + + // Seed records older than the default 30-day TTL. + $this->seed_aged_records( 1, 31 ); - // Purge old records and meta $this->admin->purge_scheduled_action(); - // Check if the old records have been cleared - $stream_results = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->stream} WHERE ID = %d", $stream_id ) ); - $this->assertEmpty( $stream_results ); + $this->assertNotFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), + 'Defaults (30-day TTL) must apply when the settings option is missing' + ); + } - // Check if the old meta has been cleared - $meta_results = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->streammeta} WHERE meta_id = %d", $meta_id ) ); - $this->assertEmpty( $meta_results ); + public function test_purge_scheduled_action_overlap_guard_skips_when_batch_already_pending() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + } + $this->seed_aged_records( 1, 5 ); + $this->set_records_ttl( 1 ); + + // First call enqueues a batch. + $this->admin->purge_scheduled_action(); + $first = as_get_scheduled_actions( + array( + 'hook' => \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ), + 'ids' + ); + $this->assertCount( 1, $first ); + + // Second call must be a no-op. + $this->admin->purge_scheduled_action(); + $second = as_get_scheduled_actions( + array( + 'hook' => \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ), + 'ids' + ); + $this->assertCount( 1, $second, 'Overlap guard must prevent stacking a second batch chain' ); } public function test_plugin_action_links() { @@ -637,6 +720,48 @@ private function dummy_meta_data( $stream_id ) { ); } + /** + * Insert N stream rows aged $days_old days, optionally pinned to a blog id. + * + * @param int $count Number of rows to insert. + * @param int $days_old How many days ago `created` should be set to. + * @param int|null $blog_id Optional blog id override. + * @return int[] Inserted stream IDs. + */ + private function seed_aged_records( int $count, int $days_old, $blog_id = null ): array { + global $wpdb; + $ids = array(); + for ( $i = 0; $i < $count; $i++ ) { + $row = $this->dummy_stream_data(); + $row['created'] = gmdate( 'Y-m-d H:i:s', strtotime( $days_old . ' days ago' ) ); + if ( null !== $blog_id ) { + $row['blog_id'] = $blog_id; + } + $wpdb->insert( $wpdb->stream, $row ); + $stream_id = (int) $wpdb->insert_id; + $ids[] = $stream_id; + $wpdb->insert( $wpdb->streammeta, $this->dummy_meta_data( $stream_id ) ); + } + return $ids; + } + + /** + * Set the records TTL in whichever option applies on this install. + */ + private function set_records_ttl( int $days ) { + if ( is_multisite() && is_plugin_active_for_network( $this->plugin->locations['plugin'] ) ) { + $options = (array) get_site_option( 'wp_stream_network', array() ); + $options['general_records_ttl'] = (string) $days; + unset( $options['general_keep_records_indefinitely'] ); + update_site_option( 'wp_stream_network', $options ); + } else { + $options = (array) get_option( 'wp_stream', array() ); + $options['general_records_ttl'] = (string) $days; + unset( $options['general_keep_records_indefinitely'] ); + update_option( 'wp_stream', $options ); + } + } + public function test_auto_purge_action_constants_exist() { $this->assertSame( 'stream_auto_purge_action', \WP_Stream\Admin::AUTO_PURGE_ACTION ); $this->assertSame( 'stream_auto_purge_batch_action', \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); From e08d680a796e004801f4880d1744d5b6884d5df3 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 10:37:32 +0530 Subject: [PATCH 05/25] feat(purge): add batched auto_purge_batch worker Window-based deletion (ID range \u2264 wp_stream_batch_size) joined against stream_meta in a single statement, mirroring erase_large_records(). Snapshotted UTC cutoff is threaded through each batch in the chain. blog_id == 0 means 'all blogs' for network-activated installs; non-zero scopes to that blog. Schedules the orphan reaper as the terminal step when no more rows are eligible. Refs XWPENG-28 --- classes/class-admin.php | 112 +++++++++++++++++++++++++++-- tests/phpunit/test-class-admin.php | 110 ++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 4 deletions(-) diff --git a/classes/class-admin.php b/classes/class-admin.php index 06586739c..95769e46d 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -955,16 +955,120 @@ function_exists( 'as_has_scheduled_action' ) /** * Async Action Scheduler callback: delete one batch of records eligible - * under the snapshotted UTC cutoff, then chain the next batch. + * under the snapshotted UTC cutoff, then chain the next batch (or the + * orphan reaper when nothing remains). * - * Stub implementation; real body is added in a later task. + * Window-based deletion mirrors {@see Admin::erase_large_records()} so the + * InnoDB lock footprint is bounded and predictable on bloated tables. * * @param string $cutoff MySQL DATETIME string in UTC. - * @param int $blog_id Blog to scope to, or 0 for all blogs. + * @param int $blog_id Blog to scope to, or 0 for all blogs (network-activated). * @return void */ public function auto_purge_batch( $cutoff, $blog_id = 0 ) { - unset( $cutoff, $blog_id ); + global $wpdb; + + $cutoff = (string) $cutoff; + $blog_id = (int) $blog_id; + + // Defensive: a malformed cutoff would otherwise translate to a no-op + // DELETE that still busies the DB. Refuse and let AS retry the action. + if ( '' === $cutoff ) { + return; + } + + /** + * Filters the number of records to delete per batch. + * + * Shared with the manual reset path (see {@see Admin::erase_large_records()}) + * so site owners only need to tune one knob. + * + * @since 4.1.0 + * + * @param int $batch_size Default 250000. + */ + $batch_size = (int) apply_filters( 'wp_stream_batch_size', 250000 ); + if ( $batch_size < 1 ) { + $batch_size = 250000; + } + + // Find the highest-ID record still eligible under the snapshotted cutoff. + if ( $blog_id > 0 ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $start_from = $wpdb->get_var( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `blog_id` = %d ORDER BY ID DESC LIMIT 1", + $cutoff, + $blog_id + ) + ); + } else { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $start_from = $wpdb->get_var( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s ORDER BY ID DESC LIMIT 1", + $cutoff + ) + ); + } + + if ( empty( $start_from ) ) { + // Chain is done. Schedule the orphan reaper as the terminal step. + as_enqueue_async_action( self::AUTO_PURGE_REAPER_ACTION, array(), self::AUTO_PURGE_GROUP ); + return; + } + + $start_from = (int) $start_from; + $window_low = max( 0, $start_from - $batch_size ); + + // Multi-table DELETE: parent + meta in one statement. Mirrors + // Admin::erase_large_records(). + if ( $blog_id > 0 ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( + $wpdb->prepare( + "DELETE `stream`, `meta` + FROM {$wpdb->stream} AS `stream` + LEFT JOIN {$wpdb->streammeta} AS `meta` + ON `meta`.`record_id` = `stream`.`ID` + WHERE `stream`.`ID` <= %d + AND `stream`.`ID` >= %d + AND `stream`.`created` < %s + AND `stream`.`blog_id` = %d;", + $start_from, + $window_low, + $cutoff, + $blog_id + ) + ); + } else { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( + $wpdb->prepare( + "DELETE `stream`, `meta` + FROM {$wpdb->stream} AS `stream` + LEFT JOIN {$wpdb->streammeta} AS `meta` + ON `meta`.`record_id` = `stream`.`ID` + WHERE `stream`.`ID` <= %d + AND `stream`.`ID` >= %d + AND `stream`.`created` < %s;", + $start_from, + $window_low, + $cutoff + ) + ); + } + + // Chain the next batch. If nothing remains the next batch will detect + // it via the SELECT above and schedule the reaper instead. + as_enqueue_async_action( + self::AUTO_PURGE_BATCH_ACTION, + array( + 'cutoff' => $cutoff, + 'blog_id' => $blog_id, + ), + self::AUTO_PURGE_GROUP + ); } /** diff --git a/tests/phpunit/test-class-admin.php b/tests/phpunit/test-class-admin.php index 5bd148177..c2920aba9 100644 --- a/tests/phpunit/test-class-admin.php +++ b/tests/phpunit/test-class-admin.php @@ -762,6 +762,116 @@ private function set_records_ttl( int $days ) { } } + public function test_auto_purge_batch_deletes_window_and_chains_next_batch() { + global $wpdb; + + // Force a small batch size so we can chain twice without seeding huge data. + add_filter( 'wp_stream_batch_size', function () { return 2; } ); + + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + + // Seed 5 aged rows. With batch_size=2 the chain runs 3 batches + reaper. + $this->seed_aged_records( 5, 5 ); + + $cutoff = ( new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ) ) + ->sub( \DateInterval::createFromDateString( '1 days' ) ) + ->format( 'Y-m-d H:i:s' ); + + $before = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->stream}" ); + + $this->admin->auto_purge_batch( $cutoff, 0 ); + + $remaining = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->stream}" ); + $this->assertLessThan( $before, $remaining, 'Batch must delete at least one row' ); + $this->assertGreaterThan( 0, $remaining, 'Batch must not delete more than one window of rows' ); + + $this->assertNotFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), + 'Next batch must be chained when more eligible rows remain' + ); + + remove_all_filters( 'wp_stream_batch_size' ); + } + + public function test_auto_purge_batch_enqueues_reaper_when_no_rows_remain() { + global $wpdb; + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + // Wipe any leftover rows from earlier tests so nothing is eligible. + $wpdb->query( "DELETE FROM {$wpdb->stream}" ); + $wpdb->query( "DELETE FROM {$wpdb->streammeta}" ); + + $cutoff = ( new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ) ) + ->sub( \DateInterval::createFromDateString( '1 days' ) ) + ->format( 'Y-m-d H:i:s' ); + + $this->admin->auto_purge_batch( $cutoff, 0 ); + + $this->assertFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), + 'No further batch must be chained when nothing is eligible' + ); + $this->assertNotFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ), + 'Reaper must be enqueued as the terminal step of the chain' + ); + } + + public function test_auto_purge_batch_respects_wp_stream_batch_size_filter() { + $invocations = 0; + add_filter( + 'wp_stream_batch_size', + function () use ( &$invocations ) { + ++$invocations; + return 1; + } + ); + + $this->seed_aged_records( 1, 5 ); + + $cutoff = ( new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ) ) + ->sub( \DateInterval::createFromDateString( '1 days' ) ) + ->format( 'Y-m-d H:i:s' ); + $this->admin->auto_purge_batch( $cutoff, 0 ); + + $this->assertGreaterThanOrEqual( 1, $invocations, 'wp_stream_batch_size filter must be consulted' ); + remove_all_filters( 'wp_stream_batch_size' ); + } + + public function test_auto_purge_batch_scopes_to_blog_id_when_non_zero() { + global $wpdb; + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite scoping test' ); + } + + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + + $current_blog = (int) get_current_blog_id(); + $other_blog = $current_blog + 1000; // arbitrary distinct id, no real blog required for SQL scoping. + + $this->seed_aged_records( 1, 5, $current_blog ); + $this->seed_aged_records( 1, 5, $other_blog ); + + $cutoff = ( new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ) ) + ->sub( \DateInterval::createFromDateString( '1 days' ) ) + ->format( 'Y-m-d H:i:s' ); + + $this->admin->auto_purge_batch( $cutoff, $current_blog ); + + $remaining_other = (int) $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->stream} WHERE blog_id = %d", $other_blog ) + ); + $this->assertSame( 1, $remaining_other, 'Per-blog scoping must leave sibling blogs untouched' ); + } + public function test_auto_purge_action_constants_exist() { $this->assertSame( 'stream_auto_purge_action', \WP_Stream\Admin::AUTO_PURGE_ACTION ); $this->assertSame( 'stream_auto_purge_batch_action', \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); From dafcbdd7c99767c2c47aeb6422a22a8004825634 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 10:39:00 +0530 Subject: [PATCH 06/25] feat(purge): add terminal orphan reaper to auto-purge chain Runs delete_orphaned_meta() once at the end of every chain so installs that already had orphan meta from historical timed-out purges heal over time without operator intervention. Lifts delete_orphaned_meta() visibility from private to protected so the reaper can call it. Refs XWPENG-28 --- classes/class-admin.php | 9 +++++--- tests/phpunit/test-class-admin.php | 33 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/classes/class-admin.php b/classes/class-admin.php index 95769e46d..f99c960db 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -881,7 +881,7 @@ public function purge_schedule_setup() { * * @global wpdb $wpdb The WordPress database object. */ - private function delete_orphaned_meta() { + protected function delete_orphaned_meta() { global $wpdb; $wpdb->query( @@ -1074,12 +1074,15 @@ public function auto_purge_batch( $cutoff, $blog_id = 0 ) { /** * Terminal Action Scheduler callback for the auto-purge chain. * - * Stub implementation; real body is added in a later task. + * Runs once per chain (after the last batch) and once when the manual + * "Clean orphaned meta now" button is used. Cleans up meta rows whose + * parent stream row is already gone — i.e. residue from historical + * unbatched purges and from any logger races during a chain. * * @return void */ public function auto_purge_reaper() { - // Filled in by a later task. + $this->delete_orphaned_meta(); } /** diff --git a/tests/phpunit/test-class-admin.php b/tests/phpunit/test-class-admin.php index c2920aba9..efe775171 100644 --- a/tests/phpunit/test-class-admin.php +++ b/tests/phpunit/test-class-admin.php @@ -762,6 +762,39 @@ private function set_records_ttl( int $days ) { } } + public function test_auto_purge_reaper_deletes_orphaned_meta_only() { + global $wpdb; + + // Seed a real record with meta, then a free-floating meta row pointing at + // a non-existent record_id. + $stream_data = $this->dummy_stream_data(); + $stream_data['created'] = gmdate( 'Y-m-d H:i:s', strtotime( '5 days ago' ) ); + $wpdb->insert( $wpdb->stream, $stream_data ); + $real_id = (int) $wpdb->insert_id; + $wpdb->insert( $wpdb->streammeta, $this->dummy_meta_data( $real_id ) ); + + // Orphan meta: record_id points nowhere. + $orphan_record_id = $real_id + 999999; + $wpdb->insert( $wpdb->streammeta, $this->dummy_meta_data( $orphan_record_id ) ); + + $before_orphans = (int) $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->streammeta} WHERE record_id = %d", $orphan_record_id ) + ); + $this->assertSame( 1, $before_orphans ); + + $this->admin->auto_purge_reaper(); + + $after_orphans = (int) $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->streammeta} WHERE record_id = %d", $orphan_record_id ) + ); + $linked_meta = (int) $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->streammeta} WHERE record_id = %d", $real_id ) + ); + + $this->assertSame( 0, $after_orphans, 'Reaper must delete meta rows whose parent stream row is absent' ); + $this->assertSame( 1, $linked_meta, 'Reaper must not touch meta rows whose parent still exists' ); + } + public function test_auto_purge_batch_deletes_window_and_chains_next_batch() { global $wpdb; From a5641a63ad2027aa5caf15c14852b7d83c6ecc60 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 10:44:24 +0530 Subject: [PATCH 07/25] feat(purge): add manual 'Clean Orphaned Meta' link on Settings \u2192 Advanced Settings UI link is nonced for users with WP_STREAM_SETTINGS_CAPABILITY; the ajax handler schedules a one-shot reaper via Action Scheduler. Idempotent: re-clicking while a reaper is pending is a no-op. Bails out early under WP_STREAM_TESTS so PHPUnit doesn't exit the worker. Refs XWPENG-28 --- classes/class-admin.php | 46 ++++++++++++++++++++++++++++++ classes/class-settings.php | 18 ++++++++++++ tests/phpunit/test-class-admin.php | 21 ++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/classes/class-admin.php b/classes/class-admin.php index f99c960db..d963d4c84 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -229,6 +229,12 @@ public function __construct( $plugin ) { ) ); + // Manual "Clean orphaned meta now" action (Settings → Advanced). + add_action( + 'wp_ajax_wp_stream_clean_orphan_meta', + array( $this, 'wp_ajax_clean_orphan_meta' ) + ); + // Auto purge setup (Action Scheduler). add_action( 'wp_loaded', array( $this, 'purge_schedule_setup' ) ); add_action( @@ -1085,6 +1091,46 @@ public function auto_purge_reaper() { $this->delete_orphaned_meta(); } + /** + * Ajax handler for the "Clean orphaned meta now" button on + * Settings → Advanced. + * + * Schedules an immediate async run of the orphan reaper. Idempotent: + * if a reaper is already scheduled, returns without enqueuing a second. + * + * @return void + */ + public function wp_ajax_clean_orphan_meta() { + if ( ! current_user_can( WP_STREAM_SETTINGS_CAPABILITY ) ) { + wp_die( esc_html__( 'You do not have permission to do this.', 'stream' ), 403 ); + } + + check_ajax_referer( 'stream_nonce_clean_orphan_meta', 'wp_stream_nonce_clean_orphan_meta' ); + + if ( ! function_exists( 'as_has_scheduled_action' ) || ! function_exists( 'as_enqueue_async_action' ) ) { + wp_die( esc_html__( 'Action Scheduler is not available.', 'stream' ), 500 ); + } + + if ( ! as_has_scheduled_action( self::AUTO_PURGE_REAPER_ACTION ) ) { + as_enqueue_async_action( self::AUTO_PURGE_REAPER_ACTION, array(), self::AUTO_PURGE_GROUP ); + } + + if ( defined( 'WP_STREAM_TESTS' ) && WP_STREAM_TESTS ) { + return true; + } + + wp_safe_redirect( + add_query_arg( + array( + 'page' => $this->settings_page_slug, + 'wp_stream_message' => 'orphan_meta_cleanup_scheduled', + ), + admin_url( $this->plugin->is_multisite_network_activated() ? 'network/admin.php' : 'admin.php' ) + ) + ); + exit; + } + /** * Returns the admin action links. * diff --git a/classes/class-settings.php b/classes/class-settings.php index 28eb9f601..553cba943 100644 --- a/classes/class-settings.php +++ b/classes/class-settings.php @@ -371,6 +371,24 @@ public function get_fields() { 'default' => 0, 'sticky' => 'bottom', ), + array( + 'name' => 'clean_orphan_meta', + 'title' => esc_html__( 'Clean Orphaned Meta', 'stream' ), + 'type' => 'link', + 'href' => add_query_arg( + array( + 'action' => 'wp_stream_clean_orphan_meta', + 'wp_stream_nonce_clean_orphan_meta' => wp_create_nonce( 'stream_nonce_clean_orphan_meta' ), + ), + admin_url( 'admin-ajax.php' ) + ), + 'desc' => esc_html__( + 'Schedules an immediate background cleanup of stream_meta rows whose parent record is missing. Safe to run while Stream is in use; runs once via Action Scheduler.', + 'stream' + ), + 'default' => 0, + 'sticky' => 'bottom', + ), ), ), ); diff --git a/tests/phpunit/test-class-admin.php b/tests/phpunit/test-class-admin.php index efe775171..a19cad53f 100644 --- a/tests/phpunit/test-class-admin.php +++ b/tests/phpunit/test-class-admin.php @@ -762,6 +762,27 @@ private function set_records_ttl( int $days ) { } } + public function test_ajax_clean_orphan_meta_schedules_reaper() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + + $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $user_id ); + + $_REQUEST['wp_stream_nonce_clean_orphan_meta'] = wp_create_nonce( 'stream_nonce_clean_orphan_meta' ); + + $result = $this->admin->wp_ajax_clean_orphan_meta(); + $this->assertTrue( $result ); + + $this->assertNotFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ), + 'Ajax handler must enqueue the reaper action' + ); + + unset( $_REQUEST['wp_stream_nonce_clean_orphan_meta'] ); + } + public function test_auto_purge_reaper_deletes_orphaned_meta_only() { global $wpdb; From 86a31bfc9b9140ead9c5a2e2aa59be2ce222d173 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 10:46:49 +0530 Subject: [PATCH 08/25] test(e2e): cover manual orphan-meta cleanup link Asserts the Clean Orphaned Meta link renders on Settings \u2192 Advanced, points at admin-ajax.php with the expected action + nonce, and that following the link redirects to the settings page with a confirmation marker in the URL. Also fixes the redirect target in the handler to use network_settings_page_slug on network-activated installs. Refs XWPENG-28 --- classes/class-admin.php | 8 ++- tests/e2e/admin-orphan-cleanup.spec.js | 76 ++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/admin-orphan-cleanup.spec.js diff --git a/classes/class-admin.php b/classes/class-admin.php index d963d4c84..0c7050310 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -1119,13 +1119,17 @@ public function wp_ajax_clean_orphan_meta() { return true; } + $is_network = $this->plugin->is_multisite_network_activated(); + $page_slug = $is_network ? $this->network->network_settings_page_slug : $this->settings_page_slug; + $base_url = $is_network ? network_admin_url( $this->admin_parent_page ) : admin_url( $this->admin_parent_page ); + wp_safe_redirect( add_query_arg( array( - 'page' => $this->settings_page_slug, + 'page' => $page_slug, 'wp_stream_message' => 'orphan_meta_cleanup_scheduled', ), - admin_url( $this->plugin->is_multisite_network_activated() ? 'network/admin.php' : 'admin.php' ) + $base_url ) ); exit; diff --git a/tests/e2e/admin-orphan-cleanup.spec.js b/tests/e2e/admin-orphan-cleanup.spec.js new file mode 100644 index 000000000..103a6f31b --- /dev/null +++ b/tests/e2e/admin-orphan-cleanup.spec.js @@ -0,0 +1,76 @@ +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Settings → Advanced manual "Clean Orphaned Meta" link. + * + * Asserts that the link is rendered, that its href points at admin-ajax.php + * with the expected action + nonce parameters, and that following the link + * redirects back to the Stream settings page with a confirmation marker in + * the URL. + */ +const ADMIN = 'http://stream.wpenv.net/wp-admin'; + +test.describe.configure( { mode: 'serial' } ); + +let page; + +test.beforeAll( async ( { browser } ) => { + page = await browser.newPage(); + + // The setup fixture deactivates Stream network-wide before the suite. + // Reactivate it so the Stream admin pages are reachable. + await page.goto( `${ ADMIN }/network/plugins.php` ); + const activate = page.getByLabel( 'Network Activate Stream' ); + if ( await activate.isVisible() ) { + await activate.click(); + } +} ); + +test.afterAll( async () => { + // Deactivate Stream again so other suites start from the same state + // as the shared setup fixture. + await page.goto( `${ ADMIN }/network/plugins.php` ); + const deactivate = page.getByLabel( 'Network Deactivate Stream' ); + if ( await deactivate.isVisible() ) { + await deactivate.click(); + } +} ); + +const ADVANCED_TAB_URL = `${ ADMIN }/network/admin.php?page=wp_stream_network_settings&tab=advanced`; + +test.describe( 'Manual orphan-meta cleanup link', () => { + test( 'is visible on the Advanced tab', async () => { + await page.goto( ADVANCED_TAB_URL ); + + const link = page.getByRole( 'link', { name: /Clean Orphaned Meta/i } ); + await expect( link ).toBeVisible(); + } ); + + test( 'links to admin-ajax with the expected action and nonce', async () => { + await page.goto( ADVANCED_TAB_URL ); + + const link = page.getByRole( 'link', { name: /Clean Orphaned Meta/i } ); + const href = await link.getAttribute( 'href' ); + + expect( href ).toContain( 'admin-ajax.php' ); + expect( href ).toContain( 'action=wp_stream_clean_orphan_meta' ); + expect( href ).toMatch( + /wp_stream_nonce_clean_orphan_meta=[a-f0-9]+/, + ); + } ); + + test( 'redirects back to settings with confirmation marker', async () => { + await page.goto( ADVANCED_TAB_URL ); + + const link = page.getByRole( 'link', { name: /Clean Orphaned Meta/i } ); + await Promise.all( [ + page.waitForURL( + /wp_stream_message=orphan_meta_cleanup_scheduled/, + ), + link.click(), + ] ); + } ); +} ); From afa5ec54fc535a29e57bfcc155e68b9029e8cdfc Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 10:54:36 +0530 Subject: [PATCH 09/25] fix(purge): ensure forward progress on tables with concurrent writes The previous auto_purge_batch SELECT used 'WHERE created < cutoff ORDER BY ID DESC LIMIT 1' to find the next window's top, but on hosts that are actively logging during a chain the ID space is sparse (eligible rows interleaved with fresh rows whose created > cutoff). That caused each subsequent batch to find a top only ~30 IDs below the previous, stalling progress. Match Admin::erase_large_records()'s pattern instead: pass last_entry (the lower bound of the previous window) through the chain and use 'WHERE ID < last_entry' to guarantee the next batch starts strictly below the previous window. Stride is now exactly wp_stream_batch_size IDs per batch. Verified end-to-end on a multisite install seeded to ~320k aged records: chain drains in ~35 batches at batch_size=10000 and terminates with zero orphans. Refs XWPENG-28 --- classes/class-admin.php | 46 ++++++++++++++++++------------ tests/phpunit/test-class-admin.php | 42 +++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/classes/class-admin.php b/classes/class-admin.php index 0c7050310..799f470eb 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -245,7 +245,7 @@ public function __construct( $plugin ) { self::AUTO_PURGE_BATCH_ACTION, array( $this, 'auto_purge_batch' ), 10, - 2 + 3 ); add_action( self::AUTO_PURGE_REAPER_ACTION, @@ -967,15 +967,20 @@ function_exists( 'as_has_scheduled_action' ) * Window-based deletion mirrors {@see Admin::erase_large_records()} so the * InnoDB lock footprint is bounded and predictable on bloated tables. * - * @param string $cutoff MySQL DATETIME string in UTC. - * @param int $blog_id Blog to scope to, or 0 for all blogs (network-activated). + * @param string $cutoff MySQL DATETIME string in UTC. + * @param int $blog_id Blog to scope to, or 0 for all blogs (network-activated). + * @param int $last_entry The lower-bound ID of the previous batch's window; 0 on the + * first batch in a chain. The next SELECT uses `ID < last_entry` + * when non-zero, guaranteeing forward progress even on tables + * that grow rapidly during the chain. * @return void */ - public function auto_purge_batch( $cutoff, $blog_id = 0 ) { + public function auto_purge_batch( $cutoff, $blog_id = 0, $last_entry = 0 ) { global $wpdb; - $cutoff = (string) $cutoff; - $blog_id = (int) $blog_id; + $cutoff = (string) $cutoff; + $blog_id = (int) $blog_id; + $last_entry = (int) $last_entry; // Defensive: a malformed cutoff would otherwise translate to a no-op // DELETE that still busies the DB. Refuse and let AS retry the action. @@ -998,22 +1003,26 @@ public function auto_purge_batch( $cutoff, $blog_id = 0 ) { $batch_size = 250000; } - // Find the highest-ID record still eligible under the snapshotted cutoff. + // Find the highest-ID record still eligible under the snapshotted cutoff + // that lies strictly below the previous window's lower bound (when set). + // $last_entry=0 means "first batch in chain" — search from the top. + $id_upper_bound_sql = $last_entry > 0 ? ' AND `ID` < %d' : ''; + $id_upper_bound_arg = $last_entry > 0 ? array( $last_entry ) : array(); + if ( $blog_id > 0 ) { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $start_from = $wpdb->get_var( $wpdb->prepare( - "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `blog_id` = %d ORDER BY ID DESC LIMIT 1", - $cutoff, - $blog_id + "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `blog_id` = %d{$id_upper_bound_sql} ORDER BY ID DESC LIMIT 1", + array_merge( array( $cutoff, $blog_id ), $id_upper_bound_arg ) ) ); } else { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $start_from = $wpdb->get_var( $wpdb->prepare( - "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s ORDER BY ID DESC LIMIT 1", - $cutoff + "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s{$id_upper_bound_sql} ORDER BY ID DESC LIMIT 1", + array_merge( array( $cutoff ), $id_upper_bound_arg ) ) ); } @@ -1065,13 +1074,14 @@ public function auto_purge_batch( $cutoff, $blog_id = 0 ) { ); } - // Chain the next batch. If nothing remains the next batch will detect - // it via the SELECT above and schedule the reaper instead. + // Chain the next batch. Pass $window_low as the new upper bound so the + // next SELECT cannot pick up rows in or above the window we just touched. as_enqueue_async_action( self::AUTO_PURGE_BATCH_ACTION, array( - 'cutoff' => $cutoff, - 'blog_id' => $blog_id, + 'cutoff' => $cutoff, + 'blog_id' => $blog_id, + 'last_entry' => $window_low, ), self::AUTO_PURGE_GROUP ); diff --git a/tests/phpunit/test-class-admin.php b/tests/phpunit/test-class-admin.php index a19cad53f..57e07bae0 100644 --- a/tests/phpunit/test-class-admin.php +++ b/tests/phpunit/test-class-admin.php @@ -897,6 +897,48 @@ function () use ( &$invocations ) { remove_all_filters( 'wp_stream_batch_size' ); } + public function test_auto_purge_batch_chain_strides_down_by_window() { + global $wpdb; + + // Force a small batch size so we can chain multiple times. + add_filter( 'wp_stream_batch_size', function () { return 3; } ); + + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + + $ids = $this->seed_aged_records( 4, 5 ); + sort( $ids ); + $top_id = end( $ids ); + + $cutoff = ( new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ) ) + ->sub( \DateInterval::createFromDateString( '1 days' ) ) + ->format( 'Y-m-d H:i:s' ); + + // First batch (last_entry=0) should pick the highest ID and pass + // last_entry = top_id - batch_size to the next batch. + $this->admin->auto_purge_batch( $cutoff, 0, 0 ); + + $pending = as_get_scheduled_actions( + array( + 'hook' => \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ) + ); + $this->assertNotEmpty( $pending ); + $next_args = array_shift( $pending )->get_args(); + + $this->assertArrayHasKey( 'last_entry', $next_args ); + $this->assertSame( + max( 0, $top_id - 3 ), + (int) $next_args['last_entry'], + 'Next batch must receive last_entry = top_id - batch_size' + ); + + remove_all_filters( 'wp_stream_batch_size' ); + } + public function test_auto_purge_batch_scopes_to_blog_id_when_non_zero() { global $wpdb; if ( ! is_multisite() ) { From 31e336639d52d0387719ac3e6dccfc2ca1104162 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 11:00:57 +0530 Subject: [PATCH 10/25] style: address PHPCS findings in auto-purge implementation - Replace interpolated-SQL pattern in auto_purge_batch with explicit prepared statements per (blog_id, last_entry) combination. - Correct @return doctype on wp_ajax_clean_orphan_meta to bool|void. - Settings UI array alignment. - Add @param to set_records_ttl test helper. Refs XWPENG-28 --- classes/class-admin.php | 42 ++++++++++++++++++++++-------- classes/class-settings.php | 2 +- tests/phpunit/test-class-admin.php | 21 ++++++++++++--- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/classes/class-admin.php b/classes/class-admin.php index 799f470eb..c0e813a96 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -1006,23 +1006,40 @@ public function auto_purge_batch( $cutoff, $blog_id = 0, $last_entry = 0 ) { // Find the highest-ID record still eligible under the snapshotted cutoff // that lies strictly below the previous window's lower bound (when set). // $last_entry=0 means "first batch in chain" — search from the top. - $id_upper_bound_sql = $last_entry > 0 ? ' AND `ID` < %d' : ''; - $id_upper_bound_arg = $last_entry > 0 ? array( $last_entry ) : array(); - - if ( $blog_id > 0 ) { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + if ( $blog_id > 0 && $last_entry > 0 ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $start_from = $wpdb->get_var( $wpdb->prepare( - "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `blog_id` = %d{$id_upper_bound_sql} ORDER BY ID DESC LIMIT 1", - array_merge( array( $cutoff, $blog_id ), $id_upper_bound_arg ) + "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `blog_id` = %d AND `ID` < %d ORDER BY ID DESC LIMIT 1", + $cutoff, + $blog_id, + $last_entry + ) + ); + } elseif ( $blog_id > 0 ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $start_from = $wpdb->get_var( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `blog_id` = %d ORDER BY ID DESC LIMIT 1", + $cutoff, + $blog_id + ) + ); + } elseif ( $last_entry > 0 ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $start_from = $wpdb->get_var( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s AND `ID` < %d ORDER BY ID DESC LIMIT 1", + $cutoff, + $last_entry ) ); } else { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $start_from = $wpdb->get_var( $wpdb->prepare( - "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s{$id_upper_bound_sql} ORDER BY ID DESC LIMIT 1", - array_merge( array( $cutoff ), $id_upper_bound_arg ) + "SELECT ID FROM {$wpdb->stream} WHERE `created` < %s ORDER BY ID DESC LIMIT 1", + $cutoff ) ); } @@ -1108,7 +1125,10 @@ public function auto_purge_reaper() { * Schedules an immediate async run of the orphan reaper. Idempotent: * if a reaper is already scheduled, returns without enqueuing a second. * - * @return void + * Returns true under WP_STREAM_TESTS so PHPUnit can call this directly + * without exiting the worker. + * + * @return bool|void True under tests; otherwise redirects and exits. */ public function wp_ajax_clean_orphan_meta() { if ( ! current_user_can( WP_STREAM_SETTINGS_CAPABILITY ) ) { diff --git a/classes/class-settings.php b/classes/class-settings.php index 553cba943..3455b03e2 100644 --- a/classes/class-settings.php +++ b/classes/class-settings.php @@ -377,7 +377,7 @@ public function get_fields() { 'type' => 'link', 'href' => add_query_arg( array( - 'action' => 'wp_stream_clean_orphan_meta', + 'action' => 'wp_stream_clean_orphan_meta', 'wp_stream_nonce_clean_orphan_meta' => wp_create_nonce( 'stream_nonce_clean_orphan_meta' ), ), admin_url( 'admin-ajax.php' ) diff --git a/tests/phpunit/test-class-admin.php b/tests/phpunit/test-class-admin.php index 57e07bae0..4f806cf61 100644 --- a/tests/phpunit/test-class-admin.php +++ b/tests/phpunit/test-class-admin.php @@ -747,6 +747,8 @@ private function seed_aged_records( int $count, int $days_old, $blog_id = null ) /** * Set the records TTL in whichever option applies on this install. + * + * @param int $days Number of days to retain records for. */ private function set_records_ttl( int $days ) { if ( is_multisite() && is_plugin_active_for_network( $this->plugin->locations['plugin'] ) ) { @@ -808,7 +810,7 @@ public function test_auto_purge_reaper_deletes_orphaned_meta_only() { $after_orphans = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->streammeta} WHERE record_id = %d", $orphan_record_id ) ); - $linked_meta = (int) $wpdb->get_var( + $linked_meta = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->streammeta} WHERE record_id = %d", $real_id ) ); @@ -820,7 +822,12 @@ public function test_auto_purge_batch_deletes_window_and_chains_next_batch() { global $wpdb; // Force a small batch size so we can chain twice without seeding huge data. - add_filter( 'wp_stream_batch_size', function () { return 2; } ); + add_filter( + 'wp_stream_batch_size', + function () { + return 2; + } + ); if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); @@ -901,7 +908,12 @@ public function test_auto_purge_batch_chain_strides_down_by_window() { global $wpdb; // Force a small batch size so we can chain multiple times. - add_filter( 'wp_stream_batch_size', function () { return 3; } ); + add_filter( + 'wp_stream_batch_size', + function () { + return 3; + } + ); if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); @@ -951,7 +963,8 @@ public function test_auto_purge_batch_scopes_to_blog_id_when_non_zero() { } $current_blog = (int) get_current_blog_id(); - $other_blog = $current_blog + 1000; // arbitrary distinct id, no real blog required for SQL scoping. + $other_blog = $current_blog + 1000; + // arbitrary distinct id, no real blog required for SQL scoping. $this->seed_aged_records( 1, 5, $current_blog ); $this->seed_aged_records( 1, 5, $other_blog ); From 185fab80496e3763c6828ed69da80a43da0f3f8d Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 11:02:23 +0530 Subject: [PATCH 11/25] docs(changelog): note XWPENG-28 auto-purge migration Refs XWPENG-28 --- changelog.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/changelog.md b/changelog.md index d64f7b8e9..9df2f7a62 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,21 @@ # Stream Changelog +## Unreleased + +### Bug Fixes + +- Fix unbounded growth of `stream` / `stream_meta` tables: the TTL-based auto-purge now runs via Action Scheduler with batched deletion (default 250,000 rows per batch via the existing `wp_stream_batch_size` filter), resolving database bloat on sites where the previous WP-Cron-driven purge silently failed or timed out on large tables (XWPENG-28). +- Fix orphan `stream_meta` rows accumulating across repeated purge cycles: a terminal orphan reaper now runs at the end of every auto-purge chain, healing installs that have residual orphans from historical interrupted purges. + +### Enhancements + +- Add **Clean Orphaned Meta** link under **Settings → Advanced** for one-shot cleanup on already-bloated installs. +- Replace the legacy `wp_stream_auto_purge` WP-Cron event with a recurring Action Scheduler action. Run history and failures are now visible under **Tools → Scheduled Actions**. + +### Notes + +- The `wp_stream_auto_purge` action continues to fire once per purge cycle for backward compatibility. The legacy WP-Cron event of the same name is automatically unscheduled on upgrade. + ## 4.1.2 - February 19, 2026 ### Bug Fixes From ea7911b13203eba75fd4c70e992fef4cfecab292 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 12:45:27 +0530 Subject: [PATCH 12/25] fix(purge): harden missing-option fallback against filtered defaults Settings::get_defaults() runs every field through wp_stream_settings_option_fields, which Network::get_network_admin_fields() uses to strip 'records_ttl' from the per-site option's defaults set. Outside any admin context (Action Scheduler, WP-CLI, system cron) the per-site option_key is in effect, so the filtered defaults never contained general_records_ttl. Without this fallback the auto-purge silently no-ops on every install where the option is missing, defeating the whole point of fixing this on bloated sites. Hardcoded fallback to 30 days (the documented default on the settings field itself) when general_records_ttl is absent after the merge. Caught by section 9 of the e2e plan. Refs XWPENG-28 --- classes/class-admin.php | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/classes/class-admin.php b/classes/class-admin.php index c0e813a96..272fa948d 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -927,7 +927,22 @@ public function purge_scheduled_action() { $options = wp_parse_args( (array) get_option( 'wp_stream', array() ), $defaults ); } - if ( ! empty( $options['general_keep_records_indefinitely'] ) || empty( $options['general_records_ttl'] ) ) { + // Hardcoded TTL fallback. Settings::get_defaults() runs every settings + // field through the `wp_stream_settings_option_fields` filter, which + // Network::get_network_admin_fields() uses to strip the `records_ttl` + // field from the per-site option's defaults set. When this callback runs + // outside any admin context (Action Scheduler, WP-CLI, system cron), the + // per-site option_key is in effect, so the filtered defaults array does + // not contain general_records_ttl at all. Without this fallback the + // purge silently no-ops on every install where the option is missing, + // defeating the whole point of fixing this on bloated sites. + // Mirrors the 30-day default declared on the settings field itself + // (classes/class-settings.php, `records_ttl` field). + if ( empty( $options['general_records_ttl'] ) ) { + $options['general_records_ttl'] = 30; + } + + if ( ! empty( $options['general_keep_records_indefinitely'] ) ) { return; } From 0c18b4b339fc7f5caae5f79e204135995c19c4c2 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 13:21:54 +0530 Subject: [PATCH 13/25] feat(purge): consult wp_stream_is_large_records_table for small-table fast path The acceptance criteria require the auto-purge to honor the same wp_stream_is_large_records_table filter the manual reset uses, so ops only have one knob to tune table-size semantics. The recurring callback now counts eligible rows once, passes the count through Plugin::is_large_records_table(), and: - Small table (filter returns false): runs a single inline multi-table DELETE for the eligible rows, then enqueues the orphan reaper as a terminal AS action so the heal step stays observable in Tools \u2192 Scheduled Actions. - Large table (filter returns true, default for record_count > 1M): enqueues the batched chain as before. Tests: - New test_purge_scheduled_action_small_table_fast_path covering the inline-DELETE branch. - New test_purge_scheduled_action_large_table_uses_batched_chain exercising the batched branch via the filter. - Existing batched-path tests now opt into the chain explicitly via add_filter('wp_stream_is_large_records_table','__return_true'). Refs XWPENG-28 --- classes/class-admin.php | 53 +++++++++++++++++++++ tests/phpunit/test-class-admin.php | 74 ++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/classes/class-admin.php b/classes/class-admin.php index 272fa948d..16707e483 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -964,6 +964,59 @@ function_exists( 'as_has_scheduled_action' ) // blog_id = 0 means "all blogs" (network-activated path). $blog_id = $this->plugin->is_multisite_not_network_activated() ? (int) get_current_blog_id() : 0; + global $wpdb; + + // "Is this a large table?" decision matches the manual reset path + // (Admin::erase_stream_records()). When the table is small the cost + // of scheduling a chain (and waiting for AS to drain it on the next + // runner tick) exceeds the cost of a single inline DELETE. Only fall + // through to the batched chain when the filter says "yes, large". + if ( $blog_id > 0 ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $record_count = (int) $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(ID) FROM {$wpdb->stream} WHERE `blog_id` = %d", $blog_id ) + ); + } else { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $record_count = (int) $wpdb->get_var( "SELECT COUNT(ID) FROM {$wpdb->stream}" ); + } + + if ( ! $this->plugin->is_large_records_table( $record_count ) ) { + // Small-table fast path: one inline multi-table DELETE, then enqueue + // the orphan reaper as a one-shot async action so the heal step is + // still observable in Tools → Scheduled Actions. + if ( $blog_id > 0 ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( + $wpdb->prepare( + "DELETE `stream`, `meta` + FROM {$wpdb->stream} AS `stream` + LEFT JOIN {$wpdb->streammeta} AS `meta` + ON `meta`.`record_id` = `stream`.`ID` + WHERE `stream`.`created` < %s AND `stream`.`blog_id` = %d;", + $cutoff, + $blog_id + ) + ); + } else { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( + $wpdb->prepare( + "DELETE `stream`, `meta` + FROM {$wpdb->stream} AS `stream` + LEFT JOIN {$wpdb->streammeta} AS `meta` + ON `meta`.`record_id` = `stream`.`ID` + WHERE `stream`.`created` < %s;", + $cutoff + ) + ); + } + + as_enqueue_async_action( self::AUTO_PURGE_REAPER_ACTION, array(), self::AUTO_PURGE_GROUP ); + return; + } + + // Large-table path: batched chain. as_enqueue_async_action( self::AUTO_PURGE_BATCH_ACTION, array( diff --git a/tests/phpunit/test-class-admin.php b/tests/phpunit/test-class-admin.php index 4f806cf61..6bd3604a1 100644 --- a/tests/phpunit/test-class-admin.php +++ b/tests/phpunit/test-class-admin.php @@ -432,11 +432,73 @@ public function test_purge_scheduled_action_fires_bc_filter() { $this->assertSame( 1, $hits, 'wp_stream_auto_purge action must fire exactly once per recurring tick' ); } + public function test_purge_scheduled_action_small_table_fast_path() { + // Default: table is "small" (filter returns false for record_count <= 1M). + global $wpdb; + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + $ids = $this->seed_aged_records( 2, 5 ); + $this->set_records_ttl( 1 ); + + $this->admin->purge_scheduled_action(); + + // Inline DELETE must have run — rows are gone. + $remaining = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->stream} WHERE ID IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', + ...$ids + ) + ); + $this->assertSame( 0, $remaining, 'Small-table fast path must delete eligible rows inline' ); + + // No batched chain was enqueued. + $this->assertFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), + 'Small-table fast path must not enqueue a batched chain' + ); + + // Reaper still runs so the heal step is observable in Scheduled Actions. + $this->assertNotFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ), + 'Small-table fast path must still enqueue the orphan reaper' + ); + } + + public function test_purge_scheduled_action_large_table_uses_batched_chain() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + // Force the "large table" branch without seeding 1M rows. + add_filter( 'wp_stream_is_large_records_table', '__return_true' ); + + $this->seed_aged_records( 2, 5 ); + $this->set_records_ttl( 1 ); + + $this->admin->purge_scheduled_action(); + + $this->assertNotFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), + 'Large table must enqueue the batched chain' + ); + $this->assertFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ), + 'Reaper is enqueued by the terminal batch worker, not by the recurring callback' + ); + + remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); + } + public function test_purge_scheduled_action_enqueues_first_batch_with_snapshotted_cutoff() { if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); } + // Force the batched path so we can assert batch args. + add_filter( 'wp_stream_is_large_records_table', '__return_true' ); + $this->seed_aged_records( 1, 5 ); $this->set_records_ttl( 1 ); @@ -459,6 +521,8 @@ public function test_purge_scheduled_action_enqueues_first_batch_with_snapshotte $args['cutoff'], 'Cutoff must be a MySQL DATETIME string' ); + + remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); } public function test_purge_scheduled_action_respects_keep_indefinitely() { @@ -492,6 +556,9 @@ public function test_purge_scheduled_action_applies_defaults_when_option_missing delete_option( 'wp_stream' ); } + // Force the batched path so the assertion targets a batch enqueue. + add_filter( 'wp_stream_is_large_records_table', '__return_true' ); + // Seed records older than the default 30-day TTL. $this->seed_aged_records( 1, 31 ); @@ -501,12 +568,17 @@ public function test_purge_scheduled_action_applies_defaults_when_option_missing as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), 'Defaults (30-day TTL) must apply when the settings option is missing' ); + + remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); } public function test_purge_scheduled_action_overlap_guard_skips_when_batch_already_pending() { if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); } + // Overlap guard only applies to the batched chain path. + add_filter( 'wp_stream_is_large_records_table', '__return_true' ); + $this->seed_aged_records( 1, 5 ); $this->set_records_ttl( 1 ); @@ -531,6 +603,8 @@ public function test_purge_scheduled_action_overlap_guard_skips_when_batch_alrea 'ids' ); $this->assertCount( 1, $second, 'Overlap guard must prevent stacking a second batch chain' ); + + remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); } public function test_plugin_action_links() { From 735a4b4643f90bca1dcb2adf23000971a952ce6d Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 13:23:05 +0530 Subject: [PATCH 14/25] feat(purge): expose Admin::is_running_auto_purge() state probe Mirrors is_running_async_deletion() but checks the auto-purge group (batch + reaper). The recurring scheduler is intentionally excluded from the probe so it doesn't always report 'running' under normal operation. Settings UI uses this in the next commit to render an 'Auto-purge currently running' notice on Settings \u2192 Advanced. Refs XWPENG-28 --- classes/class-admin.php | 21 +++++++++++++++++ tests/phpunit/test-class-admin.php | 38 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/classes/class-admin.php b/classes/class-admin.php index 16707e483..cc56758a1 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -758,6 +758,27 @@ public static function is_running_async_deletion() { return as_has_scheduled_action( self::ASYNC_DELETION_ACTION ); } + /** + * Checks if any auto-purge action is currently scheduled or in-flight. + * + * Returns true when either the batched chain worker or the terminal + * orphan reaper is pending. The recurring scheduler is intentionally + * excluded — it is always pending under normal operation, so including + * it here would make the probe useless. Used by the Settings → Advanced + * UI to render an "Auto-purge currently running" notice. + * + * @return bool + */ + public static function is_running_auto_purge() { + if ( ! function_exists( 'as_has_scheduled_action' ) ) { + return false; + } + return ( + as_has_scheduled_action( self::AUTO_PURGE_BATCH_ACTION ) + || as_has_scheduled_action( self::AUTO_PURGE_REAPER_ACTION ) + ); + } + /** * Erases large records from the stream table. * diff --git a/tests/phpunit/test-class-admin.php b/tests/phpunit/test-class-admin.php index 6bd3604a1..13b9b6333 100644 --- a/tests/phpunit/test-class-admin.php +++ b/tests/phpunit/test-class-admin.php @@ -1055,6 +1055,44 @@ public function test_auto_purge_batch_scopes_to_blog_id_when_non_zero() { $this->assertSame( 1, $remaining_other, 'Per-blog scoping must leave sibling blogs untouched' ); } + public function test_is_running_auto_purge_reflects_chain_state() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + $this->assertFalse( + \WP_Stream\Admin::is_running_auto_purge(), + 'No scheduled actions means not running' + ); + + as_enqueue_async_action( + \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, + array( 'cutoff' => '2020-01-01 00:00:00', 'blog_id' => 0, 'last_entry' => 0 ), + \WP_Stream\Admin::AUTO_PURGE_GROUP + ); + $this->assertTrue( + \WP_Stream\Admin::is_running_auto_purge(), + 'A pending batch action means running' + ); + + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_enqueue_async_action( + \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION, + array(), + \WP_Stream\Admin::AUTO_PURGE_GROUP + ); + $this->assertTrue( + \WP_Stream\Admin::is_running_auto_purge(), + 'A pending reaper action means running' + ); + + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + $this->assertFalse( + \WP_Stream\Admin::is_running_auto_purge(), + 'Chain drained: not running' + ); + } + public function test_auto_purge_action_constants_exist() { $this->assertSame( 'stream_auto_purge_action', \WP_Stream\Admin::AUTO_PURGE_ACTION ); $this->assertSame( 'stream_auto_purge_batch_action', \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); From c6546ec3b96f0fb380790abe78d6bcce6761940c Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 13:25:39 +0530 Subject: [PATCH 15/25] feat(purge): hide manual Clean Orphaned Meta link while chain is running When the auto-purge chain is active (batch worker or reaper pending), the manual Settings \u2192 Advanced cleanup link is hidden and the field description is swapped to explain that the reaper will run as part of the active cycle. Mirrors how Reset Stream Database hides itself during async deletion. Refs XWPENG-28 --- classes/class-settings.php | 9 ++++----- tests/phpunit/test-class-admin.php | 6 +++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/classes/class-settings.php b/classes/class-settings.php index 3455b03e2..8b76b3b61 100644 --- a/classes/class-settings.php +++ b/classes/class-settings.php @@ -374,7 +374,7 @@ public function get_fields() { array( 'name' => 'clean_orphan_meta', 'title' => esc_html__( 'Clean Orphaned Meta', 'stream' ), - 'type' => 'link', + 'type' => Admin::is_running_auto_purge() ? 'none' : 'link', 'href' => add_query_arg( array( 'action' => 'wp_stream_clean_orphan_meta', @@ -382,10 +382,9 @@ public function get_fields() { ), admin_url( 'admin-ajax.php' ) ), - 'desc' => esc_html__( - 'Schedules an immediate background cleanup of stream_meta rows whose parent record is missing. Safe to run while Stream is in use; runs once via Action Scheduler.', - 'stream' - ), + 'desc' => Admin::is_running_auto_purge() + ? esc_html__( 'Auto-purge is currently running. The orphan reaper will execute as part of that cycle; the manual cleanup link is hidden to avoid duplicating the work.', 'stream' ) + : esc_html__( 'Schedules an immediate background cleanup of stream_meta rows whose parent record is missing. Safe to run while Stream is in use; runs once via Action Scheduler.', 'stream' ), 'default' => 0, 'sticky' => 'bottom', ), diff --git a/tests/phpunit/test-class-admin.php b/tests/phpunit/test-class-admin.php index 13b9b6333..a33052ad6 100644 --- a/tests/phpunit/test-class-admin.php +++ b/tests/phpunit/test-class-admin.php @@ -1067,7 +1067,11 @@ public function test_is_running_auto_purge_reflects_chain_state() { as_enqueue_async_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, - array( 'cutoff' => '2020-01-01 00:00:00', 'blog_id' => 0, 'last_entry' => 0 ), + array( + 'cutoff' => '2020-01-01 00:00:00', + 'blog_id' => 0, + 'last_entry' => 0, + ), \WP_Stream\Admin::AUTO_PURGE_GROUP ); $this->assertTrue( From 9d6e0ea6bc55e9f3795649af7ec553299221f2b8 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 13:27:37 +0530 Subject: [PATCH 16/25] test(e2e): cover purge-active and purge-idle UI states Two new specs exercise the Settings \u2192 Advanced field behaviour driven by Admin::is_running_auto_purge(): - Active state: seed a pending reaper action via wp-cli, assert the Clean Orphaned Meta link is removed from the DOM and the swapped description ('Auto-purge is currently running') is visible. - Idle state: drain the seeded action and assert the link is restored. Both specs use a small wp-cli helper (execSync into the wordpress container) to seed/clear AS state, keeping the test free of any browser-side timing on the AS worker. Refs XWPENG-28 --- tests/e2e/admin-orphan-cleanup.spec.js | 88 +++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/tests/e2e/admin-orphan-cleanup.spec.js b/tests/e2e/admin-orphan-cleanup.spec.js index 103a6f31b..2f5fd6d38 100644 --- a/tests/e2e/admin-orphan-cleanup.spec.js +++ b/tests/e2e/admin-orphan-cleanup.spec.js @@ -1,8 +1,16 @@ /** * WordPress dependencies */ +/** + * External dependencies + */ +import { execSync } from 'node:child_process'; import { test, expect } from '@wordpress/e2e-test-utils-playwright'; +/** + * Node dependencies + */ + /** * Settings → Advanced manual "Clean Orphaned Meta" link. * @@ -10,9 +18,46 @@ import { test, expect } from '@wordpress/e2e-test-utils-playwright'; * with the expected action + nonce parameters, and that following the link * redirects back to the Stream settings page with a confirmation marker in * the URL. + * + * Also covers the running-state UX: when the auto-purge chain is active the + * link is hidden and the field description is swapped to explain the reaper + * will run as part of the active cycle. */ const ADMIN = 'http://stream.wpenv.net/wp-admin'; +/** + * Run a PHP snippet inside the wordpress container. + * + * @param {string} php The body of the eval call (no ` { const ADVANCED_TAB_URL = `${ ADMIN }/network/admin.php?page=wp_stream_network_settings&tab=advanced`; test.describe( 'Manual orphan-meta cleanup link', () => { - test( 'is visible on the Advanced tab', async () => { + test.beforeEach( () => { + // Idle state for the common-case assertions. + clearAutoPurgeState(); + } ); + + test( 'is visible on the Advanced tab (idle state)', async () => { await page.goto( ADVANCED_TAB_URL ); const link = page.getByRole( 'link', { name: /Clean Orphaned Meta/i } ); @@ -73,4 +123,40 @@ test.describe( 'Manual orphan-meta cleanup link', () => { link.click(), ] ); } ); + + test( 'hides the link while the auto-purge chain is running', async () => { + // Simulate an active chain by enqueuing a reaper action. + seedRunningAutoPurge(); + + try { + await page.goto( ADVANCED_TAB_URL ); + + // The link MUST NOT be rendered. + const link = page.getByRole( 'link', { + name: /Clean Orphaned Meta/i, + } ); + await expect( link ).toHaveCount( 0 ); + + // The replacement description text MUST be visible somewhere + // in the settings form. We assert on a stable substring rather + // than the full sentence. + await expect( + page.getByText( /Auto-purge is currently running/i ), + ).toBeVisible(); + } finally { + clearAutoPurgeState(); + } + } ); + + test( 'restores the link after the chain drains (idle state again)', async () => { + // Seed running, drain, confirm idle UX restored. + seedRunningAutoPurge(); + clearAutoPurgeState(); + + await page.goto( ADVANCED_TAB_URL ); + const link = page.getByRole( 'link', { + name: /Clean Orphaned Meta/i, + } ); + await expect( link ).toBeVisible(); + } ); } ); From b4c8f287fe58486c26dedf23550df40fbbcfc1ec Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 13:34:16 +0530 Subject: [PATCH 17/25] test(e2e): harden orphan-cleanup spec against activation races - wpEval helper now swallows non-zero exits from the wordpress container instead of throwing into the test runner. State seeding is best-effort; the test's own assertions are the source of truth. - beforeAll waits for the post-activation navigation and explicitly confirms 'Network Deactivate Stream' is visible before any test runs, failing fast instead of letting every test silently hit the 'Sorry, you are not allowed to access this page' redirect when a prior suite leaves Stream deactivated. In-isolation: spec is stable across 3 consecutive runs. Pre-existing cross-spec activation races (editor-new-post, admin-ui-smoke) remain out of scope here. Refs XWPENG-28 --- tests/e2e/admin-orphan-cleanup.spec.js | 35 ++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/tests/e2e/admin-orphan-cleanup.spec.js b/tests/e2e/admin-orphan-cleanup.spec.js index 2f5fd6d38..0f5eebd13 100644 --- a/tests/e2e/admin-orphan-cleanup.spec.js +++ b/tests/e2e/admin-orphan-cleanup.spec.js @@ -28,16 +28,24 @@ const ADMIN = 'http://stream.wpenv.net/wp-admin'; /** * Run a PHP snippet inside the wordpress container. * + * Best-effort: returns null on container errors (e.g. Stream not yet active + * during a parallel suite's bootstrap). State seeding is auxiliary; the + * test's own assertions are the source of truth. + * * @param {string} php The body of the eval call (no ` { page = await browser.newPage(); // The setup fixture deactivates Stream network-wide before the suite. - // Reactivate it so the Stream admin pages are reachable. + // Earlier specs in the suite may also have deactivated it. Reactivate + // and wait for the post-activation redirect so subsequent navigations + // see a fully-activated plugin. await page.goto( `${ ADMIN }/network/plugins.php` ); const activate = page.getByLabel( 'Network Activate Stream' ); if ( await activate.isVisible() ) { - await activate.click(); + await Promise.all( [ + page.waitForURL( /plugins\.php/ ), + activate.click(), + ] ); } + + // Belt-and-suspenders: confirm via the page DOM that the deactivate + // label is now present. If it isn't, fail fast rather than letting + // every test silently hit "Sorry, you are not allowed to access this page". + await page.goto( `${ ADMIN }/network/plugins.php` ); + await expect( + page.getByLabel( 'Network Deactivate Stream' ), + ).toBeVisible(); } ); test.afterAll( async () => { From 19b5db3a16de5a5ff38ece5cdc9598f77c5f54bd Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 13:34:55 +0530 Subject: [PATCH 18/25] docs(changelog): note small-table fast path + running-state UX Refs XWPENG-28 --- changelog.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index 9df2f7a62..7bbe4d3a1 100644 --- a/changelog.md +++ b/changelog.md @@ -9,8 +9,9 @@ ### Enhancements -- Add **Clean Orphaned Meta** link under **Settings → Advanced** for one-shot cleanup on already-bloated installs. +- Add **Clean Orphaned Meta** link under **Settings → Advanced** for one-shot cleanup on already-bloated installs. The link is hidden while the auto-purge chain is running and reappears once the chain drains. - Replace the legacy `wp_stream_auto_purge` WP-Cron event with a recurring Action Scheduler action. Run history and failures are now visible under **Tools → Scheduled Actions**. +- Auto-purge consults the existing `wp_stream_is_large_records_table` filter (default threshold: >1M rows) so small tables get a single inline DELETE while bloated tables go through the batched chain — same knob as the manual reset path. ### Notes From 7324212ce4471bfd9091d0f24c7394f64927830e Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 13:41:16 +0530 Subject: [PATCH 19/25] fix(e2e): drop unused catch binding for ESLint CI runs ESLint with no-unused-vars; the wpEval helper's catch block declared 'err' but never referenced it. Use the optional binding form 'catch {}' which is supported on the runner's Node version (22+). Refs XWPENG-28 --- tests/e2e/admin-orphan-cleanup.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/admin-orphan-cleanup.spec.js b/tests/e2e/admin-orphan-cleanup.spec.js index 0f5eebd13..9497b83ff 100644 --- a/tests/e2e/admin-orphan-cleanup.spec.js +++ b/tests/e2e/admin-orphan-cleanup.spec.js @@ -43,7 +43,7 @@ function wpEval( php ) { `docker compose run --rm --user $(id -u) wordpress -- wp eval '${ escaped }'`, { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'ignore' ] }, ).trim(); - } catch ( err ) { + } catch { return null; } } From b7e433ae2dd7b3115e7aceac110505a0a0694c5f Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 13:49:23 +0530 Subject: [PATCH 20/25] test: drop two low-value auto-purge unit tests - test_auto_purge_action_constants_exist was a tautology: it asserted that four constants equal the string values they are declared with. The test catches nothing that static analysis or a typo in the consumer wouldn't catch. - test_auto_purge_batch_respects_wp_stream_batch_size_filter only verified the filter was *called* (invocation counter), not that the returned value affected behaviour. test_auto_purge_batch_deletes_ window_and_chains_next_batch already drives the chain with a custom batch_size and asserts on the resulting chain, which is the functional contract that matters. No coverage lost. Refs XWPENG-28 --- tests/phpunit/test-class-admin.php | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/tests/phpunit/test-class-admin.php b/tests/phpunit/test-class-admin.php index a33052ad6..3581d8af2 100644 --- a/tests/phpunit/test-class-admin.php +++ b/tests/phpunit/test-class-admin.php @@ -957,27 +957,6 @@ public function test_auto_purge_batch_enqueues_reaper_when_no_rows_remain() { ); } - public function test_auto_purge_batch_respects_wp_stream_batch_size_filter() { - $invocations = 0; - add_filter( - 'wp_stream_batch_size', - function () use ( &$invocations ) { - ++$invocations; - return 1; - } - ); - - $this->seed_aged_records( 1, 5 ); - - $cutoff = ( new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ) ) - ->sub( \DateInterval::createFromDateString( '1 days' ) ) - ->format( 'Y-m-d H:i:s' ); - $this->admin->auto_purge_batch( $cutoff, 0 ); - - $this->assertGreaterThanOrEqual( 1, $invocations, 'wp_stream_batch_size filter must be consulted' ); - remove_all_filters( 'wp_stream_batch_size' ); - } - public function test_auto_purge_batch_chain_strides_down_by_window() { global $wpdb; @@ -1097,13 +1076,6 @@ public function test_is_running_auto_purge_reflects_chain_state() { ); } - public function test_auto_purge_action_constants_exist() { - $this->assertSame( 'stream_auto_purge_action', \WP_Stream\Admin::AUTO_PURGE_ACTION ); - $this->assertSame( 'stream_auto_purge_batch_action', \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); - $this->assertSame( 'stream_auto_purge_reaper_action', \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); - $this->assertSame( 'stream-auto-purge', \WP_Stream\Admin::AUTO_PURGE_GROUP ); - } - public function test_register_hooks_auto_purge_action_scheduler_callbacks() { // The Admin instance is constructed by the test bootstrap, so register() // has already run. Just assert the actions are wired up. From e1779d3bd619f2e3f5ac152f64cfeb0797772f4f Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 15:49:38 +0530 Subject: [PATCH 21/25] review: address code review feedback 1. auto_purge_batch() now throws InvalidArgumentException on empty cutoff instead of silently returning. A bare 'return' caused AS to mark the action complete; throwing makes AS log it as failed and surface it in Tools > Scheduled Actions. New PHPUnit test covers the throw path. In practice this branch is unreachable because purge_scheduled_action() always populates the cutoff, but the guard exists for third-party code that may enqueue with bad input. 2. wp_ajax_clean_orphan_meta() now uses $this->settings_cap to match the rest of the file (wp_ajax_reset, ajax_filters). The bare WP_STREAM_SETTINGS_CAPABILITY constant could break installs that override the capability via the property after construction. 3. The Settings 'Clean Orphaned Meta' field now calls Admin::is_running_auto_purge() once per render instead of twice. Extracted the field-building logic into a small helper so the state probe runs once and both the 'type' and 'desc' branches reuse the result. Refs XWPENG-28 --- classes/class-admin.php | 12 ++++++-- classes/class-settings.php | 49 +++++++++++++++++++----------- tests/phpunit/test-class-admin.php | 5 +++ 3 files changed, 46 insertions(+), 20 deletions(-) diff --git a/classes/class-admin.php b/classes/class-admin.php index cc56758a1..d74cc03a8 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -1062,6 +1062,7 @@ function_exists( 'as_has_scheduled_action' ) * first batch in a chain. The next SELECT uses `ID < last_entry` * when non-zero, guaranteeing forward progress even on tables * that grow rapidly during the chain. + * @throws \InvalidArgumentException When $cutoff is empty (signals AS to mark the action as failed). * @return void */ public function auto_purge_batch( $cutoff, $blog_id = 0, $last_entry = 0 ) { @@ -1072,9 +1073,14 @@ public function auto_purge_batch( $cutoff, $blog_id = 0, $last_entry = 0 ) { $last_entry = (int) $last_entry; // Defensive: a malformed cutoff would otherwise translate to a no-op - // DELETE that still busies the DB. Refuse and let AS retry the action. + // DELETE that still busies the DB. Throw so Action Scheduler marks + // the action as failed (and visible in Tools → Scheduled Actions) + // rather than silently completing. In practice this is unreachable + // because purge_scheduled_action() always populates the cutoff arg + // and AS args are immutable; the guard exists for third-party code + // that may enqueue the action with bad input. if ( '' === $cutoff ) { - return; + throw new \InvalidArgumentException( 'auto_purge_batch requires a non-empty cutoff.' ); } /** @@ -1220,7 +1226,7 @@ public function auto_purge_reaper() { * @return bool|void True under tests; otherwise redirects and exits. */ public function wp_ajax_clean_orphan_meta() { - if ( ! current_user_can( WP_STREAM_SETTINGS_CAPABILITY ) ) { + if ( ! current_user_can( $this->settings_cap ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'stream' ), 403 ); } diff --git a/classes/class-settings.php b/classes/class-settings.php index 8b76b3b61..6bcd52814 100644 --- a/classes/class-settings.php +++ b/classes/class-settings.php @@ -371,23 +371,7 @@ public function get_fields() { 'default' => 0, 'sticky' => 'bottom', ), - array( - 'name' => 'clean_orphan_meta', - 'title' => esc_html__( 'Clean Orphaned Meta', 'stream' ), - 'type' => Admin::is_running_auto_purge() ? 'none' : 'link', - 'href' => add_query_arg( - array( - 'action' => 'wp_stream_clean_orphan_meta', - 'wp_stream_nonce_clean_orphan_meta' => wp_create_nonce( 'stream_nonce_clean_orphan_meta' ), - ), - admin_url( 'admin-ajax.php' ) - ), - 'desc' => Admin::is_running_auto_purge() - ? esc_html__( 'Auto-purge is currently running. The orphan reaper will execute as part of that cycle; the manual cleanup link is hidden to avoid duplicating the work.', 'stream' ) - : esc_html__( 'Schedules an immediate background cleanup of stream_meta rows whose parent record is missing. Safe to run while Stream is in use; runs once via Action Scheduler.', 'stream' ), - 'default' => 0, - 'sticky' => 'bottom', - ), + $this->build_clean_orphan_meta_field(), ), ), ); @@ -444,6 +428,37 @@ public function get_fields() { return $this->fields; } + /** + * Build the "Clean Orphaned Meta" settings field definition. + * + * Extracted so the auto-purge running-state check + * ({@see Admin::is_running_auto_purge()}) is evaluated once per render + * instead of once per field property. + * + * @return array + */ + private function build_clean_orphan_meta_field() { + $is_running = Admin::is_running_auto_purge(); + + return array( + 'name' => 'clean_orphan_meta', + 'title' => esc_html__( 'Clean Orphaned Meta', 'stream' ), + 'type' => $is_running ? 'none' : 'link', + 'href' => add_query_arg( + array( + 'action' => 'wp_stream_clean_orphan_meta', + 'wp_stream_nonce_clean_orphan_meta' => wp_create_nonce( 'stream_nonce_clean_orphan_meta' ), + ), + admin_url( 'admin-ajax.php' ) + ), + 'desc' => $is_running + ? esc_html__( 'Auto-purge is currently running. The orphan reaper will execute as part of that cycle; the manual cleanup link is hidden to avoid duplicating the work.', 'stream' ) + : esc_html__( 'Schedules an immediate background cleanup of stream_meta rows whose parent record is missing. Safe to run while Stream is in use; runs once via Action Scheduler.', 'stream' ), + 'default' => 0, + 'sticky' => 'bottom', + ); + } + /** * Returns a list of options based on the current screen. * diff --git a/tests/phpunit/test-class-admin.php b/tests/phpunit/test-class-admin.php index 3581d8af2..0695c13dd 100644 --- a/tests/phpunit/test-class-admin.php +++ b/tests/phpunit/test-class-admin.php @@ -931,6 +931,11 @@ function () { remove_all_filters( 'wp_stream_batch_size' ); } + public function test_auto_purge_batch_throws_on_empty_cutoff() { + $this->expectException( \InvalidArgumentException::class ); + $this->admin->auto_purge_batch( '', 0, 0 ); + } + public function test_auto_purge_batch_enqueues_reaper_when_no_rows_remain() { global $wpdb; if ( function_exists( 'as_unschedule_all_actions' ) ) { From 1d0a4546e552a4bd455d402029fc5bee19676250 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Wed, 20 May 2026 10:36:41 +0530 Subject: [PATCH 22/25] review: address second round of code review feedback 1. Settings::updated_option_ttl_remove_records() now triggers an immediate purge directly. Previously it relied on the legacy wp_stream_auto_purge action being hooked to purge_scheduled_action, which this PR severed. Without this fix, shortening the TTL did not take effect until the next 12h recurring tick. 2. TTL fallback uses isset() instead of empty(), so an explicit '0' set via CLI/SQL is no longer silently overridden to 30. Added an explicit short-circuit: a non-positive TTL bails out of the cycle entirely. The UI enforces min=1; the only paths to 0 are operator error, and bailing out (records stop being purged) is a less destructive failure mode than honoring it (records get wiped repeatedly every 12h). 3. Overlap guard now reuses Admin::is_running_auto_purge(), so a pending reaper also blocks a new chain. Previously only a pending batch action blocked the guard, leaving a small window between chain completion and reaper completion where a new chain could stack. Three new PHPUnit tests cover each behaviour. Refs XWPENG-28 --- classes/class-admin.php | 34 ++++++++------ classes/class-settings.php | 13 +++++- tests/phpunit/test-class-admin.php | 74 ++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 14 deletions(-) diff --git a/classes/class-admin.php b/classes/class-admin.php index d74cc03a8..68165f97a 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -948,18 +948,17 @@ public function purge_scheduled_action() { $options = wp_parse_args( (array) get_option( 'wp_stream', array() ), $defaults ); } - // Hardcoded TTL fallback. Settings::get_defaults() runs every settings - // field through the `wp_stream_settings_option_fields` filter, which + // TTL fallback. Settings::get_defaults() runs every settings field + // through the `wp_stream_settings_option_fields` filter, which // Network::get_network_admin_fields() uses to strip the `records_ttl` // field from the per-site option's defaults set. When this callback runs // outside any admin context (Action Scheduler, WP-CLI, system cron), the // per-site option_key is in effect, so the filtered defaults array does - // not contain general_records_ttl at all. Without this fallback the - // purge silently no-ops on every install where the option is missing, - // defeating the whole point of fixing this on bloated sites. - // Mirrors the 30-day default declared on the settings field itself - // (classes/class-settings.php, `records_ttl` field). - if ( empty( $options['general_records_ttl'] ) ) { + // not contain general_records_ttl at all. Apply the documented 30-day + // default (classes/class-settings.php, `records_ttl` field) only when + // the key is genuinely missing, so an operator who set the value via + // CLI/SQL keeps their explicit choice. + if ( ! isset( $options['general_records_ttl'] ) ) { $options['general_records_ttl'] = 30; } @@ -967,11 +966,20 @@ public function purge_scheduled_action() { return; } - // Overlap guard: if a previous chain is still draining, don't stack a new one. - if ( - function_exists( 'as_has_scheduled_action' ) - && as_has_scheduled_action( self::AUTO_PURGE_BATCH_ACTION ) - ) { + // Refuse to purge with a non-positive TTL. The UI enforces min=1, but + // CLI/SQL can set 0 or a negative integer. Honoring those would mean + // "delete every record on every cycle", which has no legitimate use + // case (keep_records_indefinitely covers the opposite extreme). + // Bailing out makes operator error visible (records stop being purged) + // instead of catastrophic (records get wiped repeatedly). + if ( (int) $options['general_records_ttl'] < 1 ) { + return; + } + + // Overlap guard: if any auto-purge action (batch worker or reaper) is + // pending, don't stack a new chain. Reuses the same probe used by the + // Settings UI so the two views of "running" agree. + if ( self::is_running_auto_purge() ) { return; } diff --git a/classes/class-settings.php b/classes/class-settings.php index 6bcd52814..ceb26d365 100644 --- a/classes/class-settings.php +++ b/classes/class-settings.php @@ -1235,9 +1235,20 @@ public function updated_option_ttl_remove_records( $old_value, $new_value ) { if ( $ttl_after < $ttl_before ) { /** - * Action assists in purging when TTL is shortened + * Fires when the records TTL is shortened. + * + * Preserved for backward compatibility with third-party code that + * hooked this action in Stream <= 4.1.x. The auto-purge itself + * no longer listens to this hook (it was migrated to Action + * Scheduler), so trigger the purge directly below. */ do_action( 'wp_stream_auto_purge' ); + + // Trigger an immediate auto-purge cycle so the shortened TTL + // takes effect now instead of at the next 12h recurring tick. + if ( isset( $this->plugin->admin ) ) { + $this->plugin->admin->purge_scheduled_action(); + } } } diff --git a/tests/phpunit/test-class-admin.php b/tests/phpunit/test-class-admin.php index 0695c13dd..afecfddb1 100644 --- a/tests/phpunit/test-class-admin.php +++ b/tests/phpunit/test-class-admin.php @@ -607,6 +607,80 @@ public function test_purge_scheduled_action_overlap_guard_skips_when_batch_alrea remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); } + public function test_purge_scheduled_action_overlap_guard_skips_when_reaper_pending() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + add_filter( 'wp_stream_is_large_records_table', '__return_true' ); + + // Simulate the post-chain state: only the reaper is left pending. + as_enqueue_async_action( + \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION, + array(), + \WP_Stream\Admin::AUTO_PURGE_GROUP + ); + + $this->seed_aged_records( 1, 5 ); + $this->set_records_ttl( 1 ); + + $this->admin->purge_scheduled_action(); + + $this->assertFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), + 'Overlap guard must skip when only the reaper is pending' + ); + + remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); + } + + public function test_purge_scheduled_action_bails_when_ttl_is_zero_or_negative() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + } + add_filter( 'wp_stream_is_large_records_table', '__return_true' ); + + $this->seed_aged_records( 1, 5 ); + + // TTL=0 (operator error via CLI/SQL). Must not delete anything. + if ( is_multisite() && is_plugin_active_for_network( $this->plugin->locations['plugin'] ) ) { + update_site_option( 'wp_stream_network', array( 'general_records_ttl' => '0' ) ); + } else { + update_option( 'wp_stream', array( 'general_records_ttl' => '0' ) ); + } + + $this->admin->purge_scheduled_action(); + + $this->assertFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), + 'Non-positive TTL must short-circuit the recurring callback' + ); + + remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); + } + + public function test_settings_ttl_shortened_triggers_immediate_purge() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + } + add_filter( 'wp_stream_is_large_records_table', '__return_true' ); + + $this->seed_aged_records( 1, 5 ); + + // Simulate the option-changed event: TTL shortened from 30 to 7. + $this->plugin->settings->updated_option_ttl_remove_records( + array( 'general_records_ttl' => 30 ), + array( 'general_records_ttl' => 7 ) + ); + + $this->assertNotFalse( + as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), + 'Shortening TTL must trigger an immediate purge cycle' + ); + + remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); + } + public function test_plugin_action_links() { $links = array( 'Disconnect' ); $file = plugin_basename( $this->plugin->locations['dir'] . 'stream.php' ); From e7524fc4f1edf3f9b35a951895961a44fb3bc904 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Wed, 20 May 2026 11:20:05 +0530 Subject: [PATCH 23/25] review: address third round of code review feedback - Move wp_stream_auto_purge BC action to after all bail-out checks so it fires only when a purge actually runs (was firing on every recurring tick regardless of whether work happened). - Extend is_running_auto_purge() to also check IN-PROGRESS actions via as_get_scheduled_actions() so the overlap guard cannot let a second chain stack against rows the running batch worker is still touching. - Settings TTL-shortened path now enqueues AUTO_PURGE_ACTION via as_enqueue_async_action() so the immediate purge serializes through Action Scheduler instead of bypassing the overlap guard with an inline call. Inline fallback retained for when AS is unavailable. - Render an admin notice for wp_stream_message=orphan_meta_cleanup_scheduled on both admin_notices and network_admin_notices so the post-redirect UX is actually visible (was a half-built feature: redirect happened, no notice rendered). - E2E wpEval(): switch from 'docker compose run --rm' to 'docker compose exec -T' to attach to the long-lived container (~3-5s saved per call) and surface failures via console.warn instead of silently swallowing errors. - PHPUnit: add coverage for both the BC-action-suppressed-on-bailout path and the in-progress-action overlap guard. Update the TTL-shortened test to assert the AS enqueue path. --- classes/class-admin.php | 104 ++++++++++++++++++++----- classes/class-settings.php | 18 ++++- tests/e2e/admin-orphan-cleanup.spec.js | 20 +++-- tests/phpunit/test-class-admin.php | 85 ++++++++++++++++++-- 4 files changed, 191 insertions(+), 36 deletions(-) diff --git a/classes/class-admin.php b/classes/class-admin.php index 68165f97a..ba3797db8 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -235,6 +235,11 @@ public function __construct( $plugin ) { array( $this, 'wp_ajax_clean_orphan_meta' ) ); + // Render confirmation notices keyed by the wp_stream_message query + // arg set on post-action redirects (e.g. orphan_meta_cleanup_scheduled). + add_action( 'admin_notices', array( $this, 'maybe_display_message' ) ); + add_action( 'network_admin_notices', array( $this, 'maybe_display_message' ) ); + // Auto purge setup (Action Scheduler). add_action( 'wp_loaded', array( $this, 'purge_schedule_setup' ) ); add_action( @@ -762,21 +767,43 @@ public static function is_running_async_deletion() { * Checks if any auto-purge action is currently scheduled or in-flight. * * Returns true when either the batched chain worker or the terminal - * orphan reaper is pending. The recurring scheduler is intentionally - * excluded — it is always pending under normal operation, so including - * it here would make the probe useless. Used by the Settings → Advanced - * UI to render an "Auto-purge currently running" notice. + * orphan reaper is pending OR running. The recurring scheduler is + * intentionally excluded — it is always pending under normal operation, + * so including it here would make the probe useless. Used by the + * Settings → Advanced UI to render an "Auto-purge currently running" + * notice and by the recurring callback as an overlap guard. + * + * Checks both PENDING and IN-PROGRESS statuses so a chain that is + * mid-execution (e.g. the batch worker is currently running and has not + * yet enqueued the next batch) still reports as running. Without the + * RUNNING check the overlap guard can let a second parallel chain stack + * against the same rows. * * @return bool */ public static function is_running_auto_purge() { - if ( ! function_exists( 'as_has_scheduled_action' ) ) { + if ( ! function_exists( 'as_get_scheduled_actions' ) ) { return false; } - return ( - as_has_scheduled_action( self::AUTO_PURGE_BATCH_ACTION ) - || as_has_scheduled_action( self::AUTO_PURGE_REAPER_ACTION ) - ); + + foreach ( array( self::AUTO_PURGE_BATCH_ACTION, self::AUTO_PURGE_REAPER_ACTION ) as $hook ) { + $found = as_get_scheduled_actions( + array( + 'hook' => $hook, + 'status' => array( + \ActionScheduler_Store::STATUS_PENDING, + \ActionScheduler_Store::STATUS_RUNNING, + ), + 'per_page' => 1, + ), + 'ids' + ); + if ( ! empty( $found ) ) { + return true; + } + } + + return false; } /** @@ -922,16 +949,6 @@ protected function delete_orphaned_meta() { * @return void */ public function purge_scheduled_action() { - /** - * Fires once per auto-purge cycle, before any deletion is enqueued. - * - * Preserved for backward compatibility with consumers that hooked the - * legacy WP-Cron event of the same name in Stream <= 4.1.x. - * - * @since 1.0.0 - */ - do_action( 'wp_stream_auto_purge' ); - // Don't purge when in Network Admin unless Stream is network activated. if ( $this->plugin->is_multisite_not_network_activated() @@ -977,12 +994,25 @@ public function purge_scheduled_action() { } // Overlap guard: if any auto-purge action (batch worker or reaper) is - // pending, don't stack a new chain. Reuses the same probe used by the - // Settings UI so the two views of "running" agree. + // pending or in-progress, don't stack a new chain. Reuses the same + // probe used by the Settings UI so the two views of "running" agree. if ( self::is_running_auto_purge() ) { return; } + /** + * Fires once per auto-purge cycle, after all bail-out checks pass and + * immediately before deletion work is enqueued. + * + * Preserved for backward compatibility with consumers that hooked the + * legacy WP-Cron event of the same name in Stream <= 4.1.x. Note that + * since 4.2.0 this fires only when a purge is actually about to run — + * it no longer fires on every cron tick regardless of whether work + * happens. Hook into the recurring AS action (Admin::AUTO_PURGE_ACTION) + * directly if you need the older "every tick" semantics. + */ + do_action( 'wp_stream_auto_purge' ); + // Snapshot the UTC cutoff once per recurring tick. Each batch in this // chain operates against this fixed cutoff so the chain is finite. $days = (int) $options['general_records_ttl']; @@ -1268,6 +1298,38 @@ public function wp_ajax_clean_orphan_meta() { exit; } + /** + * Render admin notices for post-action redirects. + * + * Reads `wp_stream_message` from the query string and renders a matching + * notice. Used to surface "Clean Orphaned Meta" confirmation after the + * Ajax handler redirects back to Settings → Advanced. + * + * @return void + */ + public function maybe_display_message() { + $message = wp_stream_filter_input( INPUT_GET, 'wp_stream_message' ); + if ( empty( $message ) ) { + return; + } + + $notices = array( + 'orphan_meta_cleanup_scheduled' => __( + 'Orphaned meta cleanup scheduled. Progress is visible under Tools → Scheduled Actions.', + 'stream' + ), + ); + + if ( ! isset( $notices[ $message ] ) ) { + return; + } + + printf( + '

%s

', + esc_html( $notices[ $message ] ) + ); + } + /** * Returns the admin action links. * diff --git a/classes/class-settings.php b/classes/class-settings.php index ceb26d365..25490ad09 100644 --- a/classes/class-settings.php +++ b/classes/class-settings.php @@ -1246,7 +1246,23 @@ public function updated_option_ttl_remove_records( $old_value, $new_value ) { // Trigger an immediate auto-purge cycle so the shortened TTL // takes effect now instead of at the next 12h recurring tick. - if ( isset( $this->plugin->admin ) ) { + // + // Enqueue the recurring AS action as a one-shot async action so + // the work serializes through Action Scheduler. Calling + // purge_scheduled_action() inline here would bypass the overlap + // guard's view of "in-flight" work (the current request is not a + // scheduled action) and could stack a parallel chain when a + // real chain is already running. Falls back to inline if AS + // isn't loaded (defensive — Plugin::__construct() loads it). + if ( function_exists( 'as_enqueue_async_action' ) ) { + if ( ! \WP_Stream\Admin::is_running_auto_purge() ) { + as_enqueue_async_action( + \WP_Stream\Admin::AUTO_PURGE_ACTION, + array(), + \WP_Stream\Admin::AUTO_PURGE_GROUP + ); + } + } elseif ( isset( $this->plugin->admin ) ) { $this->plugin->admin->purge_scheduled_action(); } } diff --git a/tests/e2e/admin-orphan-cleanup.spec.js b/tests/e2e/admin-orphan-cleanup.spec.js index 9497b83ff..a89c989f1 100644 --- a/tests/e2e/admin-orphan-cleanup.spec.js +++ b/tests/e2e/admin-orphan-cleanup.spec.js @@ -26,24 +26,32 @@ import { test, expect } from '@wordpress/e2e-test-utils-playwright'; const ADMIN = 'http://stream.wpenv.net/wp-admin'; /** - * Run a PHP snippet inside the wordpress container. + * Run a PHP snippet inside the long-lived wordpress container. + * + * Uses `docker compose exec` (not `run`) so we attach to the running + * container instead of spinning a fresh one per call — `run` is ~3-5s of + * overhead per invocation and accumulates fast across this suite. * * Best-effort: returns null on container errors (e.g. Stream not yet active * during a parallel suite's bootstrap). State seeding is auxiliary; the - * test's own assertions are the source of truth. + * test's own assertions are the source of truth — but surface failures via + * console so silent breakage during CI is at least visible in logs. * * @param {string} php The body of the eval call (no `assertCount( 1, $ids, 'purge_schedule_setup() must be idempotent' ); } - public function test_purge_scheduled_action_fires_bc_filter() { + public function test_purge_scheduled_action_fires_bc_action_once_when_work_runs() { if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); } $hits = 0; @@ -429,7 +430,36 @@ public function test_purge_scheduled_action_fires_bc_filter() { $this->admin->purge_scheduled_action(); remove_action( 'wp_stream_auto_purge', $listener ); - $this->assertSame( 1, $hits, 'wp_stream_auto_purge action must fire exactly once per recurring tick' ); + $this->assertSame( 1, $hits, 'wp_stream_auto_purge action must fire exactly once per recurring tick when work runs' ); + } + + public function test_purge_scheduled_action_does_not_fire_bc_action_when_cycle_bails() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + + // keep_records_indefinitely=1 is one of the bail-out conditions. + if ( is_multisite() && is_plugin_active_for_network( $this->plugin->locations['plugin'] ) ) { + update_site_option( 'wp_stream_network', array( 'general_keep_records_indefinitely' => 1 ) ); + } else { + update_option( 'wp_stream', array( 'general_keep_records_indefinitely' => 1 ) ); + } + + $hits = 0; + $listener = function () use ( &$hits ) { + ++$hits; + }; + add_action( 'wp_stream_auto_purge', $listener ); + + $this->admin->purge_scheduled_action(); + + remove_action( 'wp_stream_auto_purge', $listener ); + $this->assertSame( + 0, + $hits, + 'wp_stream_auto_purge BC action must not fire when the cycle bails out (keep_records_indefinitely)' + ); } public function test_purge_scheduled_action_small_table_fast_path() { @@ -661,9 +691,10 @@ public function test_purge_scheduled_action_bails_when_ttl_is_zero_or_negative() public function test_settings_ttl_shortened_triggers_immediate_purge() { if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_ACTION ); as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); } - add_filter( 'wp_stream_is_large_records_table', '__return_true' ); $this->seed_aged_records( 1, 5 ); @@ -673,12 +704,20 @@ public function test_settings_ttl_shortened_triggers_immediate_purge() { array( 'general_records_ttl' => 7 ) ); - $this->assertNotFalse( - as_next_scheduled_action( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ), - 'Shortening TTL must trigger an immediate purge cycle' + // The TTL-shortened path enqueues the recurring AS action as a + // one-shot async action so work serializes through AS rather than + // running inline (which would bypass the overlap guard). + $async = as_get_scheduled_actions( + array( + 'hook' => \WP_Stream\Admin::AUTO_PURGE_ACTION, + 'status' => \ActionScheduler_Store::STATUS_PENDING, + ), + 'ids' + ); + $this->assertNotEmpty( + $async, + 'Shortening TTL must enqueue an immediate auto-purge action via Action Scheduler' ); - - remove_filter( 'wp_stream_is_large_records_table', '__return_true' ); } public function test_plugin_action_links() { @@ -1155,6 +1194,36 @@ public function test_is_running_auto_purge_reflects_chain_state() { ); } + public function test_is_running_auto_purge_includes_in_progress_actions() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_REAPER_ACTION ); + } + + // Enqueue and then flip the action's status to IN-PROGRESS to simulate + // the runner having dequeued an action and started executing it. + // Without RUNNING-aware filtering, is_running_auto_purge() would + // return false here and the overlap guard would let a second chain + // stack against the same rows. + $action_id = as_enqueue_async_action( + \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION, + array( + 'cutoff' => '2020-01-01 00:00:00', + 'blog_id' => 0, + 'last_entry' => 0, + ), + \WP_Stream\Admin::AUTO_PURGE_GROUP + ); + \ActionScheduler::store()->log_execution( $action_id ); + + $this->assertTrue( + \WP_Stream\Admin::is_running_auto_purge(), + 'In-progress (RUNNING) actions must count as running to prevent overlap' + ); + + as_unschedule_all_actions( \WP_Stream\Admin::AUTO_PURGE_BATCH_ACTION ); + } + public function test_register_hooks_auto_purge_action_scheduler_callbacks() { // The Admin instance is constructed by the test bootstrap, so register() // has already run. Just assert the actions are wired up. From 1a48b495ac37064152ceebbbdc03d8ec8e9ef976 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Wed, 20 May 2026 11:37:36 +0530 Subject: [PATCH 24/25] fix(e2e): pass --user www-data to docker compose exec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous quick-win switch from 'docker compose run --rm --user $(id -u)' to 'docker compose exec -T' dropped the user mapping. On local dev that happened to work because the wordpress container's default exec user is what the runtime image inherits; on CI it defaults to root and wp-cli refuses to run with: YIKES! It looks like you're running this as root. $(id -u) only worked on the host because UID 1000 mapped to www-data inside the container — that's not portable to the GitHub Actions runner (UID 1001). Pin the exec user explicitly to www-data, which owns the WordPress files inside the container. Failing run: actions/runs/26144103763 --- tests/e2e/admin-orphan-cleanup.spec.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/e2e/admin-orphan-cleanup.spec.js b/tests/e2e/admin-orphan-cleanup.spec.js index a89c989f1..1cc2f8d5e 100644 --- a/tests/e2e/admin-orphan-cleanup.spec.js +++ b/tests/e2e/admin-orphan-cleanup.spec.js @@ -32,6 +32,14 @@ const ADMIN = 'http://stream.wpenv.net/wp-admin'; * container instead of spinning a fresh one per call — `run` is ~3-5s of * overhead per invocation and accumulates fast across this suite. * + * `--user www-data` matches the user that owns the WordPress files inside + * the container. Without it, `exec` defaults to root and wp-cli refuses to + * run ("YIKES! It looks like you're running this as root."). The previous + * `docker compose run` form used `--user $(id -u)` for the same reason — + * it relied on host UID 1000 happening to map to www-data inside the + * container, which works on local dev but not on the GitHub Actions + * runner (UID 1001). + * * Best-effort: returns null on container errors (e.g. Stream not yet active * during a parallel suite's bootstrap). State seeding is auxiliary; the * test's own assertions are the source of truth — but surface failures via @@ -42,11 +50,11 @@ const ADMIN = 'http://stream.wpenv.net/wp-admin'; */ function wpEval( php ) { // Single-quote the PHP, escape any embedded single quotes for the - // outer shell. The `--` separates docker args from the wp-cli args. + // outer shell. const escaped = php.replace( /'/g, "'\\''" ); try { return execSync( - `docker compose exec -T wordpress wp eval '${ escaped }'`, + `docker compose exec -T --user www-data wordpress wp eval '${ escaped }'`, { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'pipe' ] }, ).trim(); } catch ( err ) { From f81ac455a2849247f63e014068425de8d7367a6a Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Wed, 20 May 2026 18:47:36 +0530 Subject: [PATCH 25/25] refactor(e2e): use Playwright baseURL for admin URLs Set baseURL in playwright.config.js and replace absolute https://stream.wpenv.net references in every spec with relative paths. Also drops the docker-exec state seeding from admin-orphan-cleanup.spec.js so it matches the browser-only convention of the rest of the suite; the running-state UX it seeded is covered by PHPUnit. --- playwright.config.js | 2 +- tests/e2e/admin-orphan-cleanup.spec.js | 152 +++---------------------- tests/e2e/admin-ui-smoke.spec.js | 18 ++- tests/e2e/editor-new-post.spec.js | 12 +- tests/e2e/network-activated.spec.js | 8 +- tests/e2e/setup/setup.js | 6 +- 6 files changed, 40 insertions(+), 158 deletions(-) diff --git a/playwright.config.js b/playwright.config.js index 6b94a177a..33ab44ed9 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -28,7 +28,7 @@ module.exports = defineConfig( { /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ - // baseURL: 'http://127.0.0.1:3000', + baseURL: 'https://stream.wpenv.net', /* Accept the locally-issued mkcert certificate without prompting. * The dev environment serves https://stream.wpenv.net using a diff --git a/tests/e2e/admin-orphan-cleanup.spec.js b/tests/e2e/admin-orphan-cleanup.spec.js index 1cc2f8d5e..848cfd69a 100644 --- a/tests/e2e/admin-orphan-cleanup.spec.js +++ b/tests/e2e/admin-orphan-cleanup.spec.js @@ -1,135 +1,55 @@ /** * WordPress dependencies */ -/** - * External dependencies - */ -import { execSync } from 'node:child_process'; import { test, expect } from '@wordpress/e2e-test-utils-playwright'; -/** - * Node dependencies - */ - /** * Settings → Advanced manual "Clean Orphaned Meta" link. * - * Asserts that the link is rendered, that its href points at admin-ajax.php - * with the expected action + nonce parameters, and that following the link - * redirects back to the Stream settings page with a confirmation marker in - * the URL. - * - * Also covers the running-state UX: when the auto-purge chain is active the - * link is hidden and the field description is swapped to explain the reaper - * will run as part of the active cycle. - */ -const ADMIN = 'http://stream.wpenv.net/wp-admin'; - -/** - * Run a PHP snippet inside the long-lived wordpress container. - * - * Uses `docker compose exec` (not `run`) so we attach to the running - * container instead of spinning a fresh one per call — `run` is ~3-5s of - * overhead per invocation and accumulates fast across this suite. - * - * `--user www-data` matches the user that owns the WordPress files inside - * the container. Without it, `exec` defaults to root and wp-cli refuses to - * run ("YIKES! It looks like you're running this as root."). The previous - * `docker compose run` form used `--user $(id -u)` for the same reason — - * it relied on host UID 1000 happening to map to www-data inside the - * container, which works on local dev but not on the GitHub Actions - * runner (UID 1001). - * - * Best-effort: returns null on container errors (e.g. Stream not yet active - * during a parallel suite's bootstrap). State seeding is auxiliary; the - * test's own assertions are the source of truth — but surface failures via - * console so silent breakage during CI is at least visible in logs. + * Asserts the idle-state UX: the link renders, its href points at admin-ajax.php + * with the expected action + nonce, and following it redirects back to the + * settings page with a confirmation marker in the URL. * - * @param {string} php The body of the eval call (no ` { page = await browser.newPage(); - // The setup fixture deactivates Stream network-wide before the suite. - // Earlier specs in the suite may also have deactivated it. Reactivate - // and wait for the post-activation redirect so subsequent navigations - // see a fully-activated plugin. - await page.goto( `${ ADMIN }/network/plugins.php` ); + // The shared setup fixture deactivates Stream network-wide before the + // suite. Reactivate so the settings page is reachable. + await page.goto( '/wp-admin/network/plugins.php' ); const activate = page.getByLabel( 'Network Activate Stream' ); if ( await activate.isVisible() ) { - await Promise.all( [ - page.waitForURL( /plugins\.php/ ), - activate.click(), - ] ); + await activate.click(); + await page.waitForURL( /plugins\.php/ ); } - // Belt-and-suspenders: confirm via the page DOM that the deactivate - // label is now present. If it isn't, fail fast rather than letting - // every test silently hit "Sorry, you are not allowed to access this page". - await page.goto( `${ ADMIN }/network/plugins.php` ); await expect( page.getByLabel( 'Network Deactivate Stream' ), ).toBeVisible(); } ); test.afterAll( async () => { - // Deactivate Stream again so other suites start from the same state - // as the shared setup fixture. - await page.goto( `${ ADMIN }/network/plugins.php` ); + // Restore the deactivated state other suites expect. + await page.goto( '/wp-admin/network/plugins.php' ); const deactivate = page.getByLabel( 'Network Deactivate Stream' ); if ( await deactivate.isVisible() ) { await deactivate.click(); } } ); -const ADVANCED_TAB_URL = `${ ADMIN }/network/admin.php?page=wp_stream_network_settings&tab=advanced`; - test.describe( 'Manual orphan-meta cleanup link', () => { - test.beforeEach( () => { - // Idle state for the common-case assertions. - clearAutoPurgeState(); - } ); - - test( 'is visible on the Advanced tab (idle state)', async () => { + test( 'is visible on the Advanced tab', async () => { await page.goto( ADVANCED_TAB_URL ); const link = page.getByRole( 'link', { name: /Clean Orphaned Meta/i } ); @@ -160,40 +80,4 @@ test.describe( 'Manual orphan-meta cleanup link', () => { link.click(), ] ); } ); - - test( 'hides the link while the auto-purge chain is running', async () => { - // Simulate an active chain by enqueuing a reaper action. - seedRunningAutoPurge(); - - try { - await page.goto( ADVANCED_TAB_URL ); - - // The link MUST NOT be rendered. - const link = page.getByRole( 'link', { - name: /Clean Orphaned Meta/i, - } ); - await expect( link ).toHaveCount( 0 ); - - // The replacement description text MUST be visible somewhere - // in the settings form. We assert on a stable substring rather - // than the full sentence. - await expect( - page.getByText( /Auto-purge is currently running/i ), - ).toBeVisible(); - } finally { - clearAutoPurgeState(); - } - } ); - - test( 'restores the link after the chain drains (idle state again)', async () => { - // Seed running, drain, confirm idle UX restored. - seedRunningAutoPurge(); - clearAutoPurgeState(); - - await page.goto( ADVANCED_TAB_URL ); - const link = page.getByRole( 'link', { - name: /Clean Orphaned Meta/i, - } ); - await expect( link ).toBeVisible(); - } ); } ); diff --git a/tests/e2e/admin-ui-smoke.spec.js b/tests/e2e/admin-ui-smoke.spec.js index 0d832ba45..77bfe3844 100644 --- a/tests/e2e/admin-ui-smoke.spec.js +++ b/tests/e2e/admin-ui-smoke.spec.js @@ -13,8 +13,6 @@ import { test, expect } from '@wordpress/e2e-test-utils-playwright'; * suites do not exercise. */ -const ADMIN = 'https://stream.wpenv.net/wp-admin'; - test.describe.configure( { mode: 'serial' } ); let page; @@ -35,7 +33,7 @@ test.beforeAll( async ( { browser } ) => { // The setup fixture deactivates Stream network-wide before the suite. // Reactivate it so the Stream admin pages are reachable. - await page.goto( `${ ADMIN }/network/plugins.php` ); + await page.goto( '/wp-admin/network/plugins.php' ); const activate = page.getByLabel( 'Network Activate Stream' ); if ( await activate.isVisible() ) { // eslint-disable-next-line no-console @@ -47,7 +45,7 @@ test.beforeAll( async ( { browser } ) => { test.afterAll( async () => { // Deactivate Stream again so other suites start from the same state // as the shared setup fixture. - await page.goto( `${ ADMIN }/network/plugins.php` ); + await page.goto( '/wp-admin/network/plugins.php' ); const deactivate = page.getByLabel( 'Network Deactivate Stream' ); if ( await deactivate.isVisible() ) { // eslint-disable-next-line no-console @@ -72,7 +70,7 @@ test.afterAll( async () => { test.describe( 'Admin UI smoke', () => { test( 'exposes window.jQuery on the Stream records page', async () => { - await page.goto( `${ ADMIN }/admin.php?page=wp_stream` ); + await page.goto( '/wp-admin/admin.php?page=wp_stream' ); const version = await page.evaluate( () => window.jQuery && window.jQuery.fn && window.jQuery.fn.jquery, ); @@ -82,12 +80,12 @@ test.describe( 'Admin UI smoke', () => { } ); test( 'renders the records list table', async () => { - await page.goto( `${ ADMIN }/admin.php?page=wp_stream` ); + await page.goto( '/wp-admin/admin.php?page=wp_stream' ); await expect( page.locator( 'table.wp-list-table' ) ).toBeVisible(); } ); test( 'opens the jQuery UI date range picker', async () => { - await page.goto( `${ ADMIN }/admin.php?page=wp_stream` ); + await page.goto( '/wp-admin/admin.php?page=wp_stream' ); // The date inputs are revealed only when the "Custom" range is selected. // The visible UI is a select2 widget on top of the real