Account for acknowledgements not yet added to the buffer on shutdown - #1664
Conversation
hasAcksLeft() decided whether the shutdown wait was done by looking at
the ack queue and the buffer. A message the polling thread has already
taken off the queue but not yet added to the buffer is in neither:
Message<T> polledMessage = this.acks.poll(1, TimeUnit.SECONDS);
if (polledMessage != null) {
addMessageToBuffer(polledMessage);
If the wait loop sampled in that window it saw nothing left and returned,
waitAcknowledgementsToFinish() then set isTimeoutElapsed and cleared the
buffer, and the polling thread added the message to a buffer nothing
would ever flush. Unlike the shutdown timeout case, this dropped the
message with no warning at all.
Track the messages that have been received but are not in the queue nor
the buffer yet, and include them in hasAcksLeft(). The counter is
incremented before the message is offered to the queue and decremented
after it has been added to the buffer, so it is never undercounted; the
brief double counting while the message sits in the buffer only makes the
wait more conservative.
At most one message per shutdown can be in this window, but it widens
whenever addMessageToBuffer() has to wait on the buffer lock, which
happens while an execution is being dispatched - and for
AcknowledgementOrdering.ORDERED, that dispatch takes the ordered
execution lock while holding the buffer lock.
Follow-up to awspring#1663.
|
@tomazfernandes this is the follow-up you asked for in #1663. Short version:
The fix counts messages that have been received but are in neither the queue nor Two things worth your attention:
The test drives the window through One structural note I put in the description rather than acting on: the polling |
|
Thanks for the PR @hyeongguen-song. After this PR, shutdown behavior is correct for all known windows. I agree the join design would make correctness structural rather than bookkeeping every transition. Since behavior is stable now, let's keep it in mind for the next major version. |
…1664) hasAcksLeft() decided whether the shutdown wait was done by looking at the ack queue and the buffer. A message the polling thread has already taken off the queue but not yet added to the buffer is in neither: Message<T> polledMessage = this.acks.poll(1, TimeUnit.SECONDS); if (polledMessage != null) { addMessageToBuffer(polledMessage); If the wait loop sampled in that window it saw nothing left and returned, waitAcknowledgementsToFinish() then set isTimeoutElapsed and cleared the buffer, and the polling thread added the message to a buffer nothing would ever flush. Unlike the shutdown timeout case, this dropped the message with no warning at all. Track the messages that have been received but are not in the queue nor the buffer yet, and include them in hasAcksLeft(). The counter is incremented before the message is offered to the queue and decremented after it has been added to the buffer, so it is never undercounted; the brief double counting while the message sits in the buffer only makes the wait more conservative. At most one message per shutdown can be in this window, but it widens whenever addMessageToBuffer() has to wait on the buffer lock, which happens while an execution is being dispatched - and for AcknowledgementOrdering.ORDERED, that dispatch takes the ordered execution lock while holding the buffer lock. Follow-up to #1663. (cherry picked from commit 423f8b7)
…wspring#1664) hasAcksLeft() decided whether the shutdown wait was done by looking at the ack queue and the buffer. A message the polling thread has already taken off the queue but not yet added to the buffer is in neither: Message<T> polledMessage = this.acks.poll(1, TimeUnit.SECONDS); if (polledMessage != null) { addMessageToBuffer(polledMessage); If the wait loop sampled in that window it saw nothing left and returned, waitAcknowledgementsToFinish() then set isTimeoutElapsed and cleared the buffer, and the polling thread added the message to a buffer nothing would ever flush. Unlike the shutdown timeout case, this dropped the message with no warning at all. Track the messages that have been received but are not in the queue nor the buffer yet, and include them in hasAcksLeft(). The counter is incremented before the message is offered to the queue and decremented after it has been added to the buffer, so it is never undercounted; the brief double counting while the message sits in the buffer only makes the wait more conservative. At most one message per shutdown can be in this window, but it widens whenever addMessageToBuffer() has to wait on the buffer lock, which happens while an execution is being dispatched - and for AcknowledgementOrdering.ORDERED, that dispatch takes the ordered execution lock while holding the buffer lock. Follow-up to awspring#1663. (cherry picked from commit 423f8b7)
…1664) hasAcksLeft() decided whether the shutdown wait was done by looking at the ack queue and the buffer. A message the polling thread has already taken off the queue but not yet added to the buffer is in neither: Message<T> polledMessage = this.acks.poll(1, TimeUnit.SECONDS); if (polledMessage != null) { addMessageToBuffer(polledMessage); If the wait loop sampled in that window it saw nothing left and returned, waitAcknowledgementsToFinish() then set isTimeoutElapsed and cleared the buffer, and the polling thread added the message to a buffer nothing would ever flush. Unlike the shutdown timeout case, this dropped the message with no warning at all. Track the messages that have been received but are not in the queue nor the buffer yet, and include them in hasAcksLeft(). The counter is incremented before the message is offered to the queue and decremented after it has been added to the buffer, so it is never undercounted; the brief double counting while the message sits in the buffer only makes the wait more conservative. At most one message per shutdown can be in this window, but it widens whenever addMessageToBuffer() has to wait on the buffer lock, which happens while an execution is being dispatched - and for AcknowledgementOrdering.ORDERED, that dispatch takes the ordered execution lock while holding the buffer lock. Follow-up to #1663. (cherry picked from commit 423f8b7)
📢 Type of change
📜 Description
Follow-up to #1663, for the window we discussed there:
hasAcksLeft()decided whether the shutdown wait was done by looking at the ackqueue and the buffer. A message the polling thread has already taken off the queue
but not yet added to the buffer is in neither of them, so the wait could end while
such a message was in flight.
💡 Motivation and Context
If the wait loop sampled in between, it saw nothing left and returned.
waitAcknowledgementsToFinish()then setisTimeoutElapsedand cleared thebuffer, and the polling thread added the message to a buffer nothing would ever
flush.
Unlike the case fixed in #1663, this one produces no warning at all — the wait
loop exits normally rather than timing out, so there is nothing in the logs to
suggest a message was dropped. In the test below the unfixed run finishes in 1.6 s
instead of spinning the 10 s shutdown timeout.
At most one message per shutdown can be in this window, since the polling thread
handles one at a time. It widens whenever
addMessageToBuffer()has to wait on thebuffer lock, which happens while an execution is being dispatched — and for
AcknowledgementOrdering.ORDEREDthat dispatch takes the ordered execution lockwhile holding the buffer lock.
The fix tracks the messages that have been received but are in neither the queue
nor the buffer, and includes them in
hasAcksLeft(). The counter is incrementedbefore the message is offered to the queue and decremented after it has been added
to the buffer, so it is never undercounted. It is briefly double counted while the
message sits in the buffer, which only makes the wait more conservative.
hasAcksLeft()now uses the counter in place ofacks.size(), since the counteris always greater than or equal to it.
I left
flushRemainingAcks()gated onacks.isEmpty()as it is. Its purpose is toavoid splitting batches, not to decide when the wait is over, and if it does flush
while a message is in flight that message is simply picked up by the next iteration
now that
hasAcksLeft()accounts for it.💚 How did you test it?
givenMessagePolledButNotBufferedYet_whenStopped_shouldAcknowledgeItreproducesthe window deterministically through a public extension point rather than by
reaching into internals. The message grouping function is applied inside
addMessageToBuffer()while the buffer lock is held, so a grouping function thatblocks parks the polling thread at exactly the point where the message is in
neither the queue nor the buffer. The test then calls
stop()from another thread,gives the wait loop several 200 ms iterations to sample, and releases the polling
thread.
Measured on JDK 17.0.19, 15 consecutive runs each:
mainOn
mainthe assertion shows no acknowledgement was executed at all:Full module:
./mvnw -pl spring-cloud-aws-sqs -am testgives 637 tests, 0failures, 0 errors, 6 skipped, spotless checks enabled, including the LocalStack
integration tests.
📝 Checklist
No documentation change needed: this restores the documented behaviour rather than
changing it.
🔮 Next steps
Nothing further from me on this path. The remaining structural asymmetry is that
the polling thread's lifetime is bounded by
isTimeoutElapsed, which is only setafter the wait loop has already decided it is finished — so correctness relies on
the wait loop accounting for everything the polling thread might still be holding,
rather than on the polling thread having demonstrably stopped. Making the wait join
the polling thread would remove that reliance, but it is a bigger change than this
one and I did not want to fold it in.