[OpAMP] Refactor OpAMP communication pipe - #4930
Conversation
Pull request dashboard statusWaiting on the author · refreshed 2026-08-19 12:31 UTC Respond to 1 review item (e.g. link a commit, explain why not, ask a follow-up):
Status above doesn't look right?
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4930 +/- ##
==========================================
+ Coverage 77.89% 78.09% +0.20%
==========================================
Files 474 476 +2
Lines 20279 20306 +27
==========================================
+ Hits 15796 15858 +62
+ Misses 4483 4448 -35
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
…metry-dotnet-contrib into opamp-com-reworks
…metry-dotnet-contrib into opamp-com-reworks
stevejgordon
left a comment
There was a problem hiding this comment.
The overall direction looks reasonable and the pipe model is the right approach to satisfy the HTTP transport spec requirement.
A few initial code comments to consider. I will do another pass soon.
| this.TryFlush(); | ||
| } | ||
|
|
||
| public Task FlushAsync() |
There was a problem hiding this comment.
Should this accept a CancellationToken passed through from StopAsync?
There was a problem hiding this comment.
Correct, we discovered this as well in the internal overhaul to prevent hangs in the pipe. Using cancellation token here gives the control to user.
| { | ||
| lock (this.frameLock) | ||
| { | ||
| this.isBusy = false; |
There was a problem hiding this comment.
This is correct for the "happy path", but what if the the web socket connection is closed or dropped? I think we'd need a mechanism for WsReciever to notify through when that happens so isBusy can be reset?
There was a problem hiding this comment.
Correct, retry / reconnect paths are missing currently intentionally. Seems a larger scope to focus on separately.
| public async Task StopAsync(CancellationToken token = default) | ||
| { | ||
| // Drain queued data. | ||
| await this.FlushAsync() |
There was a problem hiding this comment.
Pass the CancellationToken here?
| this.AppendMessage(MessageBuilderHelper.AppendAgentDisconnect); | ||
|
|
||
| // Send disconnect. | ||
| await this.FlushAsync() |
| IFrameBuilder AddCustomMessage(string capability, string type, ReadOnlyMemory<byte> data); | ||
|
|
||
| AgentToServer Build(); | ||
| IFrameBuilder Clear(); |
There was a problem hiding this comment.
Does this belong on this interface? It's beyond the concern of this abstraction. It seems to only be used from a test and could still existing on the FrameBuilder directly for that call site.
There was a problem hiding this comment.
Seems it became a leftover, removed.
| .ConfigureAwait(false); | ||
| } | ||
|
|
||
| this.AppendMessage(MessageBuilderHelper.AppendIdentification); |
There was a problem hiding this comment.
Should we flush here to ensure identification is sent before heartbeats (and other services) are started? Otherwise, a lost identification message will cause heartbeats to be sent to a server it had never seen.
There was a problem hiding this comment.
TryFlush should send it instantly since the pipe is initially free. I added Flush just in case, so nothing weird should not happen.
| internal sealed class FrameProcessor | ||
| { | ||
| private readonly ConcurrentDictionary<Type, IReadOnlyList<object>> listeners = []; | ||
| private readonly ConcurrentBag<Action<ServerToAgent>> internalListeners = []; |
There was a problem hiding this comment.
Does this need to be ConcurrentBag? One one internal listener is accepted in the ctor, can we just store that?
There was a problem hiding this comment.
The only issue today is that the pipe does not have a control over the processor (it does not construct it). Pipe itself is a user, like any internal services could be.
There was a problem hiding this comment.
That doesn't explain the need for ConcurrentBag. Looking at the code, OpAmpPipe is the only caller of SubscribeToServerMessages, and it calls it exactly once from its constructor. There's also no Unsubscribe method, so the concurrent-collection semantics are never exercised.
A simple Action<ServerToAgent>? field would be clearer and more appropriate here (as it stands) and doesn't carry the misleading implication that subscribers can be dynamically added/removed concurrently.
There was a problem hiding this comment.
Simple Action<ServerToAgent>? would meant that the owner should have set it. But because OpAmpPipe doesn't own it, then the control who sets it becomes vague. I do understand that there is a single use currently but such pattern leaves too many doors open.
I refactored this and removed that new functionality and reused the sub/unsub pattern already existing. This also helps in the future if you need to check multiple fields (sub-messages) without having any sync logic of waiting for multiple callbacks.
|
|
||
| foreach (var listener in this.internalListeners) | ||
| { | ||
| listener.Invoke(message); |
There was a problem hiding this comment.
Should we wrap this in try/catch to avoid exceptions bubbling out? We already do that for public listeners.
|
|
||
| namespace OpenTelemetry.OpAmp.Client.Internal; | ||
|
|
||
| internal sealed class OpAmpPipe : IDisposable |
There was a problem hiding this comment.
Genral comment for the OpAmpPipe implementation - For HTTP, gating on server-frame receipt looks spec-aligned: every POST gets a ServerToAgent response and PlainHttpTransport processes it before SendAsync returns. For WebSocket, the spec (in my re-reading) is full-duplex with no response requirement, so only clearing isBusy in OnServerFrameReceived can stall the pipe if the server doesn’t reply to every agent send (or unblock on an unrelated server message). I think we need to consider transport-specific pipe behavior: response-gated for HTTP, send-completion-gated for WebSocket.
| { | ||
| OpAmpClientEventSource.Log.SendingMessage(); | ||
|
|
||
| await this.transport.SendAsync(message, this.tokenSource.Token) |
There was a problem hiding this comment.
This token is only cancelled on dispose. Should we accept the tokens through from FlushAsync/StopAsync or link the the caller cancellation tokens with tokenSource.Token to ensure correct in-flight cancellation?
|
|
||
| await this.dispatcher.DispatchHeartbeatAsync(report, this.cts.Token) | ||
| .ConfigureAwait(false); | ||
| this.pipe.AppendMessage(MessageBuilderHelper.AppendHeartbeat(report)); |
There was a problem hiding this comment.
This no longer passes the cancellation token. Can this race with Stop? Would checking this.cts.IsCancellationRequested first before appending be reasonable here?
There was a problem hiding this comment.
yep, seems IsCancellationRequested check is appropriate here. d288329
|
@RassK Sorry, my feedback and your new commit overlapped, so some of my comments may no longer apply. I like the direction of the refactor. |
| { | ||
| var message = ServerToAgent.Parser.ParseFrom(sequence); | ||
|
|
||
| this.Dispatch(new ServerToAgentMessage(message)); |
There was a problem hiding this comment.
Worth checking listeners.ContainsKey(typeof(ServerToAgentMessage)) before allocating and dispatching a message no one is subscriber to?
There was a problem hiding this comment.
that might be useful for other types as well.
What
Design discussion issue open-telemetry/opamp-spec#366
This is about ensuring that communication is according to spec.
Since spec is currently not clear that messages can be accepted in sync or async manner. This PR is a basis to support both options via configuration. If it's decided that the client must block the pipe until a full response is constructed, a follow up is needed.
Changes
Breaking changes ❗
Notes
❗ This PR is a preview and a discussion object how to proceed to support corner cases in the spec.Since there seems to be a consensus with this PR, we can move forward
Merge requirement checklist
CHANGELOG.mdfiles updated for non-trivial changes < TODO until the final form is decided