Skip to content

[Resolves #1601] Share the stack output cache between resolvers - #1600

Open
tiborhercz wants to merge 2 commits into
Sceptre:masterfrom
toqan-ai:stack-output-cache
Open

tiborhercz wants to merge 2 commits into
Sceptre:masterfrom
toqan-ai:stack-output-cache

Conversation

@tiborhercz

@tiborhercz tiborhercz commented Nov 11, 2026 •

Copy link
Copy Markdown

Resolves #1601.

#1263 asked for stack outputs to be fetched once per stack, and #1312 added
functools.lru_cache on StackOutputBase._get_stack_outputs for that. The
problem is that it's an instance method, so self is part of the cache key.
Resolver doesn't define __eq__/__hash__, and every !stack_output tag
is its own resolver instance, so the cache never hits across resolvers. Ten
stacks reading the same output still make ten DescribeStacks calls. On a big
project that's enough to get throttled by CloudFormation and spend most of
the launch in the retry back-off.

I added tests/test_resolvers/test_stack_output_cache.py, which builds ten
StackOutput resolvers the way the config reader does and resolves them.
On master it fails with:

FAILED test_stack_output_cache.py::test_ten_stacks_that_read_the_same_output_fetch_one_time
AssertionError: assert 10 == 1

The fix replaces the lru_cache with a class-level dict keyed on stack name,
profile, region and role, behind a lock so the first fetch of a stack only
happens once when a whole batch of threads starts at the same time. A failed
call isn't cached. clear_cache() resets it for tests.

PR Checklist

  • Wrote a good commit message & description [see guide below].
  • Commit message starts with [Resolve #issue-number].
  • Added/Updated unit tests.
  • Added/Updated integration tests (n/a, this is unit-testable)
  • All unit tests (poetry run tox) are passing.
  • Used the same coding conventions as the rest of the project.
  • The new code passes pre-commit validations (poetry run pre-commit run --all-files).
  • The PR relates to only one subject with a clear title.
    and description in grammatically correct, complete sentences.

Approver/Reviewer Checklist

  • Before merge squash related commits.

Other Information

Guide to writing a good commit

@zaro0508

Copy link
Copy Markdown
Contributor

Can you please tell us the motivation behind this change in the PRs description or commit message? or even better create an issue describing the problem and your proposed solution then link this PR to that issue.

@tiborhercz tiborhercz changed the title Share the stack output cache between resolvers [Resolves #1601] Share the stack output cache between resolvers Nov 12, 2026
@tiborhercz
tiborhercz force-pushed the stack-output-cache branch 2 times, most recently from 3daac04 to 43b335a Compare November 12, 2026 08:45
The lru_cache on StackOutputBase._get_stack_outputs is on an instance method, so self is part of the key. Every !stack_output tag is its own resolver, so the cache never hits across resolvers and N stacks that read one output make N DescribeStacks calls. Replace it with a class-level dict keyed on stack name, profile, region and role, behind a lock. A failed call is not cached. clear_cache() resets it for tests.
@tiborhercz

Copy link
Copy Markdown
Author

Can you please tell us the motivation behind this change in the PRs description or commit message? or even better create an issue describing the problem and your proposed solution then link this PR to that issue.

Thanks for the nudge, was still working on it. I opened #1601 with the problem and the proposed fix, and linked it from the title and the description.

Short version: the lru_cache from #1312 is on an instance method, so self is part of the key, and every !stack_output tag is its own resolver instance. Ten stacks reading the same output still make ten DescribeStacks calls. The new tests/test_resolvers/test_stack_output_cache.py shows it: on master it fails with assert 10 == 1.

I also squashed the branch to one commit in the [Resolves #1601] format.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The stack-output cache can mix outputs across accounts or regions, deadlock during resolver-backed role initialization, and retain stale outputs across plans, while its global lock also serializes unrelated requests.

4 open issues
What changed in this PR

This PR addresses #1601 by sharing stack-output results across resolver instances, reducing redundant CloudFormation requests that the earlier instance-keyed cache did not prevent.

Changes:

  • Replaces lru_cache with a shared dictionary protected by a lock.
  • Separates fetching from caching and adds clear_cache() for test isolation.
  • Adds regression tests for cross-resolver reuse and retrying failed fetches.
File Description
tests/​test_resolvers/​test_stack_output.py Resets the cache and tests shared results and failed-fetch retries.
tests/​test_resolvers/​test_stack_output_cache.py Tests repeated resolution and reuse across ten resolver instances.
sceptre/​resolvers/​stack_output.py Implements shared, synchronized output caching and cache clearing.

Review effort: Balanced

)

@functools.lru_cache(maxsize=4096)
_outputs_cache = {}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good point. SceptrePlan._execute now clears the cache before it runs, so each plan starts empty. Added a test for it.


@functools.lru_cache(maxsize=4096)
_outputs_cache = {}
_outputs_cache_lock = threading.Lock()

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, sceptre_role as !stack_output would deadlock here. The connection manager is now read before any lock and the fetch runs under a per-key RLock. Added a test that re-enters from inside a fetch.

Comment thread sceptre/resolvers/stack_output.py Outdated
:returns: A formatted version of the Stack outputs.
:rtype: dict
"""
key = (stack_name, profile, region, sceptre_role)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You're right, (None, None, None) means "use the stack defaults" in call(). The key now uses the profile/region/role of the stack's connection manager in that case, same rule as _determine_session_args. Added a test with two profiles reading the same external stack name.

Comment thread sceptre/resolvers/stack_output.py Outdated
Comment on lines +72 to +76
with self._outputs_cache_lock:
if key not in self._outputs_cache:
self._outputs_cache[key] = self._fetch_stack_outputs(
stack_name, profile, region, sceptre_role
)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Changed to a lock per key, the global one only guards the dicts now. Added tests: two stacks fetch in parallel, and a second caller of the same key waits for the running fetch and gets its result.

@tiborhercz

Copy link
Copy Markdown
Author

@zaro0508 Addressed the Copilot comments

@zaro0508

Copy link
Copy Markdown
Contributor

@tiborhercz can you please merge with master branch and push an update?

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.

stack_output cache is per resolver object, so Stacks that read the same output fetch it once each

4 participants