chore: initial snapshot of 2meet-data-optimizer v0.1.0
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
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user