Skip to content

Commit fb6975e

Browse files
committed
[DependencyResolver] Add initial cut at primary resolution loop.
This is the beginnings of a bottom-up constraint solver, but backtracking is unimplemented. I plan to document the motivations for taking this resolution strategy in more detail, but for now the jist is below. The assumption based on the problem domain is that the most logical notion to the user is that of resolving the dependencies for an individual package in isolation, simply because each dependency is presumably developed by someone, and that is the situation that that developer is in. Given that assumption, it is useful to frame the dependency resolver in terms of solving individual packages with some amount of additional constraints imposed (those additional constraints represent the impact of other dependencies in the graph on that individual package). I believe this will allow us to give good diagnostics of the form: Dependency A: 1.0.0 ..< 2.0.0 could not be resolved with the following additional constraints: B_0: ... ... B_N: ... and then we can provide tools so that the author of `A` can trial the resolution of their package with those additional constraints active. This is designed to allow the *author* of the dependency which is causing dependency resolution issues to be in a good position to resolve the dependency problem (without needing to necessarily examine the client package, which may not be accessible to them). It remains to be seen how efficiently we can implement this strategy, but my motivation is to build an algorithm which gives good diagnostics first, and focus on performance second.
1 parent 5d34f13 commit fb6975e

File tree

2 files changed

+309
-20
lines changed

2 files changed

+309
-20
lines changed

Sources/PackageGraph/DependencyResolver.swift

Lines changed: 163 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@
1010

1111
import struct PackageDescription.Version
1212

13+
public enum DependencyResolverError: Error {
14+
/// The resolver was unable to find a solution to the input constraints.
15+
case unsatisfiable
16+
17+
/// The resolver hit unimplemented functionality (used temporarily for test case coverage).
18+
//
19+
// FIXME: Eliminate this.
20+
case unimplemented
21+
}
22+
1323
/// An abstract definition for a set of versions.
1424
public enum VersionSetSpecifier: Equatable {
1525
/// The universal set.
@@ -207,6 +217,11 @@ struct PackageContainerConstraintSet<C: PackageContainer>: Collection {
207217
self.constraints = [:]
208218
}
209219

220+
/// Create an constraint set from known values.
221+
init(_ constraints: [Identifier: VersionSetSpecifier]) {
222+
self.constraints = constraints
223+
}
224+
210225
/// The list of containers with entries in the set.
211226
var containerIdentifiers: AnySequence<Identifier> {
212227
return AnySequence<C.Identifier>(constraints.keys)
@@ -509,6 +524,11 @@ public class DependencyResolver<
509524
/// only kind of constraints we operate on.
510525
public typealias Constraint = PackageContainerConstraint<Identifier>
511526

527+
/// The type of constraint set the resolver operates on.
528+
typealias ConstraintSet = PackageContainerConstraintSet<Container>
529+
530+
/// The type of assignment the resolver operates on.
531+
typealias AssignmentSet = VersionAssignmentSet<Container>
512532

513533
/// The container provider used to load package containers.
514534
public let provider: Provider
@@ -526,37 +546,161 @@ public class DependencyResolver<
526546
/// - Parameters:
527547
/// - constraints: The contraints to solve.
528548
/// - Returns: A satisfying assignment of containers and versions.
549+
/// - Throws: DependencyResolverError, or errors from the underlying package provider.
529550
public func resolve(constraints: [Constraint]) throws -> [(container: Identifier, version: Version)] {
530-
// For now, we just load the transitive closure of the dependencies at
531-
// the latest version, and ignore the version requirements.
551+
// Create an assignment for the input constraints.
552+
guard let assignment = try merge(
553+
constraints: constraints, into: AssignmentSet(),
554+
subjectTo: ConstraintSet(), excluding: [:]) else {
555+
throw DependencyResolverError.unsatisfiable
556+
}
532557

533-
func visit(_ identifier: Identifier) throws {
534-
// If we already have this identifier, skip it.
535-
if containers.keys.contains(identifier) {
536-
return
558+
return assignment.map { (container, binding) in
559+
guard case .version(let version) = binding else {
560+
fatalError("unexpected exclude binding")
537561
}
562+
return (container: container.identifier, version: version)
563+
}
564+
}
538565

539-
// Otherwise, load the container and visit its dependencies.
540-
let container = try getContainer(for: identifier)
566+
/// Resolve an individual container dependency tree.
567+
///
568+
/// This is the primary method in our bottom-up algorithm for resolving
569+
/// dependencies. The inputs define an active set of constraints and set of
570+
/// versions to exclude (conceptually the latter could be merged with the
571+
/// former, but it is convenient to separate them in our
572+
/// implementation). The result is an assignment for this container's
573+
/// subtree.
574+
///
575+
/// - Parameters:
576+
/// - container: The container to resolve.
577+
/// - constraints: The external constraints which must be honored by the solution.
578+
/// - exclusions: The list of individually excluded package versions.
579+
/// - Returns: A sequence of feasible solutions, starting with the most preferable.
580+
/// - Throws: Only rethrows errors from the container provider.
581+
//
582+
// FIXME: This needs to a way to return information on the failure, or we
583+
// will need to have it call the delegate directly.
584+
//
585+
// FIXME: @testable private
586+
func resolveSubtree(
587+
_ container: Container,
588+
subjectTo allConstraints: ConstraintSet,
589+
excluding allExclusions: [Identifier: Set<Version>]
590+
) throws -> AssignmentSet? {
591+
func validVersions(_ container: Container) -> AnyIterator<Version> {
592+
let constraints = allConstraints[container.identifier] ?? .any
593+
let exclusions = allExclusions[container.identifier] ?? Set()
594+
var it = container.versions.reversed().makeIterator()
595+
return AnyIterator { () -> Version? in
596+
while let version = it.next() {
597+
if constraints.contains(version) && !exclusions.contains(version) {
598+
return version
599+
}
600+
}
601+
return nil
602+
}
603+
}
541604

542-
// Visit the dependencies at the latest version.
605+
// Attempt to select each valid version in order.
606+
//
607+
// FIXME: We must detect recursion here.
608+
for version in validVersions(container) {
609+
// Create local constaint copies we will use to build the solution.
610+
var allConstraints = allConstraints
611+
// FIXME: We need a persistent set data structure for this to be efficient.
612+
let allExclusions = allExclusions
613+
614+
// Create an assignment for this container.
615+
var assignment = AssignmentSet()
616+
assignment[container] = .version(version)
617+
618+
// Update the active constraint set to include this container's constraints.
619+
//
620+
// We want to put all of these constraints in up front so that we
621+
// are more likely to get back a viable solution.
543622
//
544-
// FIXME: What if this dependency has no versions? We should
545-
// consider it unavailable.
546-
let latestVersion = container.versions.last!
547-
let constraints = container.getDependencies(at: latestVersion)
623+
// FIXME: We should have a test for this, probably by adding some
624+
// kind of statistics on the number of backtracks.
625+
guard allConstraints.merge(assignment.constraints) else {
626+
// The constraints themselves were unsatisfiable after merging, so the version is invalid.
627+
continue
628+
}
548629

549-
for constraint in constraints {
550-
try visit(constraint.identifier)
630+
// Get the constraints for this container version and update the
631+
// assignment to include each one.
632+
if let result = try merge(
633+
constraints: container.getDependencies(at: version),
634+
into: assignment, subjectTo: allConstraints, excluding: allExclusions) {
635+
// We found a complete valid assignment.
636+
assert(result.checkIfValidAndComplete())
637+
return result
551638
}
552639
}
640+
641+
// We were unable to find a valid solution.
642+
return nil
643+
}
644+
645+
/// Solve the `constraints` and merge the results into the `assignment`.
646+
///
647+
/// - Parameters:
648+
/// - constraints: The input list of constraints to solve.
649+
/// - assignment: The assignment to merge the result into.
650+
/// - allConstraints: An additional set of constraints on the viable solutions.
651+
/// - allExclusions: A set of package assignments to exclude from consideration.
652+
/// - Returns: A satisfying assignment, if solvable.
653+
private func merge(
654+
constraints: [Constraint],
655+
into assignment: AssignmentSet,
656+
subjectTo allConstraints: ConstraintSet,
657+
excluding allExclusions: [Identifier: Set<Version>]
658+
) throws -> AssignmentSet? {
659+
var assignment = assignment
660+
var allConstraints = allConstraints
661+
553662
for constraint in constraints {
554-
try visit(constraint.identifier)
555-
}
663+
// Get the container.
664+
//
665+
// Failures here will immediately abort the solution, although in
666+
// theory one could imagine attempting to find a solution not
667+
// requiring this container. It isn't clear that is something we
668+
// would ever want to handle at this level.
669+
let container = try getContainer(for: constraint.identifier)
670+
671+
// Solve for an assignment with the current constraints.
672+
guard let subtreeAssignment = try resolveSubtree(
673+
container, subjectTo: allConstraints, excluding: allExclusions) else {
674+
// If we couldn't find an assignment, we need to backtrack in some way.
675+
throw DependencyResolverError.unimplemented
676+
}
556677

557-
return containers.map { (identifier, container) in
558-
return (container: identifier, version: container.versions.last!)
678+
// We found a valid assignment, attempt to merge it with the current solution.
679+
//
680+
// FIXME: It is rather important, subtle, and confusing that this
681+
// `merge` doesn't mutate the assignment but the one on the
682+
// constraint set does. We should probably make them consistent.
683+
guard assignment.merge(subtreeAssignment) else {
684+
// The assignment couldn't be merged with the current
685+
// assignment, or the constraint sets couldn't be merged.
686+
//
687+
// This happens when (a) the subtree has a package overlapping
688+
// with a previous subtree assignment, and (b) the subtrees
689+
// needed to resolve different versions due to constraints not
690+
// present in the top-down constraint set.
691+
throw DependencyResolverError.unimplemented
692+
}
693+
694+
// Merge the working constraint set.
695+
//
696+
// This should always be feasible, because all prior constraints
697+
// were part of the input constraint request (see comment around
698+
// initial `merge` outside the loop).
699+
let mergable = allConstraints.merge(subtreeAssignment.constraints)
700+
precondition(mergable)
559701
}
702+
703+
return assignment
560704
}
561705

562706
// MARK: Container Management

0 commit comments

Comments
 (0)