Compare commits

..
Author SHA1 Message Date
jaredandClaude Sonnet 5 4aa83ffe58 Merge development into main: bulk-op atomicity docs + double-submit guard (#33, #36)
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m5s
Lint / Deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 21:58:45 -04:00
jaredandClaude Sonnet 5 844677bbce Fix atomicity docblock and surface per-ticket bulk-op errors (#33)
Lint / PHP (phpcs PSR-12) (push) Successful in 19s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 23s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m4s
Lint / Deploy (push) Successful in 2s
processBulkOperation()'s docblock claimed the transaction "ensures
atomicity - either all tickets are updated or none are," but that's
only true when $atomic = true is passed, and the only real caller
(api/bulk_operation.php) never passes it — the actual default is
best-effort: per-ticket failures are skipped and recorded, and every
other ticket in the batch still commits. Reworded the docblock to
describe the actual default behavior and when $atomic changes it.

The model already collected per-ticket failure reasons into
$result['errors'] (dashboard.js's bulkResultMessage() already reads
data.errors to render them), but api/bulk_operation.php's success
response dropped that field entirely, so admins only ever saw a bare
"N succeeded, M failed" count with no way to see which tickets failed
or why. Added 'errors' to the response when present.

Verified against real MariaDB: a bulk_status operation against a Closed
ticket (no transition defined) and an Open ticket (Open->Pending
defined) correctly processed 1/1, and the API response now includes
errors: ["Ticket ...: transition not allowed (Closed -> Pending)"].

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 21:56:26 -04:00
jaredandClaude Sonnet 5 3fcd1cbf0e Guard bulk-action buttons against double-submit (#36)
The 4 bulk-action confirm buttons (close/assign/priority/status) called
their performBulk*() handler directly on click with no in-flight guard.
Double-clicking a confirm button fired two concurrent POST /api/
bulk_operation.php requests for the same ticket IDs, duplicating the
close-reason comment, audit-log entry, and Matrix notification on every
affected ticket.

Each performBulk*() function now takes the clicked button, no-ops if
it's already disabled, disables it before firing the request, and
re-enables it in .finally() regardless of outcome.

Verified by extracting performBulkAssign() from the real source and
driving it with a mock lt.api.post() that never resolves until told to:
a simulated rapid double-click fired exactly one request (the second
call was a no-op while the button was disabled), and a subsequent click
after the request resolved correctly fired a new request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 21:56:19 -04:00
jaredandClaude Sonnet 5 3ab33d8df2 Merge development into main: concurrency/atomicity fixes (#34, #35, #37)
Lint / PHP (phpcs PSR-12) (push) Successful in 45s
Lint / JS (eslint) (push) Successful in 13s
Lint / PHP requirements (version + extensions) (push) Successful in 27s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m7s
Lint / Deploy (push) Successful in 3s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 21:45:25 -04:00
jaredandClaude Sonnet 5 3778a599c3 Serialize dedup-hash lookups in create_ticket_api.php (#35)
Lint / PHP (phpcs PSR-12) (push) Successful in 21s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 40s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m41s
Lint / Deploy (push) Successful in 2s
Two concurrent hwmonDaemon reports carrying the same dedup hash could
both read the same pre-update ticket snapshot and each independently
apply a priority escalation (losing one), or both attempt to INSERT a
new ticket for a hash that didn't exist yet and have the loser's request
dropped with a "Duplicate ticket" error instead of falling through to
the update/escalate path.

Wrap the hash lookup through the update-or-insert in one transaction,
with the lookup taking SELECT ... FOR UPDATE. For an existing row this
serializes the read-modify-write so a second request observes the
first's committed state. For a not-yet-existing hash, InnoDB's gap lock
there is shared rather than exclusive, so two concurrent inserts can
both reach the INSERT and deadlock (1213) instead of one blocking
cleanly on the other's row; retry the whole lookup once on that
deadlock (or a lock-wait-timeout, 1205) so the retry's own SELECT finds
the winner's committed row and takes the update path instead of erroring.

Verified against real MariaDB with two concurrent OS processes for both
scenarios: (1) same existing active ticket — the second process blocked
~1.1s on the first's held row lock, then correctly escalated from the
first's committed priority rather than a stale value; (2) same
not-yet-existing hash — reproduced the 1213 deadlock deterministically
across 5/5 runs with the original code, then confirmed the retry
resolves it every time (5/5), leaving exactly one ticket row created and
no dropped/erroring request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 21:41:57 -04:00
jaredandClaude Sonnet 5 310dcd0840 Persist status-change comments transactionally with the update (#37)
A comment accompanying a status change (required or user-supplied) was
posted via a separate, independent HTTP call/write (add_comment.php,
or a second add_comment call in lt.ticketStatus.submit()'s
requires_comment retry path) before the status update itself. A failure
partway through — or the client never issuing the second call — could
leave a "reason" comment persisted with no matching status change, or
vice versa, with no rollback tying the two together.

api/update_ticket.php and api/ticket_status_api.php now post the comment
and apply the status update inside one transaction, rolling back both on
any failure. assets/js/ticket.js and lt.ticketStatus.submit() in
assets/js/base.js no longer make a separate add_comment.php call; they
pass the comment directly to update_ticket.php, which persists it
server-side alongside the status change.

Verified against real MariaDB by extracting the live ApiTicketController
and the ticket_status_api.php transaction logic and running them
directly: a forced optimistic-lock conflict correctly rolled back both
the comment and the status change, and a successful call persisted
exactly one comment alongside the status change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 21:41:48 -04:00
jaredandClaude Sonnet 5 d205a9577a Fix TOCTOU race in bulk operations by row-locking tickets (#34)
processBulkOperation() validated each ticket's status/priority transition
against a pre-transaction snapshot (ticketsById) instead of re-reading
inside the transaction, so two concurrent bulk operations touching the
same ticket could both pass validation against stale data and one
transition could silently clobber the other. Add lockTicketForUpdate(),
which re-fetches a ticket via SELECT ... FOR UPDATE, and use it wherever
the loop needs current status/priority, removing the three redundant
re-reads from the stale snapshot in the bulk_close/bulk_priority/
bulk_status branches.

Verified against real MariaDB with two concurrent OS processes: the
second process blocked ~1.2s on the first's held row lock, then correctly
observed the first's committed status and rejected an otherwise-stale-
data-permitted invalid transition. Also regression-tested normal
bulk_close/bulk_priority operation on real tickets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 21:41:41 -04:00
8 changed files with 494 additions and 346 deletions
+9 -2
View File
@@ -131,12 +131,19 @@ if (isset($result['error'])) {
if ($inaccessibleCount > 0) { if ($inaccessibleCount > 0) {
$message .= " ($inaccessibleCount skipped - no access)"; $message .= " ($inaccessibleCount skipped - no access)";
} }
echo json_encode([ $response = [
'success' => true, 'success' => true,
'operation_id' => $operationId, 'operation_id' => $operationId,
'processed' => $result['processed'], 'processed' => $result['processed'],
'failed' => $result['failed'], 'failed' => $result['failed'],
'skipped' => $inaccessibleCount, 'skipped' => $inaccessibleCount,
'message' => $message 'message' => $message
]); ];
// Best-effort batches (the default; see processBulkOperation()'s docblock)
// can partially fail — surface the per-ticket reasons so the admin isn't
// just told a count. The dashboard's bulkResultMessage() already expects this.
if (!empty($result['errors'])) {
$response['errors'] = $result['errors'];
}
echo json_encode($response);
} }
+27 -21
View File
@@ -126,23 +126,6 @@ if ($workflowModel->transitionRequiresComment($currentStatus, $newStatus) && $co
exit; exit;
} }
// Post the comment first (per-key label) so a close-with-reason is one call.
if ($comment !== '') {
$commentModel = new CommentModel($conn);
$commentResult = $commentModel->addComment($ticketId, [
'user_name' => $keyName,
'comment_text' => $comment,
'markdown_enabled' => !empty($data['markdown_enabled']),
], $createdBy);
if (empty($commentResult['success'])) {
error_log('ticket_status_api: addComment failed for ticket ' . $ticketId
. ': ' . ($commentResult['error'] ?? 'unknown'));
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to add comment']);
exit;
}
}
// Apply the status change. updateTicket sets updated_by/updated_at and handles // Apply the status change. updateTicket sets updated_by/updated_at and handles
// closed_at (set on close, cleared on reopen) via its own SQL. // closed_at (set on close, cleared on reopen) via its own SQL.
$updateData = [ $updateData = [
@@ -155,10 +138,33 @@ $updateData = [
'priority' => (int)$ticket['priority'], 'priority' => (int)$ticket['priority'],
]; ];
$updateResult = $ticketModel->updateTicket($updateData, $createdBy); // Post the comment and apply the status change in one transaction, so a
if (empty($updateResult['success'])) { // failure partway through can't leave a "reason" comment persisted with no
error_log('ticket_status_api: updateTicket failed for ticket ' . $ticketId // matching status change (previously these were two independent writes with
. ': ' . ($updateResult['error'] ?? 'unknown')); // no shared rollback).
$conn->begin_transaction();
try {
if ($comment !== '') {
$commentModel = new CommentModel($conn);
$commentResult = $commentModel->addComment($ticketId, [
'user_name' => $keyName,
'comment_text' => $comment,
'markdown_enabled' => !empty($data['markdown_enabled']),
], $createdBy);
if (empty($commentResult['success'])) {
throw new Exception($commentResult['error'] ?? 'Failed to add comment');
}
}
$updateResult = $ticketModel->updateTicket($updateData, $createdBy);
if (empty($updateResult['success'])) {
throw new Exception($updateResult['error'] ?? 'Failed to update ticket status');
}
$conn->commit();
} catch (Exception $e) {
$conn->rollback();
error_log('ticket_status_api: transaction failed for ticket ' . $ticketId . ': ' . $e->getMessage());
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to update ticket status']); echo json_encode(['success' => false, 'error' => 'Failed to update ticket status']);
exit; exit;
+41 -14
View File
@@ -195,8 +195,8 @@ try {
// Enforce requires_comment transitions server-side. // Enforce requires_comment transitions server-side.
if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) { if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) {
$comment = trim((string)($data['comment'] ?? $data['comment_text'] ?? '')); $statusChangeComment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
if ($comment === '') { if ($statusChangeComment === '') {
return [ return [
'success' => false, 'success' => false,
'error' => 'A comment is required for this status change', 'error' => 'A comment is required for this status change',
@@ -207,27 +207,55 @@ try {
} }
} }
// A comment accompanying a status change (required or optional) is
// persisted in the SAME transaction as the status update below, so
// a failure partway through can't leave an orphaned "reason"
// comment attached with no matching status change — the two
// previously ran as separate, non-transactional HTTP calls from
// the client (add_comment.php then update_ticket.php).
$statusChangeComment = $statusChangeComment ?? trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
$result = null;
$this->conn->begin_transaction();
try {
if ($statusChangeComment !== '' && $currentTicket['status'] !== $updateData['status']) {
$commentResult = $this->commentModel->addComment($id, [
'user_name' => $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? 'User',
'comment_text' => $statusChangeComment,
'markdown_enabled' => !empty($data['markdown_enabled']),
], $this->userId);
if (empty($commentResult['success'])) {
throw new Exception($commentResult['error'] ?? 'Failed to add comment');
}
}
// Update ticket with user tracking and optional optimistic locking // Update ticket with user tracking and optional optimistic locking
$expectedUpdatedAt = $data['expected_updated_at'] ?? null; $expectedUpdatedAt = $data['expected_updated_at'] ?? null;
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt); $result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
// Handle conflict case
if (!$result['success']) { if (!$result['success']) {
$response = [ throw new Exception($result['error'] ?? 'Failed to update ticket in database');
'success' => false, }
'error' => $result['error'] ?? 'Failed to update ticket in database'
]; // Handle visibility update if provided (already validated above)
if (!empty($result['conflict'])) { if (isset($data['visibility'])) {
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
if (!$visResult) {
throw new Exception('Failed to update ticket visibility');
}
}
$this->conn->commit();
} catch (Exception $e) {
$this->conn->rollback();
$response = ['success' => false, 'error' => $e->getMessage()];
if (is_array($result) && !empty($result['conflict'])) {
$response['conflict'] = true; $response['conflict'] = true;
$response['current_updated_at'] = $result['current_updated_at'] ?? null; $response['current_updated_at'] = $result['current_updated_at'] ?? null;
} }
return $response; return $response;
} }
// Handle visibility update if provided (already validated above) if (isset($data['visibility']) && $this->userId) {
if (isset($data['visibility'])) {
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
if ($visResult && $this->userId) {
$this->auditLog->log( $this->auditLog->log(
$this->userId, $this->userId,
'update', 'update',
@@ -241,7 +269,6 @@ try {
] ]
); );
} }
}
// Log ticket update to audit log — only the changed fields (delta) // Log ticket update to audit log — only the changed fields (delta)
if ($this->userId) { if ($this->userId) {
+8 -5
View File
@@ -2858,8 +2858,11 @@
TICKET STATUS CHANGE (comment-aware) TICKET STATUS CHANGE (comment-aware)
lt.ticketStatus.submit(ticketId, newStatus, { comment? }) → Promise<data> lt.ticketStatus.submit(ticketId, newStatus, { comment? }) → Promise<data>
Posts /api/update_ticket.php. If the server rejects with Posts /api/update_ticket.php. If the server rejects with
requires_comment, opens a comment modal, persists the comment via requires_comment, opens a comment modal, then retries the update once
/api/add_comment.php, then retries the update once WITH the comment. WITH the comment — update_ticket.php persists it in the same DB
transaction as the status change itself, so there's no separate
add_comment.php call that could leave an orphaned comment if the
status update then failed.
Rejects with err.cancelled === true if the user cancels the modal. Rejects with err.cancelled === true if the user cancels the modal.
================================================================ */ ================================================================ */
function _statusCommentModal(newStatus) { function _statusCommentModal(newStatus) {
@@ -2922,9 +2925,9 @@
cancelErr.cancelled = true; cancelErr.cancelled = true;
throw cancelErr; throw cancelErr;
} }
// Persist the comment, then retry the status change with it included. // Retry with the comment included — update_ticket.php persists it
return api.post('/api/add_comment.php', { ticket_id: id, comment_text: comment }) // transactionally with the status update itself.
.then(() => api.post('/api/update_ticket.php', { ticket_id: id, status: newStatus, comment: comment })); return api.post('/api/update_ticket.php', { ticket_id: id, status: newStatus, comment: comment });
}); });
}); });
}, },
+28 -12
View File
@@ -140,25 +140,25 @@ document.addEventListener('DOMContentLoaded', function() {
break; break;
// Bulk operation perform actions // Bulk operation perform actions
case 'perform-bulk-assign': case 'perform-bulk-assign':
performBulkAssign(); performBulkAssign(target);
break; break;
case 'close-bulk-assign-modal': case 'close-bulk-assign-modal':
closeBulkAssignModal(); closeBulkAssignModal();
break; break;
case 'perform-bulk-priority': case 'perform-bulk-priority':
performBulkPriority(); performBulkPriority(target);
break; break;
case 'close-bulk-priority-modal': case 'close-bulk-priority-modal':
closeBulkPriorityModal(); closeBulkPriorityModal();
break; break;
case 'perform-bulk-status': case 'perform-bulk-status':
performBulkStatusChange(); performBulkStatusChange(target);
break; break;
case 'close-bulk-status-modal': case 'close-bulk-status-modal':
closeBulkStatusModal(); closeBulkStatusModal();
break; break;
case 'perform-bulk-close': case 'perform-bulk-close':
performBulkCloseAction(); performBulkCloseAction(undefined, target);
break; break;
case 'close-bulk-close-modal': case 'close-bulk-close-modal':
closeBulkCloseModal(); closeBulkCloseModal();
@@ -491,7 +491,10 @@ function closeBulkCloseModal() {
if (modal) setTimeout(() => modal.remove(), 300); if (modal) setTimeout(() => modal.remove(), 300);
} }
function performBulkCloseAction(ticketIds) { function performBulkCloseAction(ticketIds, btn) {
if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests
if (btn) btn.disabled = true;
ticketIds = ticketIds || getSelectedTicketIds(); ticketIds = ticketIds || getSelectedTicketIds();
const commentEl = document.getElementById('bulkCloseComment'); const commentEl = document.getElementById('bulkCloseComment');
const comment = commentEl ? commentEl.value.trim() : ''; const comment = commentEl ? commentEl.value.trim() : '';
@@ -524,7 +527,8 @@ function performBulkCloseAction(ticketIds) {
} }
closeBulkCloseModal(); closeBulkCloseModal();
lt.toast.error('Bulk close failed: ' + error.message, 5000); lt.toast.error('Bulk close failed: ' + error.message, 5000);
}); })
.finally(() => { if (btn) btn.disabled = false; });
} }
var _bulkAssignUserId = null; var _bulkAssignUserId = null;
@@ -596,7 +600,8 @@ function closeBulkAssignModal() {
if (modal) setTimeout(() => modal.remove(), 300); if (modal) setTimeout(() => modal.remove(), 300);
} }
function performBulkAssign() { function performBulkAssign(btn) {
if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests
const userId = _bulkAssignUserId; const userId = _bulkAssignUserId;
const ticketIds = getSelectedTicketIds(); const ticketIds = getSelectedTicketIds();
@@ -605,6 +610,8 @@ function performBulkAssign() {
return; return;
} }
if (btn) btn.disabled = true;
lt.api.post('/api/bulk_operation.php', { lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_assign', operation_type: 'bulk_assign',
ticket_ids: ticketIds, ticket_ids: ticketIds,
@@ -625,7 +632,8 @@ function performBulkAssign() {
}) })
.catch(error => { .catch(error => {
lt.toast.error('Bulk assign failed: ' + error.message, 5000); lt.toast.error('Bulk assign failed: ' + error.message, 5000);
}); })
.finally(() => { if (btn) btn.disabled = false; });
} }
function showBulkPriorityModal() { function showBulkPriorityModal() {
@@ -672,7 +680,8 @@ function closeBulkPriorityModal() {
if (modal) setTimeout(() => modal.remove(), 300); if (modal) setTimeout(() => modal.remove(), 300);
} }
function performBulkPriority() { function performBulkPriority(btn) {
if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests
const priorityEl = document.getElementById('bulkPriority'); const priorityEl = document.getElementById('bulkPriority');
if (!priorityEl) return; if (!priorityEl) return;
const priority = priorityEl.value; const priority = priorityEl.value;
@@ -683,6 +692,8 @@ function performBulkPriority() {
return; return;
} }
if (btn) btn.disabled = true;
lt.api.post('/api/bulk_operation.php', { lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_priority', operation_type: 'bulk_priority',
ticket_ids: ticketIds, ticket_ids: ticketIds,
@@ -703,7 +714,8 @@ function performBulkPriority() {
}) })
.catch(error => { .catch(error => {
lt.toast.error('Bulk priority update failed: ' + error.message, 5000); lt.toast.error('Bulk priority update failed: ' + error.message, 5000);
}); })
.finally(() => { if (btn) btn.disabled = false; });
} }
// Make table rows clickable // Make table rows clickable
@@ -786,7 +798,8 @@ function closeBulkStatusModal() {
if (modal) setTimeout(() => modal.remove(), 300); if (modal) setTimeout(() => modal.remove(), 300);
} }
function performBulkStatusChange() { function performBulkStatusChange(btn) {
if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests
const bulkStatusEl = document.getElementById('bulkStatus'); const bulkStatusEl = document.getElementById('bulkStatus');
if (!bulkStatusEl) return; if (!bulkStatusEl) return;
const status = bulkStatusEl.value; const status = bulkStatusEl.value;
@@ -800,6 +813,8 @@ function performBulkStatusChange() {
const commentEl = document.getElementById('bulkStatusComment'); const commentEl = document.getElementById('bulkStatusComment');
const comment = commentEl ? commentEl.value.trim() : ''; const comment = commentEl ? commentEl.value.trim() : '';
if (btn) btn.disabled = true;
lt.api.post('/api/bulk_operation.php', { lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_status', operation_type: 'bulk_status',
ticket_ids: ticketIds, ticket_ids: ticketIds,
@@ -829,7 +844,8 @@ function performBulkStatusChange() {
} }
closeBulkStatusModal(); closeBulkStatusModal();
lt.toast.error('Bulk status change failed: ' + error.message, 5000); lt.toast.error('Bulk status change failed: ' + error.message, 5000);
}); })
.finally(() => { if (btn) btn.disabled = false; });
} }
/** /**
+7 -6
View File
@@ -713,12 +713,13 @@ function updateTicketStatus() {
return; return;
} }
cleanup(true); cleanup(true);
// Post comment first (persists it), then change status with the same // The comment is sent as part of the status-change request itself
// comment included so the server's requires_comment check passes. // (update_ticket.php persists it in the same DB transaction as the
const ticketId = getTicketIdFromUrl(); // status update) rather than as a separate prior add_comment.php
lt.api.post('/api/add_comment.php', { ticket_id: ticketId, comment_text: comment }) // call — previously those were two independent, non-transactional
.then(() => performStatusChange(statusSelect, selectedOption, newStatus, comment)) // writes, so a failure partway through could leave the "reason"
.catch(() => performStatusChange(statusSelect, selectedOption, newStatus, comment)); // comment persisted with no matching status change ever applied.
performStatusChange(statusSelect, selectedOption, newStatus, comment);
}); });
// Focus textarea on open // Focus textarea on open
setTimeout(() => { const ta = document.getElementById(`${modalId}_comment`); if (ta) ta.focus(); }, 100); setTimeout(() => { const ta = document.getElementById(`${modalId}_comment`); if (ta) ta.focus(); }, 100);
+83 -31
View File
@@ -232,14 +232,35 @@ $priority = (int)$priority;
$ticketHash = generateTicketHash($data); $ticketHash = generateTicketHash($data);
$auditLog = new AuditLogModel($conn); $auditLog = new AuditLogModel($conn);
// Look up any existing ticket with this hash (open OR closed) // Everything from here through either updating/reopening the matched ticket
$checkStmt = $conn->prepare("SELECT ticket_id, status, title, priority FROM tickets WHERE hash = ? ORDER BY created_at DESC LIMIT 1"); // or inserting a brand-new one runs inside one transaction with a row lock
$checkStmt->bind_param("s", $ticketHash); // on the hash lookup. Without this, two concurrent requests carrying the
$checkStmt->execute(); // same dedup hash (e.g. overlapping monitoring runs) could both read the
$existing = $checkStmt->get_result()->fetch_assoc(); // same pre-update snapshot and each independently apply an escalation. FOR
$checkStmt->close(); // 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) { // 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 ($existing) {
$existingId = $existing['ticket_id']; $existingId = $existing['ticket_id'];
$existingStatus = $existing['status']; $existingStatus = $existing['status'];
$existingTitle = $existing['title']; $existingTitle = $existing['title'];
@@ -329,6 +350,7 @@ if ($existing) {
(new StatsModel($conn))->invalidateCache(); (new StatsModel($conn))->invalidateCache();
} }
$conn->commit();
Database::close(); Database::close();
echo json_encode([ echo json_encode([
'success' => true, 'success' => true,
@@ -407,6 +429,7 @@ if ($existing) {
]); ]);
} }
$conn->commit();
Database::close(); Database::close();
if ($reopenStatus !== null) { if ($reopenStatus !== null) {
@@ -429,17 +452,31 @@ if ($existing) {
'action' => $reopenStatus !== null ? 'reopened' : 'recurrence_noted', 'action' => $reopenStatus !== null ? 'reopened' : 'recurrence_noted',
]); ]);
exit; exit;
} }
// No existing ticket — create a new one. // No existing ticket — create a new one. Still inside the transaction opened
// Generate a collision-safe unique ticket_id with a pre-check + retry loop (same // above, so a concurrent request for the same hash is blocked on its own
// approach as TicketModel::createTicket) so a ticket_id clash cannot happen. That // SELECT ... FOR UPDATE until this one commits or rolls back (see comment
// way a 1062 on INSERT below can only be the unique_hash (dedup) key racing, and // there) rather than racing this INSERT.
// is correctly reported as a duplicate rather than a dropped hardware alert. //
$ticket_id = null; // Note on FOR UPDATE over a not-yet-existing key: InnoDB's gap lock in that
$maxAttempts = 50; // case is a shared lock, not exclusive — two concurrent transactions can
$attempts = 0; // both acquire it and both reach this INSERT. The conflict only surfaces
do { // 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 { try {
$candidateId = sprintf('%09d', random_int(100000000, 999999999)); $candidateId = sprintf('%09d', random_int(100000000, 999999999));
} catch (Exception $e) { } catch (Exception $e) {
@@ -456,20 +493,21 @@ do {
$ticket_id = $candidateId; $ticket_id = $candidateId;
} }
$attempts++; $attempts++;
} while ($ticket_id === null && $attempts < $maxAttempts); } while ($ticket_id === null && $attempts < $maxAttempts);
if ($ticket_id === null) { if ($ticket_id === null) {
$conn->rollback();
error_log('create_ticket_api: failed to generate a unique ticket_id after ' . $maxAttempts . ' attempts'); error_log('create_ticket_api: failed to generate a unique ticket_id after ' . $maxAttempts . ' attempts');
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']); echo json_encode(['success' => false, 'error' => 'Internal server error']);
exit; exit;
} }
$insertStmt = $conn->prepare( $insertStmt = $conn->prepare(
"INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by) "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
); );
$insertStmt->bind_param( $insertStmt->bind_param(
"ssssssssi", "ssssssssi",
$ticket_id, $ticket_id,
$title, $title,
@@ -480,14 +518,25 @@ $insertStmt->bind_param(
$type, $type,
$ticketHash, $ticketHash,
$userId $userId
); );
try { try {
$inserted = $insertStmt->execute(); $inserted = $insertStmt->execute();
} catch (mysqli_sql_exception $e) { } catch (mysqli_sql_exception $e) {
$insertStmt->close(); $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) { if ($e->getCode() === 1062) {
// Race condition: another node inserted the same hash between our SELECT and INSERT // 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']); echo json_encode(['success' => false, 'error' => 'Duplicate ticket']);
} else { } else {
error_log('create_ticket_api: insert failed: ' . $e->getMessage()); error_log('create_ticket_api: insert failed: ' . $e->getMessage());
@@ -495,10 +544,10 @@ try {
echo json_encode(['success' => false, 'error' => 'Internal server error']); echo json_encode(['success' => false, 'error' => 'Internal server error']);
} }
exit; exit;
} }
$insertStmt->close(); $insertStmt->close();
if ($inserted) { if ($inserted) {
$auditLog->logTicketCreate($userId, $ticket_id, [ $auditLog->logTicketCreate($userId, $ticket_id, [
'title' => $title, 'title' => $title,
'priority' => $priority, 'priority' => $priority,
@@ -509,6 +558,7 @@ if ($inserted) {
// New ticket created — refresh dashboard stats. // New ticket created — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache(); (new StatsModel($conn))->invalidateCache();
$conn->commit();
Database::close(); Database::close();
require_once __DIR__ . '/helpers/NotificationHelper.php'; require_once __DIR__ . '/helpers/NotificationHelper.php';
@@ -525,8 +575,10 @@ if ($inserted) {
'ticket_id' => $ticket_id, 'ticket_id' => $ticket_id,
'message' => 'Ticket created successfully', 'message' => 'Ticket created successfully',
]); ]);
} else { } else {
$conn->rollback();
error_log('create_ticket_api: ticket insert reported failure: ' . $conn->error); error_log('create_ticket_api: ticket insert reported failure: ' . $conn->error);
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']); echo json_encode(['success' => false, 'error' => 'Internal server error']);
}
} }
+44 -8
View File
@@ -33,6 +33,23 @@ class BulkOperationsModel
return $this->workflowModel; return $this->workflowModel;
} }
/**
* Re-fetch a ticket row inside the current transaction with a row lock
* (FOR UPDATE), so a concurrent transaction touching the same row blocks
* until this one commits or rolls back instead of both validating
* against the same stale snapshot. Must be called after
* begin_transaction() and before the row is written.
*/
private function lockTicketForUpdate(string $ticketId): ?array
{
$stmt = $this->conn->prepare("SELECT * FROM tickets WHERE ticket_id = ? FOR UPDATE");
$stmt->bind_param('s', $ticketId);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
return $row ?: null;
}
/** /**
* The status a bulk operation is trying to move tickets into, or null for * The status a bulk operation is trying to move tickets into, or null for
* operations that don't change status. * operations that don't change status.
@@ -89,12 +106,18 @@ class BulkOperationsModel
/** /**
* Process a bulk operation * Process a bulk operation
* *
* Uses database transaction to ensure atomicity - either all tickets * Runs the whole batch inside one database transaction, but by default
* are updated or none are (on failure, changes are rolled back). * ($atomic = false, which is what api/bulk_operation.php uses) that
* transaction is always committed: a per-ticket failure (e.g. a
* disallowed workflow transition) is recorded in $failed/$errors and
* skipped, while every other ticket in the batch still succeeds. This
* is a best-effort batch, not an all-or-nothing one — set $atomic to
* true to roll back the entire batch when any ticket fails.
* *
* @param int $operationId Operation ID * @param int $operationId Operation ID
* @param bool $atomic If true, rollback all changes on any failure * @param bool $atomic If true, rollback all changes on any failure
* @return array Result with processed and failed counts * @return array Result with processed/failed counts and an errors[] list of
* per-ticket failure reasons (surfaced to the admin by the caller)
*/ */
public function processBulkOperation($operationId, bool $atomic = false) public function processBulkOperation($operationId, bool $atomic = false)
{ {
@@ -183,13 +206,27 @@ class BulkOperationsModel
$success = false; $success = false;
try { try {
// Re-fetch and row-lock the ticket inside the transaction for any
// operation that validates against or reads its current fields —
// the pre-transaction $ticketsById snapshot (loaded before
// begin_transaction()) can be stale by the time we get here if a
// concurrent request (a single-ticket edit, or another bulk op)
// changed the row in between. Validating a transition against a
// stale status, or writing back stale title/description/etc.,
// could silently bypass Workflow Designer rules or clobber a
// concurrent edit. FOR UPDATE blocks a concurrent transaction
// from reading/writing this row until ours commits or rolls back.
$needsCurrentTicket = $targetStatus !== null || $operation['operation_type'] === 'bulk_priority';
$currentTicket = $needsCurrentTicket
? $this->lockTicketForUpdate($ticketId)
: ($ticketsById[$ticketId] ?? null);
// bulk_status / bulk_close enforce the same Workflow Designer // bulk_status / bulk_close enforce the same Workflow Designer
// rules as the single-ticket path: a transition the designer // rules as the single-ticket path: a transition the designer
// doesn't define is refused, and requires_comment is honoured // doesn't define is refused, and requires_comment is honoured
// (checked up front, above). requires_admin is satisfied because // (checked up front, above). requires_admin is satisfied because
// api/bulk_operation.php already gates the endpoint on admin. // api/bulk_operation.php already gates the endpoint on admin.
if ($targetStatus !== null) { if ($targetStatus !== null) {
$currentTicket = $ticketsById[$ticketId] ?? null;
if ($currentTicket && $currentTicket['status'] === $targetStatus) { if ($currentTicket && $currentTicket['status'] === $targetStatus) {
// Already in the requested state — nothing to do, and // Already in the requested state — nothing to do, and
// reporting a no-op as a failure would just confuse. // reporting a no-op as a failure would just confuse.
@@ -211,8 +248,7 @@ class BulkOperationsModel
switch ($operation['operation_type']) { switch ($operation['operation_type']) {
case 'bulk_close': case 'bulk_close':
// Get current ticket from pre-loaded batch // $currentTicket is the fresh, row-locked read from above.
$currentTicket = $ticketsById[$ticketId] ?? null;
if ($currentTicket) { if ($currentTicket) {
$updateResult = $ticketModel->updateTicket([ $updateResult = $ticketModel->updateTicket([
'ticket_id' => $ticketId, 'ticket_id' => $ticketId,
@@ -264,7 +300,7 @@ class BulkOperationsModel
case 'bulk_priority': case 'bulk_priority':
if (isset($parameters['priority'])) { if (isset($parameters['priority'])) {
$currentTicket = $ticketsById[$ticketId] ?? null; // $currentTicket is the fresh, row-locked read from above.
if ($currentTicket) { if ($currentTicket) {
$updateResult = $ticketModel->updateTicket([ $updateResult = $ticketModel->updateTicket([
'ticket_id' => $ticketId, 'ticket_id' => $ticketId,
@@ -292,7 +328,7 @@ class BulkOperationsModel
case 'bulk_status': case 'bulk_status':
if (isset($parameters['status'])) { if (isset($parameters['status'])) {
$currentTicket = $ticketsById[$ticketId] ?? null; // $currentTicket is the fresh, row-locked read from above.
if ($currentTicket) { if ($currentTicket) {
$updateResult = $ticketModel->updateTicket([ $updateResult = $ticketModel->updateTicket([
'ticket_id' => $ticketId, 'ticket_id' => $ticketId,