Skip to content

Commit

Permalink
Implement dependency tree
Browse files Browse the repository at this point in the history
When building the appsignal-javascript mono repo from scratch the build
would fail as it depends on packages in the project that are not yet
compiled. This was because it tried to compile packages in the
alphabetical order, rather than what packages depend on which other
packages.

This move and expansion of the dependency tree from the PackagePromoter
adds a `packages` method that returns the packages in the order of least
dependencies first. This should make sure package A is compiled before
package B.
  • Loading branch information
tombruijn committed Jun 15, 2021
1 parent 3cf6885 commit 2c54ba1
Show file tree
Hide file tree
Showing 8 changed files with 242 additions and 51 deletions.
5 changes: 5 additions & 0 deletions .changesets/dependency-tree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
bump: "patch"
---

Implement dependency tree, this accounts for package dependencies in compilation order. When package B depends on package A it will first compile package A and then package B. This prevents compilation errors.
14 changes: 14 additions & 0 deletions lib/mono.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ def message
end
end

class CircularDependencyError < Error
def initialize(package_names)
@package_names = package_names
super()
end

def message
"Dependency loop detected! Two or more packages are configured in as " \
"circular dependencies of each other.\n" \
"Packages: #{@package_names.join(", ")}"
end
end

class NoSuchCommandError < Error
def initialize(command)
@command = command
Expand All @@ -29,6 +42,7 @@ def message
require "mono/version"
require "mono/version_object"
require "mono/version_promoter"
require "mono/dependency_tree"
require "mono/package_promoter"
require "mono/config"
require "mono/command"
Expand Down
10 changes: 9 additions & 1 deletion lib/mono/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,17 @@ def initialize(options = {})
validate(options)
end

def packages
dependency_tree.packages
end

def dependency_tree
@dependency_tree ||= DependencyTree.new(@packages)
end

private

attr_reader :options, :config, :language, :packages
attr_reader :options, :config, :language

def find_packages
package_class = PackageBase.for(config.language)
Expand Down
2 changes: 1 addition & 1 deletion lib/mono/cli/publish.rb
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ def publish_git(packages)
# Helper class to update dependencies between packages in a mono project
def package_promoter
@package_promoter ||=
PackagePromoter.new(packages, :prerelease => prerelease)
PackagePromoter.new(dependency_tree, :prerelease => prerelease)
end
end
end
Expand Down
107 changes: 107 additions & 0 deletions lib/mono/dependency_tree.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# frozen_string_literal: true

module Mono
class DependencyTree
def initialize(packages)
@raw_packages = packages
build
end

# Fetch package dependency tree entry with its dependencies and dependents
#
# @return [Hash<String, Hash<Symbol, Object>]
def [](name)
tree[name]
end

# Return list of packages in dependency order. The first item has the least
# amount of dependencies on packages in the project (most likely none) and
# the last one the most depdendencies.
#
# @raise [CircularDependencyError] if the project packages have a circular
# depdendency on one another.
# @return [Array]
def packages
return @packages if defined?(@packages)

pkgs = []
unsorted_packages = tree.keys
iterations = Hash.new { |hash, key| hash[key] = 0 }
until unsorted_packages.empty?
package_name = unsorted_packages.shift
iterations[package_name] += 1
package = tree[package_name]
if depdendencies_checked? pkgs, package[:dependencies]
# Reset iterations for remaining packages so we don't claim it to be
# a circular dependency when we're still making progress.
unsorted_packages.each { |pkg| iterations[pkg] = 0 }

# Dependencies are all ordered, so we can include it in the list next
pkgs << package_name
else
# Dependency not yet ordered, moving to last position to try again
# later when hopefully all dependencies have been ordered
unsorted_packages << package_name
end

# A package has been looped more than 10 times without resolving any
# packages, most likely a circular dependency was found.
next unless iterations[package_name] > 10

raise CircularDependencyError, unsorted_packages
end
@packages = pkgs.map { |name| tree[name][:package] }
end

private

attr_reader :raw_packages, :tree

def depdendencies_checked?(pkgs, dependencies)
dep_checks = dependencies.map { |name, _| pkgs.include? name }
dep_checks.all?(true)
end

# Build a dependency tree. Track which packages depent on other packages in
# this project. It creates a tree with dependencies going both ways:
# dependencies and dependents.
def build # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@tree =
Hash.new do |hash, key|
hash[key] = {
:package => nil,
:dependents => [], # List of packages that depend on this package
:dependencies => [] # List of packages that this package depends on
}
end

package_names = raw_packages.map(&:name)
# Build a package tree based on dependencies
raw_packages.each do |package|
# Only track packages in this project, not other ecosystem dependencies
next unless package_names.include? package.name

@tree[package.name][:package] = package
deps = package.dependencies
next unless deps

package_deps = {}
deps.each do |key, value|
package_deps[key] = value if package_names.include?(key)
end
@tree[package.name][:dependencies] = package_deps
end
# Loop through it again to figure out depenents from the dependencies
raw_packages.each do |package| # rubocop:disable Style/CombinableLoops
@tree[package.name][:dependencies].each do |dep, _version_lock|
# Only track packages in this project, not other ecosystem
# dependencies
next unless package_names.include? dep

# Keep track of dependent packages
@tree[dep][:dependents] << package.name
end
end
end
end
end
44 changes: 6 additions & 38 deletions lib/mono/package_promoter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

module Mono
class PackagePromoter
def initialize(packages, prerelease: nil)
@packages = packages
def initialize(dependency_tree, prerelease: nil)
@dependency_tree = dependency_tree
@prerelease = prerelease
@updated_packages = Set.new
end
Expand All @@ -15,9 +15,9 @@ def initialize(packages, prerelease: nil)
def changed_packages # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@changed_packages ||=
begin
build_tree
# Track which packages have registered changes and require an update
packages_with_changes = []
packages = dependency_tree.packages

# Make a registry of packages that require a new release
packages.each do |package|
Expand Down Expand Up @@ -53,13 +53,13 @@ def changed_packages # rubocop:disable Metrics/CyclomaticComplexity, Metrics/Per

private

attr_reader :packages, :tree, :updated_packages, :prerelease
attr_reader :dependency_tree, :updated_packages, :prerelease

alias prerelease? prerelease

def update_package_and_dependents(package)
tree[package.name][:dependents].each do |dependent|
dependent_package = tree[dependent][:package]
dependency_tree[package.name][:dependents].each do |dependent|
dependent_package = dependency_tree[dependent][:package]
# Update the updated package this package depends upon.
# This way they have a changeset registered on them that makes it aware
# it will be updated as well.
Expand All @@ -70,37 +70,5 @@ def update_package_and_dependents(package)
update_package_and_dependents(dependent_package)
end
end

# Build a dependency tree. Track which packages depent on other packages in
# this project. It creates a tree with dependencies going both ways:
# dependencies and dependents.
def build_tree
@tree =
Hash.new do |hash, key|
hash[key] = {
:package => nil,
:dependents => [], # List of packages that depend on this package
:dependencies => [] # List of packages that this package depends on
}
end

package_names = packages.map(&:name)
# Build a package tree based on dependencies
packages.each do |package|
# Only track packages in this project, not other ecosystem dependencies
next unless package_names.include? package.name

@tree[package.name][:package] = package
deps = package.dependencies
@tree[package.name][:dependencies] = deps.keys if deps
end
# Loop through it again to figure out depenents from the dependencies
packages.each do |package| # rubocop:disable Style/CombinableLoops
@tree[package.name][:dependencies].each do |dep, _version_lock|
# Keep track of dependent packages
@tree[dep][:dependents] << package.name
end
end
end
end
end
84 changes: 84 additions & 0 deletions spec/lib/mono/dependency_tree_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# frozen_string_literal: true

RSpec.describe Mono::DependencyTree do
describe "#packages" do
let(:config) { Mono::Config.new({}) } # Mock empty config

it "returns packages in dependency order" do
prepare_new_project do
create_package "types" do
create_package_json :version => "1.0.0"
end
create_package "nodejs-ext" do
create_package_json :version => "1.2.3"
end
create_package "nodejs" do
create_package_json :version => "2.0.0",
:dependencies => {
"nodejs-ext" => "=1.2.3",
"types" => "=1.0.0"
}
end
create_package "apollo" do
create_package_json :version => "2.0.0",
:dependencies => {
"nodejs" => "=2.0.0",
"types" => "=1.0.0"
}
end
create_package "apollo-addon" do
create_package_json :version => "2.0.0",
:dependencies => {
"apollo" => "=2.0.0"
}
end
end

package_types = nodejs_package("types")
package_ext = nodejs_package("nodejs-ext")
package_node = nodejs_package("nodejs")
package_apollo = nodejs_package("apollo")
package_apollo_addon = nodejs_package("apollo-addon")
promoter = described_class.new([
package_apollo_addon,
package_apollo,
package_node,
package_ext,
package_types
])
expect(promoter.packages).to eql([
package_ext,
package_types,
package_node,
package_apollo,
package_apollo_addon
])
end

context "with infinite loop of dependencies" do
it "raises an error if there's a circular dependency loop" do
prepare_new_project do
create_package "a" do
create_package_json :version => "1.0.1", :dependencies => { "c" => "1.0.3" }
end
create_package "b" do
create_package_json :version => "1.0.2", :dependencies => { "a" => "1.0.1" }
end
create_package "c" do
create_package_json :version => "1.0.3", :dependencies => { "b" => "1.0.2" }
end
end

package_a = nodejs_package("a")
package_b = nodejs_package("b")
package_c = nodejs_package("c")
promoter = described_class.new([package_a, package_b, package_c])
expect { promoter.packages }.to raise_error(Mono::CircularDependencyError)
end
end
end

def nodejs_package(path)
Mono::Languages::Nodejs::Package.new(nil, package_path(path), config)
end
end
Loading

0 comments on commit 2c54ba1

Please sign in to comment.