Files
2meet-data-optimizer/includes/snapshots/class-tmdo-snapshot-pruner.php
T
wpdev 76c01e44df refactor: 全部 128 個生產檔加入 declare(strict_types=1)(PR-H)
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入
並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 06:13:33 +08:00

201 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
*/
declare(strict_types=1);
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;
}
}