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.
*/