From d81d505e8a1ecbd4f8e2304260e4f771ef219c80 Mon Sep 17 00:00:00 2001 From: Chris Roberts Date: Wed, 6 Oct 2021 08:44:34 -0700 Subject: [PATCH] Add internal tools utilzing graph --- .../serve/mappers/internal/graph/mappers.rb | 157 +++++++++++++ .../serve/mappers/internal/graph/search.rb | 219 ++++++++++++++++++ .../mappers/internal/graph/topological.rb | 81 +++++++ 3 files changed, 457 insertions(+) create mode 100644 plugins/commands/serve/mappers/internal/graph/mappers.rb create mode 100644 plugins/commands/serve/mappers/internal/graph/search.rb create mode 100644 plugins/commands/serve/mappers/internal/graph/topological.rb diff --git a/plugins/commands/serve/mappers/internal/graph/mappers.rb b/plugins/commands/serve/mappers/internal/graph/mappers.rb new file mode 100644 index 000000000..c92da32eb --- /dev/null +++ b/plugins/commands/serve/mappers/internal/graph/mappers.rb @@ -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] 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] 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 diff --git a/plugins/commands/serve/mappers/internal/graph/search.rb b/plugins/commands/serve/mappers/internal/graph/search.rb new file mode 100644 index 000000000..134993006 --- /dev/null +++ b/plugins/commands/serve/mappers/internal/graph/search.rb @@ -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] 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] 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, 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 diff --git a/plugins/commands/serve/mappers/internal/graph/topological.rb b/plugins/commands/serve/mappers/internal/graph/topological.rb new file mode 100644 index 000000000..fa5fc1b32 --- /dev/null +++ b/plugins/commands/serve/mappers/internal/graph/topological.rb @@ -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] + # @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] + # @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