Add internal tools utilzing graph

This commit is contained in:
Chris Roberts 2021-10-06 08:44:34 -07:00 committed by Paul Hinze
parent 0a26c040f7
commit d81d505e8a
No known key found for this signature in database
GPG Key ID: B69DEDF2D55501C0
3 changed files with 457 additions and 0 deletions

View File

@ -0,0 +1,157 @@
module VagrantPlugins
module CommandServe
class Mappers
module Internal
class Graph
# Represent given mappers and inputs as
# graph.
class Mappers
INPUT_WEIGHT = 0
OUTPUT_WEIGHT = 10
# @return [Graph] graph instance representing mappers
attr_reader :graph
# @return [Array<Object>] input values
attr_reader :inputs
# @return [Mappers] mappers instance executing against
attr_reader :mappers
# @return [Class] expected return type
attr_reader :final
# Wrap a mappers instance into a graph with input values
# and determine and execute required path for desired output
#
# @param output_type [Class] Expected return type
# @param input_values [Array<Object>] Values provided for execution
# @param mappers [Mappers] Mappers instance to use
def initialize(output_type:, input_values:, mappers:)
if !output_type.is_a?(Class)
raise TypeError,
"Expected output type to be `Class', got `#{output_type.class}'"
end
@final = output_type
@inputs = Array(input_values).compact
if !mappers.is_a?(CommandServe::Mappers)
raise TypeError,
"Expected mapper to be `Mappers', got `#{mappers.class}'"
end
@mappers = mappers
setup!
end
# Generate path and execute required mappers
#
# @return [Object] result
def execute
# Generate list of vertices to reach destination
# from root, if possible
search = Search.new(graph: graph)
p = search.path(@root, @dst)
# Call root first and validate it was
# actually root. The value is a stub,
# so it's not saved.
result = p.shift.call
if result != :root
raise "Initial vertex is not root. Expected `:root', got `#{result}'"
end
# Execute each vertex in the path
p.each do |v|
# The argument list required by the current
# vertex will be defined by its incoming edges
args = search.graph.edges_in(v).map(&:value)
v.call(*args)
end
# The resultant value will be stored within the
# destination vertex
@dst.value
end
protected
# Setup the graph using the provided Mappers instance
def setup!
@graph = Graph.new
# Create a root vertex to provide a single starting point
@root = Graph::Vertex.new(value: :root)
# Add the provided input values
input_vertices = inputs.map do |input_value|
iv = Graph::Vertex::Value.new(value: input_value)
graph.add_weighted_edge(@root, iv, INPUT_WEIGHT)
iv
end
# Also add the known values from the mappers instance
input_vertices += mappers.known_arguments.map do |input_value|
iv = Graph::Vertex::Value.new(value: input_value)
graph.add_weighted_edge(@root, iv, INPUT_WEIGHT)
iv
end
fn_inputs = Array.new
fn_outputs = Array.new
# Create vertices for all our registered mappers,
# as well as their inputs and outputs
mappers.mappers.each do |mapper|
fn = Graph::Vertex::Method.new(callable: mapper)
fn_inputs += mapper.inputs.map do |i|
iv = Graph::Vertex::Input.new(type: i.type)
graph.add_edge(iv, fn)
iv
end
ov = Graph::Vertex::Output.new(type: mapper.output)
graph.add_edge(fn, ov)
fn_outputs << ov
end
# Create an output vertex for our expected
# result type
@dst = Graph::Vertex::Output.new(type: final)
# Add an edge from all our value vertices to
# matching input vertices
input_vertices.each do |iv|
fn_inputs.each do |f_iv|
if iv.type == f_iv.type
if iv.type == Hashicorp::Vagrant::Sdk::FuncSpec::Value ||
f_iv.type == Hashicorp::Vagrant::Sdk::FuncSpec::Value
raise "wtf, #{self.inspect}"
end
graph.add_weighted_edge(iv, f_iv, INPUT_WEIGHT)
end
end
# If a value vertex matches the desired
# output value, connect it directly
if @dst.type == iv.type
graph.add_weighted_edge(iv, @dst, INPUT_WEIGHT)
end
end
# Add an edge from all our output vertices to
# matching input vertices
fn_outputs.each do |f_ov|
fn_inputs.each do |f_iv|
if f_ov.type == f_iv.type
if f_ov.type == Hashicorp::Vagrant::Sdk::FuncSpec::Value ||
f_iv.type == Hashicorp::Vagrant::Sdk::FuncSpec::Value
raise "wtf outs, #{self.inspect}"
end
graph.add_edge(f_ov, f_iv)
end
end
# If an output value matches the desired
# output value, connect it directly
if @dst.type == f_ov.type
graph.add_weighted_edge(f_ov, @dst, OUTPUT_WEIGHT)
end
end
# Finalize the graphs so edges are properly
# sorted by their weight
graph.finalize!
end
end
end
end
end
end
end

View File

@ -0,0 +1,219 @@
module VagrantPlugins
module CommandServe
class Mappers
module Internal
class Graph
# Provides searching of a graph to determine if a
# destination vertex is accessible from a given
# root vertex.
class Search
class NoPathError < StandardError; end
# Value used for marking visited vertices
VERTEX_ID = :vertex
attr_reader :graph, :root, :visited
# Create a new DFS instance
#
# @param graph [Graph] Graph used for searching
def initialize(graph:)
@graph = graph.copy
@m = Mutex.new
@root = nil
@visited = nil
end
# Provide a list of vertices not visited to build
# the generated path. If no path has been generated,
# it will return an empty value by default.
#
# @return [Array<Vertex>] list of vertices
def orphans
@m.synchronize do
load_orphans
end
end
# Generate a path from the given source vertex to
# the given destination vertex using a depth first
# search algorithm.
#
# @param src [Vertex] Source vertex
# @param dst [Vertex] Destination vertex
# @return [Array<Vertex>] path from source to destination
# @raises [NoPathError] when no path can be determined
def path(src, dst)
@m.synchronize do
@root = src
@visited = {}
stack = Stack.new
stack.push(src)
p = find_path(src, dst, stack)
if Array(p).empty?
@visited = nil
raise NoPathError,
"failed to determine valid path"
end
# Now ensure our final path is in the correct order
# based on edge defined dependencies
load_orphans.each do |v|
graph.remove(v)
end
t = Topological.new(graph: graph)
t.sort
end
end
protected
# Find path from source to destination
#
# @param src [Vertex] Source vertex
# @param dst [Vertex] Destination vertex
# @param s [Stack] Stack for holding path information
# @return [Array<Vertex>, nil] list of vertices or nil if not found
def find_path(src, dst, s)
# If we have reached our destination then it's
# time to mark vertices included in the final
# path as visited and return the path
if src == dst
p = s.values
p.each do |v|
visited[v.hash_code] = VERTEX_ID
end
return p
end
graph.edges_out(src).each do |v|
# If the incoming edges don't define dependencies
# then we only care about a single path through
# the vertex
if !v.incoming_edges_required
if s.include?(v)
next
end
s.push(v)
if p = find_path(v, dst, s)
return p
end
s.pop
next
end
# Since the incoming edges define dependencies
# we need to validate they are reachable from
# root before we continue attempting to build
# the path
req_paths = []
if graph.edges_out(v).size > 1
d = self.class.new(graph: graph.reverse)
begin
p = d.path(dst, v)
req_paths << p
rescue NoPathError
next
end
end
failed = false
graph.edges_in(v).each do |in_v|
# This was our initial path here so ignore
if src.hash_code == in_v.hash_code
next
end
# Create a new graph but reverse the direction
new_g = graph.reverse
# Remove edges from the vertex except for the
# one we are currently processing
new_g.edges_out(v).each do |out_v|
if out_v.hash_code == in_v.hash_code
next
end
new_g.remove_edge(v, out_v)
end
# Create a new DFS to search using the reversed
# graph
new_d = self.class.new(graph: new_g)
begin
# Attempt to find a path from the vertex back
# to root
p = new_d.path(v, root)
req_paths << p
rescue NoPathError
# If a path could not be found, mark failed
# and bail
failed = true
break
end
end
# If all the incoming edges couldn't be satisfied
# then we ignore this vertex and move on
if failed
next
end
# All the incoming edges could be satisfied so
# we push this vertex on the stack and continue
# down the current path
s.push(v)
# If we were able to reach the destination on
# this path, we now need to add in the extra
# paths generated to satisfy the edge requirements
if p = find_path(v, dst, s)
# Store the position of the vertex we are processing
# in the path so we can properly cut and glue the path
pos = p.index(v)
# Cut the path prior to our current vertex
path_start = p.slice(0, pos)
# Now walk through each extra path and add any vertices
# that have not yet been seen
req_paths.each do |extra_path|
# The extra path originated from a reversed graph, so
# reverse this path before processing
extra_path.reverse.each do |extra_v|
if visited.key?(extra_v.hash_code)
next
end
visited[extra_v.hash_code] = VERTEX_ID
path_start << extra_v
end
end
# Now glue the remaining path to our modified start
# path and return the result
return path_start + p.slice(pos, p.size)
end
# If we made it here the vertex isn't part of a valid
# path so pop it off the stack and continue
s.pop
end
# If we have nothing left to process, return nothing
nil
end
def load_orphans
if visited.nil?
return []
end
graph.vertices.map do |v|
v if !visited.key?(v.hash_code)
end.compact
end
end
end
end
end
end
end

View File

@ -0,0 +1,81 @@
module VagrantPlugins
module CommandServe
class Mappers
module Internal
class Graph
# Provides topological sorting of a Graph
class Topological
class NoRootError < StandardError; end
class CycleError < StandardError; end
attr_reader :graph
# Create a new topological sorting instance
#
# @param graph [Graph] Graph used for sorting
def initialize(graph:)
@graph = graph.copy
@m = Mutex.new
end
# Generate a topological sorted path of the defined
# graph.
#
# @return [Array<Vertex>]
# @raises [NoRootError, CycleError]
def sort
@m.synchronize do
s = Stack.new
graph.vertices.each do |v|
s.push(v) if graph.edges_in(v).size < 1
end
if s.size < 1
raise NoRootError,
"graph does not contain any root vertices"
end
kahn(s)
end
end
protected
# Sort the graph vertices using Kahn's algorithm
#
# @param s [Stack] Stack to hold vertices
# @return [Array<Vertex>]
# @raises [CycleError]
def kahn(s)
p = Queue.new
while s.size > 0
v = s.pop
p.push(v)
graph.edges_out(v).each do |next_v|
graph.remove_edge(v, next_v)
if graph.edges_in(next_v).size < 1
s.push(next_v)
end
end
end
graph.vertices.each do |v|
if graph.edges_in(v).size > 1 || graph.edges_out(v).size > 1
raise CycleError,
"graph contains at least one cycle"
end
end
Array.new.tap do |path|
while p.size > 0
path << p.pop
end
end
end
end
end
end
end
end
end