Commit 1fe972ff55d for woocommerce

commit 1fe972ff55d6de70f15147f63dc87a93b829b7b0
Author: Faisal Ahammad <faisalahammad24@gmail.com>
Date:   Thu Jul 2 02:48:17 2026 +0600

    fix(logging): delete all expired log files, not just the first 20 (#66073)

    Fixes the daily log cleanup capping deletions at the default per-page (20) by deleting expired files in batches. Closes #66071.

diff --git a/plugins/woocommerce/changelog/fix-log-cleanup-20-file-cap b/plugins/woocommerce/changelog/fix-log-cleanup-20-file-cap
new file mode 100644
index 00000000000..70506108c2c
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-log-cleanup-20-file-cap
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix the daily log cleanup so it deletes all expired log files, not just the first 20.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 67e04a60a64..7a466ce944f 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -57055,17 +57055,6 @@ parameters:
 			count: 1
 			path: src/Internal/Admin/Logging/LogHandlerFileV2.php

-		-
-			message: '#^Parameter \#1 \$input of function array_filter expects array, array\<Automattic\\WooCommerce\\Internal\\Admin\\Logging\\FileV2\\File\>\|int given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Internal/Admin/Logging/LogHandlerFileV2.php
-
-		-
-			message: '#^Parameter \#1 \$var of function count expects array\|Countable, array\<Automattic\\WooCommerce\\Internal\\Admin\\Logging\\FileV2\\File\>\|int given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Internal/Admin/Logging/LogHandlerFileV2.php

 		-
 			message: '#^Call to protected method prepare_column_headers\(\) of class WC_Admin_Log_Table_List\.$#'
diff --git a/plugins/woocommerce/src/Internal/Admin/Logging/LogHandlerFileV2.php b/plugins/woocommerce/src/Internal/Admin/Logging/LogHandlerFileV2.php
index 5a4ab0c6cb5..aaccd177bba 100644
--- a/plugins/woocommerce/src/Internal/Admin/Logging/LogHandlerFileV2.php
+++ b/plugins/woocommerce/src/Internal/Admin/Logging/LogHandlerFileV2.php
@@ -11,25 +11,24 @@ use WC_Log_Handler;
  */
 class LogHandlerFileV2 extends WC_Log_Handler {
 	/**
-	 * Instance of the FileController class.
+	 * Maximum number of expired log files to delete in one loop iteration.
 	 *
-	 * @var FileController
+	 * @var int
 	 */
-	private $file_controller;
+	private const DELETE_BATCH_SIZE = 100;

 	/**
-	 * Instance of the Settings class.
+	 * Instance of the FileController class.
 	 *
-	 * @var Settings
+	 * @var FileController
 	 */
-	private $settings;
+	private $file_controller;

 	/**
 	 * LogHandlerFileV2 class.
 	 */
 	public function __construct() {
 		$this->file_controller = wc_get_container()->get( FileController::class );
-		$this->settings        = wc_get_container()->get( Settings::class );
 	}

 	/**
@@ -182,7 +181,7 @@ class LogHandlerFileV2 extends WC_Log_Handler {
 			)
 		);

-		if ( is_wp_error( $files ) || count( $files ) < 1 ) {
+		if ( is_wp_error( $files ) || ! is_array( $files ) || count( $files ) < 1 ) {
 			return 0;
 		}

@@ -235,47 +234,63 @@ class LogHandlerFileV2 extends WC_Log_Handler {
 			return 0;
 		}

-		$files = $this->file_controller->get_files(
-			array(
-				'date_filter' => 'created',
-				'date_start'  => 1,
-				'date_end'    => $timestamp,
-			)
-		);
+		$deleted = 0;
+		$skipped = 0;

-		if ( is_wp_error( $files ) ) {
-			return 0;
-		}
+		// Fetch and delete in batches so that sites with more than the default
+		// per-page of log files don't leave expired files behind.
+		do {
+			$files = $this->file_controller->get_files(
+				array(
+					'date_filter' => 'created',
+					'date_start'  => 1,
+					'date_end'    => $timestamp,
+					'per_page'    => self::DELETE_BATCH_SIZE,
+					'offset'      => $skipped,
+				)
+			);

-		$files = array_filter(
-			$files,
-			function ( $file ) use ( $timestamp ) {
-				/**
-				 * Allows preventing an expired log file from being deleted.
-				 *
-				 * @param bool $delete    True to delete the file.
-				 * @param File $file      The log file object.
-				 * @param int  $timestamp The expiration threshold.
-				 *
-				 * @since 8.7.0
-				 */
-				$delete = apply_filters( 'woocommerce_logger_delete_expired_file', true, $file, $timestamp );
-
-				return boolval( $delete );
+			if ( is_wp_error( $files ) || ! is_array( $files ) ) {
+				break;
 			}
-		);

-		if ( count( $files ) < 1 ) {
-			return 0;
-		}
+			$fetched_count = count( $files );
+			$files         = array_filter(
+				$files,
+				function ( $file ) use ( $timestamp ) {
+					/**
+					 * Allows preventing an expired log file from being deleted.
+					 *
+					 * @param bool $delete    True to delete the file.
+					 * @param File $file      The log file object.
+					 * @param int  $timestamp The expiration threshold.
+					 *
+					 * @since 8.7.0
+					 */
+					$delete = apply_filters( 'woocommerce_logger_delete_expired_file', true, $file, $timestamp );
+
+					return boolval( $delete );
+				}
+			);

-		$file_ids = array_map(
-			fn( $file ) => $file->get_file_id(),
-			$files
-		);
+			$file_count       = count( $files );
+			$vetoed_count     = $fetched_count - $file_count;
+			$deleted_in_batch = 0;
+			if ( $file_count > 0 ) {
+				$file_ids = array_map(
+					fn( $file ) => $file->get_file_id(),
+					$files
+				);
+
+				$deleted_in_batch = $this->file_controller->delete_files( $file_ids );
+				$deleted         += $deleted_in_batch;
+			}

-		$deleted        = $this->file_controller->delete_files( $file_ids );
-		$retention_days = $this->settings->get_retention_period();
+			// Deleted files disappear from the directory, so only vetoed files
+			// need to be skipped. If no progress was made, skip the full page to
+			// avoid retrying a permanently vetoed or undeletable batch forever.
+			$skipped += $deleted_in_batch > 0 ? $vetoed_count : $fetched_count;
+		} while ( self::DELETE_BATCH_SIZE === $fetched_count );

 		if ( $deleted > 0 ) {
 			$this->handle(
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Logging/LogHandlerFileV2Test.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Logging/LogHandlerFileV2Test.php
index 7ea59183d66..80264e8d07b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Logging/LogHandlerFileV2Test.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Logging/LogHandlerFileV2Test.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Admin\Logging;
 use Automattic\Jetpack\Constants;
 use Automattic\WooCommerce\Internal\Admin\Logging\{ LogHandlerFileV2, Settings };
 use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\File;
+use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
 use WC_Unit_Test_Case;

 /**
@@ -379,6 +380,93 @@ MESSAGE;
 		$this->assertStringContainsString( $expected_string, $actual_content );
 	}

+	/**
+	 * @testdox Check that delete_logs_before_timestamp deletes more than 20 expired log files in one run.
+	 */
+	public function test_delete_logs_before_timestamp_deletes_more_than_default_per_page() {
+		$current_time = time();
+		$past_time    = strtotime( '-5 days' );
+
+		// Create more expired files than a single delete batch can remove (batch size is 100).
+		$expired_count = 101;
+		foreach ( range( 1, $expired_count ) as $suffix ) {
+			$this->sut->handle( $past_time, 'debug', 'old.', array( 'source' => "source-{$suffix}" ) );
+		}
+
+		// Add a couple of non-expired files as well.
+		$this->sut->handle( $current_time, 'debug', 'new!', array( 'source' => 'fresh1' ) );
+		$this->sut->handle( $current_time, 'debug', 'new!', array( 'source' => 'fresh2' ) );
+
+		$paths = glob( Settings::get_log_directory() . '*.log' );
+		$this->assertCount( $expired_count + 2, $paths );
+
+		$result = $this->sut->delete_logs_before_timestamp( strtotime( '-3 days' ) );
+		$this->assertEquals( $expired_count, $result );
+
+		$paths = glob( Settings::get_log_directory() . '*.log' );
+		// wc_logger plus two fresh files.
+		$this->assertCount( 3, $paths );
+
+		$paths = glob( Settings::get_log_directory() . 'wc_logger*.log' );
+		$this->assertCount( 1, $paths );
+
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+		$actual_content  = file_get_contents( reset( $paths ) );
+		$expected_string = '101 expired log files were deleted.';
+		$this->assertStringContainsString( $expected_string, $actual_content );
+	}
+
+	/**
+	 * @testdox Check that delete_logs_before_timestamp advances past a fully vetoed batch.
+	 */
+	public function test_delete_logs_before_timestamp_advances_past_vetoed_batch() {
+		$current_time = time();
+		$past_time    = strtotime( '-5 days' );
+
+		// Create 100 vetoed files, then the single allowed file. Files are sorted
+		// by modified time descending, so the 100 vetoed files normally land on the
+		// first 100-file page. Touch the allowed file to an older mtime so it is
+		// guaranteed to fall on the next page and forces pagination to advance past
+		// a fully vetoed batch.
+		$expired_count = 100;
+		foreach ( range( 1, $expired_count ) as $suffix ) {
+			$this->sut->handle( $past_time, 'debug', 'old.', array( 'source' => "source-{$suffix}" ) );
+		}
+		$this->sut->handle( $past_time, 'debug', 'old.', array( 'source' => 'source-101' ) );
+
+		$allowed_paths = glob( Settings::get_log_directory() . '*source-101*.log' );
+		$this->assertCount( 1, $allowed_paths );
+		FilesystemUtil::get_wp_filesystem()->touch( reset( $allowed_paths ), $past_time - 1 );
+
+		// Allow only the older file to be deleted.
+		$filter = function ( $delete, $file ) {
+			unset( $delete );
+			$basename = basename( $file->get_path() );
+			return false !== strpos( $basename, 'source-101' );
+		};
+		add_filter( 'woocommerce_logger_delete_expired_file', $filter, 10, 2 );
+
+		try {
+			$result = $this->sut->delete_logs_before_timestamp( strtotime( '-3 days' ) );
+		} finally {
+			remove_filter( 'woocommerce_logger_delete_expired_file', $filter, 10 );
+		}
+
+		$this->assertEquals( 1, $result );
+
+		$paths = glob( Settings::get_log_directory() . '*.log' );
+		// 100 vetoed files, 1 wc_logger summary file.
+		$this->assertCount( 101, $paths );
+
+		$paths = glob( Settings::get_log_directory() . 'wc_logger*.log' );
+		$this->assertCount( 1, $paths );
+
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+		$actual_content  = file_get_contents( reset( $paths ) );
+		$expected_string = '1 expired log file was deleted.';
+		$this->assertStringContainsString( $expected_string, $actual_content );
+	}
+
 	/**
 	 * @testdox Check that the handle method does not throw an error when passed a non-array context.
 	 */