Commit 680ccddec83 for woocommerce
commit 680ccddec83a5696db9e188552dfeeae757ce122
Author: Ján Mikláš <neosinner@gmail.com>
Date: Mon Sep 14 08:47:32 2026 +0200
Clear product import mapping markers in the last import batch (#68392)
* fix: clear stale product import mapping markers when an import starts
Post-import cleanup runs in AJAX requests the client drives, so an import
whose tab is closed at 100% never clears the `_original_id` markers that
map a CSV `id:` reference to the product created for it. Those markers are
matched site-wide by get_product_id_by_original_id(), so the next import
resolves its own `id:` references against the abandoned run's products.
Clear them at the start of a run as well as at the end, so an import
always begins from a clean slate.
Leftover `importing` placeholders are unaffected: the next completed run's
cleanup phase already deletes every placeholder it finds, not only its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEcWbxtTF4Lmy1uWR8RGYw
* test: verify product import marker relationships
* fix: clear product import mapping markers in the last import batch
Clearing _original_id markers when an import starts made a retry of an
abandoned import duplicate rows without a SKU, because the markers that
let the retry skip already imported rows were gone.
Delete the markers in the request that reaches 100% instead, before it
hands over to the cleanup requests, as the importer did before #68090.
An import abandoned during cleanup still leaves no markers behind.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QD3nc7rdNBsvATqUuQTqpN
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-product-import-stale-original-id-markers b/plugins/woocommerce/changelog/fix-product-import-stale-original-id-markers
new file mode 100644
index 00000000000..1d572677101
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-product-import-stale-original-id-markers
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Clear product CSV import mapping markers in the last import batch, so an import abandoned during cleanup cannot make a later import resolve its id: references to the wrong products.
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 c7122090d92..9aca4939c49 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
@@ -346,8 +346,7 @@ class WC_Product_CSV_Importer_Controller {
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' ) );
+ self::delete_original_id_markers();
}
/** This filter is documented in includes/import/abstract-wc-product-importer.php */
@@ -449,6 +448,20 @@ class WC_Product_CSV_Importer_Controller {
wp_cache_delete_multiple( array_map( 'absint', $post_ids ), 'posts' );
}
+ /**
+ * Remove temporary mappings between CSV IDs and imported products.
+ *
+ * The last import batch clears them so a run abandoned during cleanup leaves none behind.
+ * Markers from a run abandoned earlier are kept so a retry of the same file skips rows already imported.
+ * Concurrent imports are unsupported: markers and placeholder cleanup are shared site-wide.
+ */
+ private static function delete_original_id_markers(): void {
+ global $wpdb;
+
+ // 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' ) );
+ }
+
/**
* Claim one importer placeholder and delete it only if the claim succeeded.
*
@@ -560,6 +573,10 @@ class WC_Product_CSV_Importer_Controller {
$percent_complete = $importer->get_percent_complete();
$error_log = array_merge( $error_log, $results['failed'], $results['skipped'] );
+ if ( 100 === $percent_complete ) {
+ self::delete_original_id_markers();
+ }
+
update_user_option( get_current_user_id(), 'product_import_error_log', $error_log );
// The last row hands over to the cleanup phase rather than running it in this request.
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 cd205699789..10f4bf190ce 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
@@ -7,6 +7,11 @@
*/
class WC_Product_CSV_Importer_Controller_Test extends WC_Unit_Test_Case {
+ /**
+ * A variable product and a variation that references it by CSV ID.
+ */
+ private const MARKER_CSV = "ID,Type,SKU,Name,Parent\n4242,variable,marker-parent,Marker parent,\n4243,variation,marker-variation,Marker variation,id:4242\n";
+
/**
* Load up the importer classes since they aren't loaded by default.
*/
@@ -468,6 +473,165 @@ class WC_Product_CSV_Importer_Controller_Test extends WC_Unit_Test_Case {
$this->assertNull( get_post( $queued_id ) );
}
+ /**
+ * @testdox The last import batch should clear mapping markers before handing over to cleanup.
+ */
+ public function test_last_import_batch_clears_markers_before_cleanup(): void {
+ global $wpdb;
+
+ $response = $this->dispatch_import_request( '0' );
+ $parent_id = wc_get_product_id_by_sku( 'marker-parent' );
+ $variation_id = wc_get_product_id_by_sku( 'marker-variation' );
+
+ $this->assertSame( 'cleanup:0', $response['data']['position'] );
+ $this->assertSame( 1, $response['data']['imported'] );
+ $this->assertSame( 1, $response['data']['imported_variations'] );
+ $this->assertSame( $parent_id, wp_get_post_parent_id( $variation_id ) );
+ $this->assertSame( 0, (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_original_id'" ) );
+ }
+
+ /**
+ * @testdox A resumed request should leave the markers of the run in progress alone.
+ */
+ public function test_resumed_request_keeps_the_markers_of_the_run_in_progress(): void {
+ add_filter( 'woocommerce_product_import_batch_size', array( $this, 'return_one' ) );
+
+ try {
+ $response = $this->dispatch_import_request( '0' );
+
+ // A batch size of one leaves the second row for a resumed request.
+ $this->assertIsNumeric( $response['data']['position'] );
+
+ $parent_id = wc_get_product_id_by_sku( 'marker-parent' );
+ $this->assertSame( 1, $response['data']['imported'] );
+ $this->assertSame( '4242', get_post_meta( $parent_id, '_original_id', true ) );
+
+ $response = $this->dispatch_import_request( (string) $response['data']['position'] );
+ $variation_id = wc_get_product_id_by_sku( 'marker-variation' );
+
+ $this->assertSame( 1, $response['data']['imported_variations'] );
+ $this->assertSame( $parent_id, wp_get_post_parent_id( $variation_id ) );
+ } finally {
+ remove_filter( 'woocommerce_product_import_batch_size', array( $this, 'return_one' ) );
+ }
+ }
+
+ /**
+ * @testdox Retrying an abandoned run with the same CSV should skip rows without a SKU that the abandoned run imported.
+ */
+ public function test_retrying_an_abandoned_run_does_not_duplicate_rows_without_a_sku(): void {
+ $csv = "ID,Type,SKU,Name,Parent\n5001,simple,,Retry first,\n5002,simple,,Retry second,\n";
+
+ add_filter( 'woocommerce_product_import_batch_size', array( $this, 'return_one' ) );
+
+ try {
+ $response = $this->dispatch_import_request( '0', $csv );
+ } finally {
+ remove_filter( 'woocommerce_product_import_batch_size', array( $this, 'return_one' ) );
+ }
+
+ // The run is abandoned here, before the second row and the cleanup requests.
+ $this->assertIsNumeric( $response['data']['position'] );
+ $this->assertSame( 1, $response['data']['imported'] );
+
+ $response = $this->dispatch_import_request( '0', $csv );
+
+ $first_products = wc_get_products(
+ array(
+ 'name' => 'Retry first',
+ 'return' => 'ids',
+ )
+ );
+
+ $this->assertSame( 1, $response['data']['skipped'], 'The row the abandoned run imported should be skipped.' );
+ $this->assertSame( 1, $response['data']['imported'], 'Only the row the abandoned run did not reach should be imported.' );
+ $this->assertCount( 1, $first_products, 'The retry should not duplicate the product.' );
+ }
+
+ /**
+ * Filter callback that shrinks the import batch to a single row.
+ *
+ * @return int
+ */
+ public function return_one(): int {
+ return 1;
+ }
+
+ /**
+ * Run one import request against the test CSV and return the decoded response.
+ *
+ * The AJAX wp_die() handler throws an Error because dispatch_ajax() catches Exception.
+ *
+ * @param string $position Import position to request.
+ * @param string $csv CSV contents to import.
+ * @return array
+ */
+ private function dispatch_import_request( string $position, string $csv = self::MARKER_CSV ): array {
+ $nonce = wp_create_nonce( 'wc-product-import' );
+ $file = $this->write_import_csv( $csv );
+
+ $_REQUEST['security'] = $nonce;
+ $_POST['security'] = $nonce;
+ $_POST['file'] = $file;
+ $_POST['position'] = $position;
+ $_POST['mapping'] = array(
+ 'from' => array( 'ID', 'Type', 'SKU', 'Name', 'Parent' ),
+ 'to' => array( 'id', 'type', 'sku', 'name', 'parent_id' ),
+ );
+
+ add_filter( 'wp_doing_ajax', '__return_true' );
+ add_filter( 'wp_die_ajax_handler', array( $this, 'get_throwing_die_handler' ) );
+
+ ob_start();
+
+ try {
+ WC_Product_CSV_Importer_Controller::dispatch_ajax();
+ } catch ( Error $e ) {
+ $this->assertSame( 'wp_die', $e->getMessage() );
+ } finally {
+ $response = (string) ob_get_clean();
+
+ remove_filter( 'wp_doing_ajax', '__return_true' );
+ remove_filter( 'wp_die_ajax_handler', array( $this, 'get_throwing_die_handler' ) );
+
+ wp_delete_file( $file );
+ unset( $_POST['file'], $_POST['position'], $_POST['mapping'], $_POST['security'], $_REQUEST['security'] );
+ }
+
+ $decoded = json_decode( $response, true );
+ $this->assertIsArray( $decoded, $response );
+ $this->assertTrue( $decoded['success'], $response );
+ $this->assertSame( 0, $decoded['data']['failed'], $response );
+
+ return $decoded;
+ }
+
+ /**
+ * Filter callback returning a wp_die() handler that throws instead of exiting.
+ *
+ * @return callable
+ */
+ public function get_throwing_die_handler(): callable {
+ return function () {
+ throw new Error( 'wp_die' );
+ };
+ }
+
+ /**
+ * Write the test CSV to the uploads directory, the only place the importer accepts a file from.
+ *
+ * @param string $csv CSV contents to write.
+ * @return string
+ */
+ private function write_import_csv( string $csv ): string {
+ $uploads = wp_upload_dir();
+ $path = trailingslashit( $uploads['basedir'] ) . 'wc-product-import-marker-test.csv';
+
+ file_put_contents( $path, $csv ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Writing a fixture for this test.
+
+ return $path;
+ }
+
/**
* Invoke the import cleanup routine.
*