diff --git a/create_ticket_api.php b/create_ticket_api.php index a723104..c5f11b6 100644 --- a/create_ticket_api.php +++ b/create_ticket_api.php @@ -232,301 +232,353 @@ $priority = (int)$priority; $ticketHash = generateTicketHash($data); $auditLog = new AuditLogModel($conn); -// Look up any existing ticket with this hash (open OR closed) -$checkStmt = $conn->prepare("SELECT ticket_id, status, title, priority FROM tickets WHERE hash = ? ORDER BY created_at DESC LIMIT 1"); -$checkStmt->bind_param("s", $ticketHash); -$checkStmt->execute(); -$existing = $checkStmt->get_result()->fetch_assoc(); -$checkStmt->close(); +// Everything from here through either updating/reopening the matched ticket +// or inserting a brand-new one runs inside one transaction with a row lock +// on the hash lookup. Without this, two concurrent requests carrying the +// same dedup hash (e.g. overlapping monitoring runs) could both read the +// same pre-update snapshot and each independently apply an escalation. FOR +// UPDATE on this equality lookup against the unique-indexed hash column also +// takes a lock on the "gap" where no row currently exists, so two concurrent +// requests for a genuinely new hash are still safe from a duplicate row — +// but that gap lock is shared, not exclusive, so both can reach the INSERT +// below and deadlock with each other rather than one blocking cleanly on the +// other's row. See the retry loop and comment near the INSERT's catch block +// for how that case is handled. +// Retried once if the INSERT below deadlocks with another connection's +// concurrent insert into the same not-yet-existing hash (see comment +// above the INSERT's catch block) — the retry's own SELECT ... FOR UPDATE +// will then find the winner's already-committed row and take the +// update/escalate branch instead of erroring out. +$maxDedupAttempts = 2; +for ($dedupAttempt = 1; $dedupAttempt <= $maxDedupAttempts; $dedupAttempt++) { + $conn->begin_transaction(); -if ($existing) { - $existingId = $existing['ticket_id']; - $existingStatus = $existing['status']; - $existingTitle = $existing['title']; - $existingPriority = (int)$existing['priority']; - $newPriority = (int)$priority; + // Look up any existing ticket with this hash (open OR closed) + $checkStmt = $conn->prepare("SELECT ticket_id, status, title, priority FROM tickets WHERE hash = ? ORDER BY created_at DESC LIMIT 1 FOR UPDATE"); + $checkStmt->bind_param("s", $ticketHash); + $checkStmt->execute(); + $existing = $checkStmt->get_result()->fetch_assoc(); + $checkStmt->close(); - if ($existingStatus !== 'Closed') { - // Ticket is still active — update title, escalate priority, and refresh - // description with latest sensor data. - $changes = []; - $updateSql = "UPDATE tickets SET updated_at = NOW(), updated_by = ?"; - $bindTypes = "i"; - $bindVals = [$userId]; + if ($existing) { + $existingId = $existing['ticket_id']; + $existingStatus = $existing['status']; + $existingTitle = $existing['title']; + $existingPriority = (int)$existing['priority']; + $newPriority = (int)$priority; - if ($title !== $existingTitle) { - $updateSql .= ", title = ?"; - $bindTypes .= "s"; - $bindVals[] = $title; - $changes['title'] = ['from' => $existingTitle, 'to' => $title]; - } + if ($existingStatus !== 'Closed') { + // Ticket is still active — update title, escalate priority, and refresh + // description with latest sensor data. + $changes = []; + $updateSql = "UPDATE tickets SET updated_at = NOW(), updated_by = ?"; + $bindTypes = "i"; + $bindVals = [$userId]; - if ($newPriority < $existingPriority) { - $updateSql .= ", priority = ?"; - $bindTypes .= "i"; - $bindVals[] = $newPriority; - $changes['priority'] = ['from' => $existingPriority, 'to' => $newPriority]; - } - - // Always refresh the description so the ticket body shows current sensor data - if (!empty($description)) { - $updateSql .= ", description = ?"; - $bindTypes .= "s"; - $bindVals[] = $description; - $changes['description_refreshed'] = true; - } - - if (!empty($changes)) { - $updateSql .= " WHERE ticket_id = ?"; - $bindTypes .= "s"; - $bindVals[] = $existingId; - - $updStmt = $conn->prepare($updateSql); - $updStmt->bind_param($bindTypes, ...$bindVals); - $updStmt->execute(); - $updStmt->close(); - - // Only post a comment on priority escalation — title and description updates - // are silent (title changes like rising counters would spam a comment every run). - // Keep it short: the full sensor data is refreshed in the ticket description, - // so the comment just records the bump + a brief reason (no ASCII dump). - if (isset($changes['priority'])) { - $pLabels = [1 => 'P1 (Critical)', 2 => 'P2 (High)', 3 => 'P3 (Medium)', 4 => 'P4 (Low)', 5 => 'P5 (Minimal)']; - $fromP = (int)$changes['priority']['from']; - $toP = (int)$changes['priority']['to']; - $fromL = $pLabels[$fromP] ?? "P{$fromP}"; - $toL = $pLabels[$toP] ?? "P{$toP}"; - $commentText = "**hwmonDaemon raised priority {$fromL} → {$toL}.**\n\n" - . "The latest monitoring scan reported a more severe condition for this issue, " - . "so it now needs faster attention. Current sensor data is in the ticket description above."; - $commentStmt = $conn->prepare( - "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)" - ); - $commentStmt->bind_param("sis", $existingId, $userId, $commentText); - $commentStmt->execute(); - $commentStmt->close(); + if ($title !== $existingTitle) { + $updateSql .= ", title = ?"; + $bindTypes .= "s"; + $bindVals[] = $title; + $changes['title'] = ['from' => $existingTitle, 'to' => $title]; } - $auditLog->log($userId, 'update', 'ticket', $existingId, array_merge( - array_diff_key($changes, ['description_refreshed' => true]), - ['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. - if (isset($changes['priority'])) { - require_once __DIR__ . '/helpers/NotificationHelper.php'; - NotificationHelper::sendTicketNotification($existingId, [ - 'title' => $title, - 'priority' => $changes['priority']['to'], - 'category' => $category, - 'type' => $type, - 'status' => $existingStatus, - ], 'automated'); + if ($newPriority < $existingPriority) { + $updateSql .= ", priority = ?"; + $bindTypes .= "i"; + $bindVals[] = $newPriority; + $changes['priority'] = ['from' => $existingPriority, 'to' => $newPriority]; } - // Ticket state (priority/title/description) changed — refresh dashboard stats. + // Always refresh the description so the ticket body shows current sensor data + if (!empty($description)) { + $updateSql .= ", description = ?"; + $bindTypes .= "s"; + $bindVals[] = $description; + $changes['description_refreshed'] = true; + } + + if (!empty($changes)) { + $updateSql .= " WHERE ticket_id = ?"; + $bindTypes .= "s"; + $bindVals[] = $existingId; + + $updStmt = $conn->prepare($updateSql); + $updStmt->bind_param($bindTypes, ...$bindVals); + $updStmt->execute(); + $updStmt->close(); + + // Only post a comment on priority escalation — title and description updates + // are silent (title changes like rising counters would spam a comment every run). + // Keep it short: the full sensor data is refreshed in the ticket description, + // so the comment just records the bump + a brief reason (no ASCII dump). + if (isset($changes['priority'])) { + $pLabels = [1 => 'P1 (Critical)', 2 => 'P2 (High)', 3 => 'P3 (Medium)', 4 => 'P4 (Low)', 5 => 'P5 (Minimal)']; + $fromP = (int)$changes['priority']['from']; + $toP = (int)$changes['priority']['to']; + $fromL = $pLabels[$fromP] ?? "P{$fromP}"; + $toL = $pLabels[$toP] ?? "P{$toP}"; + $commentText = "**hwmonDaemon raised priority {$fromL} → {$toL}.**\n\n" + . "The latest monitoring scan reported a more severe condition for this issue, " + . "so it now needs faster attention. Current sensor data is in the ticket description above."; + $commentStmt = $conn->prepare( + "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)" + ); + $commentStmt->bind_param("sis", $existingId, $userId, $commentText); + $commentStmt->execute(); + $commentStmt->close(); + } + + $auditLog->log($userId, 'update', 'ticket', $existingId, array_merge( + array_diff_key($changes, ['description_refreshed' => true]), + ['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. + if (isset($changes['priority'])) { + require_once __DIR__ . '/helpers/NotificationHelper.php'; + NotificationHelper::sendTicketNotification($existingId, [ + 'title' => $title, + 'priority' => $changes['priority']['to'], + 'category' => $category, + 'type' => $type, + 'status' => $existingStatus, + ], 'automated'); + } + + // Ticket state (priority/title/description) changed — refresh dashboard stats. + (new StatsModel($conn))->invalidateCache(); + } + + $conn->commit(); + Database::close(); + echo json_encode([ + 'success' => true, + 'ticket_id' => $existingId, + 'message' => empty($changes) ? 'Duplicate — no change' : 'Existing ticket updated', + 'action' => empty($changes) ? 'deduplicated' : 'updated', + 'changes' => $changes, + ]); + exit; + } + + // Ticket was closed — reopen it and add a recurrence comment. Route + // through the Workflow Designer like every other status-write path in + // the app, rather than forcing status='Open' via raw SQL regardless of + // configured transition rules. + $workflowModel = new WorkflowModel($conn); + $reopenStatus = 'Open'; + if (!$workflowModel->isTransitionAllowed('Closed', 'Open', false)) { + // Direct Closed->Open isn't configured — fall back to any transition + // the Workflow Designer does allow from Closed that this unattended, + // non-admin automation can actually satisfy (no comment prompt, no + // admin elevation). If even that doesn't exist, leave the ticket + // Closed rather than force an unconfigured state. + $reopenStatus = null; + foreach ($workflowModel->getAllowedTransitions('Closed') as $transition) { + if (!$transition['requires_comment'] && !$transition['requires_admin']) { + $reopenStatus = $transition['to_status']; + break; + } + } + } + + if ($reopenStatus !== null) { + $ticketModel = new TicketModel($conn); + $ticketModel->updateTicket([ + 'ticket_id' => $existingId, + 'title' => $title, + 'description' => $description, + 'category' => $category, + 'type' => $type, + 'status' => $reopenStatus, + 'priority' => $priority, + ], $userId); + } else { + error_log("create_ticket_api: hwmonDaemon recurrence for ticket $existingId — " + . "no admin-free, comment-free transition from Closed is configured; leaving ticket Closed"); + } + + $commentText = "**Issue recurred — ticket reopened automatically.**\n\n" . + "hwmonDaemon detected this condition again. The ticket description reflects the " + . "original report; see this comment's timestamp for when the issue recurred."; + if ($reopenStatus === null) { + $commentText = "**Issue recurred, but the ticket could not be reopened automatically.**\n\n" + . "hwmonDaemon detected this condition again. No Workflow Designer transition from " + . "Closed is configured that this automation can perform unattended (no comment/admin " + . "requirement); the ticket remains Closed. Please review and reopen manually if appropriate."; + } + $commentStmt = $conn->prepare( + "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)" + ); + $commentStmt->bind_param("sis", $existingId, $userId, $commentText); + $commentStmt->execute(); + $commentStmt->close(); + + if ($reopenStatus !== null) { + $auditLog->log($userId, 'update', 'ticket', $existingId, [ + 'status' => ['from' => 'Closed', 'to' => $reopenStatus], + 'reason' => 'auto-reopened by hwmonDaemon (issue recurred)', + ]); + + // Ticket reopened — refresh dashboard stats. (new StatsModel($conn))->invalidateCache(); + } else { + $auditLog->log($userId, 'update', 'ticket', $existingId, [ + 'reason' => 'hwmonDaemon recurrence detected but no valid reopen transition configured; ticket left Closed', + ]); } + $conn->commit(); Database::close(); + + if ($reopenStatus !== null) { + require_once __DIR__ . '/helpers/NotificationHelper.php'; + NotificationHelper::sendTicketNotification($existingId, [ + 'title' => $title, + 'priority' => $priority, + 'category' => $category, + 'type' => $type, + 'status' => $reopenStatus, + ], 'automated'); + } + echo json_encode([ - 'success' => true, - 'ticket_id' => $existingId, - 'message' => empty($changes) ? 'Duplicate — no change' : 'Existing ticket updated', - 'action' => empty($changes) ? 'deduplicated' : 'updated', - 'changes' => $changes, + 'success' => true, + 'ticket_id' => $existingId, + 'message' => $reopenStatus !== null + ? 'Existing closed ticket reopened' + : 'Recurrence noted; ticket left Closed (no valid workflow transition configured)', + 'action' => $reopenStatus !== null ? 'reopened' : 'recurrence_noted', ]); exit; } - // Ticket was closed — reopen it and add a recurrence comment. Route - // through the Workflow Designer like every other status-write path in - // the app, rather than forcing status='Open' via raw SQL regardless of - // configured transition rules. - $workflowModel = new WorkflowModel($conn); - $reopenStatus = 'Open'; - if (!$workflowModel->isTransitionAllowed('Closed', 'Open', false)) { - // Direct Closed->Open isn't configured — fall back to any transition - // the Workflow Designer does allow from Closed that this unattended, - // non-admin automation can actually satisfy (no comment prompt, no - // admin elevation). If even that doesn't exist, leave the ticket - // Closed rather than force an unconfigured state. - $reopenStatus = null; - foreach ($workflowModel->getAllowedTransitions('Closed') as $transition) { - if (!$transition['requires_comment'] && !$transition['requires_admin']) { - $reopenStatus = $transition['to_status']; - break; - } + // No existing ticket — create a new one. Still inside the transaction opened + // above, so a concurrent request for the same hash is blocked on its own + // SELECT ... FOR UPDATE until this one commits or rolls back (see comment + // there) rather than racing this INSERT. + // + // Note on FOR UPDATE over a not-yet-existing key: InnoDB's gap lock in that + // case is a shared lock, not exclusive — two concurrent transactions can + // both acquire it and both reach this INSERT. The conflict only surfaces + // when they each request the insert-intention lock for the same gap, + // which InnoDB resolves as a deadlock (error 1213), not by blocking one + // of the SELECTs. The outer loop above retries that case: the loser rolls + // back and re-runs its own SELECT ... FOR UPDATE, which by then finds the + // winner's committed row and takes the update/escalate branch instead. + // + // Generate a collision-safe unique ticket_id with a pre-check + retry loop (same + // approach as TicketModel::createTicket) so a ticket_id clash cannot happen. That + // way a 1062 on INSERT below can only be the unique_hash (dedup) key — and with + // the FOR UPDATE lock above, only in the unlikely case of a hash collision from + // two genuinely different reports, not the same-hash race this used to be. + $ticket_id = null; + $maxAttempts = 50; + $attempts = 0; + do { + try { + $candidateId = sprintf('%09d', random_int(100000000, 999999999)); + } catch (Exception $e) { + $candidateId = sprintf('%09d', mt_rand(100000000, 999999999)); } + + $idCheckStmt = $conn->prepare("SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1"); + $idCheckStmt->bind_param("s", $candidateId); + $idCheckStmt->execute(); + $idExists = $idCheckStmt->get_result()->num_rows > 0; + $idCheckStmt->close(); + + if (!$idExists) { + $ticket_id = $candidateId; + } + $attempts++; + } while ($ticket_id === null && $attempts < $maxAttempts); + + if ($ticket_id === null) { + $conn->rollback(); + error_log('create_ticket_api: failed to generate a unique ticket_id after ' . $maxAttempts . ' attempts'); + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Internal server error']); + exit; } - if ($reopenStatus !== null) { - $ticketModel = new TicketModel($conn); - $ticketModel->updateTicket([ - 'ticket_id' => $existingId, - 'title' => $title, - 'description' => $description, - 'category' => $category, - 'type' => $type, - 'status' => $reopenStatus, - 'priority' => $priority, - ], $userId); - } else { - error_log("create_ticket_api: hwmonDaemon recurrence for ticket $existingId — " - . "no admin-free, comment-free transition from Closed is configured; leaving ticket Closed"); - } - - $commentText = "**Issue recurred — ticket reopened automatically.**\n\n" . - "hwmonDaemon detected this condition again. The ticket description reflects the " - . "original report; see this comment's timestamp for when the issue recurred."; - if ($reopenStatus === null) { - $commentText = "**Issue recurred, but the ticket could not be reopened automatically.**\n\n" - . "hwmonDaemon detected this condition again. No Workflow Designer transition from " - . "Closed is configured that this automation can perform unattended (no comment/admin " - . "requirement); the ticket remains Closed. Please review and reopen manually if appropriate."; - } - $commentStmt = $conn->prepare( - "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)" + $insertStmt = $conn->prepare( + "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + $insertStmt->bind_param( + "ssssssssi", + $ticket_id, + $title, + $description, + $status, + $priority, + $category, + $type, + $ticketHash, + $userId ); - $commentStmt->bind_param("sis", $existingId, $userId, $commentText); - $commentStmt->execute(); - $commentStmt->close(); - if ($reopenStatus !== null) { - $auditLog->log($userId, 'update', 'ticket', $existingId, [ - 'status' => ['from' => 'Closed', 'to' => $reopenStatus], - 'reason' => 'auto-reopened by hwmonDaemon (issue recurred)', - ]); - - // Ticket reopened — refresh dashboard stats. - (new StatsModel($conn))->invalidateCache(); - } else { - $auditLog->log($userId, 'update', 'ticket', $existingId, [ - 'reason' => 'hwmonDaemon recurrence detected but no valid reopen transition configured; ticket left Closed', - ]); + try { + $inserted = $insertStmt->execute(); + } catch (mysqli_sql_exception $e) { + $insertStmt->close(); + $conn->rollback(); + if (in_array($e->getCode(), [1213, 1205], true) && $dedupAttempt < $maxDedupAttempts) { + // Deadlock (1213) or lock wait timeout (1205) from a concurrent + // insert into the same not-yet-existing hash gap — see the note + // above. Retry: the next iteration's own SELECT ... FOR UPDATE + // will find whichever side won and take the update/escalate path. + continue; + } + if ($e->getCode() === 1062) { + // Should be unreachable in the same-hash race this issue was filed + // for now that the SELECT above takes FOR UPDATE — kept as a + // defensive fallback in case of a genuine hash collision between two + // different reports. + echo json_encode(['success' => false, 'error' => 'Duplicate ticket']); + } else { + error_log('create_ticket_api: insert failed: ' . $e->getMessage()); + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Internal server error']); + } + exit; } + $insertStmt->close(); - Database::close(); - - if ($reopenStatus !== null) { - require_once __DIR__ . '/helpers/NotificationHelper.php'; - NotificationHelper::sendTicketNotification($existingId, [ + if ($inserted) { + $auditLog->logTicketCreate($userId, $ticket_id, [ 'title' => $title, 'priority' => $priority, 'category' => $category, 'type' => $type, - 'status' => $reopenStatus, + ]); + + // New ticket created — refresh dashboard stats. + (new StatsModel($conn))->invalidateCache(); + + $conn->commit(); + Database::close(); + + require_once __DIR__ . '/helpers/NotificationHelper.php'; + NotificationHelper::sendTicketNotification($ticket_id, [ + 'title' => $title, + 'priority' => $priority, + 'category' => $category, + 'type' => $type, + 'status' => $status, ], 'automated'); - } - echo json_encode([ - 'success' => true, - 'ticket_id' => $existingId, - 'message' => $reopenStatus !== null - ? 'Existing closed ticket reopened' - : 'Recurrence noted; ticket left Closed (no valid workflow transition configured)', - 'action' => $reopenStatus !== null ? 'reopened' : 'recurrence_noted', - ]); - exit; -} - -// No existing ticket — create a new one. -// Generate a collision-safe unique ticket_id with a pre-check + retry loop (same -// approach as TicketModel::createTicket) so a ticket_id clash cannot happen. That -// way a 1062 on INSERT below can only be the unique_hash (dedup) key racing, and -// is correctly reported as a duplicate rather than a dropped hardware alert. -$ticket_id = null; -$maxAttempts = 50; -$attempts = 0; -do { - try { - $candidateId = sprintf('%09d', random_int(100000000, 999999999)); - } catch (Exception $e) { - $candidateId = sprintf('%09d', mt_rand(100000000, 999999999)); - } - - $idCheckStmt = $conn->prepare("SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1"); - $idCheckStmt->bind_param("s", $candidateId); - $idCheckStmt->execute(); - $idExists = $idCheckStmt->get_result()->num_rows > 0; - $idCheckStmt->close(); - - if (!$idExists) { - $ticket_id = $candidateId; - } - $attempts++; -} while ($ticket_id === null && $attempts < $maxAttempts); - -if ($ticket_id === null) { - error_log('create_ticket_api: failed to generate a unique ticket_id after ' . $maxAttempts . ' attempts'); - http_response_code(500); - echo json_encode(['success' => false, 'error' => 'Internal server error']); - exit; -} - -$insertStmt = $conn->prepare( - "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" -); -$insertStmt->bind_param( - "ssssssssi", - $ticket_id, - $title, - $description, - $status, - $priority, - $category, - $type, - $ticketHash, - $userId -); - -try { - $inserted = $insertStmt->execute(); -} catch (mysqli_sql_exception $e) { - $insertStmt->close(); - if ($e->getCode() === 1062) { - // Race condition: another node inserted the same hash between our SELECT and INSERT - echo json_encode(['success' => false, 'error' => 'Duplicate ticket']); + echo json_encode([ + 'success' => true, + 'ticket_id' => $ticket_id, + 'message' => 'Ticket created successfully', + ]); } else { - error_log('create_ticket_api: insert failed: ' . $e->getMessage()); + $conn->rollback(); + error_log('create_ticket_api: ticket insert reported failure: ' . $conn->error); http_response_code(500); echo json_encode(['success' => false, 'error' => 'Internal server error']); } - exit; -} -$insertStmt->close(); - -if ($inserted) { - $auditLog->logTicketCreate($userId, $ticket_id, [ - 'title' => $title, - 'priority' => $priority, - 'category' => $category, - 'type' => $type, - ]); - - // New ticket created — refresh dashboard stats. - (new StatsModel($conn))->invalidateCache(); - - Database::close(); - - require_once __DIR__ . '/helpers/NotificationHelper.php'; - NotificationHelper::sendTicketNotification($ticket_id, [ - 'title' => $title, - 'priority' => $priority, - 'category' => $category, - 'type' => $type, - 'status' => $status, - ], 'automated'); - - echo json_encode([ - 'success' => true, - 'ticket_id' => $ticket_id, - 'message' => 'Ticket created successfully', - ]); -} else { - error_log('create_ticket_api: ticket insert reported failure: ' . $conn->error); - http_response_code(500); - echo json_encode(['success' => false, 'error' => 'Internal server error']); }