async-graph 0.1.1 → 0.1.2

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.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "graph_validation"
4
+
3
5
  module AsyncGraph
4
6
  FINISH = :__finish__
5
7
 
@@ -7,21 +9,8 @@ module AsyncGraph
7
9
  Edge = Struct.new(:to, :branch, keyword_init: true)
8
10
  AwaitSignal = Struct.new(:requests, keyword_init: true)
9
11
 
10
- def self.symbolize(object)
11
- case object
12
- when Array then object.map { |value| symbolize(value) }
13
- when Hash then object.each_with_object({}) { |(key, value), memo| memo[key.to_sym] = symbolize(value) }
14
- else object
15
- end
16
- end
17
-
18
- def self.stringify(object)
19
- case object
20
- when Array then object.map { |value| stringify(value) }
21
- when Hash then object.each_with_object({}) { |(key, value), memo| memo[key.to_s] = stringify(value) }
22
- else object
23
- end
24
- end
12
+ class ValidationError < StandardError; end
13
+ class JoinConflictError < StandardError; end
25
14
 
26
15
  class Command < Struct.new(:update, :goto, keyword_init: true)
27
16
  def self.goto(node) = new(goto: node)
@@ -44,6 +33,16 @@ module AsyncGraph
44
33
  def finished? = true
45
34
  end
46
35
 
36
+ class JoinParked < Struct.new(:joins, keyword_init: true)
37
+ def parked? = true
38
+ def released? = false
39
+ end
40
+
41
+ class JoinReleased < Struct.new(:token, :joins, keyword_init: true)
42
+ def parked? = false
43
+ def released? = true
44
+ end
45
+
47
46
  # Interesting options for issuing multiple external jobs from one logical step:
48
47
  # 1. Model fan-out explicitly in the graph as a diamond.
49
48
  # 2. Use await.all(...) inside one node to queue a batch and suspend once.
@@ -80,46 +79,97 @@ module AsyncGraph
80
79
  end
81
80
 
82
81
  class Graph
82
+ include GraphValidation
83
+
83
84
  attr_reader :entry
84
85
 
85
86
  def initialize(&block)
86
87
  @nodes = {}
87
88
  @edges = Hash.new { |hash, key| hash[key] = [] }
88
89
  @join_expects = {}
90
+ @validated = false
89
91
  instance_eval(&block) if block
90
92
  end
91
93
 
92
- def node(name, &block) = @nodes[name.to_sym] = block
94
+ def node(name, &block)
95
+ name = name.to_sym
96
+ raise ValidationError, "Node #{name} requires a block" unless block
97
+ raise ValidationError, "Node #{name} is already defined" if @nodes.key?(name)
98
+
99
+ invalidate_validation!
100
+ @nodes[name] = block
101
+ end
93
102
 
94
103
  def edge(from, to, branch: nil)
95
104
  if from.is_a?(Array)
96
- @join_expects[to.to_sym] = from.map(&:to_sym)
97
- from.each { |item| edge(item, to, branch: item) }
105
+ sources = from.map(&:to_sym)
106
+ validate_join_sources!(to, sources)
107
+
108
+ target = to.to_sym
109
+ raise ValidationError, "Join target #{target} is already defined" if @join_expects.key?(target)
110
+
111
+ invalidate_validation!
112
+ @join_expects[target] = sources
113
+ sources.each { |item| edge(item, target, branch: item) }
98
114
  else
99
- @edges[from.to_sym] << Edge.new(to: to.to_sym, branch: branch&.to_sym)
115
+ invalidate_validation!
116
+ add_edge(from.to_sym, Edge.new(to: to.to_sym, branch: branch&.to_sym))
100
117
  end
101
118
  end
102
119
 
103
- def set_entry_point(name) = @entry = name.to_sym
120
+ def set_entry_point(name)
121
+ invalidate_validation!
122
+ @entry = name.to_sym
123
+ end
124
+
104
125
  def set_finish_point(name) = edge(name, FINISH)
105
126
 
106
127
  def step(state:, node:, resolved: {})
128
+ validate!
129
+
107
130
  current = node.to_sym
108
131
  return Finished.new(state:) if current == FINISH
109
132
 
133
+ validate_known_node!(current, context: "Step node")
110
134
  await = Await.new(resolved)
111
- result = catch(:await) { [:ok, call_node(@nodes.fetch(current), state, await)] }
135
+ result = catch(:await) { [:ok, call_node(@nodes[current], state, await)] }
112
136
  return Suspended.new(state:, node: current, requests: result.requests) if result.is_a?(AwaitSignal)
113
137
 
114
138
  Advanced.new(**advance(current, state, result.last))
115
139
  end
116
140
 
117
141
  def edges_from(node)
118
- @edges[node.to_sym].yield_self { |edges| edges.empty? ? [Edge.new(to: FINISH)] : edges }
142
+ validate!
143
+
144
+ current = node.to_sym
145
+ validate_known_node!(current, context: "Node")
146
+ @edges[current].yield_self { |edges| edges.empty? ? [Edge.new(to: FINISH)] : edges }
119
147
  end
120
148
 
121
- def join?(node) = @join_expects.key?(node.to_sym)
122
- def join_for(node) = @join_expects.fetch(node.to_sym)
149
+ def join?(node)
150
+ validate!
151
+ @join_expects.key?(node.to_sym)
152
+ end
153
+
154
+ def join_for(node)
155
+ validate!
156
+ @join_expects.fetch(node.to_sym) { raise ValidationError, "Node #{node} is not a join node" }
157
+ end
158
+
159
+ def process_join(token:, joins:)
160
+ validate!
161
+
162
+ current, expects, fork_uid, source = join_context(token)
163
+ bucket_key = join_bucket_key(fork_uid, current)
164
+ current_joins = joins.dup
165
+ state = token.fetch(:state, {})
166
+ states = next_join_states(current, source, current_joins[bucket_key], state)
167
+
168
+ return park_join(current_joins, bucket_key, current, states) unless join_complete?(states, expects)
169
+
170
+ current_joins.delete(bucket_key)
171
+ release_join(current_joins, current, fork_uid, expects, states)
172
+ end
123
173
 
124
174
  private
125
175
 
@@ -136,13 +186,98 @@ module AsyncGraph
136
186
  when Hash
137
187
  { state: state.merge(result), destinations: edges_from(node) }
138
188
  when Command
189
+ destinations =
190
+ if result.goto
191
+ target = result.goto.to_sym
192
+ validate_destination!(target, context: "Goto target")
193
+ [Edge.new(to: target)]
194
+ else
195
+ edges_from(node)
196
+ end
197
+
139
198
  {
140
199
  state: state.merge(result.update || {}),
141
- destinations: result.goto ? [Edge.new(to: result.goto.to_sym)] : edges_from(node)
200
+ destinations: destinations
142
201
  }
143
202
  else
144
203
  { state: state, destinations: edges_from(node) }
145
204
  end
146
205
  end
206
+
207
+ def add_edge(from, edge)
208
+ @edges[from] << edge unless @edges[from].any? { |existing| existing.to == edge.to && existing.branch == edge.branch }
209
+ end
210
+
211
+ def join_context(token)
212
+ current = token.fetch(:node)&.to_sym
213
+ validate_known_node!(current, context: "Join node")
214
+ raise ValidationError, "Node #{current} is not a join node" unless join?(current)
215
+
216
+ fork_uid = token.fetch(:fork_uid)
217
+ raise ValidationError, "Join token for #{current} is missing fork_uid" if fork_uid.nil? || fork_uid.to_s.empty?
218
+
219
+ source = token.fetch(:source_node)&.to_sym
220
+ raise ValidationError, "Join token for #{current} is missing source_node" unless source
221
+
222
+ expects = join_for(current)
223
+ unless expects.include?(source)
224
+ raise ValidationError, "Join token for #{current} arrived from unexpected source #{source}"
225
+ end
226
+
227
+ [current, expects, fork_uid, source]
228
+ end
229
+
230
+ def join_bucket_key(fork_uid, node)
231
+ :"#{fork_uid}:#{node}"
232
+ end
233
+
234
+ def next_join_states(current, source, bucket, state)
235
+ states = (bucket || {join_node: current, states: {}}).fetch(:states, {}).dup
236
+ if states.key?(source) && states[source] != state
237
+ raise JoinConflictError, "Join node #{current} received conflicting state for source #{source}"
238
+ end
239
+
240
+ states[source] = state
241
+ states
242
+ end
243
+
244
+ def join_complete?(states, expects)
245
+ (expects - states.keys).empty?
246
+ end
247
+
248
+ def park_join(current_joins, bucket_key, current, states)
249
+ current_joins[bucket_key] = {
250
+ join_node: current,
251
+ states: states
252
+ }
253
+ JoinParked.new(joins: current_joins)
254
+ end
255
+
256
+ def release_join(current_joins, current, fork_uid, expects, states)
257
+ JoinReleased.new(
258
+ token: {
259
+ token_uid: "#{fork_uid}.join",
260
+ node: current,
261
+ state: merge_join_states(states, expects, current),
262
+ fork_uid: nil,
263
+ branch: nil,
264
+ source_node: nil,
265
+ awaits: {}
266
+ },
267
+ joins: current_joins
268
+ )
269
+ end
270
+
271
+ def merge_join_states(states, expects, current)
272
+ expects.each_with_object({}) do |source, memo|
273
+ state = states.fetch(source)
274
+ conflicting = (memo.keys & state.keys).reject { |key| memo[key] == state[key] }
275
+ unless conflicting.empty?
276
+ raise JoinConflictError, "Join node #{current} has conflicting values for: #{conflicting.join(', ')}"
277
+ end
278
+
279
+ memo.merge!(state)
280
+ end
281
+ end
147
282
  end
148
283
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsyncGraph
4
+ module GraphValidation
5
+ def validate!
6
+ return self if @validated
7
+
8
+ errors = validation_errors
9
+ raise ValidationError, errors.uniq.join("; ") unless errors.empty?
10
+
11
+ @validated = true
12
+ self
13
+ end
14
+
15
+ private
16
+
17
+ def validation_errors
18
+ [].tap do |errors|
19
+ add_entry_validation_errors(errors)
20
+ add_edge_validation_errors(errors)
21
+ add_join_validation_errors(errors)
22
+ end.uniq
23
+ end
24
+
25
+ def add_entry_validation_errors(errors)
26
+ errors << "Entry point is not set" unless @entry
27
+ return unless @entry && !@nodes.key?(@entry)
28
+
29
+ errors << "Entry point #{@entry} is not defined"
30
+ end
31
+
32
+ def add_edge_validation_errors(errors)
33
+ @edges.each do |from, edges|
34
+ errors << "Edge source #{from} is not defined" unless @nodes.key?(from)
35
+ add_edge_target_validation_errors(errors, edges)
36
+ end
37
+ end
38
+
39
+ def add_edge_target_validation_errors(errors, edges)
40
+ edges.each do |edge|
41
+ next if edge.to == FINISH || @nodes.key?(edge.to)
42
+
43
+ errors << "Edge target #{edge.to} is not defined"
44
+ end
45
+ end
46
+
47
+ def add_join_validation_errors(errors)
48
+ @join_expects.each do |target, sources|
49
+ errors << "Join target #{target} is not defined" unless @nodes.key?(target)
50
+ sources.each do |source|
51
+ errors << "Join source #{source} for #{target} is not defined" unless @nodes.key?(source)
52
+ end
53
+ end
54
+ end
55
+
56
+ def invalidate_validation!
57
+ @validated = false
58
+ end
59
+
60
+ def validate_join_sources!(target, sources)
61
+ target = target.to_sym
62
+ raise ValidationError, "Join target #{target} requires at least one source" if sources.empty?
63
+
64
+ duplicates = sources.group_by(&:itself).filter_map { |source, items| source if items.size > 1 }
65
+ return if duplicates.empty?
66
+
67
+ raise ValidationError, "Join target #{target} has duplicate sources: #{duplicates.join(', ')}"
68
+ end
69
+
70
+ def validate_known_node!(node, context:)
71
+ raise ValidationError, "#{context} is not set" unless node
72
+ return if @nodes.key?(node)
73
+
74
+ raise ValidationError, "#{context} #{node} is not defined"
75
+ end
76
+
77
+ def validate_destination!(node, context:)
78
+ return if node == FINISH
79
+
80
+ validate_known_node!(node, context:)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsyncGraph
4
+ class Runner
5
+ Run = Struct.new(:status, :tokens, :joins, :result, keyword_init: true) do
6
+ def running? = status.to_sym == :running
7
+ def finished? = status.to_sym == :finished
8
+
9
+ def to_h
10
+ {
11
+ status: status.to_s,
12
+ tokens: tokens,
13
+ joins: joins,
14
+ result: result
15
+ }
16
+ end
17
+ end
18
+
19
+ Result = Struct.new(:status, :tokens, :joins, :state, :requests, :request_refs, keyword_init: true) do
20
+ def token = tokens.first
21
+ def parked? = status == :parked
22
+ def released? = status == :released
23
+ def suspended? = status == :suspended
24
+ def advanced? = status == :advanced
25
+ def finished? = status == :finished
26
+ end
27
+
28
+ def initialize(graph)
29
+ @graph = graph
30
+ end
31
+
32
+ def start(state:, token_uid: "t1")
33
+ @graph.validate!
34
+
35
+ {
36
+ token_uid: token_uid,
37
+ node: @graph.entry,
38
+ state: state,
39
+ fork_uid: nil,
40
+ branch: nil,
41
+ source_node: nil,
42
+ awaits: {}
43
+ }
44
+ end
45
+
46
+ def start_run(state:, token_uid: "t1")
47
+ Run.new(
48
+ status: :running,
49
+ tokens: [start(state:, token_uid:)],
50
+ joins: {},
51
+ result: nil
52
+ )
53
+ end
54
+
55
+ def step(token:, joins:, resolved: {}, &)
56
+ return build_join_result(@graph.process_join(token:, joins:)) if join_token?(token)
57
+
58
+ graph_step = @graph.step(
59
+ state: token.fetch(:state),
60
+ node: token.fetch(:node),
61
+ resolved: resolved
62
+ )
63
+
64
+ case graph_step
65
+ when Suspended
66
+ build_suspended_result(token, joins, graph_step, &)
67
+ when Advanced
68
+ build_advanced_result(token, joins, graph_step)
69
+ when Finished
70
+ build_finished_result(joins, graph_step.state)
71
+ else
72
+ raise ValidationError, "Unsupported graph step result #{graph_step.class}"
73
+ end
74
+ end
75
+
76
+ def advance_run(run:, resolved_for: nil, &)
77
+ current_run = normalize_run(run)
78
+ return current_run if current_run.finished?
79
+
80
+ next_tokens, joins, final_state = advance_tokens(
81
+ current_run,
82
+ resolved_for: resolved_for,
83
+ &
84
+ )
85
+ Run.new(
86
+ status: run_status(final_state, next_tokens, joins),
87
+ tokens: next_tokens,
88
+ joins: joins,
89
+ result: final_state
90
+ )
91
+ end
92
+
93
+ private
94
+
95
+ def normalize_run(run)
96
+ return run if run.is_a?(Run)
97
+
98
+ Run.new(
99
+ status: run.fetch(:status).to_sym,
100
+ tokens: run.fetch(:tokens, []),
101
+ joins: run.fetch(:joins, {}),
102
+ result: run[:result]
103
+ )
104
+ end
105
+
106
+ def join_token?(token)
107
+ token[:source_node] && @graph.join?(token.fetch(:node))
108
+ end
109
+
110
+ def build_join_result(join_result)
111
+ case join_result
112
+ when JoinParked
113
+ Result.new(
114
+ status: :parked,
115
+ tokens: [],
116
+ joins: join_result.joins,
117
+ state: nil,
118
+ requests: [],
119
+ request_refs: {}
120
+ )
121
+ when JoinReleased
122
+ Result.new(
123
+ status: :released,
124
+ tokens: [join_result.token],
125
+ joins: join_result.joins,
126
+ state: join_result.token.fetch(:state),
127
+ requests: [],
128
+ request_refs: {}
129
+ )
130
+ else
131
+ raise ValidationError, "Unsupported join result #{join_result.class}"
132
+ end
133
+ end
134
+
135
+ def build_suspended_result(token, joins, graph_step, &)
136
+ awaits = token.fetch(:awaits, {}).dup
137
+ request_refs = graph_step.requests.to_h do |request|
138
+ key = request.key.to_sym
139
+ [key, awaits[key] ||= bind_request(request, &)]
140
+ end
141
+
142
+ Result.new(
143
+ status: :suspended,
144
+ tokens: [
145
+ {
146
+ token_uid: token.fetch(:token_uid),
147
+ node: graph_step.node,
148
+ state: graph_step.state,
149
+ fork_uid: token[:fork_uid],
150
+ branch: token[:branch],
151
+ source_node: token[:source_node],
152
+ awaits: awaits
153
+ }
154
+ ],
155
+ joins: joins,
156
+ state: graph_step.state,
157
+ requests: graph_step.requests,
158
+ request_refs: request_refs
159
+ )
160
+ end
161
+
162
+ def build_advanced_result(token, joins, graph_step)
163
+ destinations = graph_step.destinations
164
+ if destinations.size > 1
165
+ fork_uid = fork_uid_for(token.fetch(:token_uid))
166
+ tokens = destinations.map do |edge|
167
+ suffix = edge.branch || edge.to
168
+ {
169
+ token_uid: "#{token.fetch(:token_uid)}.#{suffix}",
170
+ node: edge.to,
171
+ state: graph_step.state,
172
+ fork_uid: fork_uid,
173
+ branch: edge.branch,
174
+ source_node: token.fetch(:node),
175
+ awaits: {}
176
+ }
177
+ end
178
+
179
+ return Result.new(
180
+ status: :advanced,
181
+ tokens: tokens,
182
+ joins: joins,
183
+ state: graph_step.state,
184
+ requests: [],
185
+ request_refs: {}
186
+ )
187
+ end
188
+
189
+ edge = destinations.first
190
+ return build_finished_result(joins, graph_step.state) if edge.to == FINISH
191
+
192
+ Result.new(
193
+ status: :advanced,
194
+ tokens: [
195
+ {
196
+ token_uid: token.fetch(:token_uid),
197
+ node: edge.to,
198
+ state: graph_step.state,
199
+ fork_uid: token[:fork_uid],
200
+ branch: token[:branch],
201
+ source_node: token.fetch(:node),
202
+ awaits: {}
203
+ }
204
+ ],
205
+ joins: joins,
206
+ state: graph_step.state,
207
+ requests: [],
208
+ request_refs: {}
209
+ )
210
+ end
211
+
212
+ def build_finished_result(joins, state)
213
+ Result.new(
214
+ status: :finished,
215
+ tokens: [],
216
+ joins: joins,
217
+ state: state,
218
+ requests: [],
219
+ request_refs: {}
220
+ )
221
+ end
222
+
223
+ def bind_request(request, &)
224
+ return yield(request) if block_given?
225
+
226
+ raise ArgumentError, "Runner#step requires a block to bind external IDs for new requests"
227
+ end
228
+
229
+ def advance_tokens(run, resolved_for:, &)
230
+ next_tokens = []
231
+ joins = run.joins
232
+ final_state = run.result
233
+
234
+ run.tokens.each do |token|
235
+ outcome = step(
236
+ token: token,
237
+ joins: joins,
238
+ resolved: resolved_for&.call(token) || {},
239
+ &
240
+ )
241
+ joins = outcome.joins
242
+ next_tokens.concat(outcome.tokens)
243
+ final_state = outcome.state if outcome.finished?
244
+ end
245
+
246
+ [next_tokens, joins, final_state]
247
+ end
248
+
249
+ def run_status(final_state, next_tokens, joins)
250
+ final_state && next_tokens.empty? && joins.empty? ? :finished : :running
251
+ end
252
+
253
+ def fork_uid_for(token_uid) = "fork-#{token_uid}"
254
+ end
255
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AsyncGraph
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.2"
5
5
  end
data/lib/async-graph.rb CHANGED
@@ -2,3 +2,4 @@
2
2
 
3
3
  require_relative "async-graph/version"
4
4
  require_relative "async-graph/graph"
5
+ require_relative "async-graph/runner"