-
Notifications
You must be signed in to change notification settings - Fork 5
Handle throttling in FileDescriptor implementations
#1149
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
base: master
Are you sure you want to change the base?
Changes from 3 commits
7287d5b
ec18c94
576f2b7
8397dc1
3c2395d
c91a435
a53a1bb
4509041
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,21 +4,26 @@ import java.io.* | |
| import java.util.concurrent.atomic.AtomicInteger | ||
| import java.util.concurrent.{CompletableFuture, CompletionException, LinkedBlockingQueue, ThreadPoolExecutor, TimeUnit} | ||
|
|
||
| import scala.concurrent.duration.* | ||
| import scala.jdk.CollectionConverters.* | ||
| import scala.util.{Failure, Success, Try, Using} | ||
| import scala.util.{Try, Using} | ||
|
|
||
| import com.typesafe.config.ConfigFactory | ||
| import com.typesafe.scalalogging.LazyLogging | ||
| import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider | ||
| import software.amazon.awssdk.core.ResponseInputStream | ||
| import software.amazon.awssdk.core.async.{AsyncRequestBody, AsyncResponseTransformer} | ||
| import software.amazon.awssdk.core.exception.{SdkClientException, SdkException} | ||
| import software.amazon.awssdk.core.retry.RetryUtils | ||
| import software.amazon.awssdk.regions | ||
| import software.amazon.awssdk.services.s3.S3AsyncClient | ||
| import software.amazon.awssdk.services.s3.crt.S3CrtRetryConfiguration | ||
| import software.amazon.awssdk.services.s3.model.* | ||
| import software.amazon.awssdk.transfer.s3.{S3TransferManager, model} | ||
|
|
||
| import com.kevel.apso.Retry | ||
| import com.kevel.apso.aws.S3Bucket.isSlowDown | ||
|
|
||
| /** A representation of an Amazon's S3 bucket. This class wraps an `S3AsyncClient` and provides a higher level interface | ||
| * for pushing and pulling files to and from a bucket. | ||
| * | ||
|
|
@@ -40,6 +45,8 @@ class S3Bucket( | |
| private[this] lazy val region = Try(config.getString(configPrefix + ".region")) | ||
| private[this] lazy val maxConnections = Try(config.getInt(configPrefix + ".max-connections")) | ||
| private[this] lazy val maxErrorRetry = Try(config.getInt(configPrefix + ".max-error-retry")) | ||
| private[this] lazy val retryOnSlowDown = | ||
| Try(config.getBoolean(configPrefix + ".retry-on-slow-down")).getOrElse(true) | ||
|
|
||
| @transient private[this] lazy val defaultExecutor = { | ||
| val maxPoolSize = 100 | ||
|
|
@@ -169,14 +176,15 @@ class S3Bucket( | |
| * @return | ||
| * a list of objects in a bucket matching a given prefix. | ||
| */ | ||
| def getObjectsWithMatchingPrefix(prefix: String, includeDirectories: Boolean = false): Iterator[S3Object] = retry { | ||
| logger.info(s"Finding files matching prefix '$prefix'...") | ||
| def getObjectsWithMatchingPrefix(prefix: String, includeDirectories: Boolean = false): Iterator[S3Object] = | ||
| retry { | ||
| logger.info(s"Finding files matching prefix '$prefix'...") | ||
|
|
||
| val req = ListObjectsV2Request.builder.bucket(bucketName).prefix(sanitizeKey(prefix)).build | ||
| val objects = listObjectsV2Iterator(req).flatMap(_.contents.asScala) | ||
| val req = ListObjectsV2Request.builder.bucket(bucketName).prefix(sanitizeKey(prefix)).build | ||
| val objects = listObjectsV2Iterator(req).flatMap(_.contents.asScala) | ||
|
|
||
| if (includeDirectories) objects else objects.filterNot(_.key.endsWith("/")) | ||
| }.getOrElse(Iterator.empty) | ||
| if (includeDirectories) objects else objects.filterNot(_.key.endsWith("/")) | ||
| }.getOrElse(Iterator.empty) | ||
|
|
||
| // FIXME: If the root directory/prefix was created by the `mkdirs` method (where we create an object with 0 bytes) | ||
| // that root directory will be present in the results. Evaluate if we should filter it out since it does not | ||
|
|
@@ -413,10 +421,30 @@ class S3Bucket( | |
| stream | ||
| } | ||
|
|
||
| private[aws] def retry[T](f: => T, maxRetries: Int = 2): Option[T] = | ||
| Retry | ||
| .exponentialBackOff( | ||
| maxRetries = maxRetries, | ||
| base = S3Bucket.BaseBackOff, | ||
| max = Some(S3Bucket.MaxBackOff), | ||
| jitter = S3Bucket.BackOffJitter, | ||
| retryWhen = !handler(_), | ||
| onRetry = (ex, delay, remaining) => | ||
| logger.warn(s"Error during S3 operation. Retrying in ${delay.toMillis}ms ($remaining more times)", ex), | ||
| onMaxRetriesReached = ex => logger.error("Max retries reached. Aborting S3 operation", ex) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Doesn't this mean that we are now logging the full exception three times? On handler, onRetry, and onMaxRetriesReached. This might be costly. We could just log it on the handler.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See 3c2395d |
||
| )(f) | ||
| .toOption | ||
|
|
||
| private def log(isError: Boolean, message: String, cause: Throwable): Unit = | ||
| if (isError) logger.error(message, cause) else logger.warn(message, cause) | ||
|
|
||
| private[this] def handler: PartialFunction[Throwable, Boolean] = { | ||
| // Matched ahead of the shape-specific cases below, since a slow down is reported both as a service error and as a | ||
| // client-side error, depending on the client in use. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If it's not too much of an hassle, can we leave it a small note regarding the behavior with the CRT client? That we observed with this being not a retryable exception.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See c91a435 |
||
| case ex: SdkException if isSlowDown(ex) => | ||
| log(!retryOnSlowDown, s"S3 slow down: ${ex.getMessage}", ex) | ||
| !retryOnSlowDown | ||
|
|
||
| case ex: S3Exception => | ||
| ex.statusCode() match { | ||
| case 404 => | ||
|
|
@@ -428,11 +456,12 @@ class S3Bucket( | |
| case _ => | ||
| logger.warn( | ||
| s"""|S3 service error: ${ex.getMessage}. Extended request id: ${ex.requestId} | ||
| |Message: ${ex.getMessage}""".stripMargin, | ||
| |Message: ${ex.getMessage}""".stripMargin, | ||
| ex | ||
| ) | ||
| false | ||
| } | ||
|
|
||
| case ex: SdkClientException => | ||
| log(!ex.retryable, s"Client Exception: ${ex.getMessage}", ex) | ||
| !ex.retryable | ||
|
|
@@ -450,25 +479,32 @@ class S3Bucket( | |
| false | ||
| } | ||
|
|
||
| private[this] def retry[T](f: => T, tries: Int = 3, sleepTime: Int = 5000): Option[T] = | ||
| if (tries == 0) { | ||
| logger.error("Max retries reached. Aborting S3 operation") | ||
| None | ||
| } else | ||
| Try(f) match { | ||
| case Success(res) => Some(res) | ||
| case Failure(e) if !handler(e) => | ||
| if (tries > 1) { | ||
| logger.warn(s"Error during S3 operation. Retrying in ${sleepTime}ms (${tries - 1} more times)") | ||
| Thread.sleep(sleepTime) | ||
| } | ||
| retry(f, tries - 1, sleepTime) | ||
|
|
||
| case _ => None | ||
| } | ||
|
|
||
| override def equals(obj: Any): Boolean = obj match { | ||
| case b: S3Bucket => b.bucketName == bucketName | ||
| case _ => false | ||
| } | ||
| } | ||
|
|
||
| object S3Bucket extends LazyLogging { | ||
| private[aws] val BaseBackOff = 3.seconds | ||
| private[aws] val MaxBackOff = 30.seconds | ||
| private[aws] val BackOffJitter = 1.second | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could consider making these values also configurable, via HOCON, in the scenario we detect we might be "stuck" a long time retrying things. I wouldn't block merging this PR because of that, as I think these new settings, with the default of two retries, don't change the status quo.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See 3c2395d |
||
| /** The error string with which the CRT-based S3 client reports a throttled request. | ||
| * | ||
| * The CRT signals throttling through its `AWS_ERROR_S3_SLOW_DOWN` error, which reaches the SDK as an | ||
| * `SdkClientException` whose message embeds only the rendered error string, and not the numeric error code. | ||
| */ | ||
| private[aws] val SlowDownErrorMessage = "Response code indicates throttling" | ||
|
|
||
| /** Returns whether the given exception reports S3 having throttled the request. | ||
| * | ||
| * A throttled request surfaces either as a service error, which S3 reports with a `503` status code, or as a | ||
| * client-side error, which is how the CRT-based client reports it. | ||
| */ | ||
| private[aws] def isSlowDown(ex: SdkException): Boolean = | ||
| RetryUtils.isThrottlingException(ex) || (ex match { | ||
| case ex: SdkClientException => Option(ex.getMessage).exists(_.contains(SlowDownErrorMessage)) | ||
| case _ => false | ||
| }) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| package com.kevel.apso.aws | ||
|
|
||
| import org.specs2.mutable.Specification | ||
| import software.amazon.awssdk.awscore.exception.AwsErrorDetails | ||
| import software.amazon.awssdk.core.exception.SdkClientException | ||
| import software.amazon.awssdk.services.s3.model.S3Exception | ||
|
|
||
| class S3BucketSpec extends Specification { | ||
|
|
||
| // The message the CRT-based S3 client builds for a failed request, as seen in production. | ||
| private def clientError(errorString: String) = | ||
| SdkClientException.create(s"Failed to send the request: $errorString") | ||
|
|
||
| private def serviceError(statusCode: Int, errorCode: String) = | ||
| S3Exception | ||
| .builder() | ||
| .statusCode(statusCode) | ||
| .awsErrorDetails(AwsErrorDetails.builder().errorCode(errorCode).serviceName("S3").build()) | ||
| .message("Boom") | ||
| .build() | ||
|
|
||
| "An S3Bucket" should { | ||
|
|
||
| "recognize a slow down" in { | ||
|
|
||
| "reported as a service error" in { | ||
| S3Bucket.isSlowDown(serviceError(503, "SlowDown")) must beTrue | ||
| } | ||
|
|
||
| "reported as a service error with a 429 status code" in { | ||
| S3Bucket.isSlowDown(serviceError(429, "TooManyRequestsException")) must beTrue | ||
| } | ||
|
|
||
| "reported by the CRT client as a client-side error" in { | ||
| S3Bucket.isSlowDown(clientError(S3Bucket.SlowDownErrorMessage)) must beTrue | ||
| } | ||
| } | ||
|
|
||
| "not recognize as a slow down" in { | ||
|
|
||
| // A 503 alone doesn't imply throttling, so it must keep being handled as a plain service error. | ||
| "a service error with a 503 status code but no throttling error code" in { | ||
| S3Bucket.isSlowDown(serviceError(503, "ServiceUnavailable")) must beFalse | ||
| } | ||
|
|
||
| "a service error reporting another failure" in { | ||
| S3Bucket.isSlowDown(serviceError(404, "NoSuchKey")) must beFalse | ||
| } | ||
|
|
||
| "a client-side error reporting another failure" in { | ||
| S3Bucket.isSlowDown(clientError("Socket closed")) must beFalse | ||
| } | ||
|
|
||
| "a client-side error without a message" in { | ||
| S3Bucket.isSlowDown(SdkClientException.builder().build()) must beFalse | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,10 @@ | ||
| package com.kevel.apso | ||
|
|
||
| import scala.annotation.tailrec | ||
| import scala.concurrent.duration.* | ||
| import scala.concurrent.{ExecutionContext, Future, blocking} | ||
| import scala.util.control.NonFatal | ||
| import scala.util.{Failure, Success, Try} | ||
| import scala.util.{Failure, Random, Success, Try} | ||
|
|
||
| /** Utility object with retry mechanisms. | ||
| */ | ||
|
|
@@ -73,4 +74,86 @@ object Retry { | |
| */ | ||
| def retry[T](maxRetries: Int = 10, inBetweenSleep: FiniteDuration = 100.millis)(f: => T): Try[T] = | ||
| retry(maxRetries, Option(inBetweenSleep))(f) | ||
|
|
||
| /** Computes the duration to wait before the next attempt, growing exponentially with the number of attempts made. | ||
| * | ||
| * @param attempt | ||
| * the zero-based index of the attempt that failed | ||
| * @param base | ||
| * the base waiting duration | ||
| * @param max | ||
| * the optional duration with which to cap the exponential growth | ||
| * @param factor | ||
| * the factor of the exponential duration | ||
| * @param jitter | ||
| * the upper bound of the random duration added to the result | ||
| * @return | ||
| * the duration to wait before the next attempt | ||
| */ | ||
| private[apso] def exponentialBackOffDelay( | ||
| attempt: Int, | ||
| base: FiniteDuration, | ||
| max: Option[FiniteDuration] = None, | ||
| factor: Double = 2.0, | ||
| jitter: FiniteDuration = 1.second | ||
| ): FiniteDuration = { | ||
| val exponential = (base.toMillis * Math.pow(factor, attempt.toDouble)).toLong | ||
| val capped = max.fold(exponential)(m => Math.min(exponential, m.toMillis)) | ||
| (capped + (Random.nextDouble() * jitter.toMillis).toLong).millis | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder about this jitter approach. Reading through https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/, I think we could do better. The default is 1.0 second currently and it's add after the capped value, so, to deal with contention from multiple callers, it will kind-of evenly spread things out at a maximum of like 1sec at the maximum, so for hundreds of callers, they will fall on the same second. There's a few different ways we can take it. If we commit to a "full jitter" implementation we can simplify this a lot and remove the However, if we want to be able to toggle the jitterness, maybe something like: val jitterFraction = Math.min(1.0, Math.max(0.0, jitter)) // being extra careful, lol...
(
(1.0 - jitterFraction) * capped +
ThreadLocalRandom.current().nextDouble() * jitterFraction * capped
).toLong.millisThis seems like a better alternative, but I didn't test or benchmarks it. Thoughts?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Great suggestion! I hadn't thought much about the jitter since this was mostly copied over from another internal project. See 8397dc1
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reading the javadocs of
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed, see 8397dc1 |
||
| } | ||
|
|
||
| /** Performs a function `f` until it succeeds or until maximum retries is reached, waiting between attempts for a | ||
| * duration that grows exponentially with the number of attempts made. | ||
| * | ||
| * @param maxRetries | ||
| * the number of retries | ||
| * @param base | ||
| * the base waiting duration | ||
| * @param max | ||
| * the optional duration with which to cap the exponential growth | ||
| * @param factor | ||
| * the factor of the exponential duration | ||
| * @param jitter | ||
| * the upper bound of the random duration added to each waiting duration | ||
| * @param retryWhen | ||
| * the predicate deciding whether a failure is worth retrying | ||
| * @param onRetry | ||
| * the function called before each retry with the failure being retried, the duration that will be waited for and | ||
| * the number of retries still left | ||
| * @param onMaxRetriesReached | ||
| * the function called when the max retries are reached with the latest failure | ||
| * @param f | ||
| * the function to retry | ||
| * @return | ||
| * a Try of the `f` function result | ||
| */ | ||
| def exponentialBackOff[T]( | ||
| maxRetries: Int, | ||
| base: FiniteDuration, | ||
| max: Option[FiniteDuration] = None, | ||
| factor: Double = 2.0, | ||
| jitter: FiniteDuration = 1.second, | ||
| retryWhen: Throwable => Boolean = _ => true, | ||
| onRetry: (Throwable, FiniteDuration, Int) => Unit = (_, _, _) => (), | ||
| onMaxRetriesReached: Throwable => Unit = _ => () | ||
| )(f: => T): Try[T] = { | ||
| @tailrec | ||
| def aux(attempt: Int): Try[T] = | ||
| Try(f) match { | ||
| case res @ Success(_) => res | ||
| case failure @ Failure(ex) => | ||
| if (!retryWhen(ex)) failure | ||
| else if (attempt >= maxRetries) { | ||
| onMaxRetriesReached(ex) | ||
| failure | ||
| } else { | ||
| val delay = exponentialBackOffDelay(attempt, base, max, factor, jitter) | ||
| onRetry(ex, delay, maxRetries - attempt) | ||
| Thread.sleep(delay.toMillis) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I came across this blog https://blog.damavis.com/en/blocking-calls-and-asynchronous-programming-with-scala/ and thought to share. I don't think it matter that much for the current state of things, as apso is providing a "sync" API so this wouldn't have an effect on the apps using it.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, it's not ideal. Since this was already the behavior of the Retry module and the API is sync, I think it should be acceptable. I added the |
||
| aux(attempt + 1) | ||
| } | ||
| } | ||
|
|
||
| aux(0) | ||
| } | ||
| } | ||
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.
handleris a partial function (it already was before this PR), but it's being called normally. It might make sense to add a safeguard here, something like:ex => !handler.applyOrElse(ex, (_: Throwable) => false), to make a "bug" fail-fast.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.
See 3c2395d