feat: support request hedging#6252
Conversation
🔍 Build Scan® (commit: 9c1ecca) |
1403d26 to
c1ef667
Compare
…r non-retriable response
…ter response timeout
…y its interface and add tests for RetryingScheduler
…RpcClientWithHedgingTest
… attempt number Also add more tests to Retrying(Rpc)Client
01c5f87 to
4db9525
Compare
b8f537d to
ccce2ba
Compare
ccce2ba to
164aa2d
Compare
# Conflicts: # core/src/main/java/com/linecorp/armeria/client/retry/RetryingClient.java
Codecov ReportAttention: Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6252 +/- ##
============================================
+ Coverage 74.46% 74.65% +0.18%
- Complexity 22234 22538 +304
============================================
Files 1963 1987 +24
Lines 82437 83520 +1083
Branches 10764 10859 +95
============================================
+ Hits 61385 62350 +965
- Misses 15918 15961 +43
- Partials 5134 5209 +75 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
Hi team,
|
| * Copyright 2025 LY Corporation | ||
| * | ||
| * LINE Corporation licenses this file to you under the Apache License, | ||
| * LY Corporation licenses this file to you under the Apache License, |
There was a problem hiding this comment.
Would you revert the changes which are unrelated to this PR?
There was a problem hiding this comment.
OK by looking at other PRs it seems like in Armeria, we are not updating the copyright header upon change. I correct that. As the developer guide does not mention it, let us update it. Happy to contribute that too.
Furthermore I suggest to off-load following changes into separate PRs:
- Adding
RetryingContextinRetryingClientto reduce parameter count of private methods. - Adding
endResponseWithChildtoRequestLogandRequestLogBuilder. - Adding checks to tests inside
RetryingClientTestverifying request logs are completed properly.
Let me know if this makes sense to you or if they are changes that you want to see together in this PR @ikhoon.
There was a problem hiding this comment.
As the developer guide does not mention it, let us update it. Happy to contribute that too.
Sounds great.
Furthermore I suggest to off-load following changes into separate PRs:
That is a good idea. It would help us understand the changes better.
There was a problem hiding this comment.
Here we go:
- PR for updating docs: Developer guide: add clarification on copyright header #6295
- PR for
endResponseWithChild: AddendResponseWithChildtoRequestLogBuilder#6294 - PR to add tests to
RetryClientTest: Add verifications ofClientRequestContexts toRetryClientTestandRetryingRpcClientTest#6296 - PR to clean up
Retrying(Rpc)Client: ComponentizeRetryingClientandRetryingRpcClient#6292
PTAL
THIS PR IS CURRENTLY BLOCKED
We extracted sub-PRs to reduce the overall size of this PR. Those need to be handled before we can continue:
endResponseWithChildtoRequestLogBuilder#6294ClientRequestContexts toRetryClientTestandRetryingRpcClientTest#6296RetryingClientandRetryingRpcClient#6292Motivation:
This PR enables
RetryingClientandRetryingRpcClientto perform request hedging.What is request hedging?
Detailed explanations can be found in the paper The Tail at Scale 1 and the section for request hedging in the gRPC documentation 2.
How does this implementation compare to the gRPC spec?
This implementation of request hedging tries to follow the semantics defined by gRPC as closely as possible. As such, let us take the gRPC request hedging spec 4 as a baseline and discuss how it differs from it. For that, it makes sense to study the spec first before continuing reading.
Configuration
gRPC drives request hedging via its service config in which one can specify a so-called
hedgingPolicy. It consists of three fields:maxAttemptsLimiting the number of attempts is still supported by
RetryConfig.maxTotalAttempts(). This hedging mechanism respects that limit. However, we are not imposing an absolute limit on the number of possible attempts like gRPC.hedgingDelayWe allow specifying this via
hedgingDelayandhedgingDelayMillisinRetryConfigBuilder/RetryConfig. The difference here is that we are not going to send all requests at once in case this option is unspecified, but instead assume that the user wants to use the standard retry policy instead of hedging.nonFatalStatusCodesThe pre-existing
RetryRules are able to model this behavior (and more). More specifically, a user can useRetryRuleBuilder.onStatusto decide whether to continue retrying. Note that if a rule does not provide a backoff (e.g. throughRetryingDecision.noRetry()) retrying will be stopped completely and ongoing attempts will be aborted.Server Pushback
gRPC defines a metadata key,
grpc-retry-pushback-ms3, for the server to communicate to the client a minimum retry delay. This implementation does not add support for thegrpc-retry-pushback-msmetadata key but instead continues to react on the standardRetry-Afterheader.I want to highlight a slight difference in how gRPC expects clients to behave on the
grpc-retry-pushback-msheader key compared to how Armeria now reacts onRetry-After. gRPC says:Currently, Armeria ignores the field in the unparseable case and with that just continues retrying. This implementation does not change this behavior.
Throttling
gRPC defines a throttling mechanism for the client 5. This is left unimplemented and tracked by #6282.
Modifications
RetryConfigBuilder,RetryConfigTo enable request hedging,
RetryConfigBuildernow offers two methods to specify the hedging delay:hedgingDelay(Duration hedgingDelay)hedgingDelayMillis(long hedgingDelayMillis)As mentioned above, setting this to zero is valid and makes the
Retrying(Rpc)Clientimmediately push out allmaxRequestsand make it wait for one to finish. If not set, hedging is disabled andRetrying(Rpc)Clientbehaves like theRetryingClientbefore this change. Negative values are illegal.RetrySchedulerWith request hedging,
Retrying(Rpc)Clientneeds to handle concurrent retry attemptstrying to schedule retry tasks with different backoff delays and (optional) minimal delays from servers (see
RETRY_AFTERHTTP header).To handle this complexity and improve testability, I introduced
RetryScheduler. It is used behind the scenes byAbstractRetryingClientto keep track of the next retry task to be executed while enforcing scheduling constraints given by the minimal delays from the servers (earliestRetryTimeNanos) and the attempt/response timeout (latestNextRetryTimeNanos).With that, it takes care of retry tasks "overtaking" each other when a retry rule specifies a shorter delay than the currently scheduled task, and rescheduling when a server reports a longer minimum delay than the one already scheduled.
AbstractRetryingClientThe following modifications were made (some in a breaking way):
getNextDelaygetNextDelaywas a mutable operation that increased the attempt count. This is not safe with hedging, as we need to account for concurrent attempts between this call and the subsequent call toscheduleNextRetry. For example, during that window, another attempt with an earlier backoff might callgetNextDelay, but fail because we already consumed an attempt. As a result, we would incorrectly schedule the late retry task. To fix this, we would need to re-check the state of pending attempts during scheduling, renderinggetNextDelayuseless.This is why backoff calculation and scheduling must happen synchronously. Therefore, I removed
getNextDelayand moved the delay calculation into thescheduleNextRetrymethod.onRetryingCompletewith different "retry lifecycle API"With hedging, we need to handle interleaved attempt starts and completions. As such, having a method that just completes retrying does not make sense as the caller does not/should not have any knowledge about other pending attempts or if there is a scheduled retry task. With that in mind, I replaced
onRetryingCompletewith the following "retry lifecycle API":startRetryAttemptregisters the attempt identified withattemptCtxas being started and now pending. It also registers handlers that define what should happen when the attempt is going to win the retry process (i.e. when its response should be propagated back to the decorator chain) and what should happen when it aborts (e.g. when another attempt returned with an earlier response).Internally, it is used to track the number of started attempts to be able to decide when the retrying process needs to be concluded. That is why in a retry task/in the first call to
doExecutethis method needs to be called before the task/method returns. Otherwise,AbstractRetryingClientmay see that no attempt is pending and conclude the retrying process with the last response if it exists.completeRetryAttemptis the counterpart tostartRetryAttempt. It tellsAbstractRetryingClientwhether the current attempt concludes the retry process (isWinning) or finishes while giving other attempts the chance to conclude. If the attempt is winning, we abort all other pending attempts by calling their abort handlers, then invoke the win handler for this attempt withattemptCtx.Checks whether there are any pending attempts or scheduled retry tasks and if not, concludes the retry process with the last completed attempt as the winning attempt. It can be used inside the
actionOnExceptionhandler forscheduleNextRetrywhen a non-critical exception is caught. There, we need to make sure that we are not leaving the handler without checking that we are the last point to conclude retrying. For that, it can usecompleteRetryingIfNoPendingAttempts.Concludes retrying by aborting all pending attempts by calling their abort handlers. It also cancels the current retry task if there is any. The caller must make sure to properly abort the original response themselves. This method can be called after the moment the first time
doExecuteis invoked.scheduleNextRetryI moved from
to
As mentioned above, the new
scheduleNextRetrycombines the work forgetRetryDelayand the oldscheduleNextRetry.To give a rationale,
scheduleNextRetrytries to schedule theretryTaskaccording tobackoffandretryDelayFromServerMillis. Potential exceptions are reported toactionOnException. There is a list of expected exception types inRetrySchedulingExceptionthe user can react to. TheactionOnExceptionmust either callcompleteRetryingExceptionallyorcompleteRetryingIfNoPendingAttempts.If it is unable to schedule the
retryTask, it will at least communicate theretryDelayFromServerMillisto the scheduler so that the current retry task can be rescheduled and future attempts delayed accordingly.I need to pass in the
Backoffbecause consuming an attempt for a backoff occurs upon task execution and not as before upon scheduling. This is because there is a possibility that this task is "overtaken" by a retry task with lower delay during the time the attempt is waiting for execution. In that case, we would increment the total attempt count twice but retry only once.newDerivedContexttonewAttemptContextIn case this class stays public, it is weird for users to see the word "derived". With the renaming, we make it clear that this is a helper function for creating new contexts for attempts.
RetryingClientAbstractRetryingClient.ClientRequestContexts and response objects from retry attempts from the top-level one. To assist future new readers like me, I renamed variables accordingly (e.g.attemptCtx).RetryingContextto bundle as the parameter-passing was getting unwieldy.RetryingRpcClientAbstractRetryingClient.RetryingClient, I renamed the variables to be more intuitive (hopefully).DefaultRequestLog/RequestLogBuilderAdded
endResponseWithChild(RequestLogAccess child)to be able to end the request log with any child log and not only with the one from the last retry attempt. This is necessary as a hedged request can still complete after the (hedged) requests started before it.Result:
If users did not extend
AbstractRetryingClient, there should be no breaking changes or changes in behavior by this change. They can opt-in to request hedging by configuring ahedgingDelayon a perRetryConfigbasis.In case users did extend from
AbstractRetryingClient, they would face a breaking change as they would need to adaptstartRetryAttempt,completeRetryAttempt,completeRetryingExceptionally,completeRetryingIfNoPendingAttemptsand the amendedscheduleNextRetrymethod. As a minor breaking change, they would need to rename their usages ofnewDerivedContexttonewAttemptContext. I was questioning before and now I am questioning even more havingAbstractRetryingClientin the public API.Open Questions/ TODOs
RetryingContexttoRetryingClient. It is supposed to bundle the huge amount of arguments that are passed inside the methods ofRetryingClient? Is there something else I can do to ease review?grpc-retry-pushback-msheader?grpc-retry-pushback-msbe a follow-up?