excadg 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e1d50ffd84523c400ae4733e67c53d54a791cb066cec4a49dea27b4e7072ecda
4
+ data.tar.gz: 4b5bf03ce6c2e9190e5af55a9f740247281da6ed6522c37a7ee3dd3698352dc9
5
+ SHA512:
6
+ metadata.gz: 274e5395a4d9ed5da1c5aba9a368638757f1571ac38aede22b0f729a794813c4611e41dfb901a9d555c0a202d7b494b47e077b9c379383a7cb116c2f1dd32ecd
7
+ data.tar.gz: e35cd2c39d26791742b1c9ff1255f3c2edb9247b046a09dad62013c6bd9e48fdae54be73e17ae4e1fb430a385a2ec8bc19cfba63a54deffb21b27d59a196da50
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExcADG
4
+ # collection of simple assertions
5
+ module Assertions
6
+ class << self
7
+ # asserts that all vars are instances of one of the clss
8
+ def is_a? vars, clss
9
+ return if vars.is_a?(Array) && clss == Array
10
+
11
+ clss = [clss] unless clss.is_a? Array
12
+ vars = [vars] unless vars.is_a? Array
13
+ wrong_vars = vars.reject { |var|
14
+ clss.any? { |cls|
15
+ var.is_a? cls
16
+ }
17
+ }
18
+ raise "vars #{wrong_vars} not of classes #{clss}" unless wrong_vars.empty?
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+
5
+ require_relative 'data_store'
6
+ require_relative 'log'
7
+ require_relative 'request'
8
+ require_relative 'vertex'
9
+
10
+ module ExcADG
11
+ # handle requests sending/receiving though Ractor's interface
12
+ module Broker
13
+ class << self
14
+ attr_reader :data_store
15
+
16
+ # is used from vertices to send reaqests to the broker
17
+ # @return data received in response from the main ractor
18
+ # @raise StandardError if response is a StandardError
19
+ # @raise CantSendRequest if sending request failed for any reason
20
+ def ask request
21
+ raise UnknownRequestType, request unless request.is_a? Request
22
+
23
+ begin
24
+ Ractor.main.send request
25
+ rescue StandardError => e
26
+ raise CantSendRequest, cause: e
27
+ end
28
+
29
+ Log.info 'getting response'
30
+ resp = Ractor.receive
31
+ Log.debug "got response #{resp}"
32
+ raise resp if resp.is_a? StandardError
33
+
34
+ Log.debug 'returning response'
35
+ resp
36
+ end
37
+
38
+ # start requests broker for vertices in a separated thread
39
+ # @return the thread started
40
+ def run
41
+ @data_store ||= DataStore.new
42
+ @broker = Thread.new { loop { process_request } } unless @broker&.alive?
43
+
44
+ at_exit {
45
+ Log.info 'shut down broker'
46
+ @broker.kill
47
+ }
48
+
49
+ Log.info 'broker is started'
50
+ @broker
51
+ end
52
+
53
+ # makes a thread to wait for all known vertices to reach a final state
54
+ # @param timeout total waiting timeout in seconds, nil means wait forever
55
+ # @param period time between vertices state check
56
+ def wait_all timeout: 60, period: 1
57
+ Thread.new {
58
+ Log.info "timeout is #{timeout} seconds"
59
+ Timeout.timeout(timeout) {
60
+ loop {
61
+ sleep period
62
+ states = @data_store.to_a.group_by(&:state).keys
63
+ Log.info "vertices in #{states} exist"
64
+ # that's the only final states for vertices
65
+ break if (states - %i[done failed]).empty?
66
+ }
67
+ }
68
+ }
69
+ end
70
+
71
+ private
72
+
73
+ # waits for an incoming request,
74
+ # validates request type and content
75
+ # then makes and sends an answer
76
+ def process_request
77
+ begin
78
+ request = Ractor.receive
79
+ Log.info "received request: #{request}"
80
+ request.self.send case request
81
+ when Request::GetStateData
82
+ request.filter? ? request.deps.collect { |d| @data_store[d] } : @data_store.to_a
83
+ when Request::Update
84
+ @data_store << request.data
85
+ true
86
+ when Request::AddVertex
87
+ Vertex.new payload: request.payload, deps: [request.self]
88
+ else
89
+ raise UnknownRequestType
90
+ end
91
+ rescue StandardError => e
92
+ Log.warn "error on message processing: #{e.class} / #{e.message} / #{e.backtrace}"
93
+ request.self.send RequestProcessingFailed.new cause: e
94
+ end
95
+ Log.debug 'message processed'
96
+ end
97
+ end
98
+
99
+ class UnknownRequestType < StandardError; end
100
+ class CantSendRequest < StandardError; end
101
+
102
+ # error type returned by broker thread in case it failed to process incoming request
103
+ class RequestProcessingFailed < StandardError
104
+ attr_reader :cause
105
+
106
+ def initialize cause:
107
+ super
108
+ @cause = cause
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'assertions'
4
+ require_relative 'vstate_data'
5
+
6
+ module ExcADG
7
+ # collection of {ExcADG::VStateData} for {ExcADG::Broker}
8
+ class DataStore
9
+ attr_reader :size
10
+
11
+ def initialize
12
+ # two hashes to store VStateData and access them fast by either key
13
+ @by_name = {}
14
+ @by_vertex = {}
15
+ @size = 0
16
+ end
17
+
18
+ # add new element to the store
19
+ # adds it to the two hashes to access lated by either attribute
20
+ # @return current number of elements
21
+ def << new
22
+ Assertions.is_a? new, VStateData::Full
23
+ if (by_name = @by_name[new.name]) && !(by_name.vertex.eql? new.vertex)
24
+ raise DataSkew,
25
+ "Vertex named #{new.name} - #{new.vertex} is recorded as #{by_name.vertex} in state"
26
+ end
27
+ if (by_vertex = @by_vertex[new.vertex]) && !(by_vertex.name.eql? new.name)
28
+ raise DataSkew,
29
+ "Vertex #{new.vertex} named #{new.name} is already named #{by_vertex.name}"
30
+ end
31
+
32
+ @size += 1 unless key? new
33
+
34
+ @by_name[new.name] = new if new.name
35
+ @by_vertex[new.vertex] = new if new.vertex
36
+ end
37
+
38
+ # retrieves VStateData by key
39
+ # @param key either Vertex or VStateData::Key to retrieve Full state data
40
+ # @return VStateData::Full for the respective key
41
+ # @raise StandardError if key is not of a supported type
42
+ def [] key
43
+ Assertions.is_a? key, [Vertex, VStateData::Key, Symbol, String]
44
+
45
+ case key
46
+ when Vertex then @by_vertex[key]
47
+ when Symbol, String then @by_name[key]
48
+ when VStateData::Key
49
+ if key.name && @by_name.key?(key.name)
50
+ @by_name[key.name]
51
+ elsif key.vertex && @by_vertex.key?(key.vertex)
52
+ @by_vertex[key.vertex]
53
+ else
54
+ nil
55
+ end
56
+ end
57
+ end
58
+
59
+ # retrieve all vertices state data,
60
+ # could contain huge amount of data and be slow to work with;
61
+ # prefer to access vertices data by name using [] instead
62
+ def to_a
63
+ (@by_name.values + @by_vertex.values).uniq
64
+ end
65
+
66
+ # checks if there is data for a given key
67
+ def key? key
68
+ case key
69
+ when Vertex then @by_vertex.key? key
70
+ when Symbol, String then @by_name.key? key
71
+ when VStateData::Key
72
+ @by_name.key?(key.name) || @by_vertex.key?(key.vertex)
73
+ else
74
+ false
75
+ end
76
+ end
77
+
78
+ class DataSkew < StandardError; end
79
+ end
80
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal:true
2
+
3
+ require_relative 'assertions'
4
+ require_relative 'log'
5
+ require_relative 'vstate_data'
6
+
7
+ module ExcADG
8
+ # manage list of dependencies
9
+ # dependencies could be of 2 types:
10
+ # - vertex - see vertex.rb
11
+ # - symbol - :name attribute of the vertex
12
+ class DependencyManager
13
+ attr_reader :deps, :data
14
+
15
+ # @param deps list of symbols or vertices to watch for as dependencies
16
+ def initialize deps:
17
+ Assertions.is_a? deps, Array
18
+ Assertions.is_a? deps, [Vertex, Symbol]
19
+
20
+ @deps = deps.collect { |raw_dep|
21
+ case raw_dep
22
+ when Symbol then VStateData::Key.new name: raw_dep
23
+ when Vertex then VStateData::Key.new vertex: raw_dep
24
+ end
25
+ }
26
+ @data = []
27
+ end
28
+
29
+ # deduct (update) dependencies with new data
30
+ # - counts :done dependencies
31
+ # - preserves :done deps data
32
+ # @param new_data - Array of VStateData retrieved from the broker
33
+ def deduct_deps new_data
34
+ data = filter_foreign new_data
35
+ Log.debug "received deps: #{data}"
36
+
37
+ assert_failed data
38
+
39
+ done_deps = data.select(&:done?)
40
+ @data += done_deps
41
+ Log.debug "done deps: #{done_deps}"
42
+
43
+ @deps.reject! { |dep| done_deps.include? dep }
44
+ Log.info "deps left: #{@deps.collect(&:to_s)}}"
45
+ end
46
+
47
+ private
48
+
49
+ # filters out deps doesn't belong to the manager
50
+ def filter_foreign new_data
51
+ if (new_data - @deps).empty?
52
+ new_data
53
+ else
54
+ Log.warn 'non-deps state received, filtering'
55
+ Log.debug "non-dep states: #{new_data - @deps}"
56
+ new_data.filter { |state_data| deps.include? state_data }
57
+ end
58
+ end
59
+
60
+ # checks if any dependencies in the received data are failed
61
+ def assert_failed data
62
+ failed_deps = data.select(&:failed?).collect(&:to_s)
63
+ Log.debug "failed deps: #{failed_deps}"
64
+ raise "some deps failed: #{failed_deps}" unless failed_deps.empty?
65
+ end
66
+ end
67
+ end
data/lib/excadg/log.rb ADDED
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ require_relative 'assertions'
6
+
7
+ module ExcADG
8
+ # logging support
9
+ module Log
10
+ # ractor-based logger
11
+ # this ractor logger receives messages from other ractors and log them
12
+ # it can be useful in case logging shouldn't interrupt other thread
13
+ # @param dest - what to write logs to, $stdout by default, gets interpret as filename unless IO
14
+ # @param level - one of the Logger's log levels
15
+ class RLogger < Ractor
16
+ def self.new dest: $stdout, level: Logger::INFO
17
+ super(dest, level) { |dest, level|
18
+ File.open(dest, 'w+', &:write) unless dest.is_a? IO
19
+ l ||= Logger.new dest
20
+ l.level = level
21
+ l.formatter = proc { |severity, datetime, progname, msg|
22
+ format('%20s | %4s | %-10s || %s', datetime, severity, progname, msg)
23
+ }
24
+ while log = Ractor.receive
25
+ # Expect 3 args - severity, proc name and message
26
+ l.public_send(log.first, log[1], &-> { log.last.to_s + "\n" })
27
+ end
28
+ }
29
+ end
30
+ end
31
+
32
+ def self.method_missing(method, *args, &_block)
33
+ return if @muted
34
+
35
+ r = Ractor.current
36
+ @main.send [method, r&.to_s || r.object_id, *args]
37
+ rescue Ractor::ClosedError => e
38
+ # last hope - there is tty
39
+ puts "can't send message to logging ractor: #{e}"
40
+ end
41
+
42
+ def self.respond_to_missing?
43
+ true
44
+ end
45
+
46
+ # default logger e.g. for tests
47
+ @main = RLogger.new
48
+ @muted = false
49
+
50
+ # replaces default logger with a custom logger
51
+ def self.logger new_logger
52
+ Assertions.is_a? new_logger, RLogger
53
+ @main = new_logger
54
+ end
55
+
56
+ # mute logging by ignoring all incoming log requests
57
+ def self.mute
58
+ @muted = true
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../broker'
4
+ require_relative '../request'
5
+
6
+ module ExcADG
7
+ # payload examples to demonstrate framework capabilities
8
+ # most of the payloads in the file are used in tests
9
+ module Payload::Example
10
+ # minimalistic payload that does nothing
11
+ # but echoes its args or :ping by default
12
+ class Echo
13
+ include Payload
14
+ def get
15
+ -> { @args }
16
+ end
17
+
18
+ def sanitize args
19
+ args.nil? ? :ping : args
20
+ end
21
+ end
22
+
23
+ # dependencies data processing example
24
+ # deps_data is an Array with a VStateData objects for each dependency
25
+ # the example just checks that all deps returned :ping, fails otherwise
26
+ class Receiver
27
+ include Payload
28
+ def get
29
+ lambda { |deps_data|
30
+ deps_data.collect { |d|
31
+ raise 'incorrect data received from dependencies' unless d.data.eql? :ping
32
+ }
33
+ }
34
+ end
35
+ end
36
+
37
+ # payload that fails
38
+ class Faulty
39
+ include Payload
40
+
41
+ def get
42
+ -> { raise @args }
43
+ end
44
+ end
45
+
46
+ # payload that sleeps @args or 1 second(s)
47
+ class Sleepy
48
+ include Payload
49
+ def get
50
+ -> { sleep @args }
51
+ end
52
+
53
+ def sanitize args
54
+ args.is_a?(Integer) ? args : 0.1
55
+ end
56
+ end
57
+
58
+ # calculate Nth member of the function
59
+ # y = x(n) * (x(n-1)) where x(0) = 0 and x(1) = 1
60
+ # it illustrates how payload can be customized with @args
61
+ # and extended by adding methods
62
+ class Multiplier
63
+ include Payload
64
+ def get
65
+ -> { get_el x: @args }
66
+ end
67
+
68
+ def get_el x:
69
+ case x
70
+ when 0 then 0
71
+ when 1 then 1
72
+ else
73
+ x * get_el(x: x - 1)
74
+ end
75
+ end
76
+ end
77
+
78
+ # payload that does something on condition received from its deps
79
+ # see spec/vertex_spec.rb for full example
80
+ class Condition
81
+ include Payload
82
+ def get
83
+ lambda { |deps_data|
84
+ ExcADG::Broker.ask ExcADG::Request::AddVertex.new(payload: Echo.new) if deps_data.all? { |d| d.data.eql? :trigger }
85
+ }
86
+ end
87
+ end
88
+
89
+ # payload that implements an idiomatic loop by
90
+ # making N vertices - one for each of its dependencies
91
+ class Loop
92
+ include Payload
93
+ def get
94
+ lambda { |deps_data|
95
+ deps_data.first.data.collect { |e|
96
+ ExcADG::Broker.ask ExcADG::Request::AddVertex.new payload: Echo.new(args: e)
97
+ }
98
+ }
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require 'open3'
5
+
6
+ module ExcADG
7
+ # Payloads that wraps other programs execution
8
+ module Payload::Wrapper
9
+ # runs a binary
10
+ # save its stdout, stderr and exit code to state
11
+ # provides path to temp file with dependencies data JSON in a DEPS_DATAFILE env variable
12
+ class Bin
13
+ include Payload
14
+ def get
15
+ lambda { |deps_data|
16
+ begin
17
+ f = nil
18
+ Dir.mktmpdir { |dir|
19
+ f = File.new File.join(dir, 'data.json'), 'w+'
20
+ f.write JSON.generate deps_data
21
+ f.flush
22
+ stdout, stderr, status = Open3.capture3({ 'DEPS_DATAFILE' => f.path }, args)
23
+ data = { stdout:, stderr:, exitcode: status.exitstatus }
24
+ raise CommandFailed, data unless status.exitstatus.zero?
25
+
26
+ data
27
+ }
28
+ ensure
29
+ f&.close
30
+ end
31
+ }
32
+ end
33
+
34
+ def sanitize args
35
+ raise "arguments should be a String, got #{args}" unless args.is_a? String
36
+
37
+ args
38
+ end
39
+
40
+ # exception with command execution result
41
+ # for cases when the command fails
42
+ class CommandFailed < StandardError
43
+ # @param data same data as what would be returned by a successful run
44
+ def initialize data
45
+ super 'command failed'
46
+ @data = data
47
+ end
48
+
49
+ def to_json(*args)
50
+ @data.to_json(*args)
51
+ end
52
+ end
53
+ end
54
+
55
+ # runs a ruby script
56
+ # behaves same as the Bin reg parameters and return data
57
+ class Ruby < Bin
58
+ def sanitize args
59
+ raise "arguments should be a String, got #{args}" unless args.is_a? String
60
+
61
+ "ruby #{args}"
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExcADG
4
+ # module to base payload for {ExcADG::Vertex} -es on it,
5
+ # reason for having this special module to provider simple labmdas
6
+ # is that Ractor (which is a base for {ExcADG::Vertex})
7
+ # require its parameters scope to be shareable
8
+ module Payload
9
+ attr_accessor :args
10
+
11
+ # main method of the payload that holds code to be executed within vertex,
12
+ # vertex takes care of error processing - there is no need to mask exceptions,
13
+ # this method should return a Proc, that:
14
+ # * could receive up to 1 arguments
15
+ # * 1st argument, if specified, is an {Array} of {ExcADG::VStateData} from the vertex dependencies
16
+ # * could access @args of the obejct, which was set on object's constructing
17
+ # @return {Proc}
18
+ # @raise {ExcADG::Payload::NoPayloadSet} by default to fail vertices with partially-implemented payload
19
+ def get
20
+ raise NoPayloadSet, 'payload is empty'
21
+ end
22
+
23
+ # constructor to store arguments for the lambda in the object
24
+ #
25
+ # implementation implies that child class could implement {#sanitize}
26
+ # to transform args as needed
27
+ def initialize args: nil
28
+ @args = respond_to?(:sanitize) ? send(:sanitize, args) : args
29
+ end
30
+
31
+ class NoPayloadSet < StandardError; end
32
+ class IncorrectPayloadArity < StandardError; end
33
+ end
34
+ end
35
+
36
+ require_relative 'payload/example'
37
+ require_relative 'payload/wrapper'
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'assertions'
4
+ require_relative 'payload'
5
+ require_relative 'vstate_data'
6
+
7
+ module ExcADG
8
+ # base class for messages between Ractors
9
+ class Request
10
+ attr_reader :self
11
+
12
+ def initialize
13
+ @self = Ractor.current
14
+ end
15
+
16
+ # request to get state data
17
+ class GetStateData < ExcADG::Request
18
+ attr_reader :deps
19
+
20
+ # @param deps Array of VStateData::Key
21
+ def initialize deps: nil
22
+ super()
23
+ @deps = deps
24
+ end
25
+
26
+ def filter?
27
+ !@deps.nil?
28
+ end
29
+ end
30
+
31
+ # request to update self state in the central storage
32
+ class Update < ExcADG::Request
33
+ attr_reader :data
34
+
35
+ def initialize data:
36
+ super()
37
+ Assertions.is_a? data, VStateData::Full
38
+
39
+ @data = data
40
+ end
41
+ end
42
+
43
+ # request to make and start a new vertex
44
+ class AddVertex < ExcADG::Request
45
+ attr_reader :payload
46
+
47
+ def initialize payload:
48
+ super()
49
+ raise "Incorrent payload type: #{payload.class}" unless payload.is_a? Payload
50
+
51
+ @payload = payload
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExcADG
4
+ # simple ractor-safe timeout implementation
5
+ # @param timeout timeout in seconds
6
+ # @param block payload to run with timeout
7
+ module RTimeout
8
+ def await timeout: nil, &block
9
+ return block.call if timeout.nil? || timeout.zero?
10
+
11
+ timed_out = false
12
+ payload = Thread.new { Thread.current[:result] = block.call }
13
+ Thread.new {
14
+ sleep timeout
15
+ payload.kill
16
+ timed_out = true
17
+ }
18
+
19
+ payload.join
20
+ timed_out ? raise(TimedOutError) : payload[:result]
21
+ end
22
+ module_function :await
23
+
24
+ class TimedOutError < StandardError; end
25
+ end
26
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rgl/adjacency'
4
+ require 'rgl/base'
5
+
6
+ require_relative 'broker'
7
+ require_relative 'log'
8
+ require_relative 'request'
9
+ require_relative 'vstate_data'
10
+
11
+ module ExcADG
12
+ # carry states and transitions for individual vertices
13
+ class StateMachine
14
+ # sets in stone possible state transitions
15
+ GRAPH = RGL::DirectedAdjacencyGraph.new
16
+ GRAPH.add_edge :new, :ready
17
+ GRAPH.add_edge :ready, :done
18
+ # any transition could end-up in a failed state
19
+ GRAPH.add_vertex :failed
20
+ Ractor.make_shareable GRAPH
21
+
22
+ # add states graph to the current object
23
+ def initialize name:
24
+ @state = :new
25
+ @state_edge_bindings = {}
26
+ @state_transition_data = {}
27
+
28
+ @name = name
29
+ end
30
+
31
+ # bind action to one of the state graph's edges
32
+ def bind_action source, target, &block
33
+ [source, target].each { |state|
34
+ raise WrongState, "unknown state #{state}" unless GRAPH.has_vertex? state
35
+ }
36
+ raise WrongTransition.new source, target unless GRAPH.has_edge? source, target
37
+
38
+ edge = GRAPH.edges.find { |e| e.source == source && e.target = target }
39
+ @state_edge_bindings[edge] = block
40
+ end
41
+
42
+ # transition to next state
43
+ # @return: state data (result) / nil if it's a final step
44
+ def step
45
+ Log.debug 'taking another step'
46
+ assert_state_transition_bounds
47
+
48
+ target_candidates = GRAPH.each_adjacent @state
49
+ Log.debug "possible candidates: #{target_candidates.size}"
50
+ return nil if target_candidates.none?
51
+ raise WrongState, "state #{@state} has more than one adjacent states" unless target_candidates.one?
52
+
53
+ target = target_candidates.first
54
+ Log.debug "found a candidate: #{target}"
55
+ edge = GRAPH.edges.find { |e| e.source == @state && e.target = target }
56
+ begin
57
+ @state_transition_data[target] = @state_edge_bindings[edge].call
58
+ @state = target
59
+ Log.debug "moved to #{@state}"
60
+ rescue StandardError => e
61
+ Log.error "step failed with #{e} / #{e.backtrace}"
62
+ @state_transition_data[:failed] = e
63
+ @state = :failed
64
+ ensure
65
+ begin
66
+ Broker.ask Request::Update.new data: state_data
67
+ rescue StandardError => e
68
+ @state_transition_data[:failed] = e
69
+ @state = :failed
70
+ Broker.ask Request::Update.new data: state_data
71
+ end
72
+ end
73
+ @state_transition_data[@state]
74
+ end
75
+
76
+ def assert_state_transition_bounds
77
+ raise NotAllTransitionsBound, GRAPH.edges - @state_edge_bindings.keys unless GRAPH.edges.eql? @state_edge_bindings.keys
78
+ end
79
+
80
+ # makes state data for the current vertex
81
+ def state_data
82
+ VStateData::Full.new name: @name, data: @state_transition_data[@state], state: @state
83
+ end
84
+
85
+ class WrongState < StandardError; end
86
+
87
+ class WrongTransition < StandardError
88
+ def initialize src, dest
89
+ super("Transition #{src} -> #{dest} is not available.")
90
+ end
91
+ end
92
+
93
+ class NotAllTransitionsBound < StandardError; end
94
+ end
95
+ end
data/lib/excadg/tui.rb ADDED
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'io/console'
5
+
6
+ require_relative 'broker'
7
+ require_relative 'state_machine'
8
+
9
+ module ExcADG
10
+ # render status on the screen
11
+ module Tui
12
+ MAX_VERTEX_TO_SHOW = 10
13
+ DELAY = 0.2
14
+ DEFAULT_BOX_SIZE = { height: 50, width: 150 }.freeze
15
+ # TODO: do runtime calc
16
+ BOX_SIZE = {
17
+ height: DEFAULT_BOX_SIZE[:height] > (IO.console&.winsize&.first || 1000) ? IO.console.winsize.first : DEFAULT_BOX_SIZE[:height],
18
+ width: DEFAULT_BOX_SIZE[:width] > (IO.console&.winsize&.last || 1000) ? IO.console.winsize.last : DEFAULT_BOX_SIZE[:width]
19
+ }.freeze
20
+ CONTENT_SIZE = {
21
+ height: BOX_SIZE[:height] - 4, # 2 for borders, 1 for \n, 1 for remark
22
+ width: BOX_SIZE[:width] - 5 # 2 for borders, 2 to indent
23
+ }.freeze
24
+ LINE_TEMPLATE = "| %-#{CONTENT_SIZE[:width]}s |\n".freeze
25
+
26
+ @started_at = DateTime.now.strftime('%Q').to_i
27
+
28
+ class << self
29
+ # spawns a thread to show stats to console in background
30
+ def run
31
+ Log.info 'spawning tui'
32
+ @thread = Thread.new {
33
+ loop {
34
+ print_in_box stats
35
+ sleep DELAY
36
+ }
37
+ }
38
+ end
39
+
40
+ def summarize has_failed, timed_out
41
+ @thread.kill
42
+ print_in_box stats + (print_summary has_failed, timed_out)
43
+ end
44
+
45
+ private
46
+
47
+ # @param content is a list of lines to print
48
+ def print_in_box content
49
+ clear
50
+ print "+-#{'-' * CONTENT_SIZE[:width]}-+\n"
51
+ content[..CONTENT_SIZE[:height]].each { |line|
52
+ if line.size > CONTENT_SIZE[:width]
53
+ printf LINE_TEMPLATE, "#{line[...(CONTENT_SIZE[:width] - 3)]}..."
54
+ else
55
+ printf LINE_TEMPLATE, line
56
+ end
57
+ }
58
+ if content.size < CONTENT_SIZE[:height]
59
+ (CONTENT_SIZE[:height] - content.size).times { printf LINE_TEMPLATE, ' ' }
60
+ else
61
+ printf LINE_TEMPLATE, '<some content did not fit and was cropped>'[..CONTENT_SIZE[:width]]
62
+ end
63
+ print "+-#{'-' * CONTENT_SIZE[:width]}-+\n"
64
+ end
65
+
66
+ def print_summary has_failed, timed_out
67
+ [timed_out ? 'execution timed out' : 'execution completed',
68
+ "#{has_failed ? 'some' : 'no'} vertices failed"]
69
+ end
70
+
71
+ # make summary paragraph on veritces
72
+ def stats
73
+ [
74
+ "time spent (ms): #{DateTime.now.strftime('%Q').to_i - @started_at}",
75
+ "vertices seen: #{Broker.data_store.size}",
76
+ 'progress:'
77
+ ] + state_stats.collect { |line| " #{line}" }
78
+ end
79
+
80
+ def clear
81
+ print "\e[2J\e[f"
82
+ end
83
+
84
+ # make states summary, one for a line with consistent placing
85
+ def state_stats
86
+ skeleton = StateMachine::GRAPH.vertices.collect { |v| [v, []] }.to_h
87
+ # rubocop:disable Style/HashTransformValues
88
+ filled = skeleton.merge Broker.data_store.to_a
89
+ .group_by(&:state)
90
+ .collect { |state, vertices| [state, vertices_stats(vertices)] }
91
+ .to_h
92
+ # rubocop:enable Style/HashTransformValues
93
+ filled.collect { |k, v| format '%-10s: %s', k, "#{v.empty? ? '<none>' : v}" }
94
+ end
95
+
96
+ def vertices_stats vertice_pairs
97
+ full_list = vertice_pairs.collect(&:name)
98
+ addition = full_list.size > MAX_VERTEX_TO_SHOW ? "... and #{full_list.size - MAX_VERTEX_TO_SHOW} more" : ''
99
+ full_list[0...MAX_VERTEX_TO_SHOW].join(', ') + addition
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'broker'
4
+ require_relative 'dependency_manager'
5
+ require_relative 'log'
6
+ require_relative 'payload'
7
+ require_relative 'request'
8
+ require_relative 'rtimeout'
9
+ require_relative 'state_machine'
10
+
11
+ module ExcADG
12
+ # Individual vertex of the execution graph to run in a separated Ractor
13
+ class Vertex < Ractor
14
+ include RTimeout
15
+
16
+ # @return parsed info about the Ractor: number, file, line in file, status
17
+ def info
18
+ inspect.scan(/^#<Ractor:#(\d+)\s(.*):(\d+)\s(\w+)>$/).first
19
+ end
20
+
21
+ # Ractor's internal status
22
+ # @return Symbol, :unknown if parsing failed
23
+ def status
24
+ (info&.dig(3) || :unknwon).to_sym
25
+ end
26
+
27
+ # @return Ractor's number, -1 if parsing failed
28
+ def number
29
+ info&.dig(0).to_i || -1
30
+ end
31
+
32
+ def to_s
33
+ "#{number} #{status}"
34
+ end
35
+
36
+ # below are shortcut methods to access Vertex data from the main Ractor
37
+
38
+ # obtains current Vertex-es data by lookup in the Broker's data,
39
+ # available from the main Ractor only
40
+ def data
41
+ Broker.data_store[self]
42
+ end
43
+
44
+ # gets current Vertex's state,
45
+ # available from the main Ractor only
46
+ def state
47
+ data&.state
48
+ end
49
+
50
+ # gets current Vertex's name,
51
+ # available from the main Ractor only
52
+ def name
53
+ data&.name
54
+ end
55
+
56
+ class << self
57
+ # make a vertex, it runs automagically
58
+ # @param payload Payload object to run in this Vertex
59
+ # @param name optional vertex name to identify vertex
60
+ # @param deps list of other Vertices or names to wait for
61
+ # @param timeout total time in seconds for the payload to run
62
+ # @raise Payload::IncorrectPayloadArity in case payload returns function with arity > 1
63
+ # @raise Payload::NoPayloadSet in case payload provided has incorrect type
64
+ def new payload:, name: nil, deps: [], timeout: nil
65
+ raise Payload::NoPayloadSet, "expected payload, got #{payload.class}" unless payload.is_a? Payload
66
+
67
+ raise Payload::IncorrectPayloadArity, "arity is #{payload.get.arity}, supported only 0 and 1" unless [0, 1].include? payload.get.arity
68
+
69
+ super(payload, name, timeout, DependencyManager.new(deps:)) { |payload, name, timeout, deps_manager|
70
+ state_machine = StateMachine.new(name: name || "v#{number}".to_sym)
71
+ Broker.ask Request::Update.new data: state_machine.state_data
72
+ Log.info 'building vertex lifecycle'
73
+ state_machine.bind_action(:new, :ready) {
74
+ until deps_manager.deps.empty?
75
+ deps_data = Broker.ask Request::GetStateData.new(deps: deps_manager.deps)
76
+ deps_manager.deduct_deps deps_data
77
+ sleep 0.2
78
+ end
79
+ deps_manager.data
80
+ }
81
+ state_machine.bind_action(:ready, :done) {
82
+ function = payload.get
83
+ await(timeout:) {
84
+ case function.arity
85
+ when 0 then function.call
86
+ when 1 then function.call state_machine.state_data.data
87
+ else
88
+ raise Payload::IncorrectPayloadArity, "unexpected payload arity: #{function.arity}, supported only 0 and 1"
89
+ end
90
+ }
91
+ }
92
+
93
+ Log.debug "another step fades: #{state_machine.state_data}" while state_machine.step
94
+
95
+ Log.debug 'shut down'
96
+ }
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal:true
2
+
3
+ require 'json'
4
+
5
+ require_relative 'assertions'
6
+ require_relative 'state_machine'
7
+ require_relative 'vertex'
8
+
9
+ module ExcADG
10
+ # Vertex state data as filled by {ExcADG::StateMachine}
11
+ # to be transferred between vertices
12
+ module VStateData
13
+ # class to support comparison and Arrays operations
14
+ class Key
15
+ attr_reader :name, :vertex
16
+
17
+ def initialize name: nil, vertex: nil
18
+ raise 'name or vertex are required' if name.nil? && vertex.nil?
19
+
20
+ Assertions.is_a?(vertex, Vertex) unless vertex.nil?
21
+
22
+ @name = name
23
+ @vertex = vertex
24
+ end
25
+
26
+ include Comparable
27
+ def <=> other
28
+ return nil unless (other.is_a? self.class) || (is_a? other.class)
29
+ # no mutual fields to compare
30
+ return nil if (@vertex.nil? && other.name.nil?) || (@name.nil? && other.vertex.nil?)
31
+
32
+ # name takes preference
33
+ @name.nil? || other.name.nil? ? @vertex <=> other.vertex : @name <=> other.name
34
+ end
35
+
36
+ def eql? other
37
+ (self <=> other)&.zero? || false
38
+ end
39
+
40
+ def to_s
41
+ (@name || @vertex).to_s
42
+ end
43
+ end
44
+
45
+ # contains actual data
46
+ class Full < Key
47
+ attr_reader :state, :data, :name, :vertex
48
+
49
+ # param state: Symbol, one of StateMachine.GRAPH.vertices
50
+ # param data: all data returned by a Vertice's Payload
51
+ # param name: Symboli -c name of the associated Vertex
52
+ # param vertex: Vertex that produced the data
53
+ def initialize state:, data:, name:, vertex: nil
54
+ # observation: Ractor.current returns Vertex object if invoked from a Vertex
55
+ super(name:, vertex: vertex || Ractor.current)
56
+ @state = state
57
+ @data = data
58
+ end
59
+
60
+ def to_s
61
+ "#{name || vertex} (#{state})"
62
+ end
63
+
64
+ # method omits objects without a good known text representation
65
+ def to_json(*args)
66
+ {
67
+ name: @name,
68
+ state: @state,
69
+ data: @data
70
+ }.to_json(*args)
71
+ end
72
+
73
+ # converts full object to key to use in Hash
74
+ def to_key
75
+ Key.new vertex: @vertex, name: @name
76
+ end
77
+
78
+ # auto-generated methods to check states easier;
79
+ # note: define_method causes these object to become un-shareable
80
+ # what breaks Broker's messaging
81
+ def method_missing(method, *_args, &_block)
82
+ raise NoMethodError unless respond_to_missing? method, _args
83
+
84
+ @state.eql? method[...-1].to_sym
85
+ end
86
+
87
+ def respond_to_missing? method, *_args
88
+ method.to_s.end_with?('?') && ExcADG::StateMachine::GRAPH.has_vertex?(method[...-1].to_sym)
89
+ end
90
+ end
91
+ end
92
+ end
data/lib/excadg.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal:true
2
+
3
+ # Execute Acyclic Directed Graph
4
+ module ExcADG; end
5
+
6
+ require_relative 'excadg/assertions'
7
+ require_relative 'excadg/broker'
8
+ require_relative 'excadg/data_store'
9
+ require_relative 'excadg/dependency_manager'
10
+ require_relative 'excadg/log'
11
+ require_relative 'excadg/payload'
12
+ require_relative 'excadg/request'
13
+ require_relative 'excadg/rtimeout'
14
+ require_relative 'excadg/state_machine'
15
+ require_relative 'excadg/tui'
16
+ require_relative 'excadg/vertex'
17
+ require_relative 'excadg/vstate_data'
metadata ADDED
@@ -0,0 +1,60 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: excadg
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - skorobogatydmitry
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-07-06 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: "# Description\n\nThat's a library (framework) to execute a graph of
14
+ dependent tasks (vertices). \nIts main feature is to run all possible tasks independently
15
+ in parallel once they're ready. \nAnother feature is that the graph is dynamic
16
+ and any vertex could produce another vertice(s) to extend execution graph.\n"
17
+ email: skorobogaty.dmitry@gmail.com
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - lib/excadg.rb
23
+ - lib/excadg/assertions.rb
24
+ - lib/excadg/broker.rb
25
+ - lib/excadg/data_store.rb
26
+ - lib/excadg/dependency_manager.rb
27
+ - lib/excadg/log.rb
28
+ - lib/excadg/payload.rb
29
+ - lib/excadg/payload/example.rb
30
+ - lib/excadg/payload/wrapper.rb
31
+ - lib/excadg/request.rb
32
+ - lib/excadg/rtimeout.rb
33
+ - lib/excadg/state_machine.rb
34
+ - lib/excadg/tui.rb
35
+ - lib/excadg/vertex.rb
36
+ - lib/excadg/vstate_data.rb
37
+ homepage: https://rubygems.org/gems/excadg
38
+ licenses:
39
+ - LGPL-3.0-only
40
+ metadata: {}
41
+ post_install_message:
42
+ rdoc_options: []
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: 3.3.1
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ requirements: []
56
+ rubygems_version: 3.5.9
57
+ signing_key:
58
+ specification_version: 4
59
+ summary: Execute Acyclic Directed Graph
60
+ test_files: []