Yicong-Huang commented on code in PR #7119:
URL: https://github.com/apache/texera/pull/7119#discussion_r3680531559
##########
build.sbt:
##########
@@ -117,6 +117,9 @@ val nettyDependencyOverrides = Seq(
// keep the org.apache.log4j API available at runtime.
ThisBuild / excludeDependencies += ExclusionRule("log4j", "log4j")
+// Dependency-free helpers (retry/backoff, ...) that any module may depend on.
Keep it that way:
+// anything added here lands on every service's classpath.
+lazy val Util = (project in file("common/util")).settings(commonModuleSettings)
Review Comment:
The new module needs one more registration:
`.github/workflows/build.yml:308-316` lists the common modules for the
test-running `jacoco` task by hand, and `Util` isn't in it. The comment right
above that list says why the list can't be implicit — sbt's `test` does not
transit `dependsOn` — so `WorkflowCore/jacoco` will not reach `Util`'s Test
config. Every other sbt call in CI is scoped to a named project (`:648`,
`:660`, `:796`, `:932`) and there is no root `sbt test`, so `RetryUtilSpec`'s
10 cases never execute.
That matters more than "new tests unguarded", because this PR also deletes 4
retry tests from `LakeFSStorageClientSpec` (ran under `WorkflowCore/jacoco`)
and 4 from `FileServiceSpec` (ran under the `FileService/jacoco` matrix leg).
Net -8 CI-executed tests on this behavior, and the
interrupt-during-backoff-sleep fix you highlight now has no CI guard at all —
where `FileServiceSpec` had one before. Codecov agrees from the other side: no
`util` flag, and `Files 1159 -> 1159` despite three added files.
Adding `"Util/jacoco" \` to that list fixes it; the codecov upload glob
already picks the report up. Commenting here because the workflow file isn't in
the diff.
##########
common/util/src/main/scala/org/apache/texera/common/util/RetryUtil.scala:
##########
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.common.util
+
+import scala.annotation.tailrec
+import scala.util.control.NonFatal
+
+/**
+ * Retry with exponential backoff, for blocking work.
+ *
+ * Every module can reach this one, so a new retry loop should not be written
by hand. If the work
+ * returns a `Future` rather than blocking, use the non-blocking sibling
+ * `org.apache.texera.amber.engine.common.Utils.retry` in `amber` instead: it
takes the same
+ * attempts-and-doubling-backoff knobs but waits on a `Timer`, which matters
on an actor or
+ * coordinator thread where `Thread.sleep` would stall unrelated work queued
behind it.
+ */
+object RetryUtil {
+
+ /**
+ * One failed attempt that is about to be retried. Carries everything a
caller needs to log the
+ * retry itself; `message` is the standard wording, so retries read the
same everywhere.
+ */
+ final case class RetryAttempt(
+ description: String,
+ attempt: Int,
+ maxAttempts: Int,
+ delayMillis: Long,
+ cause: Throwable
+ ) {
+ def message: String =
+ s"Failed to $description (attempt $attempt/$maxAttempts):
${cause.getMessage}. " +
+ s"Retrying in ${delayMillis}ms..."
+ }
+
+ /**
+ * Runs `operation`, retrying on failure with exponential backoff (the
delay doubles after each
+ * failed attempt) until it succeeds or `maxAttempts` is reached. The final
failure is wrapped
+ * with `description` and the last exception as its cause.
+ *
+ * Only `NonFatal` failures are treated as transient. An
`InterruptedException` -- raised by the
+ * operation or by the wait between attempts -- fails fast with the
interrupt status restored,
+ * so a caller shutting the thread down is never made to sit through the
remaining backoff.
+ *
+ * @param description verb phrase naming the work, e.g. "connect to
lake fs server". It is
+ * interpolated into every message: "Failed to
$description after ...".
+ * @param maxAttempts total attempts; 1 means no retry at all.
+ * @param initialDelayMillis wait before the first retry; doubled after
each failed attempt.
+ * @param onRetry invoked before each wait. Log
`RetryAttempt.message` through the
+ * caller's own logger, so retries are attributed
to the caller rather
+ * than to this util.
+ * @param sleep how to wait; injectable so tests exercise the
backoff without waiting.
+ * @param operation the work to run, re-evaluated on each attempt.
+ * @tparam T whatever `operation` returns.
+ * @return `operation`'s value from the first attempt that succeeds.
+ */
+ def withBackoff[T](
+ description: String,
+ maxAttempts: Int,
+ initialDelayMillis: Long,
+ onRetry: RetryAttempt => Unit,
+ sleep: Long => Unit = Thread.sleep
+ )(operation: => T): T = {
+ // Restore the interrupt status and fail fast rather than retrying,
whether the interrupt
+ // arrives while running `operation` or while waiting between attempts.
+ def failInterrupted(cause: InterruptedException): Nothing = {
+ Thread.currentThread().interrupt()
+ throw new RuntimeException(s"Interrupted while waiting to $description",
cause)
+ }
+
+ @tailrec
+ def attemptFrom(attempt: Int, delayMillis: Long): T = {
+ val outcome: Either[Throwable, T] =
+ try Right(operation)
+ catch {
+ case ie: InterruptedException => failInterrupted(ie)
+ case NonFatal(cause) => Left(cause)
Review Comment:
`NonFatal` here is wider than the `case e: Exception` both replaced loops
used, so both call sites now retry non-fatal `Error`s — `java.io.IOError`,
`ServiceConfigurationError`, `AssertionError`. On an S3/LakeFS startup path
those are reachable, and they go from propagating immediately to costing ~6s of
backoff and arriving wrapped.
I think `NonFatal` is the right choice — it's parity with `Utils.retry`,
which is the stated point of the pair — but nothing currently records it as a
decision or guards it. Both fatal-throwable cases sit on the other side of the
line: `RetryUtilSpec:177` uses `ControlThrowable` and `FileServiceSpec:125`
uses `StackOverflowError`, and both were fatal under `Exception` too, so
neither can observe the change.
One `RetryUtilSpec` case asserting that a non-fatal `Error` **is** retried
would pin the new contract, plus a line in the description noting the widening.
##########
common/util/build.sbt:
##########
@@ -0,0 +1,56 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import scala.collection.Seq
+
+name := "util"
+
+enablePlugins(JavaAppPackaging)
+
+// Enable semanticdb for Scalafix
+ThisBuild / semanticdbEnabled := true
+ThisBuild / semanticdbVersion := scalafixSemanticdb.revision
+
+// Manage dependency conflicts by always using the latest revision
+ThisBuild / conflictManager := ConflictManager.latestRevision
+
+// Restrict parallel execution of tests to avoid conflicts
+Global / concurrentRestrictions += Tags.limit(Tags.Test, 1)
+
+/////////////////////////////////////////////////////////////////////////////
+// Compiler Options
+/////////////////////////////////////////////////////////////////////////////
+
+// Scala compiler options
+Compile / scalacOptions ++= Seq(
+ "-Xelide-below", "WARNING", // Turn on optimizations with "WARNING" as
the threshold
+ "-feature", // Check feature warnings
+ "-deprecation", // Check deprecation warnings
+ "-Ywarn-unused:imports" // Check for unused imports
+)
+
+/////////////////////////////////////////////////////////////////////////////
+// Dependencies
+/////////////////////////////////////////////////////////////////////////////
+
+// This module is deliberately dependency-free apart from the test framework:
+// every other module depends on it, so anything added here lands on every
+// service's classpath. Callers pass their own logger in through the hooks
Review Comment:
The rule is right but the premise stated for it isn't: `Auth`, `Config`,
`Resource`, `DAO` and `PyBuilder` have no `dependsOn(Util)` edge, and
`ConfigService`, `AccessControlService` and `NotebookMigrationService` never
reach it even transitively. Same overstatement at `build.sbt:120` and in
`RetryUtil.scala:28` ("Every module can reach this one").
```suggestion
// any module may depend on it, so anything added here reaches the classpath
of
// every service that does. Callers pass their own logger in through the
hooks
```
##########
common/util/src/test/scala/org/apache/texera/common/util/RetryUtilSpec.scala:
##########
@@ -0,0 +1,187 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.common.util
+
+import org.apache.texera.common.util.RetryUtil.RetryAttempt
+import org.scalatest.flatspec.AnyFlatSpec
+
+import scala.collection.mutable.ListBuffer
+import scala.util.control.ControlThrowable
+
+/**
+ * Contract of the shared blocking backoff retry. `sleep` is injected
everywhere so the backoff
+ * progression is asserted exactly without any test waiting.
+ *
+ * These cases are the union of what the two hand-rolled loops this util
replaced were tested for
+ * (`LakeFSStorageClient.retryWithBackoff`, `FileService.awaitDependency`),
plus the interrupt
+ * during a backoff sleep, which the LakeFS copy did not handle.
Review Comment:
`LakeFSStorageClient.retryWithBackoff` doesn't exist after this PR, so a
reader who greps for it finds nothing. The paragraph also only makes sense to
someone holding the PR's history ("this util replaced", "the LakeFS copy") —
same at `:152` and in `LakeFSStorageClientSpec:26` ("the loop that used to live
here"). Stating the contract directly survives the merge:
```suggestion
* Coverage is the full contract both blocking callers rely on: the
doubling progression, the
* give-up wrapping, and interrupt fail-fast during the operation and
during a backoff sleep.
```
##########
amber/src/main/scala/org/apache/texera/amber/engine/common/Utils.scala:
##########
@@ -67,6 +67,11 @@ object Utils extends LazyLogging {
* for callers on an actor or coordinator thread, where a `Thread.sleep`
would also stall
* unrelated work queued on that thread.
*
+ * This is the non-blocking half of the pair: for blocking work, use
+ * `org.apache.texera.common.util.RetryUtil.withBackoff` in `common/util`,
which takes the same
+ * attempts-and-doubling knobs. It lives in a separate module because this
variant needs
+ * `com.twitter:util-core`, which only `amber` declares.
Review Comment:
This PR makes the comment 24 lines below (`:97`) false: it justifies
`NonFatal` here by saying it "matches how the blocking backoff loops elsewhere
in the repo catch `Exception`". After the consolidation there is one blocking
loop, and it catches `NonFatal`.
Worth correcting rather than deleting, because the fixed version is a
stronger claim than the original — the two halves now agree exactly:
```
// `NonFatal` so that a fatal handed back as a failed `Future` is not
retried either, matching
// the blocking sibling `RetryUtil.withBackoff`, which uses the same
`NonFatal` predicate.
```
##########
common/util/src/test/scala/org/apache/texera/common/util/RetryUtilSpec.scala:
##########
@@ -0,0 +1,187 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.common.util
+
+import org.apache.texera.common.util.RetryUtil.RetryAttempt
+import org.scalatest.flatspec.AnyFlatSpec
+
+import scala.collection.mutable.ListBuffer
+import scala.util.control.ControlThrowable
+
+/**
+ * Contract of the shared blocking backoff retry. `sleep` is injected
everywhere so the backoff
+ * progression is asserted exactly without any test waiting.
+ *
+ * These cases are the union of what the two hand-rolled loops this util
replaced were tested for
+ * (`LakeFSStorageClient.retryWithBackoff`, `FileService.awaitDependency`),
plus the interrupt
+ * during a backoff sleep, which the LakeFS copy did not handle.
+ */
+class RetryUtilSpec extends AnyFlatSpec {
+
+ private def noRetryHook: RetryAttempt => Unit = _ => ()
Review Comment:
`noRetryHook` reads as "a hook that disables retrying", which is confusing
in the cases that are actually about not retrying (`:102` maxAttempts = 1,
`:173` fatal throwable) — this hook fires on every retry, it just does nothing.
`noopRetryHook` or `ignoreRetries` would say that. Eight call sites, so not a
one-line suggestion.
##########
file-service/src/test/scala/org/apache/texera/service/FileServiceSpec.scala:
##########
@@ -92,96 +92,35 @@ class FileServiceSpec extends AnyFlatSpec {
assert(ex.getCause eq cause)
}
- it should "give up immediately without sleeping when maxAttempts is 1" in {
- var attempts = 0
- val delays = ListBuffer.empty[Long]
- val cause = new RuntimeException("still down")
- val ex = intercept[RuntimeException] {
- service.awaitDependency("dep", 1, 200L, delays += _) {
- attempts += 1
- throw cause
- }
- }
- assert(attempts == 1)
- assert(delays.isEmpty)
- assert(ex.getMessage.contains("after 1 attempts"))
- assert(ex.getCause eq cause)
- }
-
it should "fail fast and restore the interrupt status when the operation is
interrupted" in {
val ex = intercept[RuntimeException] {
- service.awaitDependency("dep", 6, 200L, _ => ()) {
+ service.awaitDependency("reach dep", 6, 200L, _ => ()) {
throw new InterruptedException("interrupted")
}
}
// Thread.interrupted() both reads and clears the flag, so the interrupt
was restored.
assert(Thread.interrupted())
- assert(ex.getMessage.contains("Interrupted while waiting for dep"))
- assert(ex.getCause.isInstanceOf[InterruptedException])
- }
-
- it should "fail fast and restore the interrupt status when interrupted while
sleeping between attempts" in {
- var attempts = 0
- val ex = intercept[RuntimeException] {
- service.awaitDependency("dep", 6, 200L, _ => throw new
InterruptedException("interrupted")) {
- attempts += 1
- throw new RuntimeException("not reachable yet")
- }
- }
- // The operation failed once, then the interrupt arrived during the
backoff sleep.
- assert(attempts == 1)
- // Thread.interrupted() both reads and clears the flag, so the interrupt
was restored.
- assert(Thread.interrupted())
- assert(ex.getMessage.contains("Interrupted while waiting for dep"))
+ assert(ex.getMessage.contains("Interrupted while waiting to reach dep"))
assert(ex.getCause.isInstanceOf[InterruptedException])
}
- it should "succeed on the final allowed attempt without giving up one try
too early" in {
- // Boundary for `attempt >= maxAttempts`: the operation only succeeds on
the very last
- // attempt, so the loop must not give up prematurely. Expect maxAttempts -
1 backoff waits.
- var attempts = 0
- val delays = ListBuffer.empty[Long]
- service.awaitDependency("dep", 3, 200L, delays += _) {
- attempts += 1
- if (attempts < 3) throw new RuntimeException("not reachable yet")
- }
- assert(attempts == 3)
- assert(delays.toList == List(200L, 400L))
- }
-
- it should "honor a custom initial delay when computing the backoff
progression" in {
- // Guards against the initial delay being hardcoded: starting from 50ms
the geometric
- // progression must be 50, 100, 200 rather than the default 200-based
sequence.
- var attempts = 0
- val delays = ListBuffer.empty[Long]
- val ex = intercept[RuntimeException] {
- service.awaitDependency("dep", 4, 50L, delays += _) {
- attempts += 1
- throw new RuntimeException("down")
- }
- }
- assert(attempts == 4)
- assert(delays.toList == List(50L, 100L, 200L))
- assert(ex.getMessage.contains("after 4 attempts"))
- }
-
- it should "include the underlying failure message when giving up" in {
+ it should "include the description and the underlying failure message when
giving up" in {
val ex = intercept[RuntimeException] {
- service.awaitDependency("dataset bucket", 2, 200L, _ => ()) {
+ service.awaitDependency("reach the dataset bucket", 2, 200L, _ => ()) {
throw new RuntimeException("connection refused")
}
}
- assert(ex.getMessage.contains("dataset bucket not ready after 2 attempts"))
+ assert(ex.getMessage.contains("Failed to reach the dataset bucket after 2
attempts"))
assert(ex.getMessage.contains("connection refused"))
}
- it should "propagate a non-Exception Throwable immediately without retrying
or wrapping it" in {
- // The catch clause only matches Exception, so an Error must escape on the
first attempt:
- // it is neither retried nor wrapped in the \"not ready after N attempts\"
RuntimeException.
+ it should "propagate a fatal Throwable immediately without retrying or
wrapping it" in {
+ // Only NonFatal failures are transient, so an Error must escape on the
first attempt: it is
+ // neither retried nor wrapped in the \"Failed to ... after N attempts\"
RuntimeException.
Review Comment:
The `\"` sequences are literal in the file — nothing needs escaping in a
Scala line comment.
```suggestion
// neither retried nor wrapped in the "Failed to ... after N attempts"
RuntimeException.
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]