* @license GPL-2.0-or-later */ defined( 'ABSPATH' ) || exit; /** * Class Studiou_WCMQ_Retention */ class Studiou_WCMQ_Retention { const HOOK = 'studiou_wcmq_retention'; const BATCH = 500; const MAX_LOOPS = 200; /** * Register hooks. */ public static function init() { add_action( self::HOOK, array( __CLASS__, 'run' ) ); } /** * Schedule the daily sweep, exactly once. */ public static function schedule() { if ( ! function_exists( 'as_next_scheduled_action' ) || ! function_exists( 'as_schedule_recurring_action' ) ) { return; } if ( as_next_scheduled_action( self::HOOK, array(), Studiou_WCMQ_Queue::GROUP ) ) { return; } as_schedule_recurring_action( time() + DAY_IN_SECONDS, DAY_IN_SECONDS, self::HOOK, array(), Studiou_WCMQ_Queue::GROUP, true ); } /** * Sweep old rows, log entries and orphaned attachment directories. */ public static function run() { Studiou_WCMQ_Reaper::run(); $now = time(); // Every retention setting is in DAYS; every timestamp column is in // SECONDS. Written as `sent_at < now - retention_sent_days`, a 7-day // retention would delete sent rows seven SECONDS after sending, silently, // while reporting success. $sent_days = (int) Studiou_WCMQ_Settings::get( 'retention_sent_days' ); $failed_days = (int) Studiou_WCMQ_Settings::get( 'retention_failed_days' ); $log_days = (int) Studiou_WCMQ_Settings::get( 'retention_log_days' ); $deleted = 0; if ( $sent_days > 0 ) { $deleted += self::purge_queue_rows( Studiou_WCMQ_DB::STATE_SENT, 'sent_at', $now - ( $sent_days * DAY_IN_SECONDS ) ); } if ( $failed_days > 0 ) { // `created_at`, NOT `sent_at`. A failed row has sent_at IS NULL by // definition, so the obvious parallel to the rule above would match // nothing and failed rows would accumulate forever. $deleted += self::purge_queue_rows( Studiou_WCMQ_DB::STATE_FAILED, 'created_at', $now - ( $failed_days * DAY_IN_SECONDS ) ); } if ( $log_days > 0 ) { self::purge_log_rows( $now - ( $log_days * DAY_IN_SECONDS ) ); } self::purge_orphan_attachment_dirs(); if ( $deleted > 0 ) { Studiou_WCMQ_DB::log( 'info', sprintf( 'Retention sweep removed %d queue rows.', $deleted ) ); } } /** * Delete queue rows in a state, older than a cutoff, in batches. * * Attachment directories are removed first: sent rows already had theirs * deleted at send, but failed rows keep theirs until now. * * @param string $state Row state. * @param string $time_column Timestamp column to compare. * @param int $cutoff Unix timestamp. * @return int Rows deleted. */ private static function purge_queue_rows( $state, $time_column, $cutoff ) { global $wpdb; $table = Studiou_WCMQ_DB::queue_table(); $total = 0; if ( ! in_array( $time_column, array( 'sent_at', 'created_at' ), true ) ) { return 0; } for ( $i = 0; $i < self::MAX_LOOPS; $i++ ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, attachment_dir FROM {$table} WHERE state = %s AND {$time_column} IS NOT NULL AND {$time_column} < %d LIMIT %d", $state, (int) $cutoff, self::BATCH ) ); if ( ! is_array( $rows ) || empty( $rows ) ) { break; } $ids = array(); foreach ( $rows as $row ) { Studiou_WCMQ_Queue::delete_attachment_dir( $row->attachment_dir ); $ids[] = (int) $row->id; } $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery $n = $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE id IN ({$placeholders})", $ids ) ); $total += (int) $n; if ( count( $ids ) < self::BATCH ) { break; } } return $total; } /** * Delete old log rows in batches. * * @param int $cutoff Unix timestamp. */ private static function purge_log_rows( $cutoff ) { global $wpdb; $table = Studiou_WCMQ_DB::log_table(); for ( $i = 0; $i < self::MAX_LOOPS; $i++ ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery $n = $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %d LIMIT %d", (int) $cutoff, self::BATCH ) ); if ( ! $n ) { break; } } } /** * Remove attachment directories whose token matches no surviving row. * * Collects directories orphaned by a fatal between mkdir and INSERT. Only * touches directories older than a day so an in-flight enqueue is never * raced. */ private static function purge_orphan_attachment_dirs() { global $wpdb; $base = Studiou_WCMQ_Queue::attachments_basedir(); if ( '' === $base || ! is_dir( $base ) ) { return; } $entries = @scandir( $base ); // phpcs:ignore if ( ! is_array( $entries ) ) { return; } $table = Studiou_WCMQ_DB::queue_table(); $cutoff = time() - DAY_IN_SECONDS; foreach ( $entries as $entry ) { if ( ! preg_match( '/^[a-f0-9]{32}$/', $entry ) ) { continue; } $path = $base . '/' . $entry; if ( ! is_dir( $path ) ) { continue; } $mtime = @filemtime( $path ); // phpcs:ignore if ( $mtime && $mtime > $cutoff ) { continue; } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery $exists = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE attachment_dir = %s", $entry ) ); if ( $exists ) { continue; } Studiou_WCMQ_Queue::rmdir_recursive( $path ); Studiou_WCMQ_DB::log( 'debug', sprintf( 'Removed orphaned attachment directory %s.', $entry ) ); } } }