Repository navigation
fix(iam): retry ConcurrentModification, and make terminate idempotent - #651
Conversation
Two issues from one report (#648), both hit by workflow executors rather than humans. PRIMARY — concurrent launches died on IAM ConcurrentModification. Two `spawn task run` invocations ~60ms apart (a Nextflow executor fanning out two processes): one succeeded, the other failed at CreateInstanceProfile with ConcurrentModification: The previous tagging operation is still ongoing. Please wait for a while and perform the next tagging operation until it finishes. and took the whole DAG with it. Verified every claim in the report before touching anything. The call was already inside the retryIAM backoff from #64 — the gap was the PREDICATE. ConcurrentModification is neither isAlreadyExists (the resource may not exist yet) nor isThrottle (the request was accepted and rejected on conflict, not rate-limited), so retryIAM hit `if !isThrottle(err) { return err }` and gave up on the FIRST attempt without ever sleeping — the opposite of what IAM asks for. `grep -rn ConcurrentModification --include=*.go .` returned nothing. This is the COMMON concurrent-launch failure, not a rare one: every CreateRole/CreateInstanceProfile on this path passes Tags, and it is that implicit tagging operation which serialises. It is also first-run-only — once the profile exists, instanceProfileExists short-circuits and the window closes — which is what makes it look like a blip. Also routed SetupSporedIAMRole's three calls through retryIAM. They were hardening the same concern a second time by hand (CreateRole / CreateInstanceProfile / AddRoleToInstanceProfile, tolerating only EntityAlreadyExists / LimitExceeded by string match), so #64's fix never reached them — and both of those codes are already success to retryIAM via isAlreadyExists, so the behaviour is preserved rather than changed. A drift gate now fails if a Create*/AddRole call is added outside a retryIAM closure. SECONDARY — `spawn terminate` reported danger where there was none. The executor's cleanup ran terminate after the failed launch and reported "the instance may still be running and billing until its TTL" with output "no instance found with name: nf-03461ad6fc36". Nothing had leaked — the instance was never created — but that is the one sentence a user cannot ignore. terminate's goal is "this instance is not running", and THREE states already satisfy it: never existed, already terminated, already shutting down. All three exited 1. The report named the first; the other two are if anything more common, because on_complete=terminate means a successful task's instance is usually gone before an executor cleans up. All three now exit 0 with a clear message. BEHAVIOUR CHANGE: a script relying on a non-zero exit to detect a missing instance now sees success. Scoped deliberately. resolveInstance is shared by ~10 commands (connect, dns, config, extend, task, service, plugin) where a non-existent instance genuinely IS an error, so the resolver's semantics are untouched; only terminate opts in, via an ErrInstanceNotFound sentinel rather than error-string matching. A test asserts no other command starts swallowing it. The sentinel is a typed error rather than a %w wrap, because wrapping appended its text and produced "no instance found with name: x: instance not found". Every gate confirmed to fail when inverted: reverting the predicate, un-wrapping one SetupSporedIAMRole call, and making terminate error on already-terminated. The 7.5s full-backoff test is skipped under -short (which CI runs); the 1.5s one that proves the retry happens is not. Fixes #648
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…till fails CI's E2E negative matrix caught me widening the change too far, and the test was right. test/e2e/tier0_output_matrix_test.go:58 asserts `spawn terminate i-doesnotexist -y` exits non-zero. My first version made EVERY not-found resolution succeed, which broke it. Rather than relax the assertion, I kept it and narrowed the fix: - unknown NAME -> exit 0 (the case reported in #648) - already terminated -> exit 0 - already shutting down -> exit 0 - unknown instance ID -> still a non-zero error The distinction is principled, not test-driven convenience. An instance ID is opaque and AWS-assigned: you do not guess or reuse one, so an ID that resolves to nothing means the caller is referring to something that never existed in this account — overwhelmingly a typo. Exiting 0 there would let a user or a script believe they stopped a still-billing instance, and cost control is this project's existential concern. A NAME is the opposite: it is a handle the caller chose, and its absence after cleanup is the expected steady state. nf-spawn's reported case used a name (nf-03461ad6fc36). So ErrInstanceNotFound is now set only on the name path, and the ID path returns a plain error again. TestUnknownInstanceIDStillFails pins both halves and was confirmed to fail in both directions: making the ID path a notFoundError, and making the name path a plain error. Full e2e tier0 passes locally (125s). Also fixed a bug in my own test: it located the return statement with strings.LastIndex and panicked on -1. It now scans statements instead of doing index arithmetic.
CI caught me widening this too far — narrowed, assertion keptPushed 5b966e8. The test was right, so I kept it and narrowed the fix rather than relaxing the assertion:
The distinction is principled rather than test-driven convenience. An instance ID is opaque and AWS-assigned — you don't guess or reuse one — so an ID resolving to nothing means the caller is referring to something that never existed in this account, overwhelmingly a typo. Exiting 0 there would let a user or script believe they stopped a still-billing instance, and cost control is this project's existential concern. A name is the opposite: a handle the caller chose, whose absence after cleanup is the expected steady state. Your reported case used a name (
Full e2e tier0 now passes locally (125s) as well as the unit suite. Also fixed a bug in my own new test: it located the return statement with |
Fixes #648. Both halves of the report, each verified in code before implementing.
Primary: concurrent launches died on IAM
ConcurrentModificationYour diagnosis was exactly right, including the file:line. Confirmed:
grep -rn ConcurrentModification --include='*.go' .→ no matchesretryIAM(pkg/aws/iam.go:74) hitsif !isThrottle(err) { return err }and gives up on the first attempt, never sleepingisThrottleonly matchesThrottling/RequestLimitExceeded;isAlreadyExistsonlyEntityAlreadyExists/LimitExceeded— soConcurrentModificationfell through bothCreateOrGetInstanceProfiledoes passTags: c.buildIAMTags(config.Tags), which is what makes the implicit tagging operation serialiseFix is the predicate, as you suggested, keeping the existing 500 ms × attempt backoff.
AccessDeniedand friends still fail fast on attempt 1 rather than waiting out five sleeps — there's a test for that, since "retry more" is easy to overdo.The adjacent spot was worth more than the one-liner
You flagged
SetupSporedIAMRole(:1077) as the same concern implemented a second time. It is, and that's why #64's fix never reached it: three bare calls (CreateRole:1101,CreateInstanceProfile:1134,AddRoleToInstanceProfile:1145) tolerating onlyEntityAlreadyExists/LimitExceededby string match.All three now route through
retryIAM. Behaviour is preserved, not changed — both of those codes are already success toretryIAMviaisAlreadyExists, so the hand-rolled tolerance was doing exactly what the wrapper does. NoteCreateRoleandCreateInstanceProfilethere also passTags, so they were equally exposed.TestEveryTaggedIAMCreateIsRetriednow fails if anyCreateRole/CreateInstanceProfile/AddRoleToInstanceProfilesits outside aretryIAMclosure — so the next one inherits the fix rather than needing its own.Secondary: and it was worse than reported
You identified not-found. There are three states that already satisfy terminate's goal of "this instance is not running", and all three exited 1:
no instance found with name: …already terminatedalready shutting downThe latter two are if anything more common on your path:
on_complete: terminatemeans a successful task's instance is usually already gone by the time an executor cleans up. So the misleading "may still be running and billing" would fire on the happy path too.This is a behaviour change — a script relying on non-zero to detect a missing instance now sees success. Called out in the changelog. I think it's right: not-found is success for an idempotent delete, which is the convention the rest of the codebase follows (EventBridge
DeleteRulelikewise returns success for a missing rule).Scoped tightly.
resolveInstanceis shared by ~10 commands —connect,dns,config,extend,task,service,plugin— where a non-existent instance genuinely is an error. The resolver's semantics are untouched; onlyterminateopts in, through anErrInstanceNotFoundsentinel rather than string matching, andTestOnlyTerminateOptsIntoNotFoundSuccessfails if another command starts doing the same.The sentinel is a typed error rather than a
%wwrap: wrapping appended the sentinel's text and producedno instance found with name: x: instance not found. The type keeps the CLI message clean whileerrors.Isstill works.On your offer to move the message to nf-spawn: the wrapping sentence is theirs, but the non-zero exit that triggers it is spawn's, so fixing it here is the right half.
Verification
SetupSporedIAMRolecall, and making terminate error on already-terminated.gofmt,go vet, fullgo test -short ./...,make check-docs, andsemgrep scan --config=auto --error .(0 findings) run locally before pushing.-short, which is what CI runs; the 1.5 s test that proves the retry actually happens is not skipped.### Fixedgroup, for the third time this session.Not reproduced against real AWS
Forcing a genuine
ConcurrentModificationneeds two launches racing on a fresh profile name, and the window closes after the first success — so a real-AWS attempt would be a coin flip that proves nothing when it passes. The unit tests drive the predicate throughsmithy.GenericAPIErrorwith IAM's verbatim message instead. Happy to attempt a live race if you'd rather have that too.