Skip to content

fix(aws/function): do not fetch the IAM role eagerly when role is passed - #6942

Open
peter-boompop wants to merge 1 commit into
anomalyco:devfrom
peter-boompop:fix/lazy-role-get
Open

fix(aws/function): do not fetch the IAM role eagerly when role is passed#6942
peter-boompop wants to merge 1 commit into
anomalyco:devfrom
peter-boompop:fix/lazy-role-get

Conversation

@peter-boompop

Copy link
Copy Markdown

Title: fix(aws/function): don't fetch the IAM role eagerly when role is passed


The problem

When role is passed to sst.aws.Function, createRole() immediately does:

if (args.role) {
  return iam.Role.get(`${name}Role`, output(args.role).apply(parseRoleArn).roleName, {}, { parent });
}

That's a Pulumi read op. Reads are never treated as unchanged — by design, a read exists to fetch
current truth — so it hits iam:GetRole on every sst dev / sst deploy, once per function.

But the fetched Role is never used for anything functional. The Lambda is wired up from the ARN that
was already passed in:

role: args.role ?? role.arn,   // args.role wins whenever the .get() happened

Its only consumer is the nodes.role getter. So each call is an IAM round-trip to populate a getter
that most apps never read.

This gets expensive for apps that share one role across many functions — which isn't exotic. It's
the only practical way to stay under the IAM "Roles per account" quota at scale: N functions × M
stages roles adds up fast, and one shared role collapses that to M. Doing the quota-friendly thing
currently costs N IAM reads per run.

Impact (measured)

On a real app: 3,576 resources, 440 sst.aws.Functions sharing a single execution role, 38 stages.
A true no-op sst dev (zero creates, zero updates):

before after
pulumi up wall time 367s 29s
read ops on aws:iam/role:Role 442 0
state checkpoint pushes 613 2
Lambda functions 492, all same 492, all same

~12.6×. The state pushes collapse as a side effect — each read resolving was forcing a checkpoint of
a 25MB state file.

The fix

Return a memoized thunk from createRole() so the .get() only runs if nodes.role is actually
read. Uses the existing lazy() helper, already imported in this file.

Behaviour is unchanged:

  • nodes.role returns the same Role as before, just fetched on demand.
  • The Lambda's role wiring is untouched (args.role already took precedence).
  • The default path — no role passed — still creates the role eagerly. That matters: role is
    consumed inside an apply in createFunction, so deferring the create path would register a
    resource inside an apply. Only the .get() is deferred.

Verification

  • bun run typecheck:platform passes.
  • platform vitest: 100 passed. 3 test files fail with ERR_TRACE_EVENTS_UNAVAILABLE on my
    machine, but they fail identically on unmodified dev — pre-existing/environmental, unrelated.
  • Exercised end-to-end on the 3,576-resource app above: no creates, no updates, all 492 Lambdas
    same. On the first run after the change, Pulumi emits a one-time discard wave retiring the now
    unused read-entries from state. discard is state-only — Pulumi never deletes resources it merely
    read — so no IAM role is touched. It doesn't recur.

Note on scope

The same pattern exists in a few other components — fargate.ts (taskRole, executionRole),
vpc.ts, cognito-identity-pool.ts. I've kept this PR to Function since that's where I have
measurements, but happy to extend it if you'd like.

…ssed

When `role` is set, the Lambda is wired up from that ARN directly, so the
`iam.Role.get()` result only ever backs the `nodes.role` getter. That lookup
is a Pulumi `read`, which is never treated as unchanged, so it costs an IAM
GetRole on every run — once per function.

Apps that share a single role across many functions therefore pay N reads for
one role. That pattern is the practical way to stay under the IAM roles-per-
account quota at scale. On a 3,576-resource app with 440 functions sharing one
role, a no-op `sst dev` spent 367s in `pulumi up`; deferring the lookup brings
it to 29s.

Make the lookup lazy so it only runs if `nodes.role` is read. The default path
(no `role` passed) still creates the role eagerly — it is consumed inside an
`apply` in createFunction and must not be deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@peter-boompop

Copy link
Copy Markdown
Author

@vimtor @thdxr please take a look when you have a chance

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.

1 participant