refactor(util): host one exponential-backoff retry - #7119
Conversation
The same doubling-backoff loop was written three times: the async one in amber's Utils, plus two structurally identical blocking copies in LakeFSStorageClient and FileService that no module could share because amber's Utils sits above them in the module graph. Add a dependency-free `common/util` module with `RetryUtil.withBackoff` and point both blocking copies at it. The util owns the attempt counting, the doubling, what counts as transient (NonFatal), interrupt fail-fast with the interrupt status restored, and the message wording; callers pass a verb-phrase description and log retries through their own logger via the `onRetry` hook, so warnings stay attributed to the caller. This also fixes a hole in the LakeFS copy: its `sleep` call sat inside the `catch` block, so an interrupt raised while waiting escaped raw with the interrupt flag left cleared. FileService's copy already handled that; the shared util keeps the better behavior for both. The module is deliberately dependency-free -- everything depends on it, so anything added there lands on every service's classpath. That is also why the non-blocking variant stays in amber: it needs com.twitter:util-core, which only amber declares. The two now cross-reference each other. Closes apache#7095
Automated Reviewer SuggestionsBased on the
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #7119 +/- ##
============================================
- Coverage 79.56% 79.54% -0.03%
+ Complexity 3834 3831 -3
============================================
Files 1159 1160 +1
Lines 46145 46147 +2
Branches 5127 5129 +2
============================================
- Hits 36717 36709 -8
- Misses 7798 7807 +9
- Partials 1630 1631 +1
☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
| config | throughput | MB/s | latency | max Δ latest / 7d | |
|---|---|---|---|---|---|
| 🔴 | bs=10 sw=10 sl=64 | 657 | 0.401 | 15,025/20,030/20,030 us | 🔴 +18.3% / 🔴 +27.0% |
| 🔴 | bs=100 sw=10 sl=64 | 1,421 | 0.867 | 68,447/98,321/98,321 us | 🔴 +20.4% / 🟢 +42.1% |
| ⚪ | bs=1000 sw=10 sl=64 | 1,655 | 1.01 | 601,913/638,705/638,705 us | ⚪ within ±5% / 🟢 +60.5% |
Baseline details
Latest main 8c255b8 from same runner
| config | metric | PR | latest main | 7d avg | Δ latest | Δ 7d |
|---|---|---|---|---|---|---|
| bs=10 sw=10 sl=64 | throughput | 657 tuples/sec | 748 tuples/sec | 786.12 tuples/sec | -12.2% | -16.4% |
| bs=10 sw=10 sl=64 | MB/s | 0.401 MB/s | 0.457 MB/s | 0.48 MB/s | -12.3% | -16.4% |
| bs=10 sw=10 sl=64 | p50 | 15,025 us | 12,701 us | 12,305 us | +18.3% | +22.1% |
| bs=10 sw=10 sl=64 | p95 | 20,030 us | 21,617 us | 15,774 us | -7.3% | +27.0% |
| bs=10 sw=10 sl=64 | p99 | 20,030 us | 21,617 us | 18,978 us | -7.3% | +5.5% |
| bs=100 sw=10 sl=64 | throughput | 1,421 tuples/sec | 1,404 tuples/sec | 999.71 tuples/sec | +1.2% | +42.1% |
| bs=100 sw=10 sl=64 | MB/s | 0.867 MB/s | 0.857 MB/s | 0.61 MB/s | +1.2% | +42.1% |
| bs=100 sw=10 sl=64 | p50 | 68,447 us | 71,956 us | 100,616 us | -4.9% | -32.0% |
| bs=100 sw=10 sl=64 | p95 | 98,321 us | 81,643 us | 107,356 us | +20.4% | -8.4% |
| bs=100 sw=10 sl=64 | p99 | 98,321 us | 81,643 us | 113,255 us | +20.4% | -13.2% |
| bs=1000 sw=10 sl=64 | throughput | 1,655 tuples/sec | 1,654 tuples/sec | 1,031 tuples/sec | +0.1% | +60.5% |
| bs=1000 sw=10 sl=64 | MB/s | 1.01 MB/s | 1.009 MB/s | 0.63 MB/s | +0.1% | +60.4% |
| bs=1000 sw=10 sl=64 | p50 | 601,913 us | 602,426 us | 980,328 us | -0.1% | -38.6% |
| bs=1000 sw=10 sl=64 | p95 | 638,705 us | 639,722 us | 1,027,528 us | -0.2% | -37.8% |
| bs=1000 sw=10 sl=64 | p99 | 638,705 us | 639,722 us | 1,054,298 us | -0.2% | -39.4% |
Raw CSV
config_idx,batch_size,schema_width,string_len,num_batches,total_ms,total_tuples,total_bytes,tuples_per_sec,mb_per_sec,lat_p50_us,lat_p95_us,lat_p99_us
0,10,10,64,20,304.26,200,128000,657,0.401,15024.92,20029.72,20029.72
1,100,10,64,20,1407.55,2000,1280000,1421,0.867,68446.79,98320.82,98320.82
2,1000,10,64,20,12085.80,20000,12800000,1655,1.010,601912.86,638705.07,638705.07
Yicong-Huang
left a comment
There was a problem hiding this comment.
🔴 1 must-fix · 6 advisory · 3 polish — the design is right; the new module just never runs its tests in CI.
Design & architecture (1)
build.sbt:122—Utilis missing from build.yml's hand-maintained common-modulejacocolist, soRetryUtilSpecnever runs in CI while 8 tests that did run were deleted in the same PR (must-fix, see inline)
Correctness (3)
Utils.scala:73— the comment at:97claiming the blocking loops catchExceptionis made false by this PR (advisory, see inline)RetryUtil.scala:93—Exception->NonFatalwidens retry to non-fatalErrors; unstated, and no test can observe it (advisory, see inline)common/util/build.sbt:51— "every other module depends on it" is false for five modules and three services (advisory, see inline)
Conventions (3)
- Title:
commonis a directory, not a module — AGENTS.md says to use the module name, and no mergedcommon/*title uses it;refactor(util): host one exponential-backoff retryis 49 chars vs. the current 67 against the ~60 cap (advisory) - Description: say why the util landed in a new
common/utilmodule rather thancommon/workflow-coreas #7095's proposal 1 specified (advisory) - Description: file a follow-up issue for the two loops left alone (
URLFetchUtil,PythonProxyClient) — that reasoning lives only in the description, which disappears on merge (advisory)
Polish: 3 quick touch-ups (see inline comments).
Verification trace
Traced every sbt invocation under .github/workflows/ to confirm Util's Test config is never executed: build.yml:308-316 enumerates the common modules for jacoco and omits Util; the comment at :283-285 states sbt's test does not transit dependsOn, so WorkflowCore/jacoco cannot reach it; :648 is WorkflowExecutionService/test, :660 is dist-only, :796/:932 are the six-service platform matrix, and no root sbt test exists. grep -rn "Util" .github/workflows/ returns only two unrelated Utils.amberHomePath comments.
Separately traced the transient-set change: NonFatal admits java.io.IOError, ServiceConfigurationError and AssertionError, which case e: Exception rejected, and neither remaining fatal-throwable fixture (ControlThrowable in RetryUtilSpec:177, StackOverflowError in FileServiceSpec:125) can observe the difference.
…otes The common-module list that `build.yml` hands to `jacoco` is maintained by hand -- sbt's `test` does not transit `dependsOn`, so nothing else reaches a module's Test config -- and `Util` was missing from it. `RetryUtilSpec` never ran in CI, while this PR removed 8 tests that did, including the only guard on the interrupt-during-backoff-sleep behavior. Register `Util/jacoco`; the codecov upload glob already picks the report up. Also from review: - pin the one behavior change the consolidation carries -- `NonFatal` is wider than the `case e: Exception` both loops used, so a non-fatal `Error` is now retried rather than propagated -- with a test and a scaladoc note. - correct the claim that every module depends on `common/util`: five common modules and three services have no edge to it. - state the spec's contract directly instead of describing what it replaced, so the docs survive this PR's history. - fix the amber-side comment this PR made false, unescape quotes in a Scala line comment, and rename `noRetryHook` -> `noopRetryHook`.
|
All ten addressed — the must-fix in 93fb507, plus the three description-level notes:
Re-verified after the changes: |
Yicong-Huang
left a comment
There was a problem hiding this comment.
🟢 7 resolved · 0 open · 0 new — every prior finding addressed correctly; nothing new across design, correctness, or conventions.
Verification trace
Confirmed RetryUtil.withBackoff is behavior-equivalent to the two blocking loops it replaces and to its non-blocking sibling Utils.retry: same NonFatal predicate, same doubling progression, same 1-based indexing, and a give-up wrapping ("Failed to $description after N attempts") that reproduces both replaced loops — the LakeFS give-up text is byte-identical. The one deliberate delta — NonFatal widening Exception, so non-fatal Errors (AssertionError, java.io.IOError, ServiceConfigurationError) are now retried on the S3/LakeFS startup paths — is pinned in both directions by RetryUtilSpec (a non-fatal Error is retried and wrapped; a ControlThrowable is not), and "Util/jacoco" (build.yml:312) makes that spec run in CI.
What changes were proposed in this PR?
The same doubling-backoff loop was written three times, and no module could share the others' copy: amber's
Utilssits abovecommon/workflow-coreandfile-servicein the module graph.New dependency-free
common/utilmodule withRetryUtil.withBackoff, and both blocking copies now call it:LakeFSStorageClient.healthCheckretryWithBackoffRetryUtil.withBackoff("connect to lake fs server", 5, 200, ...)FileService.awaitDependencyUtils.retry(amber, non-blocking)RetryUtil.withBackoff(description, maxAttempts, initialDelayMillis, onRetry, sleep)(operation)The util owns everything the copies duplicated: attempt counting, the doubling, what counts as transient (
NonFatal), interrupt fail-fast with the interrupt status restored, and the message wording.descriptionis a verb phrase ("connect to lake fs server"), interpolated into all three messages, so LakeFS's give-up text is byte-identical to before. Retries are reported through anonRetryhook carrying aRetryAttempt(attempt, budget, delay, cause, and the standardmessage), which callers log with their own logger — so a LakeFS retry still logs under LakeFS, not under the util.Bug fixed on the way in. LakeFS's
sleepcall sat inside thecatchblock, so an interrupt raised while waiting escaped raw with the interrupt flag left cleared — acatchcannot catch what its own body throws:FileService's copy already plugged that hole with an innertry; the shared util keeps the better behavior for both.Why a new module rather than
common/workflow-core, which was proposal 1 in #7095:workflow-coreis not reachable fromAuth,ConfigService, orAccessControlService, so hosting it there would have left future callers in those modules stuck writing their own loop — the exact thing this PR is meant to stop.common/configis reachable from everything and already carriesorg.apache.texera.amber.util.ConfigParserUtil, but a retry helper isn't configuration. A dependency-free module any other module may depend on avoids both problems, at the cost of onebuild.sbtentry.Why the non-blocking variant stays in amber.
Utils.retryneedscom.twitter:util-core, declared only inamber/build.sbt. Moving it down would put util-core on the classpath of every service that depends on the new module and force LICENSE-binary resyncs, so the pair is: blocking incommon/util, non-blocking inamber, each documented in terms of the other. The new module is dependency-free for the same reason.One behavior change, deliberate. Both replaced loops caught
case e: Exception; the util usesNonFatal, for parity withUtils.retry.NonFatalis wider: non-fatalErrors —AssertionError,java.io.IOError,ServiceConfigurationError— are now retried and wrapped rather than propagating immediately. On the S3/LakeFS startup paths that means such a failure costs the full backoff before surfacing.RetryUtilSpecpins it in both directions (a non-fatalErroris retried; a fatal throwable is not).Two retry sites deliberately left alone, because converting them changes behavior rather than just structure — tracked with the full reasoning in #7124:
URLFetchUtil.getInputStreamFromURLOption. Adopting backoff adds up to ~3 s before a dead URL gives up — arguably a fix (it is an unthrottled retry storm today), but a behavior change in an operator path.PythonProxyClientFlight connect loopWorkflowRuntimeExceptionon give-up. Needs a delay-multiplier knob plus a give-up type decision.Any related issues, documentation, discussions?
Closes #7095
Follows #7088, which introduced the non-blocking variant; the consolidation was split out of it to keep that fix narrow. Context: the review discussion on #7103 (the LakeFS health-check backport) asked for one util rather than a fourth copy.
How was this PR tested?
RetryUtilSpec(new, incommon/util, registered inbuild.yml's hand-maintainedjacocomodule list so it actually runs) is the union of what the two hand-rolled loops were tested for, plus what neither covered:attempt >= maxAttemptsboundary); gives up naming the description and preserving the cause;maxAttempts = 1means no retry and no sleep;onRetryhook fires once per wait with 1-based attempt numbers and never after the last attempt, and itsmessagewording is pinned;Erroris retried (theNonFatal-vs-Exceptionwidening above).At the call sites:
FileServiceSpeckeeps 8 tests that exercise the delegation, its own 6/200 defaults, and the description in its messages (the 4 that only re-tested util mechanics moved intoRetryUtilSpec).LakeFSStorageClientSpec's 4 retry tests moved out with the loop; its remaining 5 pass unchanged. amber'sUtilsSpecandRegionExecutionManagerSpecare untouched and still green.Lint plus test-source compiles for every module the new dependency edge touches, and amber's two retry specs:
Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 5)