Commit 37af65a7bd6 for woocommerce
commit 37af65a7bd69980890ecb93c2d9bf7790dde2a6c
Author: Radoslav Georgiev <rageorgiev@gmail.com>
Date: Fri Oct 16 15:24:33 2026 +0300
Product catalog: Fix ordering to avoid duplicates or missing products (#69259)
* Change the default sorting parameter for product queries.
* Changelog entry
* Update/add tests
* Fix POS catalog skipping products removed during generation (#69703)
* Resume POS catalog generation after the last exported product ID
Resuming from a page number skips the next product when an already-exported
product is trashed, unpublished, or hidden from POS between batches, since the
offset shifts. Walk batches by ID instead, via a scoped posts_where clause, and
keep the page-based walk_batches() as a deprecated wrapper.
* Test that removed products do not cause skipped POS catalog entries
* Add changelog entry
* Rename walk_batches_after_id() to walk_chunk() and last_id to last_product_id
* Restart POS catalog generation saved by an older version right away
A continuation saved before the update tracks pages, not product IDs. Leaving it
to the stuck-job recovery stalled the catalog for up to 15 minutes and
permanently stepped the chunk size down. Discard it and start over instead, and
keep the legacy page key out of the REST response.
* Align walk_chunk() docblock and follow test conventions in new tests
* Remove ProductWalker::walk_batches() in favor of walk_chunk()
The page-based method has no known callers outside core, and keeping it meant
the walker loop had to support both pages and product IDs.
* Document the fixed product feed order and use ProductStatus in tests
* Keep a deprecated ProductWalker::walk_batches() to avoid fatals
The method is internal, but removing it would fatal any unexpected
caller. It now walks the first chunk and reports it as the whole feed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: malinajirka <malinajirka@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/product-catalog-fix-query-ordering b/plugins/woocommerce/changelog/product-catalog-fix-query-ordering
new file mode 100644
index 00000000000..5af6545edfe
--- /dev/null
+++ b/plugins/woocommerce/changelog/product-catalog-fix-query-ordering
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent duplicate or missing products in the POS catalog when products share a creation date or are added while it builds.
diff --git a/plugins/woocommerce/changelog/product-catalog-prevent-skipped-products b/plugins/woocommerce/changelog/product-catalog-prevent-skipped-products
new file mode 100644
index 00000000000..661ccfaf692
--- /dev/null
+++ b/plugins/woocommerce/changelog/product-catalog-prevent-skipped-products
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent missing products in the POS catalog when products are trashed, unpublished, or hidden from POS while it builds.
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductWalker.php b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductWalker.php
index 83f72675ee9..fceed9c0d36 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductWalker.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductWalker.php
@@ -23,6 +23,11 @@ if ( ! defined( 'ABSPATH' ) ) {
* @since 10.5.0
*/
class ProductWalker {
+ /**
+ * Query var holding the ID after which the walker's product query resumes.
+ */
+ private const AFTER_ID_QUERY_VAR = 'wc_product_feed_after_id';
+
/**
* The product loader.
*
@@ -135,6 +140,7 @@ class ProductWalker {
* Allows the base arguments for querying products for product feeds to be changed.
*
* Variable products are not included by default, as their variations will be included.
+ * `orderby` and `order` are always overridden with `ID` and `ASC`, as products are walked by ID.
*
* @since 10.5.0
*
@@ -148,6 +154,10 @@ class ProductWalker {
$integration
);
+ // Batches resume after the last walked product ID, which only works with a stable ascending ID order.
+ $query_args['orderby'] = 'ID';
+ $query_args['order'] = 'ASC';
+
$instance = new self(
$integration->get_product_mapper(),
$integration->get_feed_validator(),
@@ -199,7 +209,7 @@ class ProductWalker {
*
* This is the simple, single-process entry point: it starts the feed, walks every batch, ends the
* feed and returns the number of products processed. Callers that need to write a feed across
- * several processes should own the feed lifecycle themselves and use {@see walk_batches()} instead.
+ * several processes should own the feed lifecycle themselves and use {@see walk_chunk()} instead.
*
* @since 10.5.0
*
@@ -208,57 +218,54 @@ class ProductWalker {
*/
public function walk( ?callable $callback = null ): int {
$this->feed->start();
- $progress = $this->walk_batches( $callback );
+ $progress = $this->walk_chunk( $callback );
$this->feed->end();
return $progress->processed_items;
}
/**
- * Walks through products, optionally limited to a bounded number of batches.
+ * Walks through a chunk of products, optionally limited to a bounded number of batches.
*
- * The walker does not own the feed lifecycle: the caller is responsible for starting, flushing
- * and ending the feed. This lets a feed be written across several processes by resuming the same
- * feed between calls.
+ * The walker does not own the feed lifecycle, so a feed can be written across several processes:
+ * walk a chunk, then resume the next one from the returned `last_product_id`. Resuming from an ID
+ * rather than a page keeps products from being skipped when earlier ones are removed in between.
*
- * Called with the defaults it walks every remaining page in one go; pass `$start_page` and
- * `$max_batches` to process a bounded slice and resume later from `processed_batches`.
+ * @since 11.3.0
*
- * @since 11.0.0
- *
- * @param callable|null $callback The callback to call after each batch of products is processed.
- * @param int $start_page The 1-based page (batch) to start at.
- * @param int $max_batches The maximum number of batches to process in this call.
- * @return WalkerProgress Items/batches processed here, plus the overall total_count and
- * total_batch_count so the caller knows whether the feed is complete.
+ * @param callable|null $callback The callback to call after each batch of products is processed.
+ * @param int $resume_after_product_id Resume after this product ID, usually the previous chunk's `last_product_id`; 0 starts from the beginning.
+ * @param int $max_batches The maximum number of batches to process in this call.
+ * @return WalkerProgress Items/batches processed here, plus the total_count and total_batch_count
+ * remaining when this chunk started, so the caller knows whether the feed is complete.
*/
- public function walk_batches( ?callable $callback = null, int $start_page = 1, int $max_batches = PHP_INT_MAX ): WalkerProgress {
- if ( $start_page < 1 ) {
- $start_page = 1;
- }
+ public function walk_chunk( ?callable $callback = null, int $resume_after_product_id = 0, int $max_batches = PHP_INT_MAX ): WalkerProgress {
+ $after_id = max( 0, $resume_after_product_id );
if ( $max_batches < 1 ) {
$max_batches = 1;
}
$progress = null;
- $page = $start_page;
// Check how much memory is available at first.
$initial_available_memory = $this->memory_manager->get_available_memory();
$batches_processed = 0;
do {
- $result = $this->iterate( $this->query_args, $page, $this->per_page );
+ $result = $this->iterate( $this->query_args, $after_id, $this->per_page );
$iterated = count( $result->products );
- // Only build the progress object once; the total/total batch count is stable across pages.
+ // Only build the progress object once, so the totals reflect what remained when this call started.
if ( is_null( $progress ) ) {
$progress = WalkerProgress::from_wc_get_products_result( $result );
}
+ if ( $iterated > 0 ) {
+ $after_id = end( $result->products )->get_id();
+ }
+ $progress->last_product_id = $after_id;
$progress->processed_items += $iterated;
++$progress->processed_batches;
++$batches_processed;
- ++$page;
if ( is_callable( $callback ) && $iterated > 0 ) {
$callback( $progress );
@@ -281,37 +288,78 @@ class ProductWalker {
&& $batches_processed < $max_batches
// For the cases where the above are true, make sure that we do not exceed the total number of pages.
- && ( $progress->total_batch_count <= 0 || ( $page - 1 ) < $progress->total_batch_count )
+ && ( $progress->total_batch_count <= 0 || $batches_processed < $progress->total_batch_count )
);
// The do-while body always executes at least once and assigns $progress on the first iteration.
return $progress;
}
+ /**
+ * Walks the first chunk of products and reports it as the whole feed.
+ *
+ * This class is internal and nothing outside WooCommerce should call this method. It's kept only
+ * so that any unexpected caller gets a deprecation notice instead of a fatal error that takes the
+ * site down. Resuming from a page is no longer supported, so `$start_page` is ignored.
+ *
+ * @since 11.0.0
+ * @deprecated 11.3.0 Use walk_chunk(), which resumes after a product ID instead of a page.
+ *
+ * @param callable|null $callback The callback to call after each batch of products is processed.
+ * @param int $start_page Ignored.
+ * @param int $max_batches The maximum number of batches to process in this call.
+ * @return WalkerProgress
+ */
+ public function walk_batches( ?callable $callback = null, int $start_page = 1, int $max_batches = PHP_INT_MAX ): WalkerProgress {
+ wc_deprecated_function( __METHOD__, '11.3.0', __CLASS__ . '::walk_chunk' );
+
+ $progress = $this->walk_chunk( $callback, 0, $max_batches );
+ $progress->total_count = $progress->processed_items;
+ $progress->total_batch_count = $progress->processed_batches;
+
+ return $progress;
+ }
+
/**
* Iterates through a batch of products.
*
- * @param array $args The arguments to pass to wc_get_products().
- * @param int $page The page number to iterate through.
- * @param int $limit The maximum number of products to iterate through.
+ * @param array $args The arguments to pass to wc_get_products().
+ * @param int $after_id Only include products with an ID greater than this one.
+ * @param int $limit The maximum number of products to iterate through.
* @return \stdClass The result of the query with properties: products, total, max_num_pages.
*/
- private function iterate( array $args = array(), int $page = 1, int $limit = 100 ): \stdClass {
- /**
- * Result is always stdClass when paginate=true.
- *
- * @var \stdClass $result
- */
- $result = $this->product_loader->get_products(
- array_merge(
- $args,
- array(
- 'page' => $page,
- 'limit' => $limit,
- 'paginate' => true,
+ private function iterate( array $args = array(), int $after_id = 0, int $limit = 100 ): \stdClass {
+ // WP_Query has no "ID greater than" argument, so add it to the WHERE clause of this query only.
+ $after_id_where = static function ( $where, $query ) {
+ global $wpdb;
+
+ if ( ! $query instanceof \WP_Query || '' === $query->get( self::AFTER_ID_QUERY_VAR ) ) {
+ return $where;
+ }
+
+ return $where . $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", (int) $query->get( self::AFTER_ID_QUERY_VAR ) );
+ };
+
+ add_filter( 'posts_where', $after_id_where, 10, 2 );
+ try {
+ /**
+ * Result is always stdClass when paginate=true.
+ *
+ * @var \stdClass $result
+ */
+ $result = $this->product_loader->get_products(
+ array_merge(
+ $args,
+ array(
+ self::AFTER_ID_QUERY_VAR => $after_id,
+ 'limit' => $limit,
+ 'paginate' => true,
+ )
)
- )
- );
+ );
+ } finally {
+ remove_filter( 'posts_where', $after_id_where, 10 );
+ }
foreach ( $result->products as $product ) {
$mapped_data = $this->mapper->map_product( $product );
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Feed/WalkerProgress.php b/plugins/woocommerce/src/Internal/ProductFeed/Feed/WalkerProgress.php
index 4955982b3b9..9537c30d483 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Feed/WalkerProgress.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Feed/WalkerProgress.php
@@ -43,6 +43,15 @@ final class WalkerProgress {
*/
public int $processed_batches = 0;
+ /**
+ * ID of the last walked product, to resume after.
+ *
+ * @since 11.3.0
+ *
+ * @var int
+ */
+ public int $last_product_id = 0;
+
/**
* Creates a WalkerProgress instance from a WooCommerce products query result.
*
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ApiController.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ApiController.php
index 33f5b303766..f329948bfd4 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ApiController.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ApiController.php
@@ -134,6 +134,7 @@ class ApiController {
$response['path'],
$response['file_name'],
$response['page'],
+ $response['last_product_id'],
$response['entries_written'],
$response['updated_at']
);
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGenerator.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGenerator.php
index 5abb1e59966..373a8f65c25 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGenerator.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGenerator.php
@@ -207,6 +207,14 @@ class AsyncGenerator {
$is_first_chunk = self::STATE_SCHEDULED === $status['state'];
+ // A continuation saved by an older version tracked pages rather than product IDs, so it cannot be
+ // resumed. Restart it from scratch right away instead of leaving it for the stuck-job recovery.
+ if ( ! $is_first_chunk && ! isset( $status['last_product_id'] ) ) {
+ $this->discard_feed( $status );
+ unset( $status['page'] );
+ $is_first_chunk = true;
+ }
+
// A continuation must know which feed file it is appending to. If it doesn't, the status is
// corrupt; bail and let the heartbeat-based recovery restart generation from scratch.
if ( ! $is_first_chunk && empty( $status['file_name'] ) ) {
@@ -222,7 +230,7 @@ class AsyncGenerator {
if ( $is_first_chunk ) {
$status['file_name'] = $feed->open();
- $status['page'] = 1;
+ $status['last_product_id'] = 0;
$status['processed'] = 0;
$status['entries_written'] = 0;
} else {
@@ -243,26 +251,26 @@ class AsyncGenerator {
$this->apply_mapper_args( $status['args'] ?? array() );
- $start_page = max( 1, (int) ( $status['page'] ?? 1 ) );
+ // The walker's total only counts products after `last_product_id`, so add what earlier chunks processed.
$base_processed = (int) ( $status['processed'] ?? 0 );
- $progress = $walker->walk_batches(
+ $progress = $walker->walk_chunk(
function ( WalkerProgress $progress ) use ( &$status, $option_key, $base_processed ) {
// Refresh progress and the heartbeat after every batch, so polling sees smooth progress
// within a chunk rather than a single jump at the chunk boundary.
- $status = $this->update_progress( $status, $base_processed + $progress->processed_items, $progress->total_count );
+ $status = $this->update_progress( $status, $base_processed + $progress->processed_items, $base_processed + $progress->total_count );
update_option( $option_key, $status );
},
- $start_page,
+ (int) $status['last_product_id'],
$this->get_chunk_batch_count( $option_key )
);
// The feed's entry count is already cumulative across chunks (open() seeds it with the running
// total when resuming), so store it as-is rather than adding to the previous total.
- $status = $this->update_progress( $status, $base_processed + $progress->processed_items, $progress->total_count );
+ $status = $this->update_progress( $status, $base_processed + $progress->processed_items, $base_processed + $progress->total_count );
$status['entries_written'] = $feed->get_entry_count();
- $status['page'] = $start_page + $progress->processed_batches;
+ $status['last_product_id'] = $progress->last_product_id;
- $is_complete = $progress->total_batch_count <= 0 || (int) $status['page'] > $progress->total_batch_count;
+ $is_complete = $progress->processed_batches >= $progress->total_batch_count;
if ( $is_complete ) {
$feed->end();
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Feed/ProductWalkerTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Feed/ProductWalkerTest.php
index 4d49c8a48e0..42fdfaf04d4 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Feed/ProductWalkerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Feed/ProductWalkerTest.php
@@ -165,9 +165,12 @@ class ProductWalkerTest extends \WC_Unit_Test_Case {
->with(
$this->callback(
function ( $args ) use ( &$loaded_page, $batch_size, $add_args_filter, $parent_exclude ) {
- // Check pagination.
- $this->assertEquals( ++$loaded_page, $args['page'] );
+ // Each batch resumes after the last product ID of the previous one.
+ $this->assertSame( $loaded_page * $batch_size, $args['wc_product_feed_after_id'] );
+ ++$loaded_page;
$this->assertEquals( $batch_size, $args['limit'] );
+ $this->assertSame( 'ID', $args['orderby'] );
+ $this->assertSame( 'ASC', $args['order'] );
// The argument coming from the factory method should be here.
$this->assertEquals( $parent_exclude, $args['parent_exclude'] );
@@ -247,6 +250,9 @@ class ProductWalkerTest extends \WC_Unit_Test_Case {
function ( $args, $integration ) use ( $mock_integration ) {
$this->assertSame( $mock_integration, $integration );
unset( $args['category'] );
+ // The walker must keep its ID order even if a filter changes it.
+ $args['orderby'] = 'date';
+ $args['order'] = 'DESC';
return $args;
},
10,
@@ -288,33 +294,30 @@ class ProductWalkerTest extends \WC_Unit_Test_Case {
}
/**
- * Test that a bounded walk processes a limited number of batches, resumes from the right page,
- * stops at the last page, and never manages the feed lifecycle (start/end).
+ * @testdox Should walk a bounded chunk, resume after the last walked ID, stop at the last page, and never manage the feed lifecycle.
*/
- public function test_walk_processes_bounded_pages_and_resumes() {
+ public function test_walk_processes_bounded_pages_and_resumes(): void {
$batch_size = 2;
$total = 6;
$total_pages = 3;
- // Build the pages of products the loader will return, keyed by page number.
- $pages = array();
- for ( $page = 1; $page <= $total_pages; $page++ ) {
- $products = array();
- for ( $i = 0; $i < $batch_size; $i++ ) {
- $products[] = $this->create_in_memory_product( ( ( $page - 1 ) * $batch_size ) + $i + 1 );
- }
- $pages[ $page ] = (object) array(
- 'products' => $products,
- 'total' => $total,
- 'max_num_pages' => $total_pages,
- );
- }
-
+ // Products have IDs 1 to $total; the loader returns the first batch after the requested ID.
$mock_loader = $this->createMock( ProductLoader::class );
$this->test_container->replace( ProductLoader::class, $mock_loader );
$mock_loader->method( 'get_products' )->willReturnCallback(
- function ( $args ) use ( $pages ) {
- return $pages[ $args['page'] ];
+ function ( $args ) use ( $total, $batch_size ) {
+ $after_id = $args['wc_product_feed_after_id'];
+ $remaining = $total - $after_id;
+ $products = $remaining > 0 ? array_map(
+ fn( $id ) => $this->create_in_memory_product( $id ),
+ range( $after_id + 1, min( $total, $after_id + $batch_size ) )
+ ) : array();
+
+ return (object) array(
+ 'products' => $products,
+ 'total' => $remaining,
+ 'max_num_pages' => (int) ceil( $remaining / $batch_size ),
+ );
}
);
@@ -339,19 +342,61 @@ class ProductWalkerTest extends \WC_Unit_Test_Case {
$mock_integration->method( 'get_feed_validator' )->willReturn( $mock_validator );
$mock_integration->method( 'get_product_feed_query_args' )->willReturn( array() );
- $walker = ProductWalker::from_integration( $mock_integration, $mock_feed );
- $walker->set_batch_size( $batch_size );
+ $sut = ProductWalker::from_integration( $mock_integration, $mock_feed );
+ $sut->set_batch_size( $batch_size );
- // First chunk: pages 1-2 (allotment of 2 batches reached).
- $progress = $walker->walk_batches( null, 1, 2 );
+ // First chunk: products 1-4 (allotment of 2 batches reached).
+ $progress = $sut->walk_chunk( null, 0, 2 );
$this->assertSame( $total, $progress->total_count );
$this->assertSame( $total_pages, $progress->total_batch_count );
$this->assertSame( 2, $progress->processed_batches );
$this->assertSame( 4, $progress->processed_items );
+ $this->assertSame( 4, $progress->last_product_id );
- // Final chunk: resumes at page 3 and stops at the last page even though 2 batches were allowed.
- $progress = $walker->walk_batches( null, 3, 2 );
+ // Final chunk: resumes after product 4 and stops at the last page even though 2 batches were allowed.
+ $progress = $sut->walk_chunk( null, $progress->last_product_id, 2 );
+ $this->assertSame( 2, $progress->total_count );
+ $this->assertSame( 1, $progress->total_batch_count );
$this->assertSame( 1, $progress->processed_batches );
$this->assertSame( 2, $progress->processed_items );
+ $this->assertSame( 6, $progress->last_product_id );
+ }
+
+ /**
+ * @testdox Should export every product exactly once when products share a date and a newer one is created mid-walk.
+ */
+ public function test_walk_pages_are_stable_for_products_with_the_same_date(): void {
+ $product_ids = array();
+ for ( $i = 0; $i < 3; $i++ ) {
+ $product_ids[] = \WC_Helper_Product::create_simple_product( true, array( 'date_created' => '2026-01-01 00:00:00' ) )->get_id();
+ }
+
+ $exported_ids = array();
+ $mock_feed = $this->createMock( FeedInterface::class );
+ $mock_feed->method( 'add_entry' )->willReturnCallback(
+ function ( array $entry ) use ( &$exported_ids ) {
+ $exported_ids[] = $entry['id'];
+ }
+ );
+
+ $mock_mapper = $this->createMock( ProductMapperInterface::class );
+ $mock_mapper->method( 'map_product' )->willReturnCallback(
+ fn( WC_Product $product ) => array( 'id' => $product->get_id() )
+ );
+
+ $mock_integration = $this->createMock( IntegrationInterface::class );
+ $mock_integration->method( 'get_product_mapper' )->willReturn( $mock_mapper );
+ $mock_integration->method( 'get_feed_validator' )->willReturn( $this->createMock( FeedValidatorInterface::class ) );
+ $mock_integration->method( 'get_product_feed_query_args' )->willReturn( array() );
+
+ $sut = ProductWalker::from_integration( $mock_integration, $mock_feed );
+ $sut->set_batch_size( 1 );
+ $progress = $sut->walk_chunk( null, 0, 2 );
+
+ $product_ids[] = \WC_Helper_Product::create_simple_product( true, array( 'date_created' => '2026-01-02 00:00:00' ) )->get_id();
+
+ $sut->walk_chunk( null, $progress->last_product_id );
+
+ $this->assertSame( $product_ids, $exported_ids, 'Each product should be exported once, in ID order, with the new product last' );
}
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/ApiControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/ApiControllerTest.php
index dad28754df5..4703a1fb897 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/ApiControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/ApiControllerTest.php
@@ -95,6 +95,7 @@ class ApiControllerTest extends \WC_Unit_Test_Case {
'path' => '/tmp/random_path.json',
'file_name' => 'pos-catalog-feed.json',
'page' => 3,
+ 'last_product_id' => 3,
'entries_written' => 250,
'updated_at' => time(),
'url' => 'https://old.example/uploads/product-feeds/pos-catalog-feed.json',
@@ -118,7 +119,7 @@ class ApiControllerTest extends \WC_Unit_Test_Case {
$this->assertEquals( 200, $response->get_status() );
// Sensitive and internal-only fields must never be exposed to the client.
- foreach ( array( 'action_id', 'path', 'file_name', 'page', 'entries_written', 'updated_at' ) as $internal_key ) {
+ foreach ( array( 'action_id', 'path', 'file_name', 'page', 'last_product_id', 'entries_written', 'updated_at' ) as $internal_key ) {
$this->assertArrayNotHasKey( $internal_key, $response_data );
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGeneratorTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGeneratorTest.php
index f223ad1d7b8..9695bfe1063 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGeneratorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGeneratorTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\ProductFeed\Integrations\POSCatalog;
use PHPUnit\Framework\MockObject\MockObject;
+use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedValidatorInterface;
use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\AsyncGenerator;
use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\POSIntegration;
@@ -360,11 +361,13 @@ class AsyncGeneratorTest extends \WC_Unit_Test_Case {
/**
* Configures the mock integration with a real feed and lightweight mapper/validator so the
* chunked generation path can be exercised end to end.
+ *
+ * @param array $query_args The product query args the integration should return.
*/
- private function setup_real_feed_integration(): void {
+ private function setup_real_feed_integration( array $query_args = array() ): void {
$this->delete_all_products();
- $this->mock_integration->method( 'get_product_feed_query_args' )->willReturn( array() );
+ $this->mock_integration->method( 'get_product_feed_query_args' )->willReturn( $query_args );
$this->mock_integration->method( 'create_feed' )->willReturnCallback(
fn() => new JsonFileFeed( 'pos-catalog-feed-test' )
);
@@ -468,6 +471,123 @@ class AsyncGeneratorTest extends \WC_Unit_Test_Case {
wp_delete_file( $status['path'] );
}
+ /**
+ * Data provider for removing an already-exported product between chunks.
+ *
+ * @return array
+ */
+ public function provider_removed_product(): array {
+ return array(
+ 'trashed' => array( fn( \WC_Product $product ) => $product->delete() ),
+ 'unpublished' => array(
+ function ( \WC_Product $product ) {
+ $product->set_status( ProductStatus::DRAFT );
+ $product->save();
+ },
+ ),
+ 'POS-hidden' => array( fn( \WC_Product $product ) => wp_set_object_terms( $product->get_id(), 'pos-hidden', 'pos_product_visibility' ) ),
+ );
+ }
+
+ /**
+ * @testdox Should not skip a product when an already-exported $_dataName product is removed between chunks.
+ *
+ * @dataProvider provider_removed_product
+ *
+ * @param callable $remove Removes the given product from the catalog.
+ */
+ public function test_feed_generation_does_not_skip_products_when_exported_product_is_removed( callable $remove ): void {
+ add_filter( 'woocommerce_product_feed_batch_size', fn() => 1 );
+ add_filter( 'woocommerce_product_feed_chunk_size', fn() => 1 );
+
+ $this->setup_real_feed_integration( ( new POSIntegration() )->get_product_feed_query_args() );
+
+ $products = array();
+ for ( $i = 0; $i < 3; $i++ ) {
+ $products[] = WC_Helper_Product::create_simple_product();
+ }
+ $product_ids = array_map( fn( $product ) => $product->get_id(), $products );
+
+ update_option( self::OPTION_KEY, array( 'state' => AsyncGenerator::STATE_SCHEDULED ) );
+
+ // The first chunk exports the first product, which is then removed before the next chunk.
+ $this->sut->feed_generation_action( self::OPTION_KEY );
+ $remove( $products[0] );
+ wp_cache_flush();
+
+ $iterations = 1;
+ do {
+ $this->sut->feed_generation_action( self::OPTION_KEY );
+ $status = get_option( self::OPTION_KEY );
+ ++$iterations;
+ } while ( AsyncGenerator::STATE_IN_PROGRESS === $status['state'] && $iterations < 10 );
+
+ $this->assertSame( AsyncGenerator::STATE_COMPLETED, $status['state'] );
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $entries = json_decode( (string) file_get_contents( $status['path'] ), true );
+ $this->assertSame( $product_ids, array_column( $entries, 'id' ) );
+
+ wp_delete_file( $status['path'] );
+ }
+
+ /**
+ * @testdox Should restart a continuation saved by a version that tracked pages instead of product IDs.
+ */
+ public function test_feed_generation_restarts_legacy_page_based_continuation(): void {
+ add_filter( 'woocommerce_product_feed_batch_size', fn() => 1 );
+ add_filter( 'woocommerce_product_feed_chunk_size', fn() => 1 );
+
+ $this->setup_real_feed_integration();
+
+ $product_ids = array();
+ for ( $i = 0; $i < 3; $i++ ) {
+ $product_ids[] = WC_Helper_Product::create_simple_product()->get_id();
+ }
+
+ // A partial feed written by the older version an hour ago, holding a stale entry. Feed file names
+ // are derived from the current second, so the earlier time keeps it distinct from the restarted feed.
+ $an_hour_ago = fn() => time() - HOUR_IN_SECONDS;
+ add_filter( 'woocommerce_product_feed_time', $an_hour_ago );
+ $legacy_feed = new JsonFileFeed( 'pos-catalog-feed-test' );
+ $legacy_file = $legacy_feed->open();
+ $legacy_feed->add_entry( array( 'id' => 999999 ) );
+ $legacy_feed->flush();
+ remove_filter( 'woocommerce_product_feed_time', $an_hour_ago );
+ $legacy_path = wp_upload_dir()['basedir'] . '/' . JsonFileFeed::UPLOAD_DIR . '/' . $legacy_file;
+
+ update_option(
+ self::OPTION_KEY,
+ array(
+ 'state' => AsyncGenerator::STATE_IN_PROGRESS,
+ 'file_name' => $legacy_file,
+ 'page' => 2,
+ 'processed' => 1,
+ 'entries_written' => 1,
+ 'total' => 3,
+ 'updated_at' => time(),
+ )
+ );
+
+ $iterations = 0;
+ do {
+ $this->sut->feed_generation_action( self::OPTION_KEY );
+ $status = get_option( self::OPTION_KEY );
+ ++$iterations;
+ } while ( AsyncGenerator::STATE_IN_PROGRESS === $status['state'] && $iterations < 10 );
+
+ $this->assertSame( AsyncGenerator::STATE_COMPLETED, $status['state'] );
+ $this->assertArrayNotHasKey( 'page', $status );
+ $this->assertFileDoesNotExist( $legacy_path, 'The legacy partial feed should be discarded' );
+ $this->assertFalse( get_option( self::OPTION_KEY . '_chunk_size' ), 'A legacy restart should not reduce the chunk size' );
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $entries = json_decode( (string) file_get_contents( $status['path'] ), true );
+ $this->assertSame( $product_ids, array_column( $entries, 'id' ) );
+
+ wp_delete_file( $status['path'] );
+ }
+
/**
* Test that entries_written accumulates correctly across chunks rather than being double-counted.
*
@@ -564,12 +684,12 @@ class AsyncGeneratorTest extends \WC_Unit_Test_Case {
update_option(
$option_key,
array(
- 'state' => AsyncGenerator::STATE_IN_PROGRESS,
- 'updated_at' => time() - HOUR_IN_SECONDS,
- 'file_name' => $identifier,
- 'page' => 3,
- 'processed' => 50000,
- 'total' => 120000,
+ 'state' => AsyncGenerator::STATE_IN_PROGRESS,
+ 'updated_at' => time() - HOUR_IN_SECONDS,
+ 'file_name' => $identifier,
+ 'last_product_id' => 3,
+ 'processed' => 50000,
+ 'total' => 120000,
)
);
@@ -635,7 +755,7 @@ class AsyncGeneratorTest extends \WC_Unit_Test_Case {
'state' => AsyncGenerator::STATE_IN_PROGRESS,
'updated_at' => time() - HOUR_IN_SECONDS,
'file_name' => $identifier,
- 'page' => 3,
+ 'last_product_id' => 3,
'processed' => 2500,
'total' => 12000,
'entries_written' => 2500,
@@ -682,7 +802,7 @@ class AsyncGeneratorTest extends \WC_Unit_Test_Case {
array(
'state' => AsyncGenerator::STATE_IN_PROGRESS,
'file_name' => 'pos-catalog-feed-missing.json',
- 'page' => 5,
+ 'last_product_id' => 5,
'processed' => 999,
'entries_written' => 999,
'total' => 999,
@@ -728,7 +848,7 @@ class AsyncGeneratorTest extends \WC_Unit_Test_Case {
array(
'state' => AsyncGenerator::STATE_IN_PROGRESS,
'file_name' => $identifier,
- 'page' => 2,
+ 'last_product_id' => 2,
'processed' => 1,
'entries_written' => 1,
'total' => 5,