Commit 493193751af for woocommerce

commit 493193751afecdf727f6644c842eabec8f75e666
Author: Ján Mikláš <neosinner@gmail.com>
Date:   Thu Nov 12 10:51:26 2026 +0100

    Fix persistent transient clearing and bound related-product caches (#69935)

    * Fix analytics cache invalidation in clear tool

    * Clear persistent product caches in background batches

    * Bound related-product caches while retaining two query variants

    * Add changelog for product cache fixes

    * Fix cache refreshes and scheduler compatibility

    * Expect the scheduled cleanup message in object cache API tests

    With an external object cache, the clear transients tool now schedules
    product cache cleanup and reports that instead of "Product transients
    cleared", so the test:api:object-cache run would fail on the old message.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01NM9YXwHCJysEWkJ3Pv1sqE

    * Clear persistent product transients in batches of 500

    Each batch deletes up to five cache keys per product, so 500 products is still a
    small request while needing a fifth as many scheduled actions on large
    catalogs. The batch size is now a constant shared by the query limit, the
    continuation check, and the tests.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01NM9YXwHCJysEWkJ3Pv1sqE

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-product-transient-caches b/plugins/woocommerce/changelog/fix-product-transient-caches
new file mode 100644
index 00000000000..95fa2da44b5
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-product-transient-caches
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Clear product and Analytics caches with persistent caching and prevent related-product cache growth.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 935fa2bb8f8..fb7a1347049 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -33,6 +33,7 @@ use Automattic\WooCommerce\Internal\RestockRefundedItemsAdjuster;
 use Automattic\WooCommerce\Internal\Settings\OptionSanitizer;
 use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
 use Automattic\WooCommerce\Internal\Utilities\LegacyRestApiStub;
+use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
 use Automattic\WooCommerce\Internal\Utilities\WebhookUtil;
 use Automattic\WooCommerce\Internal\Admin\EmailImprovements\EmailImprovements;
 use Automattic\WooCommerce\Internal\Email\DeferredEmailQueue;
@@ -465,6 +466,7 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\AgentReadiness\AgenticCommerceSettings::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Admin\OrderTaxLookupMigrator::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\ProductDownloads\DownloadableFileAttachments::class )->register();
+		$container->get( ProductUtil::class )->register();

 		// Classes inheriting from RestApiControllerBase.
 		$container->get( Automattic\WooCommerce\Internal\ReceiptRendering\ReceiptRenderingRestController::class )->register();
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller.php
index 86ecfc9d983..00780ffe9fe 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller.php
@@ -9,6 +9,7 @@
  */

 use Automattic\Jetpack\Constants;
+use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
 use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
 use Automattic\WooCommerce\Internal\ProductFilters\CacheController;
 use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
@@ -540,6 +541,7 @@ class WC_REST_System_Status_Tools_V2_Controller extends WC_REST_Controller {
 			case 'clear_transients':
 				wc_delete_product_transients();
 				wc_delete_shop_order_transients();
+				ReportsCache::invalidate();
 				delete_transient( 'wc_count_comments' );
 				delete_transient( 'as_comment_count' );

@@ -563,8 +565,15 @@ class WC_REST_System_Status_Tools_V2_Controller extends WC_REST_Controller {
 				wc_get_container()->get( DatabaseUtil::class )->delete_transients_by_prefix(
 					array_merge( ProductUtil::PRODUCT_SPECIFIC_TRANSIENT_PREFIXES, array( 'wc_product_loop_', 'wc_report_' ) )
 				);
+				if ( ! wc_get_container()->get( ProductUtil::class )->schedule_product_transient_cleanup() ) {
+					$ran     = false;
+					$message = __( 'Could not schedule product cache cleanup. Try again.', 'woocommerce' );
+					break;
+				}

-				$message = __( 'Product transients cleared', 'woocommerce' );
+				$message = wp_using_ext_object_cache()
+					? __( 'Product cache cleanup has been scheduled.', 'woocommerce' )
+					: __( 'Product transients cleared', 'woocommerce' );
 				break;

 			case 'clear_expired_transients':
diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php
index fe8ef32771d..6c4dbd6b90a 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -1486,11 +1486,27 @@ function wc_get_related_products( $product_id, $limit = 5, $exclude_ids = array(

 		$original_transient = $transient;
 		if ( $transient && is_array( $transient ) ) {
+			if ( ( $transient[ $query_args ] ?? null ) !== $related_posts ) {
+				unset( $transient[ $query_args ] );
+			}
 			$transient[ $query_args ] = $related_posts;
 		} else {
 			$transient = array( $query_args => $related_posts );
 		}

+		// Drop the oldest queries when over budget, but keep the latest two so large alternating queries remain cacheable.
+		$size_limit = wp_using_ext_object_cache() ? 65536 : 8192;
+		$size       = 0;
+		$kept       = array();
+		foreach ( array_reverse( $transient, true ) as $query => $posts ) {
+			$size += strlen( maybe_serialize( array( $query => $posts ) ) );
+			if ( count( $kept ) >= 2 && $size > $size_limit ) {
+				break;
+			}
+			$kept[ $query ] = $posts;
+		}
+		$transient = array_reverse( $kept, true );
+
 		if ( $original_transient !== $transient ) {
 			set_transient( $transient_name, $transient, DAY_IN_SECONDS );
 		}
diff --git a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
index 8b44c63b24f..365890855d9 100644
--- a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
+++ b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
@@ -41,6 +41,92 @@ class ProductUtil {
 		'wc_child_has_dimensions_',
 	);

+	/**
+	 * Number of products whose persistent transients are deleted per scheduled cleanup batch.
+	 *
+	 * @since 11.4.0
+	 * @internal
+	 *
+	 * @var int
+	 */
+	public const TRANSIENT_CLEANUP_BATCH_SIZE = 500;
+
+	/**
+	 * Register hooks.
+	 *
+	 * @since 11.4.0
+	 * @internal
+	 */
+	public function register(): void {
+		add_action( 'woocommerce_delete_product_transients_batch', array( $this, 'handle_woocommerce_delete_product_transients_batch' ) );
+	}
+
+	/**
+	 * Schedule cleanup of existing products' persistent transients for the current site.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param int $last_id Last product ID processed, or zero to start cleanup.
+	 * @return bool False if cleanup could not be scheduled.
+	 */
+	public function schedule_product_transient_cleanup( int $last_id = 0 ): bool {
+		if ( ! wp_using_ext_object_cache() ) {
+			return true;
+		}
+
+		// A new cleanup replaces any queued one instead of running alongside it.
+		if ( 0 === $last_id ) {
+			as_unschedule_all_actions( 'woocommerce_delete_product_transients_batch' );
+		}
+
+		return (bool) ActionSchedulerUtil::enqueue_async( 'woocommerce_delete_product_transients_batch', array( $last_id ), 'woocommerce' );
+	}
+
+	/**
+	 * Delete one batch of persistent product transients, bypassing per-transient deletion actions.
+	 * The scheduled action retains the last processed ID for the next batch.
+	 *
+	 * @since 11.4.0
+	 * @internal
+	 *
+	 * @param mixed $last_id Last product ID processed.
+	 * @return void
+	 * @throws \RuntimeException If the batch cannot be read or continued.
+	 */
+	public function handle_woocommerce_delete_product_transients_batch( $last_id ): void {
+		if ( ! wp_using_ext_object_cache() ) {
+			return;
+		}
+
+		global $wpdb;
+		$products = $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT ID, post_type FROM {$wpdb->posts} WHERE post_type IN ('product', 'product_variation') AND ID > %d ORDER BY ID LIMIT %d",
+				absint( $last_id ),
+				self::TRANSIENT_CLEANUP_BATCH_SIZE
+			)
+		);
+		if ( $wpdb->last_error ) {
+			throw new \RuntimeException( 'Unable to read products for transient cleanup.' );
+		}
+
+		$keys = array();
+		foreach ( $products as $product ) {
+			$prefixes = 'product_variation' === $product->post_type ? array( 'wc_related_' ) : self::PRODUCT_SPECIFIC_TRANSIENT_PREFIXES;
+			foreach ( $prefixes as $prefix ) {
+				$keys[] = $prefix . $product->ID;
+			}
+			$last_id = (int) $product->ID;
+		}
+
+		if ( $keys ) {
+			wp_cache_delete_multiple( $keys, 'transient' );
+		}
+		if ( self::TRANSIENT_CLEANUP_BATCH_SIZE === count( $products ) && ! $this->schedule_product_transient_cleanup( $last_id ) ) {
+			throw new \RuntimeException( 'Unable to schedule the next product transient cleanup batch.' );
+		}
+	}
+
 	/**
 	 * Get a product ID from a product, post, or numeric value.
 	 *
diff --git a/plugins/woocommerce/tests/e2e/tests/api-tests/system-status/system-status-crud.test.ts b/plugins/woocommerce/tests/e2e/tests/api-tests/system-status/system-status-crud.test.ts
index c4b97a456ce..b1ab796a84c 100644
--- a/plugins/woocommerce/tests/e2e/tests/api-tests/system-status/system-status-crud.test.ts
+++ b/plugins/woocommerce/tests/e2e/tests/api-tests/system-status/system-status-crud.test.ts
@@ -262,6 +262,13 @@ const getExpectedWooCommerceTables = ( dbPrefix ) => {
 	];
 };

+// With an external object cache, product transients are cleared in the background.
+const getExpectedClearTransientsMessage = ( externalObjectCache ) => {
+	return externalObjectCache
+		? 'Product cache cleanup has been scheduled.'
+		: 'Product transients cleared';
+};
+
 /* eslint-disable playwright/no-nested-step */
 test.describe( 'System Status API tests', () => {
 	test( 'can view all system status items', async ( { request } ) => {
@@ -617,6 +624,11 @@ test.describe( 'System Status API tests', () => {
 	} );

 	test( 'can run a tool from system status', async ( { request } ) => {
+		const statusResponse = await request.get(
+			'./wp-json/wc/v3/system_status'
+		);
+		const { environment } = await statusResponse.json();
+
 		// call API to run a system status tool
 		const response = await request.put(
 			'./wp-json/wc/v3/system_status/tools/clear_transients'
@@ -632,7 +644,9 @@ test.describe( 'System Status API tests', () => {
 				description:
 					'This tool will clear the product/shop transients cache.',
 				success: true,
-				message: 'Product transients cleared',
+				message: getExpectedClearTransientsMessage(
+					environment.external_object_cache
+				),
 			} )
 		);
 	} );
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller-test.php
index 87e493407b9..c7d2cbf6649 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller-test.php
@@ -1,6 +1,8 @@
 <?php

 use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\Query as OrdersStatsQuery;
+use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
+use Automattic\WooCommerce\Internal\Utilities\ProductUtil;

 /**
  * Class WC_REST_System_Status_Tools_V2_Controller.
@@ -204,6 +206,80 @@ class WC_REST_System_Status_Tools_V2_Controller_Test extends WC_REST_Unit_Test_C
 		return $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( $prefix ) . '%' ) );
 	}

+	/**
+	 * @testdox Clear transients invalidates Analytics and clears product caches in deferred, bounded batches.
+	 * @testWith [false, false]
+	 *           [true, false]
+	 *           [true, true]
+	 *
+	 * @param bool $external_cache Whether transients use the object cache.
+	 * @param bool $fail_enqueue   Whether scheduling should fail.
+	 */
+	public function test_clear_transients_clears_product_and_analytics_caches( bool $external_cache, bool $fail_enqueue ): void {
+		$batch_size   = ProductUtil::TRANSIENT_CLEANUP_BATCH_SIZE;
+		$ids          = $this->factory->post->create_many( $batch_size + 1, array( 'post_type' => 'product' ) );
+		$variation_id = $this->factory->post->create( array( 'post_type' => 'product_variation' ) );
+		$previous     = wp_using_ext_object_cache();
+		wp_using_ext_object_cache( $external_cache );
+		try {
+			foreach ( array( $ids[0], $ids[ $batch_size ] ) as $id ) {
+				foreach ( ProductUtil::PRODUCT_SPECIFIC_TRANSIENT_PREFIXES as $prefix ) {
+					set_transient( $prefix . $id, 'cached', HOUR_IN_SECONDS );
+				}
+			}
+			set_transient( 'wc_related_' . $variation_id, 'cached', HOUR_IN_SECONDS );
+			set_transient( 'wc_var_prices_' . $variation_id, 'kept', HOUR_IN_SECONDS );
+			ReportsCache::set( 'wc_report_orders_stats_test', array( 'total' => 123 ) );
+			$this->assertNotFalse( ReportsCache::get( 'wc_report_orders_stats_test' ) );
+			set_transient( 'other_plugin_cache', 'kept', HOUR_IN_SECONDS );
+			if ( $fail_enqueue ) {
+				add_filter( 'pre_as_enqueue_async_action', '__return_zero' );
+			}
+			$response = $this->endpoint->execute_tool( 'clear_transients' );
+			$this->assertSame( ! $fail_enqueue, $response['success'] );
+			if ( $fail_enqueue ) {
+				$this->assertStringContainsString( 'Could not schedule', $response['message'] );
+				return;
+			}
+			if ( $external_cache ) {
+				$this->assertStringContainsString( 'scheduled', $response['message'] );
+				$this->assertSame( 'cached', get_transient( 'wc_var_prices_' . $ids[0] ) );
+				$this->assertTrue( $this->endpoint->execute_tool( 'clear_transients' )['success'] );
+				$pending = as_get_scheduled_actions(
+					array(
+						'hook'   => 'woocommerce_delete_product_transients_batch',
+						'status' => ActionScheduler_Store::STATUS_PENDING,
+					),
+					'ids'
+				);
+				$this->assertCount( 1, $pending, 'A second run must not queue an overlapping cleanup.' );
+				do_action( 'woocommerce_delete_product_transients_batch', 0 );
+				$this->assertFalse( get_transient( 'wc_var_prices_' . $ids[0] ) );
+				$this->assertSame( 'cached', get_transient( 'wc_var_prices_' . $ids[ $batch_size ] ) );
+				$this->assertTrue( as_has_scheduled_action( 'woocommerce_delete_product_transients_batch', array( $ids[ $batch_size - 1 ] ), 'woocommerce' ) );
+				$this->endpoint->execute_tool( 'clear_transients' );
+				$this->assertFalse( as_has_scheduled_action( 'woocommerce_delete_product_transients_batch', array( $ids[ $batch_size - 1 ] ), 'woocommerce' ), 'A new run must replace the queued cleanup.' );
+				$this->assertTrue( as_has_scheduled_action( 'woocommerce_delete_product_transients_batch', array( 0 ), 'woocommerce' ), 'A new run must restart cleanup from the first product.' );
+				do_action( 'woocommerce_delete_product_transients_batch', $ids[ $batch_size - 1 ] );
+				$this->assertFalse( as_has_scheduled_action( 'woocommerce_delete_product_transients_batch', array( $variation_id ), 'woocommerce' ) );
+			}
+			if ( ! $external_cache ) {
+				wp_cache_flush(); // Discard the request-local option values left after the SQL sweep.
+			}
+			foreach ( array( $ids[0], $ids[ $batch_size ] ) as $id ) {
+				foreach ( ProductUtil::PRODUCT_SPECIFIC_TRANSIENT_PREFIXES as $prefix ) {
+					$this->assertFalse( get_transient( $prefix . $id ) );
+				}
+			}
+			$this->assertFalse( get_transient( 'wc_related_' . $variation_id ) );
+			$this->assertSame( $external_cache ? 'kept' : false, get_transient( 'wc_var_prices_' . $variation_id ) );
+			$this->assertFalse( ReportsCache::get( 'wc_report_orders_stats_test' ) );
+			$this->assertSame( 'kept', get_transient( 'other_plugin_cache' ) );
+		} finally {
+			wp_using_ext_object_cache( $previous );
+		}
+	}
+
 	/**
 	 * @testdox Product lookup table regeneration status is shown alongside the matching tool.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
index 4b44cd1a539..bf2e503b5ad 100644
--- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
@@ -1692,6 +1692,88 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
 		WC_Helper_Product::delete_product( $related_product3->get_id() );
 	}

+	/**
+	 * @testdox Unchanged related queries do not reorder the cache or renew its expiry.
+	 * @testWith [false]
+	 *           [true]
+	 *
+	 * @param bool $external_cache Whether transients use the object cache.
+	 */
+	public function test_unchanged_related_queries_do_not_rewrite_cache( bool $external_cache ): void {
+		$product_id = $this->factory->post->create( array( 'post_type' => 'product' ) );
+		$key        = 'wc_related_' . $product_id;
+		$previous   = wp_using_ext_object_cache();
+		wp_using_ext_object_cache( $external_cache );
+		try {
+			wc_get_related_products( $product_id, 5 );
+			wc_get_related_products( $product_id, 10 );
+			$cached = get_transient( $key );
+			$writes = did_action( 'set_transient_' . $key );
+
+			wc_get_related_products( $product_id, 5 );
+			$this->assertSame( $cached, get_transient( $key ), 'Unchanged results must preserve query order.' );
+			wc_get_related_products( $product_id, 10 );
+			$this->assertSame( $writes, did_action( 'set_transient_' . $key ), 'Unchanged results must not renew the cache expiry.' );
+		} finally {
+			wp_using_ext_object_cache( $previous );
+		}
+	}
+
+	/**
+	 * @testdox Related caches trim old variants and keep two large alternating queries cacheable.
+	 * @testWith [false, 8192]
+	 *           [true, 65536]
+	 *
+	 * @param bool $external_cache Whether transients use the object cache.
+	 * @param int  $budget         Cache size target.
+	 */
+	public function test_related_product_cache_has_size_limit( bool $external_cache, int $budget ): void {
+		$product_id = $this->factory->post->create( array( 'post_type' => 'product' ) );
+		$related_id = $this->factory->post->create( array( 'post_type' => 'product' ) );
+		$category   = $this->factory->term->create( array( 'taxonomy' => 'product_cat' ) );
+		wp_set_object_terms( $product_id, $category, 'product_cat' );
+		wp_set_object_terms( $related_id, $category, 'product_cat' );
+		$key      = 'wc_related_' . $product_id;
+		$previous = wp_using_ext_object_cache();
+		wp_using_ext_object_cache( $external_cache );
+		try {
+			$legacy = array();
+			for ( $context = 0; $context < 20000; ++$context ) {
+				$legacy[ "legacy=$context" ] = array( $context );
+			}
+			set_transient( $key, $legacy, DAY_IN_SECONDS );
+			wc_get_related_products( $product_id, 5 );
+			$cached = get_transient( $key );
+			$this->assertLessThanOrEqual( $budget, strlen( maybe_serialize( $cached ) ), 'An oversized pre-upgrade cache must be trimmed.' );
+			$this->assertArrayHasKey( 'legacy=19999', $cached, 'The newest pre-upgrade queries should remain cached.' );
+
+			$min_count = PHP_INT_MAX;
+			for ( $context = 0; $context < 1000; ++$context ) {
+				wc_get_related_products( $product_id, 5, array( 100000 + $context ) );
+				if ( $context >= 500 ) {
+					$min_count = min( $min_count, count( get_transient( $key ) ) );
+				}
+			}
+			$this->assertLessThanOrEqual( $budget, strlen( maybe_serialize( get_transient( $key ) ) ) );
+			$this->assertGreaterThan( 2, $min_count, 'Only the oldest queries should be evicted.' );
+			foreach ( array( 100000, 200000 ) as $exclude_start ) {
+				wc_get_related_products( $product_id, 1, range( $exclude_start, $exclude_start + 10000 ) );
+			}
+			$cached = get_transient( $key );
+			$this->assertCount( 2, $cached );
+			$this->assertEquals( array( $related_id ), wc_get_related_products( $product_id, 1, range( 100000, 110000 ) ) );
+			$this->assertSame( $cached, get_transient( $key ), 'Repeating a large query must use its retained entry.' );
+			$old_query            = array_key_first( $cached );
+			$cached[ $old_query ] = array();
+			set_transient( $key, $cached, DAY_IN_SECONDS );
+			wc_get_related_products( $product_id, 1, range( 100000, 110000 ) );
+			wc_get_related_products( $product_id, 1 );
+			$this->assertArrayHasKey( $old_query, get_transient( $key ), 'A refreshed query must survive the next eviction.' );
+		} finally {
+			wp_using_ext_object_cache( $previous );
+		}
+	}
+
 	/**
 	 * @testdox Product category list preserves WordPress term-list order by default.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php
index 498d3ac387f..887befc08b0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php
@@ -8,6 +8,7 @@ use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\Admin\API\Reports\Stock\Controller as StockController;
 use Automattic\WooCommerce\Caches\ProductCountCache;
+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
 use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
 use Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper;
 use Automattic\WooCommerce\StoreApi\Utilities\ProductQuery;
@@ -16,6 +17,44 @@ use Automattic\WooCommerce\StoreApi\Utilities\ProductQuery;
  * Tests for the internal ProductUtil class.
  */
 class ProductUtilTest extends \WC_Unit_Test_Case {
+	/**
+	 * @testdox Product cache cleanup can be scheduled when the loaded Action Scheduler lacks async actions.
+	 */
+	public function test_cleanup_supports_action_scheduler_without_async_actions(): void {
+		$previous = wp_using_ext_object_cache();
+		wp_using_ext_object_cache( true );
+		ActionSchedulerUtil::reset_cache();
+		$this->register_legacy_proxy_function_mocks(
+			array( 'function_exists' => static fn( $name ) => 'as_enqueue_async_action' !== $name && function_exists( $name ) )
+		);
+		add_filter( 'pre_as_enqueue_async_action', '__return_zero' );
+
+		try {
+			$this->assertTrue( wc_get_container()->get( ProductUtil::class )->schedule_product_transient_cleanup() );
+			$this->assertTrue( as_has_scheduled_action( 'woocommerce_delete_product_transients_batch', array( 0 ), 'woocommerce' ) );
+		} finally {
+			ActionSchedulerUtil::reset_cache();
+			wp_using_ext_object_cache( $previous );
+		}
+	}
+
+	/**
+	 * @testdox A product transient cleanup batch fails when it cannot schedule the next batch.
+	 */
+	public function test_cleanup_batch_fails_when_next_batch_cannot_be_scheduled(): void {
+		$this->factory->post->create_many( ProductUtil::TRANSIENT_CLEANUP_BATCH_SIZE, array( 'post_type' => 'product' ) );
+		$previous = wp_using_ext_object_cache();
+		wp_using_ext_object_cache( true );
+		add_filter( 'pre_as_enqueue_async_action', '__return_zero' );
+
+		try {
+			$this->expectException( \RuntimeException::class );
+			wc_get_container()->get( ProductUtil::class )->handle_woocommerce_delete_product_transients_batch( 0 );
+		} finally {
+			wp_using_ext_object_cache( $previous );
+		}
+	}
+
 	/**
 	 * @testdox get_product_id extracts IDs from the product references accepted by the factory.
 	 */