| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225 |
- <?php
- /**
- * Daily retention sweep.
- *
- * The queue table holds rendered customer emails and the attachment directories
- * hold their invoices — retention here is a privacy control, not only
- * housekeeping.
- *
- * @package studiou-wc-mail-queue
- * @copyright 2026 QUADARAX
- * @author Dalibor Votruba <dvotruba@quadarax.com>
- * @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 ) );
- }
- }
- }
|