From ad201691e4e1e9545879784b351b0ddccabc7d9a Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 25 Sep 2026 15:34:44 -0400 Subject: [PATCH] Fix ticket page 500 from unbounded audit timeline (#112) A ticket updated by hwmonDaemon had ~52k audit rows; getTicketTimeline() loaded all of them and exhausted PHP's 128MB memory limit, so the ticket page returned 500. Not related to the MCP server. - getTicketTimeline() takes a limit (newest first). The ticket page shows the latest 500 events with a note when older ones are omitted; the JSON export caps at 5000. - create_ticket_api.php: the description is refreshed on every run, and that alone wrote a reason-only audit row every few minutes per open ticket. Audit only real title/priority changes. - create_ticket_api.php: after creating a brand-new ticket the dedup retry loop fell through into a second iteration on a closed connection, appending a 500 error body after the success response. Exit instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X --- api/export_tickets.php | 2 +- controllers/TicketController.php | 10 ++++++++-- create_ticket_api.php | 19 +++++++++++++++---- models/AuditLogModel.php | 13 ++++++++++--- views/TicketView.php | 3 +++ 5 files changed, 37 insertions(+), 10 deletions(-) diff --git a/api/export_tickets.php b/api/export_tickets.php index c649fbf..1dba6f2 100644 --- a/api/export_tickets.php +++ b/api/export_tickets.php @@ -192,7 +192,7 @@ try { // Load flat comment list (no threading nesting in export) $rawComments = $commentModel->getCommentsByTicketId($ticket['ticket_id'], false); - $timeline = $auditLogModel->getTicketTimeline((string)$ticket['ticket_id']); + $timeline = $auditLogModel->getTicketTimeline((string)$ticket['ticket_id'], 5000); $comments = array_map(function ($c) { return [ diff --git a/controllers/TicketController.php b/controllers/TicketController.php index fd4dc51..bcbf7bf 100644 --- a/controllers/TicketController.php +++ b/controllers/TicketController.php @@ -54,8 +54,14 @@ class TicketController $totalComments = $this->commentModel->getCommentCount((int)$id); $comments = $this->commentModel->getCommentsByTicketId($id, true, $commentPageSize, 0); - // Get timeline for this ticket - $timeline = $this->auditLogModel->getTicketTimeline($id); + // Get the newest timeline events for this ticket. One extra row is + // fetched only to tell the view that older events were left out. + $timelineLimit = 500; + $timeline = $this->auditLogModel->getTicketTimeline($id, $timelineLimit + 1); + $timelineTruncated = count($timeline) > $timelineLimit; + if ($timelineTruncated) { + array_pop($timeline); + } // Get all users for assignment dropdown $allUsers = $this->userModel->getAllUsers(); diff --git a/create_ticket_api.php b/create_ticket_api.php index c5f11b6..0809d1e 100644 --- a/create_ticket_api.php +++ b/create_ticket_api.php @@ -328,10 +328,17 @@ for ($dedupAttempt = 1; $dedupAttempt <= $maxDedupAttempts; $dedupAttempt++) { $commentStmt->close(); } - $auditLog->log($userId, 'update', 'ticket', $existingId, array_merge( - array_diff_key($changes, ['description_refreshed' => true]), - ['reason' => 'auto-updated by hwmonDaemon (condition worsened)'] - )); + // Audit only real title/priority changes. The description is + // refreshed on every run, and logging that alone wrote a + // reason-only row every few minutes per open ticket — 50k+ rows + // on one ticket, which broke its page (tinker_tickets#112). + $auditedChanges = array_diff_key($changes, ['description_refreshed' => true]); + if (!empty($auditedChanges)) { + $auditLog->log($userId, 'update', 'ticket', $existingId, array_merge( + $auditedChanges, + ['reason' => 'auto-updated by hwmonDaemon (condition worsened)'] + )); + } // Only notify on priority escalation — title-only updates (e.g. rising // Power_On_Hours counter) should not generate a Matrix ping every hour. @@ -581,4 +588,8 @@ for ($dedupAttempt = 1; $dedupAttempt <= $maxDedupAttempts; $dedupAttempt++) { http_response_code(500); echo json_encode(['success' => false, 'error' => 'Internal server error']); } + // Done — only the deadlock `continue` above may run the loop again. + // Falling through re-entered it on a closed connection, appending a + // 500 error body after the success response for every new ticket. + exit; } diff --git a/models/AuditLogModel.php b/models/AuditLogModel.php index 001abcb..9b211ed 100644 --- a/models/AuditLogModel.php +++ b/models/AuditLogModel.php @@ -561,20 +561,27 @@ class AuditLogModel * Get formatted timeline for a specific ticket * Includes all ticket updates and comments * + * Returns only the newest $limit events: a ticket touched by an automated + * reporter can accumulate tens of thousands of rows, and loading them all + * exhausted PHP memory on the ticket page (tinker_tickets#112). + * * @param string $ticketId Ticket ID + * @param int $limit Maximum number of events (newest first) * @return array Timeline events */ - public function getTicketTimeline($ticketId) + public function getTicketTimeline($ticketId, $limit = self::DEFAULT_LIMIT) { + $limit = $this->validateLimit((int)$limit, self::EXPORT_LIMIT); $stmt = $this->conn->prepare( "SELECT al.*, u.username, u.display_name FROM audit_log al LEFT JOIN users u ON al.user_id = u.user_id WHERE (al.entity_type = 'ticket' AND al.entity_id = ?) OR (al.entity_type = 'comment' AND JSON_UNQUOTE(JSON_EXTRACT(al.details, '$.ticket_id')) = ?) - ORDER BY al.created_at DESC" + ORDER BY al.created_at DESC, al.audit_id DESC + LIMIT ?" ); - $stmt->bind_param("ss", $ticketId, $ticketId); + $stmt->bind_param("ssi", $ticketId, $ticketId, $limit); $stmt->execute(); $result = $stmt->get_result(); diff --git a/views/TicketView.php b/views/TicketView.php index 3c65875..a3efee0 100644 --- a/views/TicketView.php +++ b/views/TicketView.php @@ -819,6 +819,9 @@ document.addEventListener('DOMContentLoaded', function() { + +
Showing the most recent events; older activity is in the audit log.
+