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; } }