Retry shared e2e cluster start on transient node-placement failures - #2182
Conversation
|
🤖 Integration tests running for |
cf50af3 to
8b8908e
Compare
|
🤖 Integration tests running for |
8b8908e to
6d847f2
Compare
|
🤖 Integration tests running for |
6d847f2 to
238e063
Compare
|
🤖 Integration tests ❌ failed for |
*Why* The shared e2e cluster (TEST_DEFAULT_CLUSTER_ID, warmed once per shard in onPrepare) is cold-started every nightly, so each run pays a fresh cloud node placement. When the test region is capacity-tight, placement terminates (e.g. "Timeout while placing nodes") and fails the shard even though a fresh start would usually succeed. Because ~40 shards share the one cluster, a single placement blip fails a whole batch at once. *What* Warm-up is best-effort, so startCluster now re-issues start() on ANY post-start terminal state (bounded to MAX_START_ATTEMPTS=3, jittered backoff, all inside the existing single 60-min deadline). A start() the control plane outright rejects while the cluster stays terminal (auth / bad request) still fails fast — retrying the same rejected call can't help. The retry is deliberately NOT gated on the failure's reason string: an earlier revision classified transient-vs- deterministic by regex-matching the cloud error prose, which reviewers flagged as brittle (wording drift silently disables it) and which only ever under- retried; dropping it removes that fragility and a genuine failure still surfaces after the bounded attempts. No production extension code changes; test only. *Verification* prettier + eslint clean; tsc has no errors in the changed files. startCluster .test.ts: 9/9 passing, covering already-RUNNING, start+poll, start race, fail-fast on a rejected start, TERMINATING wait, transient-terminal-then-RUNNING, retry-to-max-then-surface-reason, deadline-exhausted-by-backoff, and start-race- during-retry. Co-authored-by: Isaac <no-reply@databricks.com>
238e063 to
4f7c687
Compare
|
If integration tests don't run automatically, an authorized user can run them manually by following the instructions below: Trigger: Inputs:
Checks will be approved automatically on success. |
|
🤖 Integration tests ✅ passed for |
| const backoffMs = Math.min( | ||
| Math.round( | ||
| START_RETRY_BACKOFF.toMillSeconds().value * | ||
| (0.5 + Math.random()) | ||
| ), | ||
| remaining().toMillSeconds().value | ||
| ); | ||
| if (backoffMs > 0) { | ||
| await new Promise((resolve) => setTimeout(resolve, backoffMs)); | ||
| } |
There was a problem hiding this comment.
The cap here is doing two different jobs, and it's only right for one of them:
backoffMs = min(jitter, remaining)
| |
| +-- "don't sleep past the deadline" <- wrong response
+-- "space out ~40 shards"
When remaining < jitter there isn't room for another attempt, so the loop sleeps out the clock and then discovers that.
The timeline from your own stops re-starting once the deadline is exhausted by backoff test (timeout = 5s, jitter in [10s, 30s]):
t=0ms get() -> TERMINATED
attempt 1: remaining 5000 > 0 OK
start() -> poll -> TERMINATED -> ClusterStartError
attempt 1 < MAX, so back off
backoffMs = min( ~18000, 5000 ) = 5000
^jitter ^remaining <- already knows a retry is impossible
t=0ms sleep(5000) ---------------------+
| nothing happens
t=5000ms <--------------------------------+
attempt 2: remaining 0 -> break
throw lastError
The sleep is decided after the value that makes it pointless is already in hand.
The cap can go away entirely, because the guard subsumes it: if there's room to sleep, sleeping the full jitter is safe; if there isn't, the answer is break. That also drops the now-dead backoffMs > 0 check.
| const backoffMs = Math.min( | |
| Math.round( | |
| START_RETRY_BACKOFF.toMillSeconds().value * | |
| (0.5 + Math.random()) | |
| ), | |
| remaining().toMillSeconds().value | |
| ); | |
| if (backoffMs > 0) { | |
| await new Promise((resolve) => setTimeout(resolve, backoffMs)); | |
| } | |
| const backoffMs = Math.round( | |
| START_RETRY_BACKOFF.toMillSeconds().value * | |
| (0.5 + Math.random()) | |
| ); | |
| // No room to back off and still launch, so stop rather than | |
| // sleeping out the remaining deadline. | |
| if (backoffMs >= remaining().toMillSeconds().value) { | |
| break; | |
| } | |
| await new Promise((resolve) => setTimeout(resolve, backoffMs)); |
This is free with respect to the existing test, which asserts verifyStarted(1) and a /Timeout while placing nodes/ message. Both hold either way:
verifyStarted(1) |
error message | wall clock | |
|---|---|---|---|
| current (sleep 5s) | pass | pass | 5000ms |
| with early break | pass | pass | 0ms |
So the test never pinned the sleep, only the clock did.
One knock-on worth noting: after this change the remaining() <= 0 guard at the top of the loop is no longer reachable via the backoff path. It stays useful only for a caller-supplied already-expired deadline, which your 0 ms test covers.
Worst case in production is one jitter window (~30s) per failing shard, so this is a nit rather than a blocker.
There was a problem hiding this comment.
Thanks Anton — agreed, this is a clean simplification (break instead of sleeping out the deadline, and the backoffMs > 0 check does become dead code). Since you flagged it non-blocking and it's test-neutral, I'm intentionally leaving it as-is here to avoid re-spinning the ~45-min integration run and churning the approval — noting it as a small follow-up. Appreciate the careful read on the shared-deadline invariant.
anton-107
left a comment
There was a problem hiding this comment.
LGTM, approving.
I checked the parts most likely to go wrong here:
- The single shared deadline still holds across the new loop.
remaining()is a closure re-evaluated at eachretry({timeout: remaining()}), so 3 attempts plus 2 backoffs can't exceed the caller's bound. The worst case is unchanged from main, where one stuck-PENDINGpoll could already burn the full hour. - Dropping the reason-string classification is the right call, for the reason you give: matching cloud error prose only ever under-retries, and it fails silently when the wording drifts.
terminationReason()is a real improvement over the oldstate_message ?? termination_reason.??only skips null/undefined, so an empty-stringstate_messageused to produce an empty reason; now it falls through and both fields survive.verifyStarted(n)on every case pins the attempt count, which is the thing most likely to regress silently in this file.
One non-blocking nit inline, on the backoff cap sleeping out the remaining deadline. Good to fix, but don't hold the PR on it.
CI green on 4f7c6871, including an Integration run that exercised the new path on a real placement timeout.
Why
The e2e suite warms one shared cluster (
TEST_DEFAULT_CLUSTER_ID) per shard in the wdioonPreparehook, and it is cold-started on every nightly run. When the test region is capacity-tight, its node placement terminates (e.g.Timeout while placing nodes) and aborts the shard — even though a freshstart()usually succeeds. Because ~40 shards share the one cluster, a single placement blip fails a whole batch at once; this signature dominated recent red nightlies.What
startCluster(the warm-up helper) now retries the start instead of failing on the first terminal launch:start()on any post-start terminal state (TERMINATED/ERROR/UNKNOWN), bounded to 3 attempts with a jittered backoff, all within the existing single 60-minute deadline (so total wall-time stays bounded and ~40 shards don't re-start in lockstep).start()call itself and the cluster stays terminal (auth / bad request) — retrying an already-rejected call can't help.Test-harness only; no production extension code, settings, telemetry, or persisted state changes.
Testing
startCluster.test.ts(11 cases, ts-mockito + faked timers): covers already-RUNNING, stopped→RUNNING, the concurrent-start race (initial and on a retry), TERMINATING-wait, fail-fast on a rejectedstart(), transient-terminal-then-RUNNING, retry-to-max-then-surface-reason, deadline-exhausted-by-backoff, stuck-PENDINGtimeout surfaced without retry, and deadline-already-spent-on-entry.Timeout while placing nodesrecovered and completed instead of aborting warm-up.Reviewer notes
PENDINGcase (cloud never terminates placement). It is not fixable by retrying and belongs to a separate warm-cluster / instance-pool change in the test environment; such a poll timeout is surfaced as-is here, not retried.This pull request and its description were written by Isaac.