Commit 0a355db52cf for woocommerce

commit 0a355db52cf384ab03822ea7b71da2db1759bb64
Author: Peter Petrov <peter.petrov89@gmail.com>
Date:   Thu Sep 17 13:23:21 2026 +0300

    Fix Analytics report exports dropping each other's progress (#68742)

    * Store Analytics report export progress in one option per export

    * Read report export progress saved before each export had its own option

    * Delete the shared report export status option once its exports expire

    * Name report export status option and path values before using them

    * Match extension report types when deleting expired export progress

    * Hash the report export progress option name so any export ID fits

diff --git a/plugins/woocommerce/changelog/fix-analytics-concurrent-export-progress b/plugins/woocommerce/changelog/fix-analytics-concurrent-export-progress
new file mode 100644
index 00000000000..febf1e489b2
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-analytics-concurrent-export-progress
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix Analytics report exports losing each other's progress when running at the same time, which left an export stuck and its download email unsent.
diff --git a/plugins/woocommerce/src/Admin/ReportExporter.php b/plugins/woocommerce/src/Admin/ReportExporter.php
index f9d3d49a0f7..6fc5f39c899 100644
--- a/plugins/woocommerce/src/Admin/ReportExporter.php
+++ b/plugins/woocommerce/src/Admin/ReportExporter.php
@@ -107,20 +107,69 @@ class ReportExporter {
 		// Both the report body and its `.headers` companion, but never the directory's
 		// .htaccess and index.html guards.
 		$paths = glob( ReportCSVExporter::get_reports_directory() . '*.csv*' );
-		if ( ! $paths ) {
+
+		if ( $paths ) {
+			foreach ( $paths as $path ) {
+				if ( ! is_file( $path ) ) {
+					continue;
+				}
+
+				$modified = filemtime( $path );
+				if ( $modified && $modified < $expired_before ) {
+					wp_delete_file( $path );
+					self::delete_export_status( $path );
+				}
+			}
+		}
+
+		self::delete_shared_export_status();
+	}
+
+	/**
+	 * Delete the option every export shared before 11.3.0, once none of the exports in it can still be downloaded.
+	 *
+	 * @return void
+	 */
+	private static function delete_shared_export_status() {
+		$exports_status = get_option( self::EXPORT_STATUS_OPTION );
+
+		if ( false === $exports_status ) {
 			return;
 		}

-		foreach ( $paths as $path ) {
-			if ( ! is_file( $path ) ) {
-				continue;
-			}
+		if ( is_array( $exports_status ) ) {
+			foreach ( array_keys( $exports_status ) as $status_key ) {
+				$key_parts   = explode( ':', (string) $status_key, 2 );
+				$report_type = $key_parts[0];
+				$export_id   = isset( $key_parts[1] ) ? $key_parts[1] : '';
+				$filename    = self::get_export_filename( $report_type, $export_id );

-			$modified = filemtime( $path );
-			if ( $modified && $modified < $expired_before ) {
-				wp_delete_file( $path );
+				$exporter = new ReportCSVExporter();
+				$exporter->set_filename( $filename );
+
+				$path = ReportCSVExporter::get_reports_directory() . $exporter->get_filename();
+				if ( file_exists( $path ) ) {
+					return;
+				}
 			}
 		}
+
+		delete_option( self::EXPORT_STATUS_OPTION );
+	}
+
+	/**
+	 * Delete the stored progress of an export whose file has been deleted.
+	 *
+	 * @param string $path Path of the deleted export body or its `.headers` companion.
+	 * @return void
+	 */
+	private static function delete_export_status( $path ) {
+		$filename = basename( $path );
+
+		if ( preg_match( '/^wc-(.+?)-report-export-(.+)\.csv(?:\.headers)?$/', $filename, $matches ) ) {
+			$option_name = self::get_status_option_name( $matches[1], $matches[2] );
+			delete_option( $option_name );
+		}
 	}

 	/**
@@ -185,6 +234,21 @@ class ReportExporter {
 		return $report_type . ':' . $export_id;
 	}

+	/**
+	 * Get the name of the option an export's progress is stored under.
+	 *
+	 * The key is hashed so the name fits option_name whatever length the export ID has.
+	 *
+	 * @param string $report_type Report type. E.g. 'customers'.
+	 * @param string $export_id Unique ID for report (timestamp expected).
+	 * @return string Option name.
+	 */
+	protected static function get_status_option_name( $report_type, $export_id ) {
+		$status_key = self::get_status_key( $report_type, $export_id );
+
+		return self::EXPORT_STATUS_OPTION . '_' . md5( $status_key );
+	}
+
 	/**
 	 * Update the completion percentage of a report export.
 	 *
@@ -194,14 +258,11 @@ class ReportExporter {
 	 * @return void
 	 */
 	public static function update_export_percentage_complete( $report_type, $export_id, $percentage ) {
-		$exports_status = get_option( self::EXPORT_STATUS_OPTION, array() );
-		$status_key     = self::get_status_key( $report_type, $export_id );
-
-		$exports_status[ $status_key ] = $percentage;
+		$option_name = self::get_status_option_name( $report_type, $export_id );

 		// Not autoloaded: a persistent object cache can write back a stale copy of the autoloaded options from another
 		// request, which left the email action reading an old percentage and never sending the download link.
-		update_option( self::EXPORT_STATUS_OPTION, $exports_status, false );
+		update_option( $option_name, $percentage, false );
 	}

 	/**
@@ -212,14 +273,22 @@ class ReportExporter {
 	 * @return bool|int Completion percentage, or false if export not found.
 	 */
 	public static function get_export_percentage_complete( $report_type, $export_id ) {
-		$exports_status = get_option( self::EXPORT_STATUS_OPTION, array() );
-		$status_key     = self::get_status_key( $report_type, $export_id );
+		$option_name = self::get_status_option_name( $report_type, $export_id );
+		$percentage  = get_option( $option_name );
+
+		if ( false === $percentage ) {
+			// Exports queued before 11.3.0 report through the option every export shared.
+			$exports_status = get_option( self::EXPORT_STATUS_OPTION );
+			$status_key     = self::get_status_key( $report_type, $export_id );
+
+			if ( ! is_array( $exports_status ) || ! isset( $exports_status[ $status_key ] ) ) {
+				return false;
+			}

-		if ( isset( $exports_status[ $status_key ] ) ) {
-			return $exports_status[ $status_key ];
+			$percentage = $exports_status[ $status_key ];
 		}

-		return false;
+		return (int) $percentage;
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Admin/ReportExporterTest.php b/plugins/woocommerce/tests/php/src/Admin/ReportExporterTest.php
index 3c9b09f0745..7b2ced1d22e 100644
--- a/plugins/woocommerce/tests/php/src/Admin/ReportExporterTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/ReportExporterTest.php
@@ -138,16 +138,64 @@ class ReportExporterTest extends WC_Unit_Test_Case {
 		$expired     = $this->create_export( 'wc-orders-report-export-expired', "1,2\n", ReportExporter::EXPORT_RETENTION_PERIOD + HOUR_IN_SECONDS );
 		$fresh       = $this->create_export( 'wc-orders-report-export-fresh', "3,4\n", ReportExporter::EXPORT_RETENTION_PERIOD - HOUR_IN_SECONDS );

+		ReportExporter::update_export_percentage_complete( 'orders', 'expired', 100 );
+		ReportExporter::update_export_percentage_complete( 'orders', 'fresh', 100 );
+
 		ReportExporter::delete_expired_exports();

 		$this->assertFileDoesNotExist( $reports_dir . $expired, 'An expired export should be deleted.' );
 		$this->assertFileDoesNotExist( $reports_dir . $expired . '.headers', 'An expired export header row should be deleted too.' );
+		$this->assertFalse( ReportExporter::get_export_percentage_complete( 'orders', 'expired' ), 'An expired export should not keep its progress option around.' );
 		$this->assertFileExists( $reports_dir . $fresh, 'An export inside the retention period should be kept.' );
 		$this->assertFileExists( $reports_dir . $fresh . '.headers', 'An export header row inside the retention period should be kept.' );
+		$this->assertSame( 100, ReportExporter::get_export_percentage_complete( 'orders', 'fresh' ), 'An export inside the retention period should keep its progress.' );
 		$this->assertFileExists( $reports_dir . '.htaccess', 'Cleanup should not touch the directory guards.' );
 		$this->assertFileExists( $reports_dir . 'index.html', 'Cleanup should not touch the directory guards.' );
 	}

+	/**
+	 * @testdox Daily cleanup drops the progress of an expired export of an extension's report type.
+	 */
+	public function test_cleanup_deletes_the_progress_of_an_extension_report_type(): void {
+		$this->create_export( 'wc-stock_notifications-report-export-expired', "1,2\n", ReportExporter::EXPORT_RETENTION_PERIOD + HOUR_IN_SECONDS );
+		ReportExporter::update_export_percentage_complete( 'stock_notifications', 'expired', 100 );
+
+		ReportExporter::delete_expired_exports();
+
+		$this->assertFalse( ReportExporter::get_export_percentage_complete( 'stock_notifications', 'expired' ), 'Report types registered by extensions are not limited to letters.' );
+	}
+
+	/**
+	 * @testdox Daily cleanup deletes the option every export used to share once its exports can no longer be downloaded.
+	 */
+	public function test_cleanup_deletes_the_shared_status_option_once_its_exports_are_gone(): void {
+		$reports_dir = ReportCSVExporter::get_reports_directory();
+		$expired     = $this->create_export( 'wc-orders-report-export-legacyexpired', "1,2\n", ReportExporter::EXPORT_RETENTION_PERIOD + HOUR_IN_SECONDS );
+		$fresh       = $this->create_export( 'wc-orders-report-export-legacyfresh', "3,4\n", ReportExporter::EXPORT_RETENTION_PERIOD - HOUR_IN_SECONDS );
+
+		update_option(
+			ReportExporter::EXPORT_STATUS_OPTION,
+			array(
+				'orders:legacyexpired' => 100,
+				'orders:legacyfresh'   => 100,
+				'orders:neverwritten'  => 0,
+			)
+		);
+
+		ReportExporter::delete_expired_exports();
+
+		$this->assertFileDoesNotExist( $reports_dir . $expired );
+		$this->assertFileExists( $reports_dir . $fresh );
+		$this->assertSame( 100, ReportExporter::get_export_percentage_complete( 'orders', 'legacyfresh' ), 'The shared option must stay while one of its exports can still be downloaded.' );
+
+		wp_delete_file( $reports_dir . $fresh );
+		wp_delete_file( $reports_dir . $fresh . '.headers' );
+
+		ReportExporter::delete_expired_exports();
+
+		$this->assertFalse( get_option( ReportExporter::EXPORT_STATUS_OPTION ), 'The shared option should be deleted once none of its exports has a file left.' );
+	}
+
 	/**
 	 * @testdox A report's date range is read from the arguments it was exported with.
 	 *
@@ -495,6 +543,44 @@ class ReportExporterTest extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Progress is stored and read back whatever length the export ID has.
+	 *
+	 * The export ID is filterable and unbounded, while option names are limited to 191 characters.
+	 */
+	public function test_export_progress_survives_a_long_export_id(): void {
+		global $wpdb;
+
+		$export_id = str_repeat( 'jane.doe-orders-2026-01-01-to-2026-03-31-', 5 );
+
+		ReportExporter::update_export_percentage_complete( 'orders', $export_id, 100 );
+
+		$option = ReportExporter::EXPORT_STATUS_OPTION . '_' . md5( 'orders:' . $export_id );
+
+		$this->assertLessThanOrEqual( 191, strlen( $option ) );
+		$this->assertSame( '100', $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $option ) ), 'The row should be stored under the full name.' );
+		$this->assertSame( 100, ReportExporter::get_export_percentage_complete( 'orders', $export_id ) );
+	}
+
+	/**
+	 * @testdox An export queued before each export had its own option is still emailed from the shared one.
+	 */
+	public function test_export_queued_before_per_export_options_is_emailed(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		$mailer  = tests_retrieve_phpmailer_instance();
+
+		update_option( ReportExporter::EXPORT_STATUS_OPTION, array( 'products:legacy' => 100 ) );
+
+		$this->assertSame( 100, ReportExporter::get_export_percentage_complete( 'products', 'legacy' ), 'Progress saved in the shared option should still be read.' );
+		$this->assertFalse( ReportExporter::get_export_percentage_complete( 'products', 'other' ), 'The shared option should only answer for exports it holds.' );
+
+		ReportExporter::email_report_download_link( $user_id, 'legacy', 'products' );
+
+		$sent = end( $mailer->mock_sent );
+		$this->assertIsArray( $sent, 'An export finished before the update should still be emailed after it.' );
+		$this->assertStringContainsString( 'filename=wc-products-report-export-legacy', $sent['body'] );
+	}
+
 	/**
 	 * Email the download link for a finished export and return the message that went out.
 	 *
@@ -540,18 +626,34 @@ class ReportExporterTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Export progress is saved outside the autoloaded options, and an autoloaded copy is moved out on the next save.
+	 * @testdox Export progress is saved outside the autoloaded options.
 	 */
 	public function test_export_progress_is_not_autoloaded(): void {
 		global $wpdb;

-		update_option( ReportExporter::EXPORT_STATUS_OPTION, array( 'orders:earlier' => 100 ), true );
-
 		ReportExporter::update_export_percentage_complete( 'orders', 'current', 50 );

-		$this->assertArrayNotHasKey( ReportExporter::EXPORT_STATUS_OPTION, wp_load_alloptions(), 'A persistent object cache can write stale copies of the autoloaded options back, so export progress must not live there.' );
-		$this->assertSame( 'off', $wpdb->get_var( $wpdb->prepare( "SELECT autoload FROM {$wpdb->options} WHERE option_name = %s", ReportExporter::EXPORT_STATUS_OPTION ) ) );
-		$this->assertSame( 100, ReportExporter::get_export_percentage_complete( 'orders', 'earlier' ), 'Progress of earlier exports should survive the move.' );
+		$option = ReportExporter::EXPORT_STATUS_OPTION . '_' . md5( 'orders:current' );
+
+		$this->assertArrayNotHasKey( $option, wp_load_alloptions(), 'A persistent object cache can write stale copies of the autoloaded options back, so export progress must not live there.' );
+		$this->assertSame( 'off', $wpdb->get_var( $wpdb->prepare( "SELECT autoload FROM {$wpdb->options} WHERE option_name = %s", $option ) ) );
 		$this->assertSame( 50, ReportExporter::get_export_percentage_complete( 'orders', 'current' ) );
 	}
+
+	/**
+	 * @testdox Each export's progress is stored on its own, so saving one export cannot drop another's.
+	 */
+	public function test_export_progress_is_stored_per_export(): void {
+		ReportExporter::update_export_percentage_complete( 'orders', 'first', 100 );
+		ReportExporter::update_export_percentage_complete( 'orders', 'second', 10 );
+
+		// Read back from the database, as the email action and the status endpoint do from their own request.
+		wp_cache_delete( ReportExporter::EXPORT_STATUS_OPTION . '_' . md5( 'orders:first' ), 'options' );
+		wp_cache_delete( ReportExporter::EXPORT_STATUS_OPTION . '_' . md5( 'orders:second' ), 'options' );
+
+		$this->assertSame( 100, ReportExporter::get_export_percentage_complete( 'orders', 'first' ), 'Saving a later export must leave an earlier export finished.' );
+		$this->assertSame( 10, ReportExporter::get_export_percentage_complete( 'orders', 'second' ) );
+		$this->assertFalse( ReportExporter::get_export_percentage_complete( 'orders', 'unknown' ), 'An export that was never queued has no progress.' );
+		$this->assertFalse( get_option( ReportExporter::EXPORT_STATUS_OPTION ), 'Progress must not be written to the option every export used to share.' );
+	}
 }