0 ) { Logger::info( 'queue', 'Old log rows removed.', [ 'count' => $removed ] ); } self::schedule(); } /** * Ensure the recurring purge is queued. * * Idempotent, so calling it from activation, from an upgrade, and from the * logs screen is safe. The screen calls it as a self-heal: a queue row can be * lost to a manual database edit or a restore from backup, and a retention * policy that has silently stopped running is worse than none. */ public static function schedule(): void { Queue::pushUnique( self::JOB_HOOK, [], 'logs:purge', self::INTERVAL ); } /** * Apply every retention rule. * * @return int Rows removed. */ public static function purge(): int { $started = microtime( true ); // Both rules pivot on the same threshold, one taking each side of it, so // every level is covered by exactly one of them. Getting this wrong is // silent: the first version pivoted the severe rule on EMERGENCY (0) while // errors are ERROR (1), so errors were never purged at all — and WARNING // (2) fell between the two rules and accumulated forever. // // `level <= ERROR` is emergencies and errors; `level > ERROR` is warnings, // info and debug. $removed = self::purgeByAge( Logger::ERROR, self::infoDays(), $started, false ); $removed += self::purgeByAge( Logger::ERROR, self::errorDays(), $started, true ); $removed += self::enforceRowCap( $started ); return $removed; } /** * Delete rows older than a cutoff. * * @param int $level Level threshold, exclusive on one side. * @param int $days Retention in days. * @param float $started When the run began, for the time budget. * @param bool $atOrBelow True to match levels at or below the threshold — the * more severe ones, since 0 is the most severe. False * matches strictly above it. * * @return int Rows removed. */ private static function purgeByAge( int $level, int $days, float $started, bool $atOrBelow = false ): int { global $wpdb; $table = Schema::table( 'logs' ); $cutoff = gmdate( 'Y-m-d H:i:s', time() - ( max( 1, $days ) * DAY_IN_SECONDS ) ); // Levels are numeric with 0 as the most severe, so "errors" are `<=` the // error constant and "everything else" is `>`. $comparison = $atOrBelow ? '<=' : '>'; $removed = 0; while ( ( microtime( true ) - $started ) < self::BUDGET ) { // `$comparison` is one of two literals set above. Passing it as a value // would turn an index range on `level` into an expression MySQL cannot // use the index for, on the table this job exists to keep small. // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $deleted = (int) $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE level {$comparison} %d AND created_at < %s LIMIT %d", $table, $level, $cutoff, self::BATCH ) ); // phpcs:enable, PluginCheck.Security.DirectDB.UnescapedDBParameter $removed += $deleted; if ( $deleted < self::BATCH ) { break; } } return $removed; } /** * Trim the table down to the configured ceiling, oldest first. * * @param float $started When the run began, for the time budget. * * @return int Rows removed. */ private static function enforceRowCap( float $started ): int { global $wpdb; $max = self::maxRows(); if ( $max <= 0 ) { return 0; } $table = Schema::table( 'logs' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $total = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); if ( $total <= $max ) { return 0; } $excess = $total - $max; $removed = 0; while ( $removed < $excess && ( microtime( true ) - $started ) < self::BUDGET ) { // Deleting by ascending id is deleting oldest first, and walks the // primary key rather than sorting. // phpcs:ignore WordPress.DB.DirectDatabaseQuery $deleted = (int) $wpdb->query( $wpdb->prepare( 'DELETE FROM %i ORDER BY id ASC LIMIT %d', $table, min( self::BATCH, $excess - $removed ) ) ); $removed += $deleted; if ( 0 === $deleted ) { break; } } return $removed; } /** * Delete every row. * * `TRUNCATE` rather than `DELETE`, so the table is reclaimed on disk and the * auto-increment restarts — which is what someone pressing "clear everything" * actually wants. */ public static function purgeAll(): int { global $wpdb; $table = Schema::table( 'logs' ); $total = LogRepository::total(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange $wpdb->query( $wpdb->prepare( 'TRUNCATE TABLE %i', $table ) ); return $total; } /** * Delete rows older than a given number of days, whatever their level. * * The manual counterpart to the scheduled rules. * * @param int $days Age in days. * * @return int Rows removed. */ public static function purgeOlderThan( int $days ): int { global $wpdb; $table = Schema::table( 'logs' ); $cutoff = gmdate( 'Y-m-d H:i:s', time() - ( max( 0, $days ) * DAY_IN_SECONDS ) ); $started = microtime( true ); $removed = 0; while ( ( microtime( true ) - $started ) < self::BUDGET ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery $deleted = (int) $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE created_at < %s LIMIT %d', $table, $cutoff, self::BATCH ) ); $removed += $deleted; if ( $deleted < self::BATCH ) { break; } } return $removed; } /** * Configured retention for errors, in days. */ public static function errorDays(): int { return max( 1, (int) Settings::get( 'core.log_retention_error', self::DEFAULT_ERROR_DAYS ) ); } /** * Configured retention for everything else, in days. */ public static function infoDays(): int { return max( 1, (int) Settings::get( 'core.log_retention_info', self::DEFAULT_INFO_DAYS ) ); } /** * Configured ceiling on total rows. Zero disables the cap. */ public static function maxRows(): int { return max( 0, (int) Settings::get( 'core.log_max_rows', self::DEFAULT_MAX_ROWS ) ); } }