Skip to content

Do not fail a transient run on a throttled DescribeCluster - #107

Merged
Ian Streeter (istreeter) merged 2 commits into
developfrom
fix/describe-throttling
Aug 10, 2026
Merged

Ian Streeter (istreeter) merged 2 commits into
developfrom
fix/describe-throttling

Conversation

@istreeter

@istreeter Ian Streeter (istreeter) commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Jira ref: PDP-2824

Production runs were reported as failed after a single ThrottlingException. None had failed: clusters had come up, or terminated cleanly, and we exited anyway — in the launch cases leaving a cluster running its steps unwatched. Live testing since reproduced this exactly: the old binary abandoned a cluster that went on to reach ALL_STEPS_COMPLETED four minutes later.

The waiters swallow API errors and keep polling, so the throttled call was never a poll. It was the describe around one: the existence check before it, or the re-describe after it, since waiter.Wait discards its response and the status had to be fetched again. WaitForOutput hands back the response that satisfied the acceptor, so that second call is gone from the happy path entirely; what remains covers only a waiter that errored or timed out, and a TERMINATED that EMR has not yet attached a reason to.

Both transient waits lose their existence check as well. Their callers pass a jobflow ID RunJobFlow has just returned, so it guards against nothing, and the waiter's first poll is issued with no delay — it asks the same question and resolves the same terminal states through its own acceptors. Dropping it removes a duplicate call from the path where call volume is the problem, and turns a throttled first poll into something the waiter absorbs rather than something a retry budget has to outlast. Only down's wait keeps one, since only down takes a cluster ID from the operator.

Removing the job phase's check also closes a second false failure, one no throttling was needed to reach. That check returned an already-TERMINATED cluster with a nil error whether or not EMR had attached its reason yet, and a reasonless TERMINATED reaches the caller's "cannot be confirmed complete" branch. The waiter's output is now only trusted when the reason is present, and recovered by a retried describe when it is not — and a status recovered that way never downgrades one the launch already classified, since a bootstrap failure often presents as plain TERMINATED and losing that code loses the relaunch.

A launch that cannot be read is left alone rather than terminated: not observing RUNNING says nothing about whether the cluster reached it, and killing one mid-step leaves half-written output, which is worse than an unwatched cluster that finishes and self-terminates. Both paths that give up without a status say which cluster they gave up on — and a wait that did see the cluster terminate hands that status back, so nobody is sent after a cluster that has already gone.

Waiting is bounded at both ends by what could still change the answer. A throttle, a dropped connection, a timeout or a 5xx gets the whole wait; an answer AWS actually gave us — a role without DescribeCluster, an ID that does not exist — ends it after three polls rather than holding the waiter for its full bound. Waiters warn after five consecutive failed polls and every five after that, counted in polls so the threshold means the same on a 30-90s launch wait as on a 60-300s job one. Six hours of unbroken blindness ends the wait outright, so the job phase cannot spend its fortnight-long bound on a cluster that finished long ago.

The same line decides how long a call outside the waiters retries. Failures that might clear are waited out for minutes, because these calls are how a run learns whether it succeeded and each is issued while a cluster is already up or once it has gone. The rest get the few seconds they always had — enough that EMR briefly not recognising a jobflow ID it just issued is survivable, while a bad --emr-cluster is still reported in seconds. That covers ListSteps and DescribeStep, which sit on the transient success path and could equally turn a completed run into a failed one, and TerminateJobFlows, which is idempotent and whose throttling would otherwise fail down in three seconds.

To reduce the pressure in the first place, waiters poll at 30-90s while a cluster launches and 60-300s during a transient job, rather than a fixed 30s: smithy only jitters when the bounds differ, so every runner had been polling on the same beat against a budget measured at ~18 requests of burst and ~0.8/sec sustained for the whole account. That reduction, not the retries, is what removes load, and the note by the budgets now points at the step-status loop as the larger remaining source.

Both EMR clients move to adaptive retry mode, which damps a single process's own burst rather than coordinating anything. Neither raises its attempt count, which is passed per call instead, and only on calls that are safe to repeat: RunJobFlow and AddJobFlowSteps share those clients and no EMR write has an idempotency token, so retrying one whose response was merely lost would launch a second cluster or submit the playbook's steps twice.

Three fixes to what up tells the operator. A successful launch no longer logs at error level, nor fails when the waiter resolves on RUNNING rather than WAITING. A bootstrap failure that is about to be retried is a warning, matching the transient path, and no longer sleeps before the attempt that gives up. And an up that fails after launching names the cluster it left behind — it sets KeepJobFlowAliveWhenNoSteps, so that cluster will not terminate itself, and its ID reached the operator nowhere else.

Also drops fetchStateChangeReason, which has had no callers since before e9939d3 and was the last unretried DescribeCluster in the file.

@istreeter
Ian Streeter (istreeter) force-pushed the fix/describe-throttling branch 3 times, most recently from 3fee8f9 to 5c43a05 Compare August 7, 2026 13:27
@istreeter

Ian Streeter (istreeter) commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor Author

Live testing against real EMR

Tested against real EMR in the sandbox account (719197435995, eu-central-1), this branch compared against e9939d3 (the revision in production). 48 clusters created, 0 leaked.

Common setup

Master-only clusters, one m5.xlarge, EMR 6.15.0, private subnet with a NAT route, EMR_DefaultRole / EMR_EC2_DefaultRole, credentials "default" so the profile's chain is used. Every cluster tagged so strays could be found regardless of how a test ended. Three playbooks: trivial (echo, ~2s), medium (sleep 300), and marker (writes an S3 object every 20s for 20 minutes, so a cluster's progress is externally visible).

The playbook choice matters more than it looks. With a 2-second step the launch waiter never observes RUNNING — the cluster is already terminating by the next poll — so short jobs take the settle branch and never exercise the job-phase waiter at all. Both need testing, and they are different code.

Six techniques do all the work below:

  1. Induce throttling. A separate Go program issues DescribeCluster from N goroutines with SDK retries disabled. DescribeCluster is throttled per account and region, so this starves any dataflow-runner in the region — no cooperation from the binary under test needed.
  2. Fault injection. A wrapper around the EMRAPI interface fails DescribeCluster over a configurable window — either a genuine ThrottlingException or a dial-time *net.OpError, which is what a network partition looks like to the SDK — and passes everything else through. Reaches states that induced throttling can't reliably produce.
  3. Call logging. The same wrapper logs every EMR operation — this is how "no TerminateJobFlows was issued" is asserted rather than assumed.
  4. Shortened bounds. clusterWaitMaxDuration and maxBlindDuration patched down from 60 minutes and 6 hours, so long paths are observable in minutes.
  5. SDK middleware census. Counts every DescribeCluster an unmodified run makes, with timestamps.
  6. A deliberately under-permissioned IAM user — Allow: * with an explicit Deny on elasticmapreduce:DescribeCluster, used as static credentials. Tests a real misconfiguration against the error EMR actually returns.

Binaries were built from a given git ref with an optional patch applied, so old and new could be run side by side under identical conditions.


1. What is the account's actual DescribeCluster quota?

Run: technique 1 with 4 workers for 30s, counting served vs throttled per second.

Result: burst bucket ~18 requests, sustained refill ~0.8/sec (21 served in the 27s after the burst drained).

Why it matters: e9939d3 set MinDelay == MaxDelay, and smithy only jitters when they differ — so every runner polled on the same 30s beat. A cohort of more than ~18 runners started together drains the whole bucket in one instant, every 30 seconds. That is the failure mechanism, and it is a threshold rather than a vague "too much load".

2. Does the reported failure reproduce, and what did it cost?

Run: start the throttle load, then run-transient on the production binary with the trivial playbook.

Assert: exit 1 with the ThrottlingException signature from the reported logs. Then — separately, after stopping the load — describe-cluster on the jobflow ID it abandoned.

Result: exit 1 after 22s, matching the reported logs line for line. The abandoned cluster reached TERMINATED / ALL_STEPS_COMPLETED four and a half minutes later. So the failure was false and left a cluster running the playbook unwatched.

Same test, this branch: exit 0, ALL_STEPS_COMPLETED, with the budget held at 100% saturation for the entire run. Repeated on both the trivial and medium playbooks.

3. Neither wait has an existence check any more — what did that cost, and what did it buy?

Run: technique 5, unmodified binary, no throttling, on both the medium playbook (job-phase waiter) and the trivial playbook (settle branch).

Assert: the number of DescribeCluster calls at the launch/job phase boundary, and whether a recovery describe fires after either waiter resolves.

Result: the boundary is now two calls — the launch waiter resolving, then the job waiter's zero-delay first poll — where it was three, because the pre-flight sat between them asking the identical question a millisecond apart. Totals are 10 for an 11-minute run and 8 for a short one, against ~25 under e9939d3. Absolute totals vary with provisioning time, so the boundary shape is the thing to compare.

No recovery describe fired on either path, which answers the question that made the removal safe: EMR does attach StateChangeReason to the response satisfying the terminated waiter, so the saving does not quietly evaporate into a second call.

Worth noting this removal also closes a correctness hole, not just a throttling one. The old pre-flight did case ClusterStateTerminated: return status, nil — returning whether or not a reason was attached. A reasonless TERMINATED then reached transient.go's default: branch and became "no state change reason, so the run cannot be confirmed complete": a false failure on a successful run, needing no throttling at all. The new path requires the reason and otherwise recovers it with a retried describe.

4. When a launch cannot be read at all, is the cluster terminated?

This is the branch that previously killed a cluster mid-step. It is now only reachable if every describe fails for the whole wait, so induced throttling won't get you there.

Run: techniques 2+3+4 — fault DescribeCluster permanently, bound cut to 4-15 minutes, marker playbook. Let the cluster run on after the process exits.

Assert: (a) no TerminateJobFlows in the call log; (b) a WARN naming the cluster; (c) exit 1 carrying both the waiter timeout and the describe error; (d) the cluster afterwards.

Result: all four. The abandoned cluster wrote 60/60 markers and self-terminated ALL_STEPS_COMPLETED — transient clusters carry KeepJobFlowAliveWhenNoSteps=false, so leaving it alone yields complete output. The marker playbook is what makes this checkable: it shows exactly how much real work the cluster did.

5. The blind-poll heartbeat

Three questions, three configurations.

Cadence — fault permanently, bound 15 minutes. Assert: a warning every five consecutive failing polls. Observed at polls 5, 10 and 15, the first at 3m58s. (Read elapsed figures in monotonic terms — a suspended host makes wall-clock timestamps diverge, since Go measures both the waiter bound and the blind duration monotonically.)

Recovery, and that the wrapper is harmless — fault for a fixed 7-minute window, then pass through, medium playbook. Assert: a WARN, then exactly one info recovery line, then the run completes normally. That last clause is the real test: the heartbeat wraps the waiter's Retryable hook, so if it disturbed the inner verdict the waiter would never resolve. It resolved on the same poll that recovered, and the run exited 0.

How much it fires on runs that succeed — real induced throttling, no fault injection, medium playbook, 13,493 throttled calls during the run. Exit 0 with no job-phase warnings at all, against two plus a recovery when the threshold was wall-clock rather than poll-counted. One launch-phase warning did fire, at five consecutive blind polls — that is ~4 minutes of unbroken blindness and is the signal the heartbeat exists for, so the claim is "the job phase no longer warns about itself", not "a saturated run is silent".

6. When does a wait give up rather than keep polling?

The line is "AWS answered and said no" versus "we could not get an answer". The first is hopeless and aborts after three consecutive polls; the second gets the whole wait. Two bounds and a before/after.

AWS answered and said no. Run: technique 6 — a role with RunJobFlow but not DescribeCluster, an ordinary first-deploy misconfiguration. Assert: it fails in a minute or two rather than sitting in the waiter for an hour, names the underlying error, and does not terminate the cluster.

22:23:09  WARN  j-18NQG5D42XPR0 could not be observed and is being left to run unwatched
22:23:09  ERROR ... could not be described on 3 consecutive polls over 1m18s ... AccessDeniedException

Exit 1 after 92s against an hour before, with the real AccessDeniedException attached and the recovery describe correctly on the short budget.

We could not get an answer. Run: the same harness faulting with *net.OpError{Op: "dial"} instead, on this branch and on its immediate predecessor, so the difference is visible. Assert: the wait continues rather than aborting.

predecessor this branch
outcome abandoned the run at 5 polls / 3m41s no give-up, ran the full 15-minute bound
recovery describe budget 3 attempts (short) 6 attempts (long)

A three-and-a-half-minute network partition used to produce the same false failure this PR exists to remove, reached through a different error code. It no longer does, and the read budget moved with the wait.

Unbroken blindness beyond maxBlindDuration. Run: the bound scaled from 6 hours to 6 minutes, with a throttle fault so the rule above cannot fire first. It gave up at 7m10s on 9 consecutive polls, then the recovery describe spent its own budget on top. So the effective figure at production settings is nearer 6h15m than 6h: the bound is only evaluated when a poll happens, and the recovery describe follows it.

7. Bootstrap-failure retry, both paths

Run: a bootstrap action that always exit 1, on run-transient and on up.

Assert: three distinct jobflow IDs (from list-clusters, not our own logs), BOOTSTRAP_FAILURE on each, exit 1, and the retry logged at WARN not ERROR.

Result: all four, both paths. Re-run after the job-phase pre-flight was removed, since TERMINATED_WITH_ERRORS is a failure acceptor on the terminated waiter — so the bootstrap path now reaches its reason through the waiter and a recovery describe rather than the short-circuit that used to catch it. Also asserted as a negative: none of the bootstrap-failed clusters produced the "may still be running" leak warning, since they are terminal.

8. Does up name a persistent cluster it leaves behind?

up launches with KeepJobFlowAliveWhenNoSteps=true, so a cluster it fails to hand back never self-terminates. Two paths reach that.

Blind: fault permanently, bound 8 minutes. Launch timeout: no fault, bound cut to 2 minutes so the waiter gives up while the cluster is still STARTING.

Assert: the error names the jobflow ID; then confirm out-of-band that the cluster is WAITING with AutoTerminate: false.

Result: both paths name it, both orphans confirmed non-self-terminating and cleaned up by hand.

9. Persistent path, run latency, and fail-fast

up / run / down under sustained throttling, six-step playbook: all exit 0.

down under saturation exits 0, riding out three throttles on the terminate path's budget — it used to fail in three seconds, the same bug in another command.

run timing was measured specifically, because adaptive retry mode's limiter sleeps before sending once it has learned throttling, and run polls every 15s with one DescribeStep per step — so it could compound. It did not: 190s under saturation against a 209s baseline, i.e. no slower.

Successful up logs its state change reason at info, not error, and returns the jobflow ID.

Bad --emr-cluster: fails in 6s, unchanged. Real EMR returns InvalidRequestException for both malformed and well-formed-but-absent IDs, and the SDK does not classify it as a throttle — so the long throttle budget does not catch a hopeless answer.


Behaviour changes reviewers should weigh

  1. Detection latency rises — measured against clusters' own end times: 10s → 65s unthrottled on a short run, and minutes when the budget is saturated.
  2. A launch gives up after ~63 minutes when EMR is throttling and never clears, against a few seconds before. No longer silent, and a recoverable outage now resolves into a successful run — but during a regional incident processes hold their slot for an hour rather than failing fast. Worth checking against whatever supervises these runs.
  3. The job phase is bounded at 6 hours of unbroken blindness (maxBlindDuration), inside transientMaxWaitDuration's 14 days. Effectively ~6h15m, per test 6.
  4. A wait now aborts after three consecutive failures that AWS answered. Good — it turns an hour-long hang on a misconfigured role into a 92-second failure. But it makes the retryable/not-retryable classification load-bearing for liveness, where previously it only chose a retry budget: an error misclassified as hopeless would abort a wait rather than merely get less patience. Nothing misclassified across 22,612 real throttles, and the connection-error case is covered in test 6.
  5. The post-waiter recovery describe is where a completed run could still be reported as failed. On a fully saturated run it used 4 of its 6 attempts before getting through. It succeeded, but that is the narrowest margin measured anywhere in this branch, and it is where to look if throttling ever costs a run again.

Not covered

  • run's step polling is untouched — 15s polls, one DescribeStep per step, so six steps is 0.4 calls/sec against the ~0.8/sec measured in test 1. Half the account's budget from one process, and 20× a transient cluster poll after this change. ListSteps already returns each step's status. Needs production CloudTrail to know whether it is implicated.
  • Fleet scale. All tests were one runner against a saturated budget, not many runners sharing one. By arithmetic the launch phase saturates at ~48 concurrent launches rather than ~18, and decorrelated rather than in lockstep — calculation, not measurement.
  • The specific production clusters from the incident, and what else calls EMR in that account: both need production credentials.

@istreeter
Ian Streeter (istreeter) force-pushed the fix/describe-throttling branch 5 times, most recently from 5418da6 to 11b7820 Compare August 7, 2026 22:39
Production runs were reported as failed after a single ThrottlingException.
None had failed: clusters had come up, or terminated cleanly, and we exited
anyway — in the launch cases leaving a cluster running its steps unwatched.
Live testing since reproduced this exactly: the old binary abandoned a cluster
that went on to reach ALL_STEPS_COMPLETED four minutes later.

The waiters swallow API errors and keep polling, so the throttled call was never
a poll. It was the describe around one: the existence check before it, or the
re-describe after it, since waiter.Wait discards its response and the status had
to be fetched again. WaitForOutput hands back the response that satisfied the
acceptor, so that second call is gone from the happy path entirely; what remains
covers only a waiter that errored or timed out, and a TERMINATED that EMR has
not yet attached a reason to.

Both transient waits lose their existence check as well. Their callers pass a
jobflow ID RunJobFlow has just returned, so it guards against nothing, and the
waiter's first poll is issued with no delay — it asks the same question and
resolves the same terminal states through its own acceptors. Dropping it removes
a duplicate call from the path where call volume is the problem, and turns a
throttled first poll into something the waiter absorbs rather than something a
retry budget has to outlast. Only `down`'s wait keeps one, since only `down`
takes a cluster ID from the operator.

Removing the job phase's check also closes a second false failure, one no
throttling was needed to reach. That check returned an already-TERMINATED
cluster with a nil error whether or not EMR had attached its reason yet, and a
reasonless TERMINATED reaches the caller's "cannot be confirmed complete"
branch. The waiter's output is now only trusted when the reason is present, and
recovered by a retried describe when it is not — and a status recovered that way
never downgrades one the launch already classified, since a bootstrap failure
often presents as plain TERMINATED and losing that code loses the relaunch.

A launch that cannot be read is left alone rather than terminated: not observing
RUNNING says nothing about whether the cluster reached it, and killing one
mid-step leaves half-written output, which is worse than an unwatched cluster
that finishes and self-terminates. Both paths that give up without a status say
which cluster they gave up on — and a wait that did see the cluster terminate
hands that status back, so nobody is sent after a cluster that has already gone.

Waiting is bounded at both ends by what could still change the answer. A
throttle, a dropped connection, a timeout or a 5xx gets the whole wait; an
answer AWS actually gave us — a role without DescribeCluster, an ID that does
not exist — ends it after three polls rather than holding the waiter for its
full bound. Waiters warn after five consecutive failed polls and every five
after that, counted in polls so the threshold means the same on a 30-90s launch
wait as on a 60-300s job one. Six hours of unbroken blindness ends the wait
outright, so the job phase cannot spend its fortnight-long bound on a cluster
that finished long ago.

The same line decides how long a call outside the waiters retries. Failures that
might clear are waited out for minutes, because these calls are how a run learns
whether it succeeded and each is issued while a cluster is already up or once it
has gone. The rest get the few seconds they always had — enough that EMR briefly
not recognising a jobflow ID it just issued is survivable, while a bad
--emr-cluster is still reported in seconds. That covers ListSteps and
DescribeStep, which sit on the transient success path and could equally turn a
completed run into a failed one, and TerminateJobFlows, which is idempotent and
whose throttling would otherwise fail `down` in three seconds.

To reduce the pressure in the first place, waiters poll at 30-90s while a
cluster launches and 60-300s during a transient job, rather than a fixed 30s:
smithy only jitters when the bounds differ, so every runner had been polling on
the same beat against a budget measured at ~18 requests of burst and ~0.8/sec
sustained for the whole account. That reduction, not the retries, is what
removes load, and the note by the budgets now points at the step-status loop as
the larger remaining source.

Both EMR clients move to adaptive retry mode, which damps a single process's own
burst rather than coordinating anything. Neither raises its attempt count, which
is passed per call instead, and only on calls that are safe to repeat: RunJobFlow
and AddJobFlowSteps share those clients and no EMR write has an idempotency
token, so retrying one whose response was merely lost would launch a second
cluster or submit the playbook's steps twice.

Three fixes to what `up` tells the operator. A successful launch no longer logs
at error level, nor fails when the waiter resolves on RUNNING rather than
WAITING. A bootstrap failure that is about to be retried is a warning, matching
the transient path, and no longer sleeps before the attempt that gives up. And
an `up` that fails after launching names the cluster it left behind — it sets
KeepJobFlowAliveWhenNoSteps, so that cluster will not terminate itself, and its
ID reached the operator nowhere else.

Also drops fetchStateChangeReason, which has had no callers since before
e9939d3 and was the last unretried DescribeCluster in the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/emr_cluster.go Outdated
// the cluster becomes visible, without disturbing the decision itself.
//
// A waiter treats an API error as "not there yet" and polls again. That is what
// stops a throttled poll failing a run, and it is why a throttled launch phase is

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is version-sensitive. return inner(...) defers the verdict to the SDK's generated acceptor, polls again is only true while that acceptor returns (true, nil) on an API error.

In the current v1.47.0 the acceptor ends in return true, nil (error falls through → re-polls), but as of v1.47.5 it gained a top-level if err != nil { return false, err } so the first throttled poll ends the wait with the error, silently reverting exactly the failure this PR removes. None of the current tests exercise a waiter polling through an API error.

Could we add a test, in the spirit of TestSDKRetryAssumptions, asserting the pinned waiter's acceptor returns (true, nil) on an API error, so a bump crossing v1.47.5 fails the build?

@istreeter
Ian Streeter (istreeter) force-pushed the fix/describe-throttling branch 2 times, most recently from 17c8095 to a7041df Compare August 10, 2026 13:07
pollThroughBlindness now decides for itself that a failed poll means "ask again",
instead of deferring to the generated waiter acceptor — which returns (true, nil)
on an API error in service/emr v1.47.0 but (false, err) from v1.47.5 onward. The
acceptors are consulted only when there is a response to inspect. No behaviour
change on the pinned version.

Also reworks the unreadable-launch test to reach its branch via a cancelled
context, and corrects the blind-poll threshold comment, which implied a healthy
run never warns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@istreeter

Copy link
Copy Markdown
Contributor Author

Oguzhan Unlu (@oguzhanunlu) you're right about the change in behaviour after v1.47.4. Well noticed.

I've gone a bit further than the test you suggested. warnWhileBlind is now pollThroughBlindness, and it decides for itself that a failed poll means "ask again" — the acceptor is only consulted when there's a response to inspect. That's arguably where the split belonged anyway: which cluster states resolve a wait is the acceptor's business, whereas "an API error isn't an answer" is ours, and I'd borrowed it from an implementation detail instead of writing it down.

I didn't add the assertion you proposed, because once the decision moves it pins something we no longer depend on and would block the upgrade it's meant to enable. TestPollThroughBlindnessOwnsTheErrorDecision pins our side instead: a failed poll continues the wait without consulting the acceptor, and a response still gets the acceptor's verdict unaltered.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense!

@istreeter
Ian Streeter (istreeter) merged commit a8534ff into develop Aug 10, 2026
7 checks passed
@istreeter
Ian Streeter (istreeter) deleted the fix/describe-throttling branch August 10, 2026 15:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants