async-graph 0.1.2 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e191c8f8d75367ded4fb71dc739d1c337bc41b158e40577aaf06a054a0321f4
4
- data.tar.gz: b82e743e0b5119862364200eb8ff558312cb7d0963dd7257e87b38f8895e5924
3
+ metadata.gz: 5fb46c9d6fc4ea1d3954655810b8605acdb74a84e3299f4910a2e8af30e19618
4
+ data.tar.gz: 8384644bec8efa74c592fd2bef726ccee2c41fe7596b802ea1f26fedde3ee25f
5
5
  SHA512:
6
- metadata.gz: a351f8bdbb37c7b7da3d09ce4c63d965d9981d6ebeb97bd7473837909085d186c31e2d1efe2a28e46c9d3bd417b20f1cd851a7a4d44ef817360b026d249f7729
7
- data.tar.gz: 1ad91c064b0dcd6bafd8bc8f62e987d6b909fb77f713eedadbe2581b6025e47a03d2ee1b36f2f04752718df9efb2fbbd743743ee837012053957634214ed2d7e
6
+ metadata.gz: aa56a0e83197515c7e66f869a8edfc8dd4db46a9fb08f030d38d05af93bc2e6b44168c689706b8061f5b6f63b57dcf8fd1d9d963260911805aaf1882ef7007fc
7
+ data.tar.gz: '083844bd7e389b9ccf3b68ed642cce69730c64f324efe416f3496e0531a9ea42f23c237ddcfb6cf1f20da73f3289c4ecdad31b3a7ef0ed8dd2785be340538bb0'
@@ -15,7 +15,7 @@ jobs:
15
15
  - uses: ruby/setup-ruby@v1
16
16
  with:
17
17
  ruby-version: "3.4.4"
18
- bundler-cache: true
18
+ - run: bundle install
19
19
  - run: bundle exec rake
20
20
  - name: Run RuboCop
21
21
  run: |
data/README.erb CHANGED
@@ -51,7 +51,7 @@ snapshot while your application still owns persistence and external jobs.
51
51
 
52
52
  ## Demo
53
53
 
54
- The repository includes two runnable examples in `examples/`:
54
+ The repository includes three runnable examples in `examples/`:
55
55
 
56
56
  - persisted multi-pass flow with external job persistence:
57
57
 
@@ -59,10 +59,16 @@ The repository includes two runnable examples in `examples/`:
59
59
  bash examples/run.sh
60
60
  ```
61
61
 
62
- - self-contained runner loop with inline `:add` / `:subtract` request handling:
62
+ - self-contained runner loop with inline `:add` / `:subtract` request handling through `resolve_request:`:
63
63
 
64
64
  ```bash
65
- ruby examples/all_in_one_runner.rb
65
+ ruby examples/all_in_one_inline_runner.rb
66
+ ```
67
+
68
+ - self-contained runner loop that still suspends, binds requests to in-memory job IDs, and resumes in the same process:
69
+
70
+ ```bash
71
+ ruby examples/all_in_one_jobs_runner.rb
66
72
  ```
67
73
 
68
74
  ## Documentation
data/README.md CHANGED
@@ -51,7 +51,7 @@ snapshot while your application still owns persistence and external jobs.
51
51
 
52
52
  ## Demo
53
53
 
54
- The repository includes two runnable examples in `examples/`:
54
+ The repository includes three runnable examples in `examples/`:
55
55
 
56
56
  - persisted multi-pass flow with external job persistence:
57
57
 
@@ -59,10 +59,16 @@ The repository includes two runnable examples in `examples/`:
59
59
  bash examples/run.sh
60
60
  ```
61
61
 
62
- - self-contained runner loop with inline `:add` / `:subtract` request handling:
62
+ - self-contained runner loop with inline `:add` / `:subtract` request handling through `resolve_request:`:
63
63
 
64
64
  ```bash
65
- ruby examples/all_in_one_runner.rb
65
+ ruby examples/all_in_one_inline_runner.rb
66
+ ```
67
+
68
+ - self-contained runner loop that still suspends, binds requests to in-memory job IDs, and resumes in the same process:
69
+
70
+ ```bash
71
+ ruby examples/all_in_one_jobs_runner.rb
66
72
  ```
67
73
 
68
74
  ## Documentation
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../lib/async-graph"
5
+
6
+ runner = AsyncGraph::Runner.new(
7
+ AsyncGraph::Graph.new do
8
+ node :calculate do |state, await|
9
+ results = await.all(
10
+ added: [:add, {left: state[:left], right: state[:right]}],
11
+ subtracted: [:subtract, {left: state[:total], right: state[:discount]}]
12
+ )
13
+
14
+ {
15
+ added: results[:added],
16
+ subtracted: results[:subtracted],
17
+ answer: results[:added] - results[:subtracted]
18
+ }
19
+ end
20
+
21
+ set_entry_point :calculate
22
+ set_finish_point :calculate
23
+ end
24
+ )
25
+
26
+ run = runner.start_run state: {left: 7, right: 5, total: 20, discount: 3}
27
+
28
+ until run.finished?
29
+ run = runner.advance_run(
30
+ run: run,
31
+ resolve_request: lambda do |kind, payload|
32
+ case kind
33
+ when :add then payload[:left] + payload[:right]
34
+ when :subtract then payload[:left] - payload[:right]
35
+ else AsyncGraph::DEFER
36
+ end
37
+ end
38
+ )
39
+ end
40
+
41
+ puts JSON.pretty_generate(run.result)
@@ -29,7 +29,7 @@ run = runner.start_run state: {left: 7, right: 5, total: 20, discount: 3}
29
29
  until run.finished?
30
30
  run = runner.advance_run(
31
31
  run: run,
32
- resolved_for: lambda do |token|
32
+ resolved: lambda do |token|
33
33
  token[:awaits].each_with_object({}) do |(key, request_id), memo|
34
34
  memo[key.to_s] = results[request_id] if results.key?(request_id)
35
35
  end
@@ -7,7 +7,7 @@ def advance_graph_state!(graph_state, job_list, jobs_by_uid)
7
7
  graph_uid = graph_state.fetch(:graph_uid)
8
8
  next_run = RUNNER.advance_run(
9
9
  run: graph_state.fetch(:run),
10
- resolved_for: lambda do |token|
10
+ resolved: lambda do |token|
11
11
  token.fetch(:awaits, {}).each_with_object({}) do |(key, job_uid), memo|
12
12
  job = jobs_by_uid[job_uid]
13
13
  memo[key.to_s] = job[:result] if job&.[](:status) == "done"
data/examples/run.sh CHANGED
@@ -3,7 +3,7 @@ set -euo pipefail
3
3
 
4
4
  cd "$(dirname "$0")"
5
5
 
6
- for file in app_graph.rb reset.rb graph_run.rb execute_jobs.rb all_in_one_runner.rb; do
6
+ for file in app_graph.rb reset.rb graph_run.rb execute_jobs.rb all_in_one_inline_runner.rb all_in_one_jobs_runner.rb; do
7
7
  ruby -c "$file"
8
8
  done
9
9
 
@@ -41,5 +41,8 @@ ruby graph_run.rb
41
41
  cat graph_states.json
42
42
  cat jobs.json
43
43
 
44
- printf '\n== all-in-one runner ==\n'
45
- ruby all_in_one_runner.rb
44
+ printf '\n== all-in-one inline runner ==\n'
45
+ ruby all_in_one_inline_runner.rb
46
+
47
+ printf '\n== all-in-one jobs runner ==\n'
48
+ ruby all_in_one_jobs_runner.rb
@@ -4,6 +4,7 @@ require_relative "graph_validation"
4
4
 
5
5
  module AsyncGraph
6
6
  FINISH = :__finish__
7
+ DEFER = Object.new.freeze
7
8
 
8
9
  Request = Struct.new(:key, :kind, :payload, keyword_init: true)
9
10
  Edge = Struct.new(:to, :branch, keyword_init: true)
@@ -48,15 +49,20 @@ module AsyncGraph
48
49
  # 2. Use await.all(...) inside one node to queue a batch and suspend once.
49
50
  # 3. Use a two-phase API such as await.defer + await.resolve_all.
50
51
  class Await
51
- def initialize(resolved)
52
+ def initialize(resolved, resolve_request = nil)
52
53
  @resolved = resolved
54
+ @resolve_request = resolve_request
53
55
  end
54
56
 
55
57
  def call(key, kind, payload = {})
56
58
  key = key.to_s
57
59
  return @resolved[key] if @resolved.key?(key)
58
60
 
59
- throw :await, AwaitSignal.new(requests: [Request.new(key:, kind:, payload:)])
61
+ request = Request.new(key:, kind:, payload:)
62
+ resolved = resolve(request)
63
+ throw :await, AwaitSignal.new(requests: [request]) if resolved.equal?(DEFER)
64
+
65
+ resolved
60
66
  end
61
67
 
62
68
  def all(definitions)
@@ -64,18 +70,37 @@ module AsyncGraph
64
70
  [key.to_s, [kind, payload || {}]]
65
71
  end
66
72
 
73
+ resolved = {}
67
74
  missing = normalized.filter_map do |key, (kind, payload)|
68
- next if @resolved.key?(key)
75
+ if @resolved.key?(key)
76
+ resolved[key] = @resolved[key]
77
+ next
78
+ end
69
79
 
70
- Request.new(key:, kind:, payload:)
80
+ request = Request.new(key:, kind:, payload:)
81
+ value = resolve(request)
82
+ if value.equal?(DEFER)
83
+ request
84
+ else
85
+ resolved[key] = value
86
+ nil
87
+ end
71
88
  end
72
89
 
73
90
  throw :await, AwaitSignal.new(requests: missing) unless missing.empty?
74
91
 
75
- normalized.keys.to_h { |key| [key.to_sym, @resolved[key]] }
92
+ normalized.keys.to_h { |key| [key.to_sym, resolved[key]] }
76
93
  end
77
94
 
78
95
  def to_proc = method(:call).to_proc
96
+
97
+ private
98
+
99
+ def resolve(request)
100
+ return DEFER unless @resolve_request
101
+
102
+ @resolve_request.call(request.kind, request.payload)
103
+ end
79
104
  end
80
105
 
81
106
  class Graph
@@ -124,14 +149,14 @@ module AsyncGraph
124
149
 
125
150
  def set_finish_point(name) = edge(name, FINISH)
126
151
 
127
- def step(state:, node:, resolved: {})
152
+ def step(state:, node:, resolved: {}, resolve_request: nil)
128
153
  validate!
129
154
 
130
155
  current = node.to_sym
131
156
  return Finished.new(state:) if current == FINISH
132
157
 
133
158
  validate_known_node!(current, context: "Step node")
134
- await = Await.new(resolved)
159
+ await = Await.new(resolved, resolve_request)
135
160
  result = catch(:await) { [:ok, call_node(@nodes[current], state, await)] }
136
161
  return Suspended.new(state:, node: current, requests: result.requests) if result.is_a?(AwaitSignal)
137
162
 
@@ -52,13 +52,14 @@ module AsyncGraph
52
52
  )
53
53
  end
54
54
 
55
- def step(token:, joins:, resolved: {}, &)
55
+ def step(token:, joins:, resolved: {}, resolve_request: nil, &)
56
56
  return build_join_result(@graph.process_join(token:, joins:)) if join_token?(token)
57
57
 
58
58
  graph_step = @graph.step(
59
59
  state: token.fetch(:state),
60
60
  node: token.fetch(:node),
61
- resolved: resolved
61
+ resolved: resolved,
62
+ resolve_request: resolve_request
62
63
  )
63
64
 
64
65
  case graph_step
@@ -73,13 +74,18 @@ module AsyncGraph
73
74
  end
74
75
  end
75
76
 
76
- def advance_run(run:, resolved_for: nil, &)
77
+ def advance_run(run:, resolved: nil, resolved_for: nil, resolve_request: nil, &)
78
+ if resolved && resolved_for
79
+ raise ArgumentError, "Runner#advance_run accepts either resolved: or resolved_for:, not both"
80
+ end
81
+
77
82
  current_run = normalize_run(run)
78
83
  return current_run if current_run.finished?
79
84
 
80
85
  next_tokens, joins, final_state = advance_tokens(
81
86
  current_run,
82
- resolved_for: resolved_for,
87
+ resolved_source: resolved || resolved_for,
88
+ resolve_request: resolve_request,
83
89
  &
84
90
  )
85
91
  Run.new(
@@ -226,7 +232,7 @@ module AsyncGraph
226
232
  raise ArgumentError, "Runner#step requires a block to bind external IDs for new requests"
227
233
  end
228
234
 
229
- def advance_tokens(run, resolved_for:, &)
235
+ def advance_tokens(run, resolved_source:, resolve_request:, &)
230
236
  next_tokens = []
231
237
  joins = run.joins
232
238
  final_state = run.result
@@ -235,7 +241,8 @@ module AsyncGraph
235
241
  outcome = step(
236
242
  token: token,
237
243
  joins: joins,
238
- resolved: resolved_for&.call(token) || {},
244
+ resolved: resolved_source&.call(token) || {},
245
+ resolve_request: resolve_request,
239
246
  &
240
247
  )
241
248
  joins = outcome.joins
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AsyncGraph
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.3"
5
5
  end
@@ -47,6 +47,96 @@ RSpec.describe AsyncGraph::Graph do
47
47
  end
48
48
  end
49
49
 
50
+ it "resolves await.call inline through resolve_request" do
51
+ graph = described_class.new do
52
+ node :calculate do |state, await|
53
+ sum = await.call(:sum, :add, left: state[:left], right: state[:right])
54
+ {sum: sum}
55
+ end
56
+
57
+ set_entry_point :calculate
58
+ set_finish_point :calculate
59
+ end
60
+
61
+ step = graph.step(
62
+ state: {left: 7, right: 5},
63
+ node: graph.entry,
64
+ resolve_request: lambda do |kind, payload|
65
+ case kind
66
+ when :add then payload[:left] + payload[:right]
67
+ else AsyncGraph::DEFER
68
+ end
69
+ end
70
+ )
71
+
72
+ aggregate_failures do
73
+ expect(step).to be_a(AsyncGraph::Advanced)
74
+ expect(step.state).to eq(left: 7, right: 5, sum: 12)
75
+ expect(step.destinations.map(&:to)).to eq([AsyncGraph::FINISH])
76
+ end
77
+ end
78
+
79
+ it "suspends await.all only for deferred requests" do
80
+ graph = described_class.new do
81
+ node :calculate do |state, await|
82
+ results = await.all(
83
+ added: [:add, {left: state[:left], right: state[:right]}],
84
+ discounted: [:discount, {total: state[:total], amount: state[:discount]}]
85
+ )
86
+
87
+ {
88
+ added: results[:added],
89
+ discounted: results[:discounted]
90
+ }
91
+ end
92
+
93
+ set_entry_point :calculate
94
+ set_finish_point :calculate
95
+ end
96
+
97
+ step = graph.step(
98
+ state: {left: 7, right: 5, total: 20, discount: 3},
99
+ node: graph.entry,
100
+ resolve_request: lambda do |kind, payload|
101
+ case kind
102
+ when :add then payload[:left] + payload[:right]
103
+ else AsyncGraph::DEFER
104
+ end
105
+ end
106
+ )
107
+
108
+ aggregate_failures do
109
+ expect(step).to be_a(AsyncGraph::Suspended)
110
+ expect(step.requests.map(&:key)).to eq(["discounted"])
111
+ expect(step.requests.map(&:kind)).to eq([:discount])
112
+ end
113
+
114
+ resumed = graph.step(
115
+ state: step.state,
116
+ node: step.node,
117
+ resolved: {"discounted" => 17},
118
+ resolve_request: lambda do |kind, payload|
119
+ case kind
120
+ when :add then payload[:left] + payload[:right]
121
+ else AsyncGraph::DEFER
122
+ end
123
+ end
124
+ )
125
+
126
+ aggregate_failures do
127
+ expect(resumed).to be_a(AsyncGraph::Advanced)
128
+ expect(resumed.state).to eq(
129
+ left: 7,
130
+ right: 5,
131
+ total: 20,
132
+ discount: 3,
133
+ added: 12,
134
+ discounted: 17
135
+ )
136
+ expect(resumed.destinations.map(&:to)).to eq([AsyncGraph::FINISH])
137
+ end
138
+ end
139
+
50
140
  it "builds a barrier edge from multiple sources" do
51
141
  graph = described_class.new do
52
142
  node :left do
@@ -0,0 +1,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe AsyncGraph::Runner do
4
+ it "creates a start token from the graph entry point" do
5
+ graph = AsyncGraph::Graph.new do
6
+ node :start do
7
+ end
8
+
9
+ set_entry_point :start
10
+ end
11
+
12
+ token = described_class.new(graph).start(state: {user_id: 7})
13
+
14
+ expect(token).to eq(
15
+ token_uid: "t1",
16
+ node: :start,
17
+ state: {user_id: 7},
18
+ fork_uid: nil,
19
+ branch: nil,
20
+ source_node: nil,
21
+ awaits: {}
22
+ )
23
+ end
24
+
25
+ it "builds an opaque persisted run from the entry point" do
26
+ graph = AsyncGraph::Graph.new do
27
+ node :start do
28
+ end
29
+
30
+ set_entry_point :start
31
+ end
32
+
33
+ run = described_class.new(graph).start_run(state: {user_id: 7})
34
+
35
+ aggregate_failures do
36
+ expect(run).to be_running
37
+ expect(run.result).to be_nil
38
+ expect(run.to_h).to eq(
39
+ status: "running",
40
+ tokens: [
41
+ {
42
+ token_uid: "t1",
43
+ node: :start,
44
+ state: {user_id: 7},
45
+ fork_uid: nil,
46
+ branch: nil,
47
+ source_node: nil,
48
+ awaits: {}
49
+ }
50
+ ],
51
+ joins: {},
52
+ result: nil
53
+ )
54
+ end
55
+ end
56
+
57
+ it "spawns branch tokens for fan-out and finishes on finish edges" do
58
+ graph = AsyncGraph::Graph.new do
59
+ node :split do
60
+ end
61
+
62
+ node :left do
63
+ {left_ready: true}
64
+ end
65
+
66
+ node :right do
67
+ {right_ready: true}
68
+ end
69
+
70
+ set_entry_point :split
71
+ edge :split, :left, branch: :left
72
+ edge :split, :right, branch: :right
73
+ set_finish_point :left
74
+ set_finish_point :right
75
+ end
76
+
77
+ runner = described_class.new(graph)
78
+ split = runner.step(
79
+ token: runner.start(state: {user_id: 7}),
80
+ joins: {}
81
+ )
82
+
83
+ aggregate_failures do
84
+ expect(split).to be_advanced
85
+ expect(split.tokens).to eq(
86
+ [
87
+ {
88
+ token_uid: "t1.left",
89
+ node: :left,
90
+ state: {user_id: 7},
91
+ fork_uid: "fork-t1",
92
+ branch: :left,
93
+ source_node: :split,
94
+ awaits: {}
95
+ },
96
+ {
97
+ token_uid: "t1.right",
98
+ node: :right,
99
+ state: {user_id: 7},
100
+ fork_uid: "fork-t1",
101
+ branch: :right,
102
+ source_node: :split,
103
+ awaits: {}
104
+ }
105
+ ]
106
+ )
107
+ end
108
+
109
+ left = runner.step(token: split.tokens.first, joins: split.joins)
110
+ right = runner.step(token: split.tokens.last, joins: split.joins)
111
+
112
+ aggregate_failures do
113
+ expect(left).to be_finished
114
+ expect(left.state).to eq(user_id: 7, left_ready: true)
115
+ expect(right).to be_finished
116
+ expect(right.state).to eq(user_id: 7, right_ready: true)
117
+ end
118
+ end
119
+
120
+ it "advances a persisted run without exposing joins to the caller" do
121
+ graph = AsyncGraph::Graph.new do
122
+ node :split do
123
+ end
124
+
125
+ node :left do
126
+ {left_ready: true}
127
+ end
128
+
129
+ node :right do
130
+ {right_ready: true}
131
+ end
132
+
133
+ node :merge do |state, await|
134
+ profile = await.call(:profile, :fetch_profile, user_id: state[:user_id])
135
+ {profile: profile}
136
+ end
137
+
138
+ set_entry_point :split
139
+ edge :split, :left, branch: :left
140
+ edge :split, :right, branch: :right
141
+ edge %i[left right], :merge
142
+ set_finish_point :merge
143
+ end
144
+
145
+ runner = described_class.new(graph)
146
+ run = runner.start_run(state: {user_id: 7})
147
+ run1 = runner.advance_run(run: run)
148
+ run2 = runner.advance_run(run: run1)
149
+ run3 = runner.advance_run(run: run2)
150
+ run4 = runner.advance_run(run: run3) { |_| "job-1" }
151
+ run5 = runner.advance_run(
152
+ run: run4,
153
+ resolved_for: ->(token) { token[:awaits][:profile] == "job-1" ? {"profile" => {id: 7, name: "Ada"}} : {} }
154
+ )
155
+
156
+ aggregate_failures do
157
+ expect(run1.tokens.map { |token| token[:token_uid] }).to eq(%w[t1.left t1.right])
158
+ expect(run2.tokens.map { |token| token[:node] }).to eq(%i[merge merge])
159
+ expect(run3.tokens.map { |token| token[:token_uid] }).to eq(["fork-t1.join"])
160
+ expect(run4.tokens.first.fetch(:awaits)).to eq(profile: "job-1")
161
+ expect(run5).to be_finished
162
+ expect(run5.result).to eq(
163
+ user_id: 7,
164
+ left_ready: true,
165
+ right_ready: true,
166
+ profile: {id: 7, name: "Ada"}
167
+ )
168
+ end
169
+ end
170
+
171
+ it "finishes a run inline when resolve_request handles all awaits" do
172
+ graph = AsyncGraph::Graph.new do
173
+ node :calculate do |state, await|
174
+ results = await.all(
175
+ added: [:add, {left: state[:left], right: state[:right]}],
176
+ subtracted: [:subtract, {left: state[:total], right: state[:discount]}]
177
+ )
178
+
179
+ {
180
+ added: results[:added],
181
+ subtracted: results[:subtracted],
182
+ answer: results[:added] - results[:subtracted]
183
+ }
184
+ end
185
+
186
+ set_entry_point :calculate
187
+ set_finish_point :calculate
188
+ end
189
+
190
+ runner = described_class.new(graph)
191
+ run = runner.advance_run(
192
+ run: runner.start_run(state: {left: 7, right: 5, total: 20, discount: 3}),
193
+ resolve_request: lambda do |kind, payload|
194
+ case kind
195
+ when :add then payload[:left] + payload[:right]
196
+ when :subtract then payload[:left] - payload[:right]
197
+ else AsyncGraph::DEFER
198
+ end
199
+ end
200
+ )
201
+
202
+ aggregate_failures do
203
+ expect(run).to be_finished
204
+ expect(run.result).to eq(
205
+ left: 7,
206
+ right: 5,
207
+ total: 20,
208
+ discount: 3,
209
+ added: 12,
210
+ subtracted: 17,
211
+ answer: -5
212
+ )
213
+ end
214
+ end
215
+
216
+ it "binds only deferred requests when resolve_request handles part of await.all inline" do
217
+ graph = AsyncGraph::Graph.new do
218
+ node :calculate do |state, await|
219
+ results = await.all(
220
+ added: [:add, {left: state[:left], right: state[:right]}],
221
+ discounted: [:discount, {total: state[:total], amount: state[:discount]}]
222
+ )
223
+
224
+ {
225
+ added: results[:added],
226
+ discounted: results[:discounted]
227
+ }
228
+ end
229
+
230
+ set_entry_point :calculate
231
+ set_finish_point :calculate
232
+ end
233
+
234
+ resolve_request = lambda do |kind, payload|
235
+ case kind
236
+ when :add then payload[:left] + payload[:right]
237
+ else AsyncGraph::DEFER
238
+ end
239
+ end
240
+
241
+ runner = described_class.new(graph)
242
+ first = runner.advance_run(
243
+ run: runner.start_run(state: {left: 7, right: 5, total: 20, discount: 3}),
244
+ resolved: ->(_token) { nil },
245
+ resolve_request: resolve_request
246
+ ) { |request| "job-#{request.key}" }
247
+
248
+ finished = runner.advance_run(
249
+ run: first,
250
+ resolved: lambda do |token|
251
+ token[:awaits][:discounted] == "job-discounted" ? {"discounted" => 17} : {}
252
+ end,
253
+ resolve_request: resolve_request
254
+ )
255
+
256
+ aggregate_failures do
257
+ expect(first).to be_running
258
+ expect(first.tokens.first.fetch(:awaits)).to eq(discounted: "job-discounted")
259
+ expect(finished).to be_finished
260
+ expect(finished.result).to eq(
261
+ left: 7,
262
+ right: 5,
263
+ total: 20,
264
+ discount: 3,
265
+ added: 12,
266
+ discounted: 17
267
+ )
268
+ end
269
+ end
270
+
271
+ it "reuses existing request bindings when a suspended token is retried" do
272
+ graph = AsyncGraph::Graph.new do
273
+ node :fetch do |state, await|
274
+ user = await.call(:user, :fetch_user, user_id: state[:user_id])
275
+ {user: user}
276
+ end
277
+
278
+ set_entry_point :fetch
279
+ set_finish_point :fetch
280
+ end
281
+
282
+ runner = described_class.new(graph)
283
+ first = runner.step(token: runner.start(state: {user_id: 7}), joins: {}) { |_| "job-1" }
284
+ second = runner.step(token: first.token, joins: first.joins)
285
+ finished = runner.step(
286
+ token: first.token,
287
+ joins: first.joins,
288
+ resolved: {"user" => {id: 7, name: "Ada"}}
289
+ )
290
+
291
+ aggregate_failures do
292
+ expect(first).to be_suspended
293
+ expect(first.token.fetch(:awaits)).to eq(user: "job-1")
294
+ expect(second).to be_suspended
295
+ expect(second.token.fetch(:awaits)).to eq(user: "job-1")
296
+ expect(second.request_refs).to eq(user: "job-1")
297
+ expect(finished).to be_finished
298
+ expect(finished.state).to eq(
299
+ user_id: 7,
300
+ user: {id: 7, name: "Ada"}
301
+ )
302
+ end
303
+ end
304
+
305
+ it "parks and releases join tokens through the runner" do
306
+ graph = AsyncGraph::Graph.new do
307
+ node :split do
308
+ end
309
+
310
+ node :left do
311
+ {left_ready: true}
312
+ end
313
+
314
+ node :right do
315
+ {right_ready: true}
316
+ end
317
+
318
+ node :merge do
319
+ end
320
+
321
+ set_entry_point :split
322
+ edge :split, :left, branch: :left
323
+ edge :split, :right, branch: :right
324
+ edge %i[left right], :merge
325
+ end
326
+
327
+ runner = described_class.new(graph)
328
+ split = runner.step(
329
+ token: runner.start(state: {user_id: 7}),
330
+ joins: {}
331
+ )
332
+ left = runner.step(token: split.tokens.first, joins: split.joins)
333
+ right = runner.step(token: split.tokens.last, joins: split.joins)
334
+ parked = runner.step(token: left.token, joins: left.joins)
335
+ released = runner.step(token: right.token, joins: parked.joins)
336
+
337
+ aggregate_failures do
338
+ expect(parked).to be_parked
339
+ expect(parked.joins.keys).to eq([:'fork-t1:merge'])
340
+ expect(released).to be_released
341
+ expect(released.tokens).to eq(
342
+ [
343
+ {
344
+ token_uid: "fork-t1.join",
345
+ node: :merge,
346
+ state: {user_id: 7, left_ready: true, right_ready: true},
347
+ fork_uid: nil,
348
+ branch: nil,
349
+ source_node: nil,
350
+ awaits: {}
351
+ }
352
+ ]
353
+ )
354
+ expect(released.joins).to eq({})
355
+ end
356
+ end
357
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-graph
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Artem Borodkin
@@ -114,7 +114,8 @@ files:
114
114
  - Rakefile
115
115
  - async-graph.gemspec
116
116
  - bin/console
117
- - examples/all_in_one_runner.rb
117
+ - examples/all_in_one_inline_runner.rb
118
+ - examples/all_in_one_jobs_runner.rb
118
119
  - examples/app_graph.rb
119
120
  - examples/execute_jobs.rb
120
121
  - examples/graph_run.rb
@@ -126,6 +127,7 @@ files:
126
127
  - lib/async-graph/runner.rb
127
128
  - lib/async-graph/version.rb
128
129
  - spec/async_graph_spec.rb
130
+ - spec/runner_spec.rb
129
131
  - spec/spec_helper.rb
130
132
  homepage: https://rubygems.org/gems/async-graph
131
133
  licenses: