Implement message acknowledgment with retry logic for SignalR#4122
Implement message acknowledgment with retry logic for SignalR#4122
Conversation
Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com>
Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com>
This comment was marked as resolved.
This comment was marked as resolved.
Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com>
I've reverted all implementation changes in commit d0119a5 and created a focused plan for solution #1 only (message acknowledgment). The plan now:
The plan is ready for your review. I will not proceed with any implementation until you approve. |
This comment was marked as resolved.
This comment was marked as resolved.
…ortHub Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com>
Implemented message acknowledgment for both MergeHub and ExportHub in commit 2b3f91a. Changes:
All backend tests pass (10/10 MergeController tests). |
This comment was marked as resolved.
This comment was marked as resolved.
I've created a detailed plan for implementing the retry logic with exponential backoff (5s, 10s, 15s intervals). Key components:
The plan is ready for your review. I will not proceed with implementation until approved. |
This comment was marked as resolved.
This comment was marked as resolved.
Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com>
Implemented retry logic with acknowledgment tracking in commit ad8d37f. Key changes:
All backend tests pass (10/10 MergeController tests). |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@Backend/Controllers/LiftController.cs`:
- Around line 423-426: The background fire-and-forget call to
_ackTracker.SendWithRetryAsync(RequestId, ...) can produce unobserved
exceptions; wrap the Task.Run payload in a try/catch that catches exceptions
from _ackTracker.SendWithRetryAsync and the inner
_notifyService.Clients.All.SendAsync call, and log them using the controller's
logger (e.g., _logger.LogError) so failures are observed; additionally, ensure
the returned Task is observed (assign to a variable and optionally attach a
continuation to log faults) and consider adding try/finally inside
SendWithRetryAsync to perform any necessary cleanup.
In `@Backend/Services/AcknowledgmentTracker.cs`:
- Around line 37-44: MarkAcknowledged currently does a non-atomic TryGetValue
then indexer set on _pendingAcks which can race with RemoveRequest and re-add a
key; change it to perform an atomic conditional update using
ConcurrentDictionary.TryUpdate so you only set the value to true if the existing
value matches the observed value (e.g., read old value with TryGetValue then
call _pendingAcks.TryUpdate(requestId, true, old)); this ensures the update is
atomic and avoids re-adding keys removed concurrently.
- Around line 52-86: SendWithRetryAsync currently calls TrackRequest(requestId)
but may exit early if sendMessageAsync() throws, leaving the entry in
_pendingAcks; wrap the send/retry logic in a try/finally so
RemoveRequest(requestId) is always executed regardless of exceptions, and
optionally catch/log exceptions from sendMessageAsync() inside the try before
rethrowing or letting them propagate so failures are visible while guaranteeing
cleanup of the tracked request.
🧹 Nitpick comments (5)
Backend/Interfaces/IAcknowledgmentTracker.cs (1)
11-17: Consider addingCancellationTokensupport for graceful shutdown.The
SendWithRetryAsyncmethod runs a retry loop with delays totaling up to 30 seconds. Without aCancellationToken, in-flight retries cannot be cancelled during application shutdown, potentially delaying graceful termination.💡 Optional enhancement
- Task SendWithRetryAsync(string requestId, string userId, Func<Task> sendMessageAsync); + Task SendWithRetryAsync(string requestId, string userId, Func<Task> sendMessageAsync, CancellationToken cancellationToken = default);Backend/Services/AcknowledgmentTracker.cs (1)
61-82: Optional: Early exit upon acknowledgment would reduce unnecessary delays.Currently, after sending, the method always waits the full delay period before checking acknowledgment. If the client acknowledges quickly, the method still waits. Consider using
TaskCompletionSourceor polling with smaller intervals for more responsive cleanup.Backend/Services/LiftService.cs (1)
225-230: Consider centralizing request ID generation.This
GenerateRequestId()implementation is identical toMergeService.GenerateRequestId(). Since both services generate IDs solely for theAcknowledgmentTracker, consider moving this responsibility to the tracker itself or a shared utility to reduce duplication.♻️ Optional: Move ID generation to AcknowledgmentTracker
The
IAcknowledgmentTrackerinterface could expose aGenerateRequestId()method, eliminating the need for both service interfaces to declare it:// In IAcknowledgmentTracker string GenerateRequestId(); // In AcknowledgmentTracker public string GenerateRequestId() => Guid.NewGuid().ToString();Then controllers would call
_ackTracker.GenerateRequestId()instead of_mergeService.GenerateRequestId().Backend/Helper/CombineHub.cs (1)
14-22: Consider validating requestId ownership.Any connected client can acknowledge any
requestId, since there's no validation that the acknowledging client is the intended recipient. While the risk is low (GUIDs are unguessable and short-lived), for defense-in-depth you could track which userId owns each requestId and validate it here.🛡️ Optional: Add ownership validation
-public void AcknowledgeMessage(string requestId) +public void AcknowledgeMessage(string requestId, string userId) { - _ackTracker.MarkAcknowledged(requestId); + _ackTracker.MarkAcknowledged(requestId, userId); }This would require the tracker to store and validate the userId associated with each requestId.
Backend/Controllers/MergeController.cs (1)
156-159: Unnecessary nestedTask.Run- consider simplifying.
GetDuplicatesThenSignalis already invoked viaTask.Runon line 144, so it's already running on a thread pool thread. The innerTask.Runon line 158 adds overhead without benefit. You can awaitSendWithRetryAsyncdirectly (fire-and-forget with discard if truly non-blocking is needed).♻️ Simplify by removing nested Task.Run
if (success) { var requestId = _mergeService.GenerateRequestId(); - // Run retry logic in background without blocking - _ = Task.Run(() => _ackTracker.SendWithRetryAsync(requestId, userId, - () => _notifyService.Clients.All.SendAsync(CombineHub.MethodSuccess, userId, requestId))); + // Fire-and-forget: don't await, just discard the task + _ = _ackTracker.SendWithRetryAsync(requestId, userId, + () => _notifyService.Clients.All.SendAsync(CombineHub.MethodSuccess, userId, requestId)); }Since this method is already running on a background thread, the async continuation will naturally run without blocking. The
_ =discard pattern is sufficient for fire-and-forget.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@Backend/Helper/CombineHub.cs`:
- Around line 6-22: Update AcknowledgeMessage in CombineHub to validate that the
caller is the intended recipient before marking acknowledgment: change
AcknowledgeMessage to either accept a userId parameter or infer it and ensure
IAcknowledgmentTracker (referenced as _ackTracker) exposes methods to record the
mapping and to validate ownership (e.g., store requestId→userId when sending and
add a tracker method like TryMarkAcknowledged(requestId, userId) or
GetOwner(requestId)); inside AcknowledgeMessage, compare Context.UserIdentifier
to the owner userId from the tracker and only call
_ackTracker.MarkAcknowledged(requestId) if they match, otherwise ignore or
log/deny the attempt. Ensure CombineHub uses Context.UserIdentifier for the
check and that the tracker stores the mapping when you create/send messages so
lookup succeeds.
🧹 Nitpick comments (2)
src/components/App/SignalRHub.tsx (1)
95-109: Avoid blocking success dispatch on the acknowledgment RPC.
await connection.invoke(...)can delay UI updates if the call stalls or times out. Consider firing the ack call without awaiting it and keep the dispatch immediate.🛠️ Suggested adjustment
- if (connection && requestId) { - try { - await connection.invoke(acknowledgeMethodName, requestId); - } catch (error) { - console.warn("Failed to send acknowledgment:", error); - } - } - - dispatch(successAction); + if (connection && requestId) { + void connection + .invoke(acknowledgeMethodName, requestId) + .catch((error) => console.warn("Failed to send acknowledgment:", error)); + } + + dispatch(successAction);Backend/Startup.cs (1)
259-261: Singleton tracker needs instance affinity in multi-node deployments.If you run multiple backend instances, ensure sticky sessions (or a backplane/distributed cache) so a client’s ack hits the same instance that created the requestId; otherwise retries may not clear correctly.
imnasnainaec
left a comment
There was a problem hiding this comment.
@imnasnainaec reviewed 6 files and all commit messages.
Reviewable status: 6 of 14 files reviewed, 1 unresolved discussion.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as outdated.
This comment was marked as outdated.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@Backend/Services/AcknowledgmentTracker.cs`:
- Around line 52-82: The SendUntilAcknowledged method currently awaits
Task.Delay before the first send causing unnecessary initial latency; change the
flow in SendUntilAcknowledged so it calls sendMessageAsync immediately (and logs
via logger.LogInformation) on the first attempt, then enter a retry loop that
waits delaySeconds between subsequent attempts and checks IsAcknowledged before
each retry, preserving the existing warning/error logging and sendCount
semantics; locate the method SendUntilAcknowledged and references to
sendMessageAsync, IsAcknowledged, and logger to implement this reordering.
jasonleenaylor
left a comment
There was a problem hiding this comment.
One thing I can't quite track is if the following scenario is problematic:
The connection is interrupted when the client tries to send the acknowledgement back to the server.
So the client received everything properly but the server doesn't realize.
What happens there.
@jasonleenaylor partially reviewed 12 files and made 2 comments.
Reviewable status: 11 of 14 files reviewed, 1 unresolved discussion (waiting on @imnasnainaec).
Backend/Services/AcknowledgmentTracker.cs line 57 at r5 (raw file):
for (int sent = 0; sent <= sendCount; sent++) { await Task.Delay(delaySeconds * 1000);
I see the same thing as code rabbit here, it seems like this should be skipped if sent = 0;
Code quote:
await Task.Delay(delaySeconds * 1000);
imnasnainaec
left a comment
There was a problem hiding this comment.
The backend just logs that it never received an acknowledgement and gives up after 30 seconds.
@imnasnainaec made 2 comments.
Reviewable status: 11 of 14 files reviewed, 1 unresolved discussion (waiting on @coderabbitai and @jasonleenaylor).
Backend/Services/AcknowledgmentTracker.cs line 57 at r5 (raw file):
Previously, jasonleenaylor (Jason Naylor) wrote…
I see the same thing as code rabbit here, it seems like this should be skipped if sent = 0;
Fixed.
|
Wait on #4039 |
Original prompt
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.
This change is
Summary by CodeRabbit
New Features
Bug Fixes