Skip to content

fix(conditions): pair each Fn::Equals once when building condition CNF - #4987

Merged
kddejong merged 2 commits into
aws-cloudformation:mainfrom
kddejong:fix/issue/4984
Nov 3, 2026
Merged

kddejong merged 2 commits into
aws-cloudformation:mainfrom
kddejong:fix/issue/4984

Conversation

@kddejong

@kddejong kddejong commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #4984

Problem

A condition's equals traverses into every condition it references, so the same Fn::Equals is reachable once per referrer. The mutual-exclusion loop added in #4805 paired an Fn::Equals against everything already seen on each of those visits, re-emitting exclusion clauses it had already emitted.

The duplicates are bit-identical clauses. Counting multiplicity in the unfixed CNF for the reporter's template:

total clauses in cnf.data: 53025
distinct clauses:            825
multiplicity histogram:   {1: 390, 121: 435}

390 clauses appear once — the base condition equivalences, untouched by this PR. The other 435 are the exclusion pairs, each stored 121 times. 390 + 435×121 = 53,025 (the figure in the issue), and 390 + 435 = 825 (what this PR produces). The clause set is unchanged; only the duplicate copies go away.

The 121 is exact. The template has 30 Fn::Equals on AWS::Region and 30 Fn::Or groups that are sliding windows of 10 over them, so each comparison is reached 11 times — once through its own definition and once through each of the 10 groups containing it. The pairing loop appends every visit to its seen list, so a given pair of comparisons is emitted once per ordered (visit, visit) combination: 11 × 11 = 121, across C(30,2) = 435 distinct pairs.

Note the Equal objects are distinct Python objects but the same sympy variable, which is why only the clause count grows and not the variable count:

same object: False | same hash: True | same symbol: True

Fix

Skip an Fn::Equals whose hash has already been paired.

This is the guard the template-level engine has always had. Conditions._build_cnf in src/cfnlint/conditions/conditions.py opens its equivalent loop with:

def _build_equal_vars(c_equals: list[Equal]):
    for c_equal in c_equals:
        if c_equal.hash in equal_vars:
            continue

#4805 set out to bring the context-level engine in line with the template-level one, and it ported the constraint correctly — but not that guard. This PR adds it.

The skip is logically a no-op rather than a behaviour change: Equal.cnf is Symbol(self.hash), so a repeat visit hands sympy a clause it already holds. The constraint #4805 added is fully preserved — the resulting CNF is the pre-#4805 baseline plus exactly the C(n,2) pairs that PR intends.

Before #4805 the loop was gated to parameters with AllowedValues, so AWS::Region never entered it. #4805 correctly widened the constraint to every Ref; the duplicate pairing was pre-existing in the loop's shape and became reachable for the common case.

Second commit: the same guard on the AllowedValues loop

The loop below it walked the same conditions x equals product and re-visited each Fn::Equals once per referrer too, appending duplicate symbols into equals_cnfs. That one was already harmless, because sympy's Or is idempotent and collapsed them to a single clause:

Or with 330 duplicate args -> a | b

No blowup, but it is the same latent shape, so it gets the same treatment. Rather than two separate guards, the dedupe is hoisted into one distinct_equals map built before both loops — mirroring how the template-level engine keeps a single shared equal_vars dict across every _build_equal_vars call. The per-parameter Ref hash also moves out of the inner loop.

Behaviour is unchanged. Insertion order preserves the previous first-seen iteration order, so the NAND pairs are emitted in the same order, and the AllowedValues coverage check is unaffected because two distinct Fn::Equals on one Ref always carry different values (Equal.create_from_instance sorts the instance before hashing, so the operand order cannot make one comparison look like two).

Measurements

template clauses before clauses after wall before wall after
20 Fn::Equals / 20 groups 23,250 450 5.2 s 1.1 s
30 Fn::Equals / 30 groups 53,025 825 17.4 s 1.3 s

The before-counts match the figures in the issue exactly. The after-counts are the 1.58.0 baseline (260 / 390) plus the 190 / 435 pairs #4805 adds.

Verification

# reproduction, before and after
cfn-lint --regions us-east-1 -- repro.yaml

# clause count
python -c 'import cfnlint.decode; from cfnlint.context import create_context_for_template; \
from cfnlint.template import Template; p,_=cfnlint.decode.decode("repro.yaml"); \
print(len(create_context_for_template(Template(None,p,["us-east-1"])).conditions.cnf.data))'

python -m pytest test -q
pre-commit run --all-files

Output is unchanged. Every template under test/fixtures/templates/ (260 files) was linted through cfnlint.api.lint_all before and after, with PYTHONHASHSEED=0 pinned, and the collected matches are byte-identical. (Without pinning the seed, two runs of the same code differ, because E3019 interpolates a Python set into its message — unrelated to this change.)

Templates confirmed to stay silent:

  • test/fixtures/templates/good/conditions/e3014_exclusive_conditions.yaml — the E3014: two Fn::Equals on the same Ref are treated as exclusive only for a Parameter with AllowedValues #4799 regression fixture, and itself the referencing shape this change dedupes (UseGroup is an Fn::Not of an Fn::Or over UseIpv4/UseIpv6, so their Fn::Equals are visited twice). Still reports nothing.
  • All 127 tests in test/unit/module/context/conditions/, test/unit/module/conditions/ and test/unit/module/test_api.py.
  • Full suite: 3,348 passed. The 11 failures in test/integration/ and test_cli_args.py are pre-existing in my sandbox (they shell out to a cfn-lint binary that is not on PATH) and fail identically on an unmodified main.

Tests added

test_referenced_equals_paired_once asserts the clause count stays bounded on the reproduction shape, and that the #4805 exclusion still holds. It fails without the fix (assert 53025 < 1800).

test_inline_equals_mutual_exclusion covers an Fn::Equals reachable only by traversing another condition, so a naive revert of #4805 would not pass it. It guards the other direction: that deduping does not drop the constraint.

test_allowed_values_covered_through_reference covers the second loop: an AllowedValues parameter fully covered by Fn::Equals that are only reachable by traversal. It is what catches a dedupe keyed on the wrong field — keying on the Ref hash instead of the Fn::Equals hash leaves a value uncovered, drops the Or, and fails this test (along with six others).

Note on the second suggestion in the issue

The reporter also proposed memoizing Conditions.evolve. That is a separate optimization with its own correctness argument (it assumes CNF clause lists are never mutated), and it is not needed to close this regression — the dedupe alone restores baseline timing. Left out to keep this change minimal; happy to open it separately if wanted.

@codecov

codecov Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.65217% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 94.80%. Comparing base (a6eee92) to head (c7a9339).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
src/cfnlint/context/conditions/_conditions.py 95.65% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4987      +/-   ##
==========================================
- Coverage   94.80%   94.80%   -0.01%     
==========================================
  Files         441      441              
  Lines       16124    16128       +4     
  Branches     3130     3130              
==========================================
+ Hits        15287    15290       +3     
  Misses        463      463              
- Partials      374      375       +1     
Flag Coverage Δ
unittests 94.79% <95.65%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@kddejong
kddejong marked this pull request as ready for review November 3, 2026 22:03
A condition's `equals` includes the Fn::Equals of every condition it
references, so the mutual-exclusion loop added in aws-cloudformation#4805 visited the same
Fn::Equals once per referrer and re-emitted its exclusion clauses each
time. The clause count grew quadratically in the number of references
rather than in the number of distinct Fn::Equals.

Skipping an Fn::Equals whose hash has already been paired is logically a
no-op: `Equal.cnf` is `Symbol(hash)`, so a repeat visit produces a clause
sympy has already been given. On the template from aws-cloudformation#4984 (30 Fn::Equals on
AWS::Region, 30 Fn::Or groups of 10) this takes the CNF from 53,025 clauses
to 825 and the lint from 17.4s to 1.3s, with identical output.

Fixes aws-cloudformation#4984
…oops

The AllowedValues loop walked the same conditions x equals product and
re-visited each Fn::Equals once per referrer, appending duplicate symbols
into equals_cnfs. That one was harmless -- sympy's Or is idempotent, so the
duplicates collapsed to a single clause -- but it is the same latent shape
as the clause blowup, so give it the same guard.

Hoist the dedupe into one distinct_equals map built before both loops,
mirroring the shared equal_vars dict in the template-level engine
(cfnlint/conditions/conditions.py) rather than keeping a separate guard per
loop. Also hoist the per-parameter Ref hash out of the inner loop.

Behaviour is unchanged: insertion order preserves the previous first-seen
iteration order, so the NAND pairs are emitted in the same order, and the
AllowedValues coverage check is unaffected because two distinct Fn::Equals
on one Ref always carry different values.
@kddejong
kddejong merged commit dbb3da3 into aws-cloudformation:main Nov 3, 2026
24 checks passed
@kddejong
kddejong deleted the fix/issue/4984 branch November 3, 2026 22:34
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.

Lint time grows sharply in 1.59.0 when conditions reference many Fn::Equals on AWS::Region

1 participant