From 3221ccfd29e08d2807c0269db4a372b5409710b4 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 8 Sep 2026 11:53:42 -0400 Subject: [PATCH] Batch the audit log retention DELETE to bound lock hold time (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deleteOldLogs() ran a single unbounded DELETE. created_at is indexed so row selection itself is cheap, but on a large qualifying set (first run after enabling/changing AUDIT_LOG_RETENTION_DAYS, or after the cron silently missed runs) an unbounded single-statement DELETE holds row locks for the full duration — risking contention with the frequent concurrent INSERTs the audit log receives from live traffic. Now deletes in batches of 1000 (parameterized), looping until nothing qualifies. Verified against a local MariaDB instance with a batch size of 10 forcing multiple loop iterations: deleted exactly the stale rows, left recent rows untouched, correct total count returned. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X --- models/AuditLogModel.php | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/models/AuditLogModel.php b/models/AuditLogModel.php index f0eea92..dd691cc 100644 --- a/models/AuditLogModel.php +++ b/models/AuditLogModel.php @@ -309,17 +309,28 @@ class AuditLogModel * @param int $daysToKeep Number of days of logs to keep * @return int Number of deleted records */ - public function deleteOldLogs($daysToKeep = 90) + public function deleteOldLogs($daysToKeep = 90, $batchSize = 1000) { + // Batched to bound how long each statement holds row locks — an + // unbounded single DELETE on a large backlog (e.g. the first run after + // enabling/changing retention, or after the cron silently missed runs) + // would otherwise contend with the frequent concurrent INSERTs the + // audit log receives from live traffic. $stmt = $this->conn->prepare( - "DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)" + "DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY audit_id LIMIT ?" ); - $stmt->bind_param("i", $daysToKeep); - $stmt->execute(); - $affectedRows = $stmt->affected_rows; + $stmt->bind_param("ii", $daysToKeep, $batchSize); + + $totalDeleted = 0; + do { + $stmt->execute(); + $affected = $stmt->affected_rows; + $totalDeleted += $affected; + } while ($affected > 0); + $stmt->close(); - return $affectedRows; + return $totalDeleted; } /**