d36bb954d1
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
199 lines
6.6 KiB
PHP
199 lines
6.6 KiB
PHP
<?php
|
|
/**
|
|
* TMDO_Snapshot_Pruner — Daily prune of expired/over-cap snapshots
|
|
* (v2.2.0 M1).
|
|
*
|
|
* Two pruning rules, applied in order:
|
|
* 1. Drop catalog rows with `expires_at < NOW()`.
|
|
* 2. If backup directory size still exceeds `$size_cap_bytes`, evict the
|
|
* oldest non-pre_uninstall / non-pre_v2_upgrade rows until under cap.
|
|
*
|
|
* pre_uninstall and pre_v2_upgrade snapshots are protected from size-cap
|
|
* eviction (they are emergency restore points; only TTL prune touches them).
|
|
*
|
|
* @package WP_Data_Optimizer
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Prune helper. Stateless static API.
|
|
*/
|
|
class TMDO_Snapshot_Pruner {
|
|
|
|
/** Triggers exempt from size-cap eviction. */
|
|
private const PROTECTED_TRIGGERS = array(
|
|
'pre_uninstall',
|
|
'pre_v2_upgrade',
|
|
);
|
|
|
|
/**
|
|
* Run prune.
|
|
*
|
|
* @param int $older_than_days Only used for the TTL phase logging summary;
|
|
* actual TTL is enforced via wp_wpdo_snapshots.expires_at.
|
|
* @param int $size_cap_bytes Backup-dir size cap. 0 disables.
|
|
* @return array {pruned:int, freed_bytes:int, errors:array, ttl_pruned:int, sizecap_pruned:int}
|
|
*/
|
|
public static function prune( int $older_than_days, int $size_cap_bytes ): array {
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . TMDO_Snapshot_Manager::TABLE_SLUG;
|
|
|
|
$ttl_rows = $wpdb->get_results( // phpcs:ignore WordPress.DB
|
|
"SELECT id, snapshot_id, file_path, size_bytes FROM `{$table}` WHERE expires_at IS NOT NULL AND expires_at < UTC_TIMESTAMP()", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table()
|
|
ARRAY_A
|
|
);
|
|
$result = self::evict_rows( is_array( $ttl_rows ) ? $ttl_rows : array(), 'ttl' );
|
|
|
|
if ( $size_cap_bytes > 0 ) {
|
|
$current_size = self::current_dir_size();
|
|
if ( $current_size > $size_cap_bytes ) {
|
|
$over = $current_size - $size_cap_bytes;
|
|
$candidates = $wpdb->get_results(
|
|
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
|
"SELECT id, snapshot_id, file_path, size_bytes FROM `{$table}` WHERE trigger_type NOT IN ('" . implode( "','", array_map( 'esc_sql', self::PROTECTED_TRIGGERS ) ) . "') ORDER BY created_at ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.QuotedDynamicPlaceholderGeneration -- dynamic IN clause with trusted table name and string-literal enum values
|
|
100
|
|
),
|
|
ARRAY_A
|
|
);
|
|
$cap_result = self::evict_to_recover( is_array( $candidates ) ? $candidates : array(), $over );
|
|
$result['sizecap_pruned'] = $cap_result['pruned'];
|
|
$result['freed_bytes'] += $cap_result['freed_bytes'];
|
|
$result['pruned'] += $cap_result['pruned'];
|
|
$result['errors'] = array_merge( $result['errors'], $cap_result['errors'] );
|
|
} else {
|
|
$result['sizecap_pruned'] = 0;
|
|
}
|
|
} else {
|
|
$result['sizecap_pruned'] = 0;
|
|
}
|
|
|
|
TMDO_Logger::info(
|
|
'snapshot_prune',
|
|
array(
|
|
'older_than_days' => $older_than_days,
|
|
'size_cap_bytes' => $size_cap_bytes,
|
|
'pruned' => $result['pruned'],
|
|
'freed_bytes' => $result['freed_bytes'],
|
|
'ttl_pruned' => $result['ttl_pruned'] ?? 0,
|
|
'sizecap_pruned' => $result['sizecap_pruned'],
|
|
)
|
|
);
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Cron handler — called from class-tmdo-core.php via the wpdo_daily_health_check
|
|
* subroutine (v2.3.0) or its own scheduled hook.
|
|
*
|
|
* @return void
|
|
*/
|
|
public static function cron_run(): void {
|
|
self::prune( TMDO_Snapshot_Manager::DEFAULT_RETENTION_DAYS, TMDO_Snapshot_Manager::DEFAULT_SIZE_CAP_BYTES );
|
|
}
|
|
|
|
// ─── private ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Delete rows + their files. Returns prune accounting.
|
|
*
|
|
* @param array $rows Rows to evict.
|
|
* @param string $phase Tag for logs.
|
|
* @return array {pruned:int,freed_bytes:int,ttl_pruned?:int,errors:array}
|
|
*/
|
|
private static function evict_rows( array $rows, string $phase ): array {
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . TMDO_Snapshot_Manager::TABLE_SLUG;
|
|
$pruned = 0;
|
|
$freed = 0;
|
|
$errors = array();
|
|
foreach ( $rows as $row ) {
|
|
$path = (string) ( $row['file_path'] ?? '' );
|
|
if ( '' !== $path && file_exists( $path ) ) {
|
|
$ok = @unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
|
if ( ! $ok ) {
|
|
$errors[] = "unlink failed: {$path}";
|
|
}
|
|
}
|
|
$deleted = $wpdb->delete( $table, array( 'id' => (int) $row['id'] ), array( '%d' ) ); // phpcs:ignore WordPress.DB
|
|
if ( false === $deleted ) {
|
|
$errors[] = 'db delete failed: ' . $row['snapshot_id'];
|
|
continue;
|
|
}
|
|
++$pruned;
|
|
$freed += (int) $row['size_bytes'];
|
|
}
|
|
$out = array(
|
|
'pruned' => $pruned,
|
|
'freed_bytes' => $freed,
|
|
'errors' => $errors,
|
|
);
|
|
if ( 'ttl' === $phase ) {
|
|
$out['ttl_pruned'] = $pruned;
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Evict from candidates until we've freed `$target_bytes` (or run out).
|
|
*
|
|
* @param array $candidates Sorted oldest-first.
|
|
* @param int $target_bytes Bytes to free.
|
|
* @return array {pruned:int,freed_bytes:int,errors:array}
|
|
*/
|
|
private static function evict_to_recover( array $candidates, int $target_bytes ): array {
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . TMDO_Snapshot_Manager::TABLE_SLUG;
|
|
$pruned = 0;
|
|
$freed = 0;
|
|
$errors = array();
|
|
foreach ( $candidates as $row ) {
|
|
if ( $freed >= $target_bytes ) {
|
|
break;
|
|
}
|
|
$path = (string) ( $row['file_path'] ?? '' );
|
|
if ( '' !== $path && file_exists( $path ) ) {
|
|
$ok = @unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
|
if ( ! $ok ) {
|
|
$errors[] = "unlink failed: {$path}";
|
|
}
|
|
}
|
|
$deleted = $wpdb->delete( $table, array( 'id' => (int) $row['id'] ), array( '%d' ) ); // phpcs:ignore WordPress.DB
|
|
if ( false === $deleted ) {
|
|
$errors[] = 'db delete failed: ' . $row['snapshot_id'];
|
|
continue;
|
|
}
|
|
++$pruned;
|
|
$freed += (int) $row['size_bytes'];
|
|
}
|
|
return array(
|
|
'pruned' => $pruned,
|
|
'freed_bytes' => $freed,
|
|
'errors' => $errors,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Sum all backup directory file sizes.
|
|
*
|
|
* @return int Bytes.
|
|
*/
|
|
private static function current_dir_size(): int {
|
|
$dir = TMDO_Snapshot_Manager::backup_dir();
|
|
if ( ! is_dir( $dir ) ) {
|
|
return 0;
|
|
}
|
|
$total = 0;
|
|
$it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ) );
|
|
foreach ( $it as $file ) {
|
|
if ( $file->isFile() ) {
|
|
$total += $file->getSize();
|
|
}
|
|
}
|
|
return $total;
|
|
}
|
|
}
|