-
Notifications
You must be signed in to change notification settings - Fork 59
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix: emit amqp Flow on message handling completion #20
Conversation
@@ -1264,7 +1289,9 @@ func (l *link) muxReceive(fr performTransfer) error { | |||
// discard message if it's been aborted | |||
if fr.Aborted { | |||
l.buf.reset() | |||
l.msg = Message{} | |||
l.msg = Message{ | |||
doneSignal: make(chan struct{}), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
init the signal channel to avoid nil. Probably unecessary, since we take care of it in the Receive()/HandleMessage()
client.go
Outdated
return nil | ||
} | ||
|
||
func uuidFromLockTokenBytes(bytes []byte) (*uuid.UUID, error) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
brought this in for better logging. might be better in a different file, but none really stood out :)
debug(3, "Receive() unpause link on completion") | ||
default: | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
allow link to check for flow each time we complete a message.
msg.receiver = r | ||
if msg.doneSignal == nil { | ||
msg.doneSignal = make(chan struct{}) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
init doneSignal, it's used only once by getting closed.
if msg.doneSignal == nil { | ||
msg.doneSignal = make(chan struct{}) | ||
} | ||
go trackCompletion(msg) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
block on doneSignal in a separate goroutine
// tracks messages until exiting handler | ||
if err := handle(msg); err != nil { | ||
debug(3, "Receive() blocking %d - error: %s", msg.deliveryID, err.Error()) | ||
return err |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should we call msg.done()
here?
I think it's not necessary since a handler error closes the connection and stops the listener entirely.
client.go
Outdated
// we remove the message from pending as soon as it's popped off the channel | ||
// This makes the pending count the same as messages buffer count | ||
// and keeps the behavior the same as before the pending messages tracking was introduced | ||
defer r.link.deletePending(&msg) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
defering the deletePending call effectively makes the Receive() func behave almost the same as before. it removes the message from the map as soon as it's given to the caller
local integration tests logs with no concurrency and no linkcredit = 1 (no prefetch) :
|
46272ee
to
cb808e7
Compare
e9d47cc
to
4a812bf
Compare
For future reference, this is the Receive from azure servicebus. in the Azure AMQP library, here is the implementation of the link.Dispose : You can also see just below that OnSettled is where the link credit is incremented. The behavior is the same in go-amqp, but the link credit includes the |
see Azure/azure-service-bus-go#189 for details of the problem this fixes.
To fix the flow control, it is necessary to be aware of when a message is terminally handled by the consumer. We should not emit a Flow frame before the message has been disposed.
Implementation
debug logs
Lots of debug logs were added to the code. I would like to keep them in, although they should maybe be trimmed/reviewed
receiver's
pendingMessages map[uint32]struct{}
The receiver was previously looking at the receiver's
messages
channel to decide whether aFlow
frame should be emitted.This channel is drained by the consumer as soon as the message start being handled.
The messages are pulled from the channel to be passed to the downstream handler. While the handler processes the messages, the messages channel is empty, which causes the receiver to emit a flow frame right away and buffer new messages well before a receiver is ready.
this PR introduces a
pendingMessages
map to keep track of messages being handled.the message
DeliveryID
is used to identify and track a message. It is added to thepending
map right before the message is pushed into the receiver'smessages
channel.go-amqp/client.go
Lines 875 to 876 in f1f2eda
The map is accessed concurrently, so I provide
add
anddelete
funcs on thereceiver
to always respect the locking sequence:go-amqp/client.go
Lines 1029 to 1039 in f1f2eda
Message
doneSignal
We need to remove messages from the pending map after they are handled.
Since the downstream handler can be asynchronous, we cannot rely on code flow. We need the message itself to emit a signal.
I added a private
doneSignal
that can be triggered viamsg.done()
func. It gets triggered in each disposition funcs.go-amqp/types.go
Lines 1765 to 1766 in f1f2eda
go-amqp/types.go
Lines 1781 to 1787 in f1f2eda
go-amqp/types.go
Lines 1800 to 1806 in f1f2eda
We use this signal to trigger a delete from the
pending
map, and tell the link to resume the flow:go-amqp/client.go
Lines 1963 to 1964 in f1f2eda
HandleMessage()
HandleMessage
takes in a func handler. it follows the same pattern as the Receive implementation to drain the buffer if the link is closed. it ensure the message signal isn't nil, and start a tracking goroutine that awaits thedoneSignal
.The same behavior could be achieved with the
Receive
signature, since we use an asynchronous signal. However, the behavior would change for the users, and could cause the user's message processor to stall due to not terminating the message correctly.The requirement for the consumer to explicitly send a disposition for each message becomes stricter to keep the Flow in sync.
This can certainly be mitigated but is not addressed in this PR.
The
HandleMessage()
signature also allows for middleware and to plug in functionality without impacting existing code, which is a nice added benefit.Deprecate receiver Receive()
The
receiver.Receive()
func is marked as deprecated in favor ofreceiver.HandleMessage()
func.The intent is to keep the
receiver.Receive()
func behavior unchanged, while providing a safer API to control the flow of message accurately via theHandleMessage
func.Consideration
The current implementation of
HandleMessage
requires the user to call a disposition on the message.if no disposition is called, the message will not signal to the receiver that it was handled, and it will stay in the receiver's tracking map.
As a result, the flow will stop once
n
messages are leaked in the tracking map, wheren > linkCredit/2
This is reasonable in my opinion, provided that it is well documented, and discoverable.I don't think we have a reliable way to ensure the message is removed from the pending map if the user fails to do it.
We can make it actionable though :
expose
message.Ignore()
which would only triggers thedoneSignal
. This allows downstream code to release the message in a handler, in a defer statement, or on errors, in case something goes wrong before they get a chance to invoke a disposition.we can log spans than earn about likely message leaks
Consumers can be smarter about message TTL and peeklock timeouts, and clean up after themselves.