Repository: chainguard-dev/apko
Pull request #1406 (2024-11-15 23:16:11)

Consider already selected packages during solve
    We ran into a very very slow solve. I can't tell if it was actually slow
    or infinite, but it didn't seem to be convering very quickly. I realized
    that we essentially recurse the entire graph for every constraint, even
    for things we've already solved. This was surprising, because I thought
    we handled that already, but it seems like we didn't.
    
    I've added an example "abseil-regression.yaml" that took over a second
    to solve (as of this writing) before this change and takes about 1ms
    after this change. There's an open PR in wolfi that timed out because
    the abseil dep graph got even more gnarly and took several hours, so
    there was something (at least) quadratic going on here, oops.
    
    I've deleted a test that was exercising a scenario where packages were
    disallowed from providing themselves because there is no code path where
    that was actually possible (all invocations had "true" hard-coded).
    
    I also ran this against a several thousand image configs and they all
    produced the same results before and after this change, so I am fairly
    confident in it.

jonjohnsonjr: e2e tests are failing because a lot of our examples use alpine repos, and currently `musl` on x86_64 is at `1.2.5-r6` whereas `musl` on aarch64 is at `1.2.5-r5`
dannf: I've tested this branch and it fixes the issue for me. Thanks! Do you mind referencing https://github.com/chainguard-dev/melange/issues/1651 in the commit log?

Patch:
diff --git a/examples/abseil-regression.yaml b/examples/abseil-regression.yaml
new file mode 100644
index 0000000..61b2430
--- /dev/null
+++ b/examples/abseil-regression.yaml
@@ -0,0 +1,13 @@
+# This was very slow once.
+contents:
+  keyring:
+    - https://packages.wolfi.dev/os/wolfi-signing.rsa.pub
+  repositories:
+    - https://packages.wolfi.dev/os
+
+  packages:
+    - abseil-cpp-dev
+    - pkgconf
+
+archs:
+- arm64
diff --git a/pkg/apk/apk/repo.go b/pkg/apk/apk/repo.go
index d1cc54c..95403a5 100644
--- a/pkg/apk/apk/repo.go
+++ b/pkg/apk/apk/repo.go
@@ -203,6 +203,9 @@ type PkgResolver struct {
 	indexes      []NamedIndex
 	nameMap      map[string][]*repositoryPackage
 	installIfMap map[string][]*repositoryPackage // contains any package that should be installed if the named package is installed
+
+	// Short-circuit providers we have already selected.
+	selected map[string]*RepositoryPackage
 }
 
 // Clone returns a copy of PkgResolver.
@@ -211,6 +214,7 @@ func (p *PkgResolver) Clone() *PkgResolver {
 		indexes:      p.indexes,
 		nameMap:      maps.Clone(p.nameMap),
 		installIfMap: maps.Clone(p.installIfMap),
+		selected:     map[string]*RepositoryPackage{},
 	}
 }
 
@@ -234,7 +238,8 @@ func newPkgResolver(ctx context.Context, indexes []NamedIndex) *PkgResolver {
 		installIfMap = map[string][]*repositoryPackage{}
 	)
 	p := &PkgResolver{
-		indexes: indexes,
+		indexes:  indexes,
+		selected: map[string]*RepositoryPackage{},
 	}
 
 	// create a map of every package by name and version to its RepositoryPackage
@@ -383,6 +388,35 @@ func (p *PkgResolver) disqualifyConflicts(pkg *RepositoryPackage, dq map[*Reposi
 	}
 }
 
+func (p *PkgResolver) pick(pkg *RepositoryPackage) error {
+	if conflict, ok := p.selected[pkg.Name]; ok {
+		// Trying to re-select the same thing is fine actually.
+		if conflict == pkg {
+			return nil
+		}
+
+		return fmt.Errorf("selecting package %s conflicts with %s on %q", pkg.Filename(), conflict.Filename(), pkg.Name)
+	}
+
+	p.selected[pkg.Name] = pkg
+
+	for _, prov := range pkg.Provides {
+		constraint := cachedResolvePackageNameVersionPin(prov)
+		if conflict, ok := p.selected[constraint.name]; ok {
+			return fmt.Errorf("selecting package %s conflicts with %s on %q", pkg.Filename(), conflict.Filename(), constraint.name)
+		}
+
+		// We don't care about virtuals, actually.
+		if constraint.version == "" {
+			continue
+		}
+
+		p.selected[constraint.name] = pkg
+	}
+
+	return nil
+}
+
 func (p *PkgResolver) disqualify(dq map[*RepositoryPackage]string, pkg *RepositoryPackage, reason string) {
 	dq[pkg] = reason
 
@@ -452,7 +486,7 @@ func (p *PkgResolver) constrain(constraints []string, dq map[*RepositoryPackage]
 // GetPackagesWithDependencies get all of the dependencies for the given packages based on the
 // indexes. Does not filter for installed already or not.
 func (p *PkgResolver) GetPackagesWithDependencies(ctx context.Context, packages []string, allArchs map[string][]NamedIndex) (toInstall []*RepositoryPackage, conflicts []string, err error) {
-	_, span := otel.Tracer("go-apk").Start(ctx, "GetPackageWithDependencies")
+	_, span := otel.Tracer("go-apk").Start(ctx, "GetPackagesWithDependencies")
 	defer span.End()
 
 	// Tracks all the packages we have disqualified and the reason we disqualified them.
@@ -494,10 +528,11 @@ func (p *PkgResolver) GetPackagesWithDependencies(ctx context.Context, packages
 
 	// now get the dependencies for each package
 	for _, pkgName := range packages {
-		pkg, deps, confs, err := p.GetPackageWithDependencies(pkgName, dependenciesMap, dq)
+		pkg, deps, confs, err := p.GetPackageWithDependencies(ctx, pkgName, dependenciesMap, dq)
 		if err != nil {
 			return toInstall, nil, &ConstraintError{pkgName, err}
 		}
+
 		for _, dep := range deps {
 			if _, ok := installTracked[dep.Name]; !ok {
 				toInstall = append(toInstall, dep)
@@ -527,7 +562,7 @@ func (p *PkgResolver) GetPackagesWithDependencies(ctx context.Context, packages
 // Requires the existing set because the logic for resolving dependencies between competing
 // options may depend on whether or not one already is installed.
 // Must not modify the existing map directly.
-func (p *PkgResolver) GetPackageWithDependencies(pkgName string, existing map[string]*RepositoryPackage, dq map[*RepositoryPackage]string) (*RepositoryPackage, []*RepositoryPackage, []string, error) {
+func (p *PkgResolver) GetPackageWithDependencies(ctx context.Context, pkgName string, existing map[string]*RepositoryPackage, dq map[*RepositoryPackage]string) (*RepositoryPackage, []*RepositoryPackage, []string, error) {
 	parents := make(map[string]bool)
 	localExisting := make(map[string]*RepositoryPackage, len(existing))
 	existingOrigins := map[string]bool{}
@@ -544,10 +579,11 @@ func (p *PkgResolver) GetPackageWithDependencies(pkgName string, existing map[st
 	}
 
 	pin := cachedResolvePackageNameVersionPin(pkgName).pin
-	deps, conflicts, err := p.getPackageDependencies(pkg, pin, true, parents, localExisting, existingOrigins, dq)
+	deps, conflicts, err := p.getPackageDependencies(ctx, pkg, pin, parents, localExisting, existingOrigins, dq)
 	if err != nil {
 		return nil, nil, nil, &DepError{pkg, err}
 	}
+
 	// eliminate duplication in dependencies
 	added := make(map[string]*RepositoryPackage, len(deps))
 	dependencies := make([]*RepositoryPackage, 0, len(deps))
@@ -672,7 +708,10 @@ func (p *PkgResolver) resolvePackage(pkgName string, dq map[*RepositoryPackage]s
 // It might change the order of install.
 // In other words, this _should_ be a DAG (acyclical), but because the packages
 // are just listing dependencies in text, it might be cyclical. We need to be careful of that.
-func (p *PkgResolver) getPackageDependencies(pkg *RepositoryPackage, allowPin string, allowSelfFulfill bool, parents map[string]bool, existing map[string]*RepositoryPackage, existingOrigins map[string]bool, dq map[*RepositoryPackage]string) (dependencies []*RepositoryPackage, conflicts []string, err error) {
+func (p *PkgResolver) getPackageDependencies(ctx context.Context, pkg *RepositoryPackage, allowPin string, parents map[string]bool, existing map[string]*RepositoryPackage, existingOrigins map[string]bool, dq map[*RepositoryPackage]string) (dependencies []*RepositoryPackage, conflicts []string, err error) {
+	if err := ctx.Err(); err != nil {
+		return nil, nil, context.Cause(ctx)
+	}
 	// check if the package we are checking is one of our parents, avoid cyclical graphs
 	if _, ok := parents[pkg.Name]; ok {
 		return nil, nil, nil
@@ -715,7 +754,7 @@ func (p *PkgResolver) getPackageDependencies(pkg *RepositoryPackage, allowPin st
 				continue
 			}
 
-			if allowSelfFulfill && pkg.Name == name {
+			if pkg.Name == name {
 				var (
 					actualVersion, requiredVersion Version
 					err1, err2                     error
@@ -733,6 +772,30 @@ func (p *PkgResolver) getPackageDependencies(pkg *RepositoryPackage, allowPin st
 				}
 			}
 
+			if picked, ok := p.selected[name]; ok {
+				if version == "" {
+					// If we don't care which version, and we've already selected something, fantastic.
+					continue
+				}
+
+				actualVersion, err := cachedParseVersion(picked.Version)
+				if err != nil {
+					return nil, nil, err
+				}
+				requiredVersion, err := cachedParseVersion(version)
+				if err != nil {
+					return nil, nil, err
+				}
+
+				// We do care which version and they match.
+				if compare.satisfies(actualVersion, requiredVersion) {
+					continue
+				}
+
+				// We already selected something to satisfy "name" and it does not match the "version" we need now.
+				return nil, nil, fmt.Errorf("we already selected %q=%q which conflicts with %q=%q", picked.Name, picked.Version, name, version)
+			}
+
 			// first see if it is a name of a package
 			depPkgWithVersions, ok := p.nameMap[name]
 			if !ok {
@@ -793,7 +856,12 @@ func (p *PkgResolver) getPackageDependencies(pkg *RepositoryPackage, allowPin st
 			childParents[k] = true
 		}
 		childParents[pkg.Name] = true
-		subDeps, confs, err := p.getPackageDependencies(depPkg, allowPin, true, childParents, existing, existingOrigins, dq)
+
+		if err := p.pick(pkg); err != nil {
+			return nil, nil, err
+		}
+
+		subDeps, confs, err := p.getPackageDependencies(ctx, depPkg, allowPin, childParents, existing, existingOrigins, dq)
 		if err != nil {
 			return nil, nil, &ConstraintError{name, &DepError{depPkg, err}}
 		}


Test patch:
diff --git a/pkg/apk/apk/repo_test.go b/pkg/apk/apk/repo_test.go
index 90a0988..146ae3b 100644
--- a/pkg/apk/apk/repo_test.go
+++ b/pkg/apk/apk/repo_test.go
@@ -128,7 +128,7 @@ func TestGetRepositoryIndexes(t *testing.T) {
 		a.SetClient(&http.Client{
 			Transport: &testLocalTransport{root: testPrimaryPkgDir, basenameOnly: true},
 		})
-		indexes, err := a.GetRepositoryIndexes(context.TODO(), false)
+		indexes, err := a.GetRepositoryIndexes(context.Background(), false)
 		require.NoErrorf(t, err, "unable to get indexes")
 		require.Greater(t, len(indexes), 0, "no indexes found")
 	})
@@ -137,7 +137,7 @@ func TestGetRepositoryIndexes(t *testing.T) {
 		a.SetClient(&http.Client{
 			Transport: &testLocalTransport{root: testRSA256IndexPkgDir, basenameOnly: true},
 		})
-		indexes, err := a.GetRepositoryIndexes(context.TODO(), false)
+		indexes, err := a.GetRepositoryIndexes(context.Background(), false)
 		require.NoErrorf(t, err, "unable to get indexes")
 		require.Greater(t, len(indexes), 0, "no indexes found")
 	})
@@ -149,7 +149,7 @@ func TestGetRepositoryIndexes(t *testing.T) {
 		a.SetClient(&http.Client{
 			Transport: &testLocalTransport{fail: true},
 		})
-		_, err := a.GetRepositoryIndexes(context.TODO(), false)
+		_, err := a.GetRepositoryIndexes(context.Background(), false)
 		require.Error(t, err, "should fail when no cache and no network")
 	})
 	t.Run("we can fetch, but do not cache indices without etag", func(t *testing.T) {
@@ -161,7 +161,7 @@ func TestGetRepositoryIndexes(t *testing.T) {
 		a.SetClient(&http.Client{
 			Transport: &testLocalTransport{root: testPrimaryPkgDir, basenameOnly: true},
 		})
-		indexes, err := a.GetRepositoryIndexes(context.TODO(), false)
+		indexes, err := a.GetRepositoryIndexes(context.Background(), false)
 		require.NoErrorf(t, err, "unable to get indexes")
 		require.Greater(t, len(indexes), 0, "no indexes found")
 
@@ -189,7 +189,7 @@ func TestGetRepositoryIndexes(t *testing.T) {
 				},
 			},
 		})
-		indexes, err := a.GetRepositoryIndexes(context.TODO(), false)
+		indexes, err := a.GetRepositoryIndexes(context.Background(), false)
 		require.NoErrorf(t, err, "unable to get indexes")
 		require.Greater(t, len(indexes), 0, "no indexes found")
 		// check that the contents are the same
@@ -227,7 +227,7 @@ func TestGetRepositoryIndexes(t *testing.T) {
 			Transport: &testLocalTransport{root: testPrimaryPkgDir, basenameOnly: true, headers: map[string][]string{http.CanonicalHeaderKey("etag"): {testEtag}}},
 		})
 		// Use the client to fill the cache.
-		indexes, err := a.GetRepositoryIndexes(context.TODO(), false)
+		indexes, err := a.GetRepositoryIndexes(context.Background(), false)
 		require.NoErrorf(t, err, "unable to get indexes")
 		require.Greater(t, len(indexes), 0, "no indexes found")
 		// Capture the initial index.
@@ -238,7 +238,7 @@ func TestGetRepositoryIndexes(t *testing.T) {
 		a.SetClient(&http.Client{
 			Transport: &testLocalTransport{root: testAlternatePkgDir, basenameOnly: true, headers: map[string][]string{http.CanonicalHeaderKey("etag"): {testEtag}}},
 		})
-		indexes, err = a.GetRepositoryIndexes(context.TODO(), false)
+		indexes, err = a.GetRepositoryIndexes(context.Background(), false)
 		require.NoErrorf(t, err, "unable to get indexes")
 		require.Greater(t, len(indexes), 0, "no indexes found")
 		// Capture the resulting index.
@@ -259,7 +259,7 @@ func TestGetRepositoryIndexes(t *testing.T) {
 			Transport: &testLocalTransport{root: testPrimaryPkgDir, basenameOnly: true, headers: map[string][]string{http.CanonicalHeaderKey("etag"): {testEtag}}},
 		})
 		// Use the client to fill the cache.
-		indexes, err := a.GetRepositoryIndexes(context.TODO(), false)
+		indexes, err := a.GetRepositoryIndexes(context.Background(), false)
 		require.NoErrorf(t, err, "unable to get indexes")
 		require.Greater(t, len(indexes), 0, "no indexes found")
 		// Capture the initial index.
@@ -272,7 +272,7 @@ func TestGetRepositoryIndexes(t *testing.T) {
 			Transport: &testLocalTransport{root: testAlternatePkgDir, basenameOnly: true, headers: map[string][]string{http.CanonicalHeaderKey("etag"): {testEtag + "change"}}},
 		})
 
-		indexes, err = a.GetRepositoryIndexes(context.TODO(), false)
+		indexes, err = a.GetRepositoryIndexes(context.Background(), false)
 		require.NoErrorf(t, err, "unable to get indexes")
 		require.Greater(t, len(indexes), 0, "no indexes found")
 		// Capture the resulting index.
@@ -297,7 +297,7 @@ func TestGetRepositoryIndexes(t *testing.T) {
 						headers:      map[string][]string{http.CanonicalHeaderKey("etag"): {fmt.Sprint(i)}},
 					},
 				})
-				indexes, err := a.GetRepositoryIndexes(context.TODO(), false)
+				indexes, err := a.GetRepositoryIndexes(context.Background(), false)
 				require.NoErrorf(t, err, "unable to get indexes")
 				require.Greater(t, len(indexes), 0, "no indexes found")
 				return nil
@@ -594,7 +594,7 @@ func TestGetPackageDependencies(t *testing.T) {
 		_, index := testGetPackagesAndIndex()
 
 		resolver := NewPkgResolver(context.Background(), testNamedRepositoryFromIndexes(index))
-		_, pkgs, _, err := resolver.GetPackageWithDependencies("package1", nil, map[*RepositoryPackage]string{})
+		_, pkgs, _, err := resolver.GetPackageWithDependencies(context.Background(), "package1", nil, map[*RepositoryPackage]string{})
 		require.NoErrorf(t, err, "unable to get dependencies")
 
 		actual := make([]string, 0, len(pkgs))
@@ -609,7 +609,7 @@ func TestGetPackageDependencies(t *testing.T) {
 		_, index := testGetPackagesAndIndex()
 
 		resolver := NewPkgResolver(context.Background(), testNamedRepositoryFromIndexes(index))
-		_, pkgs, _, err := resolver.GetPackageWithDependencies("package3", nil, map[*RepositoryPackage]string{})
+		_, pkgs, _, err := resolver.GetPackageWithDependencies(context.Background(), "package3", nil, map[*RepositoryPackage]string{})
 		require.NoErrorf(t, err, "unable to get dependencies")
 
 		actual := make([]string, 0, len(pkgs))
@@ -618,34 +618,6 @@ func TestGetPackageDependencies(t *testing.T) {
 		}
 		require.True(t, reflect.DeepEqual(expected, actual), "dependencies mismatch:\nactual %v\nexpect %v", actual, expected)
 	})
-	t.Run("self-fulfill", func(t *testing.T) {
-		_, index := testGetPackagesAndIndex()
-
-		resolver := NewPkgResolver(context.Background(), testNamedRepositoryFromIndexes(index))
-		pkg6, err := resolver.ResolvePackage("package6", map[*RepositoryPackage]string{})
-		require.NoErrorf(t, err, "unable to resolve package6")
-		require.GreaterOrEqual(t, len(pkg6), 1, "package6 should have at least one match")
-		tests := []struct {
-			name     string
-			expected []string
-			allow    bool
-		}{
-			{"allowed", []string{"package5"}, true},
-			{"not allowed", []string{"package6", "package5"}, false},
-		}
-		for _, tt := range tests {
-			t.Run(tt.name, func(t *testing.T) {
-				deps, _, err := resolver.getPackageDependencies(pkg6[0], "", tt.allow, nil, nil, nil, map[*RepositoryPackage]string{})
-				require.NoErrorf(t, err, "unable to get dependencies")
-
-				actual := make([]string, 0, len(deps))
-				for _, p := range deps {
-					actual = append(actual, p.Name)
-				}
-				require.True(t, reflect.DeepEqual(tt.expected, actual), "dependencies mismatch:\nactual %v\nexpect %v", actual, tt.expected)
-			})
-		}
-	})
 	t.Run("existing dependency", func(t *testing.T) {
 		origPkgs, index := testGetPackagesAndIndex()
 		resolver := NewPkgResolver(context.Background(), testNamedRepositoryFromIndexes(index))
@@ -653,7 +625,7 @@ func TestGetPackageDependencies(t *testing.T) {
 		// start with regular resolution, just to compare
 		expectedName := "package5"
 		expectedVersion := "2.0.0" // highest version
-		_, pkgs, _, err := resolver.GetPackageWithDependencies("package9", nil, map[*RepositoryPackage]string{})
+		_, pkgs, _, err := resolver.GetPackageWithDependencies(context.Background(), "package9", nil, map[*RepositoryPackage]string{})
 		require.NoErrorf(t, err, "unable to get dependencies")
 		require.Len(t, pkgs, 1, "package9 should have one dependency, %s", expectedName)
 		require.Equal(t, expectedName, pkgs[0].Name)
@@ -669,7 +641,7 @@ func TestGetPackageDependencies(t *testing.T) {
 				break
 			}
 		}
-		_, pkgs, _, err = resolver.GetPackageWithDependencies("package9", existingPkgs, map[*RepositoryPackage]string{})
+		_, pkgs, _, err = resolver.GetPackageWithDependencies(context.Background(), "package9", existingPkgs, map[*RepositoryPackage]string{})
 		require.NoErrorf(t, err, "unable to get dependencies")
 		require.Len(t, pkgs, 1, "package9 should have one dependency, %s", expectedName)
 		require.Equal(t, expectedName, pkgs[0].Name)