Commit 658f4454a11 for woocommerce
commit 658f4454a11ae9bf5ba87a237b1ddf1482e16a20
Author: Ján Mikláš <neosinner@gmail.com>
Date: Fri Sep 4 15:32:15 2026 +0200
Limit product CSV import cleanup to temporary products (#68090)
* Scope product import cleanup to placeholders
* Add changelog entry for import cleanup
* Recheck product import placeholders before deletion
* Delete product import placeholders in batches
The cleanup loaded every 'importing' post ID at once and primed the post
cache for all of them before deleting each one through wp_delete_post().
_prime_post_caches() does not chunk, so a store with a large backlog of
placeholders could exhaust memory or time in the final AJAX request and
leave the cleanup unfinished.
Select and delete a bounded batch per query instead, and stop when a batch
yields no deletions so a placeholder another plugin keeps alive cannot spin
the loop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cu33WrEUCZnef8WnShfwFh
* Page import cleanup candidates by post ID
wp_delete_post() returns whatever pre_delete_post filtered, so a plugin
returning the post counted as a deletion and the same page was re-read
forever. Counting successful deletions also abandoned every placeholder
behind a full page of blocked ones.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeXcfqsS3sEYHukycQgGH2
* Claim import placeholders before deleting them
A process that completes a placeholder does not invalidate this process's
object cache, so the pre-delete status recheck could still read 'importing'
and force delete a published product. A guarded UPDATE per post makes the
check atomic, and the claim is released if the post survives deletion.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeXcfqsS3sEYHukycQgGH2
* Resume import cleanup across requests within a time budget
One full wp_delete_post() lifecycle per placeholder is slow enough that an
import leaving hundreds of them ran well past the request limit, and the
client only logged the failure. Cleanup now runs in requests of its own and
returns a cleanup:<id> position until no placeholders are left.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeXcfqsS3sEYHukycQgGH2
* Cover a pre_delete_post filter that reports a deletion it did not make
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeXcfqsS3sEYHukycQgGH2
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/performance-scope-product-import-cleanup b/plugins/woocommerce/changelog/performance-scope-product-import-cleanup
new file mode 100644
index 00000000000..e7d4737b194
--- /dev/null
+++ b/plugins/woocommerce/changelog/performance-scope-product-import-cleanup
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Avoid site-wide orphan cleanup after product CSV imports.
diff --git a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php
index 1d5a89c709c..c7122090d92 100644
--- a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php
+++ b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php
@@ -26,6 +26,26 @@ if ( ! class_exists( 'WP_Importer' ) ) {
*/
class WC_Product_CSV_Importer_Controller {
+ /**
+ * Number of temporary products deleted per query when cleaning up after an import.
+ */
+ private const CLEANUP_BATCH_SIZE = 100;
+
+ /**
+ * Status the importer gives the temporary products it creates for unresolved references.
+ */
+ private const CLEANUP_PLACEHOLDER_STATUS = 'importing';
+
+ /**
+ * Status a temporary product is moved to once cleanup has claimed it for deletion.
+ */
+ private const CLEANUP_CLAIMED_STATUS = 'importing-cleanup';
+
+ /**
+ * Prefix of the AJAX position that runs, and resumes, the cleanup phase.
+ */
+ private const CLEANUP_POSITION_PREFIX = 'cleanup:';
+
/**
* The path to the current file.
*
@@ -316,23 +336,197 @@ class WC_Product_CSV_Importer_Controller {
$this->output_footer();
}
+ /**
+ * Remove temporary products and mapping data left by the importer.
+ *
+ * @param int $cursor Highest placeholder ID earlier cleanup requests have examined.
+ * @return int|null ID to resume from, or null once no placeholders are left.
+ */
+ private static function cleanup_after_import( int $cursor = 0 ): ?int {
+ global $wpdb;
+
+ if ( 0 === $cursor ) {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- The importer requires one uncached cleanup of its temporary mapping markers.
+ $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_original_id' ) );
+ }
+
+ /** This filter is documented in includes/import/abstract-wc-product-importer.php */
+ $time_limit = (int) apply_filters( 'woocommerce_product_importer_default_time_limit', 20 ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
+ $deadline = time() + $time_limit;
+
+ // Page by ID so a placeholder that cannot be deleted is passed over instead of being read again.
+ while ( true ) {
+ // The claimed status is also selected so a request that died mid-batch does not strand its placeholders.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $post_ids = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT ID FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation' ) AND post_status IN ( %s, %s ) AND ID > %d ORDER BY ID ASC LIMIT %d",
+ self::CLEANUP_PLACEHOLDER_STATUS,
+ self::CLEANUP_CLAIMED_STATUS,
+ $cursor,
+ self::CLEANUP_BATCH_SIZE
+ )
+ );
+
+ if ( ! $post_ids ) {
+ return null;
+ }
+
+ $post_ids = array_map( 'absint', $post_ids );
+ $cursor = (int) end( $post_ids );
+
+ // Ascending IDs delete a placeholder parent before its variations, so WooCommerce removes them through the normal lifecycle.
+ foreach ( $post_ids as $post_id ) {
+ self::delete_claimed_placeholder( $post_id );
+
+ // One delete lifecycle can take seconds, so check the budget per placeholder rather than per batch.
+ if ( time() >= $deadline ) {
+ return $post_id;
+ }
+ }
+ }
+ }
+
+ /**
+ * Run one bounded pass of the post-import cleanup and tell the client where to resume.
+ *
+ * The cursor only ever skips placeholders forward, and the queries it feeds match nothing
+ * but the importer's own temporary products, so it needs no server-side state of its own.
+ *
+ * @param int $cursor Highest placeholder ID earlier cleanup requests have examined.
+ */
+ private static function dispatch_ajax_cleanup( int $cursor ): void {
+ $next_cursor = self::cleanup_after_import( $cursor );
+
+ $response = array(
+ 'position' => null === $next_cursor ? 'done' : self::CLEANUP_POSITION_PREFIX . $next_cursor,
+ 'percentage' => 100,
+ 'imported' => 0,
+ 'imported_variations' => 0,
+ 'failed' => 0,
+ 'updated' => 0,
+ 'skipped' => 0,
+ );
+
+ if ( null === $next_cursor ) {
+ $response['url'] = add_query_arg( array( '_wpnonce' => wp_create_nonce( 'woocommerce-csv-importer' ) ), admin_url( 'edit.php?post_type=product&page=product_importer&step=done' ) );
+ }
+
+ wp_send_json_success( $response );
+ }
+
+ /**
+ * Release temporary products a cleanup request claimed but did not live to delete.
+ *
+ * A fatal inside the delete lifecycle leaves the claimed status behind, and the importer only
+ * recognises a placeholder by the status it was created with, so the CSV row owning that SKU
+ * would be skipped as an existing product. Healing at the start of a run keeps that contained.
+ */
+ private static function release_stranded_cleanup_claims(): void {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $post_ids = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT ID FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation' ) AND post_status = %s",
+ self::CLEANUP_CLAIMED_STATUS
+ )
+ );
+
+ if ( ! $post_ids ) {
+ return;
+ }
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->query(
+ $wpdb->prepare(
+ "UPDATE {$wpdb->posts} SET post_status = %s WHERE post_type IN ( 'product', 'product_variation' ) AND post_status = %s",
+ self::CLEANUP_PLACEHOLDER_STATUS,
+ self::CLEANUP_CLAIMED_STATUS
+ )
+ );
+
+ wp_cache_delete_multiple( array_map( 'absint', $post_ids ), 'posts' );
+ }
+
+ /**
+ * Claim one importer placeholder and delete it only if the claim succeeded.
+ *
+ * Reading the status from the object cache cannot decide this: a process that completes a
+ * placeholder does not invalidate this process's cache, so a published product would still
+ * look like a placeholder and be force deleted. The guarded UPDATE makes the status check
+ * atomic instead, the way ActionScheduler_DBStore::claim_actions() claims actions, and runs
+ * per post so a placeholder completed while the batch is being deleted is still protected.
+ *
+ * @param int $post_id Candidate post ID.
+ */
+ private static function delete_claimed_placeholder( int $post_id ): void {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->query(
+ $wpdb->prepare(
+ "UPDATE {$wpdb->posts} SET post_status = %s WHERE ID = %d AND post_type IN ( 'product', 'product_variation' ) AND post_status IN ( %s, %s )",
+ self::CLEANUP_CLAIMED_STATUS,
+ $post_id,
+ self::CLEANUP_PLACEHOLDER_STATUS,
+ self::CLEANUP_CLAIMED_STATUS
+ )
+ );
+
+ // The claim wrote the row behind the object cache's back, so read it back from the database.
+ wp_cache_delete( $post_id, 'posts' );
+
+ $post = get_post( $post_id );
+
+ if ( ! $post || self::CLEANUP_CLAIMED_STATUS !== $post->post_status ) {
+ return;
+ }
+
+ try {
+ wp_delete_post( $post_id, true );
+ } finally {
+ // Release the claim if the post outlived the delete lifecycle, so it stays a placeholder for the next run.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $released = $wpdb->query(
+ $wpdb->prepare(
+ "UPDATE {$wpdb->posts} SET post_status = %s WHERE ID = %d AND post_status = %s",
+ self::CLEANUP_PLACEHOLDER_STATUS,
+ $post_id,
+ self::CLEANUP_CLAIMED_STATUS
+ )
+ );
+
+ if ( $released ) {
+ wp_cache_delete( $post_id, 'posts' );
+ }
+ }
+ }
+
/**
* Processes AJAX requests related to a product CSV import.
*
* @since 9.3.0
*/
public static function dispatch_ajax() {
- global $wpdb;
-
check_ajax_referer( 'wc-product-import', 'security' );
try {
+ $position = wc_clean( wp_unslash( $_POST['position'] ?? '' ) );
+
+ // Cleanup runs in requests of its own so it cannot time out behind the last import batch.
+ if ( is_string( $position ) && preg_match( '/^' . preg_quote( self::CLEANUP_POSITION_PREFIX, '/' ) . '(\\d+)$/', $position, $matches ) ) {
+ self::dispatch_ajax_cleanup( (int) $matches[1] );
+
+ return;
+ }
+
$file = wc_clean( wp_unslash( $_POST['file'] ?? '' ) ); // PHPCS: input var ok.
self::validate_file_path( $file );
$params = array(
'delimiter' => ! empty( $_POST['delimiter'] ) ? wc_clean( wp_unslash( $_POST['delimiter'] ) ) : ',', // PHPCS: input var ok.
- 'start_pos' => isset( $_POST['position'] ) ? absint( $_POST['position'] ) : 0, // PHPCS: input var ok.
+ 'start_pos' => absint( $position ),
'mapping' => isset( $_POST['mapping'] ) ? (array) wc_clean( wp_unslash( $_POST['mapping'] ) ) : array(), // PHPCS: input var ok.
'update_existing' => isset( $_POST['update_existing'] ) ? (bool) $_POST['update_existing'] : false, // PHPCS: input var ok.
'character_encoding' => isset( $_POST['character_encoding'] ) ? wc_clean( wp_unslash( $_POST['character_encoding'] ) ) : '',
@@ -355,6 +549,10 @@ class WC_Product_CSV_Importer_Controller {
$error_log = array();
}
+ if ( 0 === $params['start_pos'] ) {
+ self::release_stranded_cleanup_claims();
+ }
+
include_once WC_ABSPATH . 'includes/import/class-wc-product-csv-importer.php';
$importer = self::get_importer( $file, $params );
@@ -364,70 +562,20 @@ class WC_Product_CSV_Importer_Controller {
update_user_option( get_current_user_id(), 'product_import_error_log', $error_log );
- if ( 100 === $percent_complete ) {
- // @codingStandardsIgnoreStart.
- $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_original_id' ) );
- $wpdb->delete( $wpdb->posts, array(
- 'post_type' => 'product',
- 'post_status' => 'importing',
- ) );
- $wpdb->delete( $wpdb->posts, array(
- 'post_type' => 'product_variation',
- 'post_status' => 'importing',
- ) );
- // @codingStandardsIgnoreEnd.
-
- // Clean up orphaned data.
- $wpdb->query(
- "
- DELETE {$wpdb->posts}.* FROM {$wpdb->posts}
- LEFT JOIN {$wpdb->posts} wp ON wp.ID = {$wpdb->posts}.post_parent
- WHERE wp.ID IS NULL AND {$wpdb->posts}.post_type = 'product_variation'
- "
- );
- $wpdb->query(
- "
- DELETE {$wpdb->postmeta}.* FROM {$wpdb->postmeta}
- LEFT JOIN {$wpdb->posts} wp ON wp.ID = {$wpdb->postmeta}.post_id
- WHERE wp.ID IS NULL
- "
- );
- // @codingStandardsIgnoreStart.
- $wpdb->query( "
- DELETE tr.* FROM {$wpdb->term_relationships} tr
- LEFT JOIN {$wpdb->posts} wp ON wp.ID = tr.object_id
- LEFT JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
- WHERE wp.ID IS NULL
- AND tt.taxonomy IN ( '" . implode( "','", array_map( 'esc_sql', get_object_taxonomies( 'product' ) ) ) . "' )
- " );
- // @codingStandardsIgnoreEnd.
-
- // Send success.
- wp_send_json_success(
- array(
- 'position' => 'done',
- 'percentage' => 100,
- 'url' => add_query_arg( array( '_wpnonce' => wp_create_nonce( 'woocommerce-csv-importer' ) ), admin_url( 'edit.php?post_type=product&page=product_importer&step=done' ) ),
- 'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0,
- 'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0,
- 'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0,
- 'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0,
- 'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0,
- )
- );
- } else {
- wp_send_json_success(
- array(
- 'position' => $importer->get_file_position(),
- 'percentage' => $percent_complete,
- 'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0,
- 'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0,
- 'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0,
- 'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0,
- 'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0,
- )
- );
- }
+ // The last row hands over to the cleanup phase rather than running it in this request.
+ $next_position = 100 === $percent_complete ? self::CLEANUP_POSITION_PREFIX . '0' : $importer->get_file_position();
+
+ wp_send_json_success(
+ array(
+ 'position' => $next_position,
+ 'percentage' => $percent_complete,
+ 'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0,
+ 'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0,
+ 'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0,
+ 'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0,
+ 'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0,
+ )
+ );
} catch ( \Exception $e ) {
wp_send_json_error( array( 'message' => $e->getMessage() ) );
}
diff --git a/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php b/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php
index 3fd85e8e208..cd205699789 100644
--- a/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php
@@ -84,4 +84,413 @@ class WC_Product_CSV_Importer_Controller_Test extends WC_Unit_Test_Case {
$this->assertSame( $delimiter, $params['delimiter'], 'The delimiter should survive the query string round trip unchanged' );
$this->assertSame( $character_encoding, $params['character_encoding'], 'The character encoding should survive the query string round trip unchanged' );
}
+
+ /**
+ * @testdox Import cleanup should delete placeholders through the product deletion lifecycle.
+ */
+ public function test_cleanup_after_import_deletes_importing_products_and_related_data(): void {
+ global $wpdb;
+
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Import cleanup placeholder' );
+ $product->set_sku( 'IMPORT-CLEANUP-PARENT' );
+ $product->set_status( 'importing' );
+ $product->save();
+
+ $variation = new WC_Product_Variation();
+ $variation->set_parent_id( $product->get_id() );
+ $variation->set_sku( 'IMPORT-CLEANUP-VARIATION' );
+ $variation->set_status( 'publish' );
+ $variation->save();
+
+ $term_id = self::factory()->term->create(
+ array(
+ 'taxonomy' => 'product_cat',
+ 'name' => 'Import cleanup category',
+ )
+ );
+ wp_set_object_terms( $product->get_id(), array( $term_id ), 'product_cat' );
+
+ $product_id = $product->get_id();
+ $variation_id = $variation->get_id();
+
+ $this->assertSame( 1, $this->get_product_lookup_row_count( $product_id ) );
+ $this->assertSame( 1, $this->get_product_lookup_row_count( $variation_id ) );
+
+ $this->invoke_cleanup_after_import();
+
+ $this->assertNull( get_post( $product_id ) );
+ $this->assertNull( get_post( $variation_id ) );
+ $this->assertSame( 0, $this->get_product_lookup_row_count( $product_id ) );
+ $this->assertSame( 0, $this->get_product_lookup_row_count( $variation_id ) );
+ $this->assertSame( 0, (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE post_id IN ( %d, %d )", $product_id, $variation_id ) ) );
+ $this->assertSame( 0, (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->term_relationships} WHERE object_id = %d", $product_id ) ) );
+ $this->assertSame( 0, wc_get_product_id_by_sku( 'IMPORT-CLEANUP-PARENT' ) );
+ $this->assertSame( 0, wc_get_product_id_by_sku( 'IMPORT-CLEANUP-VARIATION' ) );
+ }
+
+ /**
+ * @testdox Import cleanup should delete importing variations whose parent remains.
+ */
+ public function test_cleanup_after_import_deletes_remaining_importing_variations(): void {
+ $parent = WC_Helper_Product::create_simple_product();
+
+ $variation = new WC_Product_Variation();
+ $variation->set_parent_id( $parent->get_id() );
+ $variation->set_sku( 'IMPORT-CLEANUP-REMAINING-VARIATION' );
+ $variation->set_status( 'importing' );
+ $variation->save();
+
+ $parent_id = $parent->get_id();
+ $variation_id = $variation->get_id();
+
+ $this->invoke_cleanup_after_import();
+
+ $this->assertNotNull( get_post( $parent_id ) );
+ $this->assertNull( get_post( $variation_id ) );
+ $this->assertSame( 0, $this->get_product_lookup_row_count( $variation_id ) );
+ }
+
+ /**
+ * @testdox Import cleanup should preserve a placeholder another process completed after candidate selection.
+ */
+ public function test_cleanup_after_import_rechecks_placeholder_status_before_deletion(): void {
+ $post_ids = array(
+ wp_insert_post(
+ array(
+ 'post_type' => 'product',
+ 'post_status' => 'importing',
+ 'post_title' => 'First import cleanup placeholder',
+ )
+ ),
+ wp_insert_post(
+ array(
+ 'post_type' => 'product',
+ 'post_status' => 'importing',
+ 'post_title' => 'Second import cleanup placeholder',
+ )
+ ),
+ );
+ $completed_id = 0;
+ $complete_next_placeholder = static function ( $deleted_post_id ) use ( $post_ids, &$completed_id ): void {
+ if ( ! in_array( $deleted_post_id, $post_ids, true ) || $completed_id ) {
+ return;
+ }
+
+ $completed_id = current( array_diff( $post_ids, array( $deleted_post_id ) ) );
+
+ // Suspending invalidation leaves this process holding the stale placeholder, the way a
+ // concurrent request completing the placeholder would.
+ wp_suspend_cache_invalidation( true );
+
+ try {
+ wp_update_post(
+ array(
+ 'ID' => $completed_id,
+ 'post_status' => 'publish',
+ )
+ );
+ } finally {
+ wp_suspend_cache_invalidation( false );
+ }
+ };
+ add_action( 'delete_post', $complete_next_placeholder, 1 );
+
+ try {
+ $this->invoke_cleanup_after_import();
+ } finally {
+ remove_action( 'delete_post', $complete_next_placeholder, 1 );
+ }
+
+ $this->assertNotSame( 0, $completed_id );
+ $this->assertNotNull( get_post( $completed_id ) );
+ $this->assertSame( 'publish', get_post_status( $completed_id ) );
+ }
+
+ /**
+ * @testdox Import cleanup should leave unrelated orphaned data for explicit repair tools.
+ */
+ public function test_cleanup_after_import_preserves_unrelated_orphans(): void {
+ global $wpdb;
+
+ $missing_post_id = 999999999;
+ $variation_id = wp_insert_post(
+ array(
+ 'post_type' => 'product_variation',
+ 'post_status' => 'publish',
+ 'post_parent' => $missing_post_id,
+ 'post_title' => 'Unrelated orphan variation',
+ )
+ );
+ add_post_meta( $variation_id, '_unrelated_orphan_marker', 'preserve' );
+
+ $term_id = self::factory()->term->create(
+ array(
+ 'taxonomy' => 'product_cat',
+ 'name' => 'Unrelated orphan category',
+ )
+ );
+ $term = get_term( $term_id, 'product_cat' );
+ $term_taxonomy_id = $term instanceof WP_Term ? $term->term_taxonomy_id : 0;
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- The test deliberately creates orphaned rows that WordPress APIs do not support.
+ $wpdb->insert(
+ $wpdb->postmeta,
+ array(
+ 'post_id' => $missing_post_id,
+ 'meta_key' => '_unrelated_missing_post_marker',
+ 'meta_value' => 'preserve',
+ )
+ );
+ $wpdb->insert(
+ $wpdb->term_relationships,
+ array(
+ 'object_id' => $missing_post_id,
+ 'term_taxonomy_id' => $term_taxonomy_id,
+ 'term_order' => 0,
+ )
+ );
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+
+ $this->invoke_cleanup_after_import();
+
+ $this->assertNotNull( get_post( $variation_id ) );
+ $this->assertSame( 'preserve', get_post_meta( $variation_id, '_unrelated_orphan_marker', true ) );
+ $this->assertSame( 1, (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE post_id = %d", $missing_post_id ) ) );
+ $this->assertSame( 1, (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->term_relationships} WHERE object_id = %d", $missing_post_id ) ) );
+ }
+
+ /**
+ * @testdox Import cleanup should clear original ID markers without deleting completed products.
+ */
+ public function test_cleanup_after_import_clears_original_id_without_deleting_completed_product(): void {
+ global $wpdb;
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product_id = $product->get_id();
+ add_post_meta( $product_id, '_original_id', '12345' );
+
+ $this->assertSame( 1, (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = '_original_id'", $product_id ) ) );
+
+ $this->invoke_cleanup_after_import();
+
+ $this->assertNotNull( get_post( $product_id ) );
+ $this->assertSame( 0, (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = '_original_id'", $product_id ) ) );
+ }
+
+ /**
+ * @testdox Import cleanup should delete more placeholders than fit in a single batch.
+ */
+ public function test_cleanup_after_import_deletes_more_placeholders_than_one_batch(): void {
+ $post_ids = array();
+
+ for ( $index = 0; $index < 101; $index++ ) {
+ $post_ids[] = wp_insert_post(
+ array(
+ 'post_type' => 'product',
+ 'post_status' => 'importing',
+ 'post_title' => 'Import cleanup batch placeholder ' . $index,
+ )
+ );
+ }
+
+ $this->invoke_cleanup_after_import();
+
+ foreach ( $post_ids as $post_id ) {
+ $this->assertNull( get_post( $post_id ) );
+ }
+ }
+
+ /**
+ * @testdox A new import run should release a claim an earlier cleanup did not live to finish.
+ */
+ public function test_release_stranded_cleanup_claims_restores_the_placeholder_status(): void {
+ $stranded_id = wp_insert_post(
+ array(
+ 'post_type' => 'product',
+ 'post_status' => 'importing-cleanup',
+ 'post_title' => 'Import cleanup stranded placeholder',
+ )
+ );
+ $unrelated_id = wp_insert_post(
+ array(
+ 'post_type' => 'product',
+ 'post_status' => 'publish',
+ 'post_title' => 'Unrelated published product',
+ )
+ );
+
+ $class = new ReflectionClass( WC_Product_CSV_Importer_Controller::class );
+ $method = $class->getMethod( 'release_stranded_cleanup_claims' );
+ $method->setAccessible( true );
+ $method->invoke( null );
+
+ // The importer only treats the placeholder status as absent, so the row has to read that way again.
+ $this->assertSame( 'importing', get_post_status( $stranded_id ) );
+ $this->assertSame( 'publish', get_post_status( $unrelated_id ) );
+ }
+
+ /**
+ * @testdox Import cleanup should keep a placeholder it cannot delete.
+ */
+ public function test_cleanup_after_import_keeps_a_placeholder_it_cannot_delete(): void {
+ $post_id = wp_insert_post(
+ array(
+ 'post_type' => 'product',
+ 'post_status' => 'importing',
+ 'post_title' => 'Import cleanup undeletable placeholder',
+ )
+ );
+
+ $block_deletion = static function () {
+ return false;
+ };
+
+ add_filter( 'pre_delete_post', $block_deletion );
+
+ try {
+ $this->invoke_cleanup_after_import();
+ } finally {
+ remove_filter( 'pre_delete_post', $block_deletion );
+ }
+
+ $this->assertNotNull( get_post( $post_id ) );
+ $this->assertSame( 'importing', get_post_status( $post_id ) );
+ }
+
+ /**
+ * @testdox Import cleanup should return a resume cursor once its time budget is spent.
+ */
+ public function test_cleanup_after_import_returns_a_resume_cursor_when_time_is_exceeded(): void {
+ $post_ids = array();
+
+ for ( $index = 0; $index < 3; $index++ ) {
+ $post_ids[] = wp_insert_post(
+ array(
+ 'post_type' => 'product',
+ 'post_status' => 'importing',
+ 'post_title' => 'Import cleanup resumable placeholder ' . $index,
+ )
+ );
+ }
+
+ $spend_budget = static function () {
+ return 0;
+ };
+
+ add_filter( 'woocommerce_product_importer_default_time_limit', $spend_budget );
+
+ try {
+ // A spent budget stops after the first placeholder and reports where to resume.
+ $cursor = $this->invoke_cleanup_after_import();
+
+ $this->assertSame( $post_ids[0], $cursor );
+ $this->assertNull( get_post( $post_ids[0] ) );
+ $this->assertNotNull( get_post( $post_ids[1] ) );
+
+ $cursor = $this->invoke_cleanup_after_import( $cursor );
+
+ $this->assertSame( $post_ids[1], $cursor );
+ $this->assertNull( get_post( $post_ids[1] ) );
+
+ $this->assertSame( $post_ids[2], $this->invoke_cleanup_after_import( $cursor ) );
+ $this->assertNull( $this->invoke_cleanup_after_import( $post_ids[2] ) );
+ } finally {
+ remove_filter( 'woocommerce_product_importer_default_time_limit', $spend_budget );
+ }
+
+ foreach ( $post_ids as $post_id ) {
+ $this->assertNull( get_post( $post_id ) );
+ }
+ }
+
+ /**
+ * @testdox Import cleanup should keep a placeholder a filter only claims to have deleted.
+ */
+ public function test_cleanup_after_import_keeps_a_placeholder_a_filter_claims_to_have_deleted(): void {
+ $post_id = wp_insert_post(
+ array(
+ 'post_type' => 'product',
+ 'post_status' => 'importing',
+ 'post_title' => 'Import cleanup falsely deleted placeholder',
+ )
+ );
+
+ // wp_delete_post() returns whatever this filter returns, so a post here reads as a deletion.
+ $report_deleted = static function ( $check, $post ) {
+ return $post;
+ };
+
+ add_filter( 'pre_delete_post', $report_deleted, 10, 2 );
+
+ try {
+ $this->assertNull( $this->invoke_cleanup_after_import() );
+ } finally {
+ remove_filter( 'pre_delete_post', $report_deleted, 10 );
+ }
+
+ $this->assertNotNull( get_post( $post_id ) );
+ $this->assertSame( 'importing', get_post_status( $post_id ) );
+ }
+
+ /**
+ * @testdox Import cleanup should delete placeholders queued behind one it cannot delete.
+ */
+ public function test_cleanup_after_import_continues_past_a_placeholder_it_cannot_delete(): void {
+ $blocked_id = wp_insert_post(
+ array(
+ 'post_type' => 'product',
+ 'post_status' => 'importing',
+ 'post_title' => 'Import cleanup blocked placeholder',
+ )
+ );
+ $queued_id = wp_insert_post(
+ array(
+ 'post_type' => 'product',
+ 'post_status' => 'importing',
+ 'post_title' => 'Import cleanup queued placeholder',
+ )
+ );
+
+ $block_first = static function ( $check, $post ) use ( $blocked_id ) {
+ return $post->ID === $blocked_id ? false : $check;
+ };
+
+ add_filter( 'pre_delete_post', $block_first, 10, 2 );
+
+ try {
+ $this->invoke_cleanup_after_import();
+ } finally {
+ remove_filter( 'pre_delete_post', $block_first, 10 );
+ }
+
+ $this->assertNotNull( get_post( $blocked_id ) );
+ $this->assertNull( get_post( $queued_id ) );
+ }
+
+ /**
+ * Invoke the import cleanup routine.
+ *
+ * @param int $cursor Highest placeholder ID earlier cleanup requests have examined.
+ * @return int|null ID to resume from, or null once no placeholders are left.
+ */
+ private function invoke_cleanup_after_import( int $cursor = 0 ): ?int {
+ $class = new ReflectionClass( WC_Product_CSV_Importer_Controller::class );
+ $method = $class->getMethod( 'cleanup_after_import' );
+ $method->setAccessible( true );
+
+ return $method->invoke( null, $cursor );
+ }
+
+ /**
+ * Get the number of product lookup rows for a product.
+ *
+ * @param int $product_id Product ID.
+ * @return int
+ */
+ private function get_product_lookup_row_count( int $product_id ): int {
+ global $wpdb;
+
+ return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->wc_product_meta_lookup} WHERE product_id = %d", $product_id ) );
+ }
}