Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Do not unnecessarily load replaced packages into the pool #9619

Conversation

driskell
Copy link
Contributor

I'm seeing slow performance with composer and Drupal. I thought it may be related to the Pool Optimiser PR not having much impact on Drupal installations (see #9261 (comment)) but it might not be. To be specific, this is when performing updates. It heavily impacts dependabot.

To reproduce. Take the composer.json from https://github.com/drupal/recommended-project/blob/9.2.x/composer.json
Then install composer install --profile
Then install a module composer require drupal/media_entity_twitter:2.5.0 --profile
Modify composer.json for media_entity_twitter to ^2.0
Then update that module composer update -W drupal/media_entity_twitter --profile (goes to 2.6.0 currently.)

The timings I get are:

$ composer --version
Composer version 2.0.7 2020-11-13 17:31:06
$ composer install --profile
[31.7MiB/31.07s] Memory usage: 31.66MiB (peak: 228.37MiB), time: 31.07s
$ composer require drupal/media_entity_twitter:2.5.0 --profile
[19.1MiB/3.14s] Memory usage: 19.07MiB (peak: 20.42MiB), time: 3.14s
$ nano composer.json
$ composer update -W drupal/media_entity_twitter --profile
[46.9MiB/25.46s] Memory usage: 46.86MiB (peak: 203.91MiB), time: 25.46s

As you can see the update takes a long time. When using a composer version that outputs the network requests, you can see many many 404 like these:

[23.5MiB/4.08s] [404] https://packages.drupal.org/files/packages/8/p2/drupal/core-bridge.json
[23.5MiB/4.19s] [404] https://packages.drupal.org/files/packages/8/p2/drupal/core-front-matter.json
[23.5MiB/4.34s] [404] https://packages.drupal.org/files/packages/8/p2/drupal/core-diff.json
[23.5MiB/4.35s] [404] https://packages.drupal.org/files/packages/8/p2/drupal/core-php-storage.json
[23.6MiB/4.43s] [404] https://packages.drupal.org/files/packages/8/p2/drupal/core-discovery.json
[23.6MiB/4.52s] [404] https://packages.drupal.org/files/packages/8/p2/drupal/core-file-cache.json
[23.6MiB/4.58s] [404] https://packages.drupal.org/files/packages/8/p2/drupal/core-file-security.json
[23.6MiB/4.62s] [404] https://packages.drupal.org/files/packages/8/p2/drupal/core-filesystem.json
[23.6MiB/4.83s] [404] https://packages.drupal.org/files/packages/8/p2/drupal/core-http-foundation.json
[23.6MiB/4.87s] [404] https://packages.drupal.org/files/packages/8/p2/drupal/core-graph.json
[23.6MiB/4.95s] [404] https://packages.drupal.org/files/packages/8/p2/drupal/core-gettext.json
[23.6MiB/5.00s] [404] https://packages.drupal.org/files/packages/8/p2/drupal/core-proxy-builder.json
[23.7MiB/5.05s] [404] https://packages.drupal.org/files/packages/8/p2/drupal/core-render.json
[23.7MiB/5.06s] [404] https://packages.drupal.org/files/packages/8/p2/drupal/core-uuid.json
[23.7MiB/5.09s] [404] https://packages.drupal.org/files/packages/8/p2/drupal/core-plugin.json
[23.7MiB/5.29s] [404] https://packages.drupal.org/files/packages/8/p2/drupal/core-utility.json
[23.7MiB/5.29s] [404] https://packages.drupal.org/files/packages/8/p2/drupal/datetime.json
[23.7MiB/5.35s] [404] https://packages.drupal.org/files/packages/8/p2/drupal/core-transliteration.json

This goes on for a while.

Essentially the drupal/core-recommended depends on drupal/core package which has a large replaces list. This list contains the name of every core module. Whether or not it should is left for discussion elsewhere (there are reasons to do so.) This list of replaced packages for the most part do not exist - for example drupal/taxonomy is, and has always been, a core module (I think). When you run an update it sees that media_entity_twitter depends on drupal/core and thus unlocks it as a non-root dependency and marks it for load, but it then proceeds to load all the replacements - even though no other package ever requires them.

So it seems somewhat unnecessary to be loading the replaced packages. If there is indeed a require to one of them somewhere, it would make sense. Maybe I'm missing something.

With this PR the 404s disappear and the timings for the update becomes as expected:

$ composer update -W drupal/media_entity_twitter --profile
[29.7MiB/5.84s] Memory usage: 29.66MiB (peak: 250.26MiB), time: 5.84s

Savings of 20 seconds.

WDYT?

CC @Toflar after discussion in #9261 (Thanks!)

@@ -399,7 +399,6 @@ private function loadPackage(Request $request, PackageInterface $package, $propa
if (isset($this->loadedPackages[$replace], $this->skippedLoad[$replace])) {
if ($request->getUpdateAllowTransitiveRootDependencies() || !$this->isRootRequire($request, $this->skippedLoad[$replace])) {
$this->unlockPackage($request, $replace);
$this->markPackageNameForLoading($request, $replace, $link->getConstraint());
Copy link
Contributor

Choose a reason for hiding this comment

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

this means that this constraint won't be taken into account anymore if we then have a reason to load that package. Is this correct @naderman ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Absolutely need some insight on this. From what I can gather, this just means the REPLACED package isn't loaded. Which should be fine as there should still be a REQUIRE for it somewhere that causes it to load. Because of the skippedLoad check it only does the unlock if it was locked initially. And I think if a REQUIRE was hit, it would unlock it already.

Essentially I'm just trying to prevent composer loading packages that are never referenced anywhere except with a REPLACE. As with drupal/core-recommended the long list of replace pretty much don't exist. Ideally composer would only load a package referenced by REPLACE if it was also referenced by a REQUIRE. Since it loads everything in a REQUIRE anyway it means don't need to do anything for REPLACE. (I think!)

Copy link
Contributor

Choose a reason for hiding this comment

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

@driskell but if later (or ealier) we decide to load the package due to a require rule, the constraint of the replace rule is now totally ignored. That's what I want to confirm whether it is safe or no.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can try to do some tests. I did find after removing this line that tests continued to pass with an exception of a handful where I couldn't see any impact based on what they tested. It would be great to get a test added for the scenarios this could break.

For your example, I did think, that if earlier we hit a require then skippedLoad would be empty as it would've been unlocked and loaded per the transitive dependencies flags.

This is where requires is explored on loaded packages:

// if the required package is loaded as a locked package only and hasn't had its deps analyzed

            // if the required package is loaded as a locked package only and hasn't had its deps analyzed
            if (isset($this->skippedLoad[$require])) {
                // if we're doing a full update or this is a partial update with transitive deps and we're currently
                // looking at a package which needs to be updated we need to unlock the package we now know is a
                // dependency of another package which we are trying to update, and then attempt to load it again
                if ($propagateUpdate && $request->getUpdateAllowTransitiveDependencies()) {
                    if ($request->getUpdateAllowTransitiveRootDependencies() || !$this->isRootRequire($request, $this->skippedLoad[$require])) {
                        $this->unlockPackage($request, $require);
                        $this->markPackageNameForLoading($request, $require, $linkConstraint);
                  } elseif (!isset($this->updateAllowWarned[$this->skippedLoad[$require]])) {
                        $this->updateAllowWarned[$this->skippedLoad[$require]] = true;
                        $this->io->writeError('<warning>Dependency "'.$this->skippedLoad[$require].'" is also a root requirement. Package has not been listed as an update argument, so keeping locked at old version. Use --with-all-dependencies (-W) to include root dependencies.</warning>');
                    }
                }
            } else {
                $this->markPackageNameForLoading($request, $require, $linkConstraint);
            }
        }

The subsequent unlock would've unset the skippedLoad, and thus the code in this PR would never run anyway, thus the constraint from the replace does not trigger any further package loads. Which makes perfect sense as a conflict: * should not load all package versions - it's the require that matters.

For the second scenario of later hitting the require - it was already unlocked by the code handling replace and skippedLoad is now empty - it then goes into the else above and just marks it for loading.

For the third scenario I'm thinking of now, where it hits the replace first, unlocks, then never sees a require, it would just not load it, and wouldn't need to as nothing requires what it is replacing.

I can try dig through existing tests see if the above are tested, or potentially maybe it is worth adding some new ones mentioning require/replace interactions explicitly and checking the above. That would at least check the above which is honestly just thoughts at this point that could still be missing something.

Copy link
Member

Choose a reason for hiding this comment

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

I agree with what this is trying to do, but I do think this may cause bugs, so I'll try and come up with a test case for a scenario where this causes a problem so we can figure out a way to address that.

Copy link
Member

Choose a reason for hiding this comment

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

I didn't completely follow but I'd say yes please open a PR for the change in load-replaced-package-if-replacer-dropped, if we can get that merged first maybe it'll help simplify this PR.

Copy link
Contributor Author

@driskell driskell Dec 31, 2021

Choose a reason for hiding this comment

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

I opened a PR for that branch with the test and what I think was the fix. #10410

So what's remaining for this PR I guess is to find a test for the below code or see what it's trying to do, as deleting it entirely (lines 468-489) currently still passes all tests, along with greatly improving the performance for Drupal site module updates due to preventing 404s when trying to load replaced packages that don't exist.

// if we're doing a partial update with deps we also need to unlock packages which are being replaced in case
// they are currently locked and thus prevent this updateable package from being installable/updateable
if ($propagateUpdate && $request->getUpdateAllowTransitiveDependencies()) {
foreach ($package->getReplaces() as $link) {
$replace = $link->getTarget();
if (isset($this->loadedPackages[$replace], $this->skippedLoad[$replace])) {
$skippedRootRequires = $this->getSkippedRootRequires($request, $replace);
if ($request->getUpdateAllowTransitiveRootDependencies() || !$skippedRootRequires) {
$this->unlockPackage($request, $repositories, $replace);
$this->markPackageNameForLoading($request, $replace, $link->getConstraint());
} else {
foreach ($skippedRootRequires as $rootRequire) {
if (!isset($this->updateAllowWarned[$rootRequire])) {
$this->updateAllowWarned[$rootRequire] = true;
$this->io->writeError('<warning>Dependency '.$rootRequire.' is also a root requirement. Package has not been listed as an update argument, so keeping locked at old version. Use --with-all-dependencies (-W) to include root dependencies.</warning>');
}
}
}
}
}
}

(NB: Happy New Year all 😄)

Copy link
Contributor

Choose a reason for hiding this comment

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

If you rebase on main you'll notice that you cannot remove this code - it's been covered with some tests in the meantime :)

Copy link
Member

Choose a reason for hiding this comment

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

And to add to that, the opposite case may be interesting too. Should upgrading a package to a version which now replaces a different locked package not result in that package getting unlocked/loaded and potentially removed? But I suppose the unlock call remains so that may still work as desired?

This is already covered in https://github.com/composer/composer/blob/main/tests/Composer/Test/Fixtures/installer/update-allow-list-with-dependencies-require-new-replace.test

Copy link
Member

Choose a reason for hiding this comment

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

I think there may be another bug relating to multiple replacers replacing different versions of the same package and doing a partial update of it, but that's such an extrem edge case, and definitely already problematic in main, that I don't think it's worthwhile looking into now and further delaying this work here.

@naderman naderman self-assigned this Jan 19, 2021
@driskell
Copy link
Contributor Author

Hi @naderman @Seldaek
Do you have any other questions about the change? Is it worth rebasing time check any new tests?
Would be great to work out what might be able to add confidence to this as I appreciate it’s quite deep in the critical areas.

Fixes Drupal drupal/core performance issue due to large number of replace declarations
@Seldaek Seldaek force-pushed the avoid-unnecessary-loading-of-replaced-packages branch from 818c24d to 60bd9b0 Compare November 28, 2021 16:00
@stof
Copy link
Contributor

stof commented Dec 22, 2021

replaces and provides should indeed not mark things for loading if they get unlocked IMO, as in a normal pool without any locking done, those don't trigger loading of metadata AFAIK.

@naderman
Copy link
Member

@stof see #9619 (comment) ?

@driskell
Copy link
Contributor Author

driskell commented Jul 7, 2022

@stof @naderman Regarding comment #9619 (comment)

I initially investigated this and found it was related to a different area of code: #9619 (comment)

As per comment #9619 (comment) I raised PR #10410 with that test and also what I think is the fix for the test.

So I'm unsure if there's anything remaining outstanding except a closer scrutiny on the change in this PR, which as per the last comment above could be increased in scope to delete the entire section of code below without failing a single test. So I think the remaining questions are: Is this code that slows down composer needed? Is there a test that will fail without it?

// if we're doing a partial update with deps we also need to unlock packages which are being replaced in case
// they are currently locked and thus prevent this updateable package from being installable/updateable
if ($propagateUpdate && $request->getUpdateAllowTransitiveDependencies()) {
foreach ($package->getReplaces() as $link) {
$replace = $link->getTarget();
if (isset($this->loadedPackages[$replace], $this->skippedLoad[$replace])) {
$skippedRootRequires = $this->getSkippedRootRequires($request, $replace);
if ($request->getUpdateAllowTransitiveRootDependencies() || !$skippedRootRequires) {
$this->unlockPackage($request, $repositories, $replace);
$this->markPackageNameForLoading($request, $replace, $link->getConstraint());
} else {
foreach ($skippedRootRequires as $rootRequire) {
if (!isset($this->updateAllowWarned[$rootRequire])) {
$this->updateAllowWarned[$rootRequire] = true;
$this->io->writeError('<warning>Dependency '.$rootRequire.' is also a root requirement. Package has not been listed as an update argument, so keeping locked at old version. Use --with-all-dependencies (-W) to include root dependencies.</warning>');
}
}
}
}
}
}

@Seldaek Seldaek modified the milestones: 2.2, 2.4 Aug 31, 2022
@jeffwidman
Copy link
Contributor

jeffwidman commented Sep 14, 2022

👋 Hi from the Dependabot team.

I was digging into a few reports we've received of timeouts during composer updates, particularly Drupal and stumbled across this PR.

Given the last response from @driskell above seems to indicate this PR is pretty much ready, what else is needed to move this forward?

Is it just lack of maintainer time? (which as a fellow maintainer of several OSS projects, I realize is always in short supply!)

@naderman
Copy link
Member

Yeah that's all it is, I've had these on my list for a while and am really sorry I still haven't gotten to it ...

@Seldaek Seldaek modified the milestones: 2.4, Bugs Nov 24, 2022
@markdorison
Copy link

Hi all! We are still battling with the Dependabot timeouts on Drupal projects that we hope this may resolve. As Jeff mentioned, I completely understand your time is in short supply but I wanted to add a friendly bump to this. ❤️

@Toflar
Copy link
Contributor

Toflar commented May 2, 2023

I did some in-depth analysis of this PR (finally - sorry @driskell) and I'm fairly confident that the approach is actually correct! There's no point in marking the replace links as required for download (markPackageNameForLoading()).
Basically, a replace target is optional until some other package requires it. Because if not, then there's no point in loading something that is replaced anyway. So we can really just remove this one line 👍
There are only two tests that need adjustments (and actually prove that there are useless packages in the pool).
So once this PR is rebased, it should look like this:

Index: src/Composer/DependencyResolver/PoolBuilder.php
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/Composer/DependencyResolver/PoolBuilder.php b/src/Composer/DependencyResolver/PoolBuilder.php
--- a/src/Composer/DependencyResolver/PoolBuilder.php	(revision 11879ea737978fabb8127616e703e571ff71b184)
+++ b/src/Composer/DependencyResolver/PoolBuilder.php	(date 1683036654517)
@@ -483,7 +483,6 @@
 
                     if ($request->getUpdateAllowTransitiveRootDependencies() || !$skippedRootRequires) {
                         $this->unlockPackage($request, $repositories, $replace);
-                        $this->markPackageNameForLoading($request, $replace, $link->getConstraint());
                     } else {
                         foreach ($skippedRootRequires as $rootRequire) {
                             if (!isset($this->updateAllowWarned[$rootRequire])) {
Index: tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/multi-repo-replace-partial-update-all.test
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/multi-repo-replace-partial-update-all.test b/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/multi-repo-replace-partial-update-all.test
--- a/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/multi-repo-replace-partial-update-all.test	(revision 11879ea737978fabb8127616e703e571ff71b184)
+++ b/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/multi-repo-replace-partial-update-all.test	(date 1683024149715)
@@ -101,7 +101,6 @@
     "indirect/replacer-1.0.0.0",
     "replacer/package-1.2.0.0",
     "replacer/package-1.0.0.0",
-    "base/package-1.0.0.0",
     "shared/dep-1.0.0.0",
     "shared/dep-1.2.0.0"
 ]
@@ -112,6 +111,5 @@
     "indirect/replacer-1.0.0.0",
     "replacer/package-1.2.0.0",
     "replacer/package-1.0.0.0",
-    "base/package-1.0.0.0",
     "shared/dep-1.2.0.0"
 ]
Index: tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/partial-update-unfixing-with-replacers.test
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/partial-update-unfixing-with-replacers.test b/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/partial-update-unfixing-with-replacers.test
--- a/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/partial-update-unfixing-with-replacers.test	(revision 11879ea737978fabb8127616e703e571ff71b184)
+++ b/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder/partial-update-unfixing-with-replacers.test	(date 1683024343988)
@@ -46,9 +46,7 @@
     "root/req1-1.0.0.0",
     "root/req1-1.1.0.0",
     "replacer/pkg-1.0.0.0",
-    "replacer/pkg-1.1.0.0",
-    "replaced/pkg-1.2.3.0",
-    "replaced/pkg-1.2.4.0"
+    "replacer/pkg-1.1.0.0"
 ]
 
 --EXPECT-OPTIMIZED--
@@ -56,6 +54,5 @@
     "root/req3-1.0.0.0 (locked)",
     "dep/dep-2.3.5.0 (locked)",
     "root/req1-1.1.0.0",
-    "replacer/pkg-1.1.0.0",
-    "replaced/pkg-1.2.4.0"
+    "replacer/pkg-1.1.0.0"
 ]

@stof
Copy link
Contributor

stof commented May 2, 2023

@Toflar how does this analysis interact with #10410 ?

@Toflar
Copy link
Contributor

Toflar commented May 2, 2023

@stof actually, your comment made me think about "why do we need markPackageNameForLoading() in #10410 but here we want to get rid of it for replace packages?".
So I've incorporated both PRs in another approach in #11449.

I hope this makes it much more readable because all it is is basically not calling markPackageNameForLoading() for replace definitions but only load them if any other package requires one of them 😊 Should also be easier to understand from the diff - I hope.

@naderman
Copy link
Member

naderman commented May 2, 2023

@driskell can you confirm this is fixed by the merge of #11449 now?

@naderman
Copy link
Member

naderman commented May 2, 2023

Same question for @markdorison

@markdorison
Copy link

I can test this once the latest changes from #11449 make it into Dependabot! @jeffwidman may be able to help with that or at least let us know when it has been included. 🥳

@jeffwidman
Copy link
Contributor

jeffwidman commented May 3, 2023

Historically we've only used released composer versions to keep life simple, they get picked up by :dependabot: :
https://github.com/dependabot/dependabot-core/pulls?q=is%3Apr+author%3Aapp%2Fdependabot+%22composer%2Fcomposer%22+

If you want to test ahead of time, you could theoretically do it locally, but my guess is it'd be painful to wire that in since our whole build wiring assumes a released version, eg https://github.com/dependabot/dependabot-core/blob/36c7ccb07121ae9d574b264c8bc4b27cb4b65bf3/composer/Dockerfile#L3

I assume #11449 will land in composer 2.6 so once that drops we can pick it up/deploy it within a few hours.

@driskell
Copy link
Contributor Author

driskell commented May 4, 2023

I can confirm this is fixed by the changes in main! No more unexpected 404s.

Profile results of the OP from running on 2.5.5 (using Drupal fixed to 9.2.* as that's a branch with replaces still in place):

[20.4MiB/7.64s] Memory usage: 20.36MiB (peak: 79.86MiB), time: 7.64s
[20.4MiB/8.06s] Memory usage: 20.36MiB (peak: 79.86MiB), time: 8.06s
[20.4MiB/7.77s] Memory usage: 20.36MiB (peak: 79.86MiB), time: 7.77s

Using commit 595559f on main containing the changes:

[20.3MiB/3.99s] Memory usage: 20.28MiB (peak: 75.13MiB), time: 3.99s
[20.3MiB/3.77s] Memory usage: 20.28MiB (peak: 75.13MiB), time: 3.77s
[20.3MiB/3.69s] Memory usage: 20.28MiB (peak: 75.13MiB), time: 3.69s

And when modifying the CurlDownloader to log requests, all the unexpected 404s are gone.

I'll post in the Dependabot ticket some timings for Dependabot too. I tested 9.5.* too which no longer has replaces and it still benefits (and still has 404s on 2.5.5) and it is faster as well (though the unpatched version is already a couple of seconds faster, so the gains are a little less.)

@driskell driskell closed this May 4, 2023
@Toflar
Copy link
Contributor

Toflar commented May 4, 2023

🥳
by the way: you don't need to modify anything to log requests, it should be visible when running with -vvv 😊

@driskell
Copy link
Contributor Author

driskell commented May 4, 2023

🥳
by the way: you don't need to modify anything to log requests, it should be visible when running with -vvv 😊

Hahaha 😆 Yeh I don't know if I forgot about that or the output was too noisy and I was too lazy to grep but I'll definitely remember that 😊

@driskell driskell deleted the avoid-unnecessary-loading-of-replaced-packages branch May 4, 2023 11:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants