Skip to content

retryWithBackoff has no jitter — N sliced PIT readers retry in lockstep on a 429/503 burst #240

Description

@fupelaqu

Summary

retryWithBackoff (core/src/main/scala/app/softnetwork/elastic/client/package.scala, RetryConfig in client/scroll/package.scala) retries with a deterministic exponential backoff — initialDelay = 1 s, backoffFactor = 2.0, maxDelay = 10 s, no jitter. Every retrying caller that hits the same failure at the same moment retries at exactly the same instants.

Until now that was one caller per stream. Since sliced PIT paging (#238, 0.21.0) an un-ordered extraction runs up to min(primary shards, elastic.scroll.max-slices) readers (default cap 8) against one cluster, each wrapped in its own retryWithBackoff. A 429 es_rejected_execution_exception / 503 burst on the search thread pool therefore fails N readers within the same few milliseconds, and N retries land together 1 s later, then 2 s, 4 s, 8 s — the retry itself becomes a synchronized burst against the pool that is already saturated (classic thundering herd). With K concurrent extractions that is K × N lockstep retries.

Not a regression introduced by #238 (the helper predates it; the benchmark concurrency arm at k = 4 × 6 slices measured 0 rejections on 3 × 2-CPU nodes), but slicing multiplies the exposure and the fix is a one-liner in a shared helper — hence its own issue.

Where

  • retryWithBackoffcore/.../client/package.scala (attempt(retriesLeft, delay), akka.pattern.after(delay, …); nextDelay = min(delay × factor, maxDelay))
  • RetryConfigcore/.../client/scroll/package.scala
  • Callers: PIT page fetch in es7 RestHighLevelClientApi.pitSearchAfter, es8/es9 JavaClientApi.pitSearchAfter (one per slice), classic scroll and search_after paths, BulkApi has its own private retryWithBackoff (BulkApi.scala) — check whether it has the same shape.

Proposal

  1. Add jitter to the shared helper — full jitter (delay × U(0, 1)) or decorrelated jitter (AWS-style min(maxDelay, U(initialDelay, previous × 3))). Full jitter is the simplest and keeps maxDelay as the hard ceiling.
  2. Surface it on RetryConfig as jitter: Double = 1.0 (fraction of the delay randomised; 0.0 = today's deterministic behaviour for tests that pin timings), so existing explicit configs compile unchanged.
  3. Keep the WARN per retry but include the actual delay chosen (Retrying in 1.37 s (2 retries left)), so a storm is visible in logs.
  4. Align BulkApi's private helper (or make it use the shared one) if it has the same deterministic schedule.

Acceptance

  • A unit test (no Docker) that runs N = 8 concurrent retryWithBackoff calls against an operation failing twice with a retriable IOException and asserts the retry instants are NOT identical across callers (spread > 0) while each stays ≤ maxDelay.
  • jitter = 0.0 reproduces today's schedule exactly (existing timing-sensitive tests keep passing).
  • es7/es8/es9 ScrollCompletenessSpec / SlicedScrollCompletenessSpec unchanged and green.

Not in scope

Found during the #238 code review (decision item "retry storm without jitter across N slices").

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions