Skip to content

array_merge() accumulators in loops make cache tag cleaning, flat indexing and Analytics collection quadratic #41098

Description

@lbajsarowicz

Preconditions and environment

  • Magento version: 2.4-develop (verified at b977e369455)
  • PHP 8.5, no third-party modules involved — this is a property of the core code, not of a
    particular installation

Steps to reproduce

array_merge() allocates a new array and copies both operands. Merging into an accumulator
inside a loop therefore copies the whole partial result on every iteration, so the total work
grows with the square of the number of iterations while the output grows linearly:

$result = [];
foreach ($chunks as $chunk) {
    $result = array_merge($result, $chunk);   // copies everything collected so far
}

The equivalent single merge is array_merge(...$chunks).

To see the scale of it in the core code base:

  1. Check out 2.4-develop.
  2. Run the sniff that is supposed to catch this shape over the whole tree:
    vendor/bin/phpcs --standard=Magento2 --sniffs=Magento2.Performance.ForeachArrayMerge \
      --extensions=php --warning-severity=1 --ignore-annotations \
      app/code/Magento lib/internal/Magento setup/src
    
  3. Count the suppressions that hide the same finding in day-to-day runs:
    grep -rn "phpcs:ignore Magento2.Performance.ForeachArrayMerge" app/code lib/internal setup dev | wc -l
    

Expected result

Accumulating merges are collected and merged once, so the cost of building a list is linear in
the amount of data.

Actual result

  • 126 array_merge calls inside loop bodies are detected by the sniff across
    app/code/Magento, lib/internal/Magento and setup/src.
  • 77 of the findings are hidden behind // phpcs:ignore Magento2.Performance.ForeachArrayMerge
    annotations, so only 46 warnings actually surface in a normal run.
  • An AST pass over the same tree finds 52 genuine accumulators — loops where the merge
    target really does grow on every iteration. The rest are calls that copy a constant amount
    of data per iteration, or targets that are rebuilt from scratch inside the loop.

Most of the 52 are bounded by small counts (bundle options, validation rules, config nodes) and
are not worth touching. Some are bounded by merchant data, and there the cost is real. Measured
on this branch, median of 5 runs, PHP 8.5:

Location Loop runs once per before after
Framework\Cache\...\FilesystemTagAdapter::getIdsMatchingAnyTags() cache tag 33 271 ms @ 20 000 tags 4 185 ms
Framework\Cache\...\FilesystemTagAdapter::getAllIds() (via getIdsNotMatchingTags()) tag file 27 823 ms @ 20 000 tags 1 686 ms
Catalog\Helper\Product\Flat\Indexer::getFlatColumns() / ::getFlatIndexes() flat attribute 16.0 ms @ 2 000 attributes 0.06 ms
Analytics\Model\StoreConfigurationProvider::getReport() website / store view 77.4 ms @ 1 000 store views 0.25 ms

The first two sit on the cache invalidation path: Symfony::clean() calls
getIdsMatchingAnyTags() for CLEANING_MODE_MATCHING_ANY_TAG and getIdsNotMatchingTags()
for CLEANING_MODE_NOT_MATCHING_TAG, which is what happens whenever cache is cleaned by tag.

Additional information

Why the existing guard did not prevent this. Magento2.Performance.ForeachArrayMerge
(magento/magento-coding-standard) warns on every array_merge token inside a for or
foreach body, whether or not anything accumulates, and its register() returns only
T_FOREACH and T_FOR, so while and do/while bodies are never inspected. Two
consequences:

  • 53 of the 126 detections (42 %) cannot grow anything, so phpcs:ignore becomes the natural
    response — and once the annotation is there, a genuine finding on the same line is hidden too.
    All three fixes referenced below were sitting behind such an annotation.
  • Accumulators in while loops were never reported at all, for example
    Framework\View\Design\Fallback\Rule\Theme::getPatternDirs().

magento/magento-coding-standard#503 narrows the sniff to the accumulating shape and adds
while/do coverage: 126 raw detections → 74, one new true finding, no accumulating call lost.
Merging it is what keeps the remaining suppressions in this repository meaningful.

Fixes opened for the three measured cases, one per module:

Each replaces the accumulator with a collected list and a single array_merge(...), preserving
array order and key-override behaviour, and removes the suppression annotations that are no
longer needed.

Remaining accumulators outside those three are bounded by small counts on the paths I checked;
they are consistency cleanups rather than performance work, and are deliberately not part of
those pull requests.

Release note

Cache cleaning by tag, flat catalog structure building and Advanced Reporting configuration
collection no longer grow quadratically with the number of tags, attributes or store views.

Triage and priority

  • Severity: S3 - Affects performance, but does not break functionality

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: FrameworkComponent: Framework/CacheIssue: ConfirmedGate 3 Passed. Manual verification of the issue completed. Issue is confirmedPriority: P2A defect with this priority could have functionality issues which are not to expectations.Reported on 2.4.xIndicates original Magento version for the Issue report.Reproduced on 2.4.xThe issue has been reproduced on latest 2.4-develop branch

    Type

    No type

    Projects

    Status
    Ready for Development

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions