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.' );
+ }
}