async-graph 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: f23474b45b79529d8bed62b984c0329017e85daee7521bddf070558cc1f7ff92
4
+ data.tar.gz: b26664dbb15b6ba75b04e8ad07ac7ea7e6c39f0d532ac24f0b2f007647f2c6e0
5
+ SHA512:
6
+ metadata.gz: 64933573224dc275532309ede2a9e6a699115ae6e52a6389171249412a9b9dd2786d728ec803bf06f7d9483d910fdd5ae048e299cf0151b7857df758f9ec6ac2
7
+ data.tar.gz: e81029fc6bb0a4b7a14b470beb7b885dc9d5692ed77c1dde69bfa7d88580b6450279c61c77e3c4433ea54cb2360857fe3d3ad9837551c5539b7f109684940892
@@ -0,0 +1,18 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: ruby/setup-ruby@v1
13
+ with:
14
+ ruby-version: "3.4.4"
15
+ bundler-cache: true
16
+ - run: bundle exec rake
17
+ - run: bundle exec rubocop
18
+ - run: bash examples/run.sh
@@ -0,0 +1,21 @@
1
+ name: release
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+
7
+ jobs:
8
+ build_and_publish:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: ruby/setup-ruby@v1
13
+ with:
14
+ ruby-version: "3.4.4"
15
+ - run: |
16
+ mkdir -p ~/.gem && touch ~/.gem/credentials && chmod 0600 ~/.gem/credentials
17
+ printf -- "---\n:rubygems_api_key: ${API_KEY}\n" > ~/.gem/credentials
18
+ bundle install
19
+ bundle exec rake push
20
+ env:
21
+ API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
data/.gitignore ADDED
@@ -0,0 +1,7 @@
1
+ /.bundle/
2
+ /pkg/
3
+ /results/*
4
+ !/results/.gitkeep
5
+ /.idea/workspace.xml
6
+ /examples/*.json
7
+ /*.gem
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --color
2
+ --require spec_helper
3
+ --warnings
data/.rubocop.yml ADDED
@@ -0,0 +1,98 @@
1
+ require:
2
+ - rubocop-rake
3
+ - rubocop-rspec
4
+
5
+ AllCops:
6
+ NewCops: enable
7
+ Exclude:
8
+ - "*.gemspec"
9
+
10
+ Layout/LineLength:
11
+ Enabled: false
12
+
13
+ Layout/MultilineMethodCallIndentation:
14
+ Enabled: false
15
+
16
+ Layout/LineEndStringConcatenationIndentation:
17
+ Enabled: false
18
+
19
+ Layout/SpaceInsideHashLiteralBraces:
20
+ Enabled: false
21
+
22
+ Lint/EmptyBlock:
23
+ Exclude:
24
+ - "examples/**/*.rb"
25
+ - "spec/**/*.rb"
26
+
27
+ Metrics/AbcSize:
28
+ Enabled: false
29
+
30
+ Metrics/BlockLength:
31
+ Enabled: false
32
+
33
+ Metrics/ClassLength:
34
+ Enabled: false
35
+
36
+ Metrics/CyclomaticComplexity:
37
+ Max: 12
38
+
39
+ Metrics/MethodLength:
40
+ Enabled: false
41
+
42
+ Naming/AccessorMethodName:
43
+ Enabled: false
44
+
45
+ Naming/FileName:
46
+ Exclude:
47
+ - "lib/*-*.rb"
48
+
49
+ Naming/MethodName:
50
+ Enabled: false
51
+
52
+ Naming/MethodParameterName:
53
+ Enabled: false
54
+
55
+ Naming/MemoizedInstanceVariableName:
56
+ Enabled: false
57
+
58
+ Naming/PredicatePrefix:
59
+ Enabled: false
60
+
61
+ RSpec/BeEq:
62
+ Enabled: false
63
+
64
+ RSpec/ExampleLength:
65
+ Enabled: false
66
+
67
+ RSpec/FilePath:
68
+ Enabled: false
69
+
70
+ RSpec/MultipleExpectations:
71
+ Enabled: false
72
+
73
+ Style/Documentation:
74
+ Enabled: false
75
+
76
+ Style/FrozenStringLiteralComment:
77
+ Enabled: false
78
+
79
+ Style/IfUnlessModifier:
80
+ Enabled: false
81
+
82
+ Style/ObjectThen:
83
+ Enabled: false
84
+
85
+ Style/RedundantStructKeywordInit:
86
+ Enabled: false
87
+
88
+ Style/StringConcatenation:
89
+ Enabled: false
90
+
91
+ Style/StringLiteralsInInterpolation:
92
+ Enabled: false
93
+
94
+ Style/StringLiterals:
95
+ Enabled: false
96
+
97
+ Style/StructInheritance:
98
+ Enabled: false
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.4.4
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
data/README.erb ADDED
@@ -0,0 +1,51 @@
1
+ # AsyncGraph
2
+
3
+ AsyncGraph is a small Ruby runtime for graph-style workflows that suspend on external work,
4
+ store jobs outside the graph, and resume on later passes. It supports:
5
+
6
+ - single-step graph execution
7
+ - barrier joins such as `edge %i[left right], :merge`
8
+ - `await.call(...)` for one external job
9
+ - `await.all(...)` for multiple parallel jobs in one node
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ gem install async-graph
15
+ ```
16
+
17
+ ## Example
18
+
19
+ ```ruby
20
+ require "async_graph"
21
+
22
+ graph = AsyncGraph::Graph.new do
23
+ node :fetch_user do |state, await|
24
+ user = await.call("user", :fetch_user, user_id: state[:user_id])
25
+ { user: user }
26
+ end
27
+
28
+ set_entry_point :fetch_user
29
+ set_finish_point :fetch_user
30
+ end
31
+
32
+ step = graph.step(state: { user_id: 7 }, node: graph.entry)
33
+ request = step.requests.first
34
+
35
+ resumed = graph.step(
36
+ state: step.state,
37
+ node: step.node,
38
+ resolved: { request.key => { id: 7, name: "Ada" } }
39
+ )
40
+
41
+ resumed.state
42
+ # => { user_id: 7, user: { id: 7, name: "Ada" } }
43
+ ```
44
+
45
+ ## Demo
46
+
47
+ The repository includes a runnable example in `examples/`:
48
+
49
+ ```bash
50
+ bash examples/run.sh
51
+ ```
data/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # AsyncGraph
2
+
3
+ AsyncGraph is a small Ruby runtime for graph-style workflows that suspend on external work,
4
+ store jobs outside the graph, and resume on later passes. It supports:
5
+
6
+ - single-step graph execution
7
+ - barrier joins such as `edge %i[left right], :merge`
8
+ - `await.call(...)` for one external job
9
+ - `await.all(...)` for multiple parallel jobs in one node
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ gem install async-graph
15
+ ```
16
+
17
+ ## Example
18
+
19
+ ```ruby
20
+ require "async_graph"
21
+
22
+ graph = AsyncGraph::Graph.new do
23
+ node :fetch_user do |state, await|
24
+ user = await.call("user", :fetch_user, user_id: state[:user_id])
25
+ { user: user }
26
+ end
27
+
28
+ set_entry_point :fetch_user
29
+ set_finish_point :fetch_user
30
+ end
31
+
32
+ step = graph.step(state: { user_id: 7 }, node: graph.entry)
33
+ request = step.requests.first
34
+
35
+ resumed = graph.step(
36
+ state: step.state,
37
+ node: step.node,
38
+ resolved: { request.key => { id: 7, name: "Ada" } }
39
+ )
40
+
41
+ resumed.state
42
+ # => { user_id: 7, user: { id: 7, name: "Ada" } }
43
+ ```
44
+
45
+ ## Demo
46
+
47
+ The repository includes a runnable example in `examples/`:
48
+
49
+ ```bash
50
+ bash examples/run.sh
51
+ ```
data/Rakefile ADDED
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "fileutils"
5
+ require "rspec/core/rake_task"
6
+ require "rubocop/rake_task"
7
+ require_relative "lib/async_graph/version"
8
+
9
+ RSpec::Core::RakeTask.new(:spec)
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec]
13
+
14
+ desc "CI RSpec run with reports"
15
+ task :rspec do
16
+ FileUtils.mkdir_p("results")
17
+ RSpec::Core::RakeTask.new(:ci_spec) do |rspec|
18
+ rspec.rspec_opts = "--profile --color -f documentation " \
19
+ "-f RspecJunitFormatter --out ./results/rspec.xml"
20
+ end
21
+ Rake::Task[:ci_spec].invoke
22
+ end
23
+
24
+ desc "Update README.md from README.erb"
25
+ task :readme do
26
+ template = File.read("README.erb")
27
+ renderer = ERB.new(template, trim_mode: "-")
28
+ File.write("README.md", renderer.result)
29
+ end
30
+
31
+ desc "Build and push a new version"
32
+ task push: %i[spec readme] do
33
+ gem_name = "async-graph-#{AsyncGraph::VERSION}.gem"
34
+
35
+ system("gem build async-graph.gemspec") or exit 1
36
+ system("gem install ./#{gem_name}") or exit 1
37
+ system("gem push #{gem_name}") or exit 1
38
+ system("gem list -r async-graph") or exit 1
39
+ end
40
+
41
+ desc "Build a new version"
42
+ task build: %i[spec readme] do
43
+ system("gem build async-graph.gemspec") or exit 1
44
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/async_graph/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "async-graph"
7
+ spec.version = AsyncGraph::VERSION
8
+ spec.summary = "Small async graph runtime with external job scheduling"
9
+ spec.description = "A minimal Ruby graph runtime that suspends on external work and resumes later."
10
+ spec.authors = ["Author"]
11
+ spec.email = ["author@email.address"]
12
+ spec.files = Dir[
13
+ "{bin,examples,lib,spec}/**/*",
14
+ ".github/workflows/*",
15
+ ".gitignore",
16
+ ".rspec",
17
+ ".rubocop.yml",
18
+ ".ruby-version",
19
+ "Gemfile",
20
+ "Rakefile",
21
+ "README.erb",
22
+ "README.md",
23
+ "async-graph.gemspec"
24
+ ]
25
+ spec.require_paths = ["lib"]
26
+ spec.homepage = "https://rubygems.org/gems/async-graph"
27
+ spec.license = "MIT"
28
+ spec.metadata = { "source_code_uri" => "https://github.com/example/async-graph" }
29
+ spec.required_ruby_version = ">= #{File.read(File.join(__dir__, ".ruby-version")).strip}"
30
+
31
+ spec.add_development_dependency "rake", "~> 13.0"
32
+ spec.add_development_dependency "rspec", "~> 3.10"
33
+ spec.add_development_dependency "rspec_junit_formatter", "~> 0.5.1"
34
+ spec.add_development_dependency "rubocop", "~> 1.12"
35
+ spec.add_development_dependency "rubocop-rake", "~> 0.6.0"
36
+ spec.add_development_dependency "rubocop-rspec", "~> 2.14.2"
37
+ end
data/bin/console ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "async_graph"
6
+ require "irb"
7
+
8
+ IRB.start(__FILE__)
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lib/async_graph"
4
+
5
+ GRAPH = AsyncGraph::Graph.new do
6
+ node :split do
7
+ end
8
+
9
+ node :left do
10
+ { left_ready: true }
11
+ end
12
+
13
+ node :right do
14
+ { right_ready: true }
15
+ end
16
+
17
+ node :merge do |state, await|
18
+ results = await.all(
19
+ profile: [:fetch_profile, {user_id: state[:user_id]}],
20
+ score: [:fetch_score, {user_id: state[:user_id]}]
21
+ )
22
+
23
+ {
24
+ left: results[:profile],
25
+ right: results[:score],
26
+ message: "#{results[:profile][:name]} score=#{results[:score][:score]}"
27
+ }
28
+ end
29
+
30
+ set_entry_point :split
31
+ edge :split, :left, branch: :left
32
+ edge :split, :right, branch: :right
33
+ edge %i[left right], :merge
34
+ set_finish_point :merge
35
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ Dir.chdir(__dir__)
6
+
7
+ jobs = JSON.parse(File.read("jobs.json"))
8
+
9
+ jobs.fetch("jobs", []).each do |job|
10
+ next unless job["status"] == "pending"
11
+
12
+ job["result"] =
13
+ case job["kind"]
14
+ when "fetch_profile"
15
+ user_id = job.dig("payload", "user_id")
16
+ {"id" => user_id, "name" => "Ada-#{user_id}"}
17
+ when "fetch_score"
18
+ user_id = job.dig("payload", "user_id")
19
+ {"score" => user_id * 10}
20
+ end
21
+
22
+ next unless job["result"]
23
+
24
+ job["status"] = "done"
25
+ puts "done #{job["job_uid"]} #{job["kind"]}"
26
+ end
27
+
28
+ File.write("jobs.json", JSON.pretty_generate(jobs) + "\n")
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "app_graph"
5
+
6
+ def job_for(jobs, job_uid)
7
+ jobs.fetch("jobs", []).find { |job| job["job_uid"] == job_uid }
8
+ end
9
+
10
+ def resolved_for(token, jobs)
11
+ token.fetch("awaits", {}).each_with_object({}) do |(key, job_uid), memo|
12
+ job = job_for(jobs, job_uid)
13
+ memo[key] = AsyncGraph.symbolize(job["result"]) if job && job["status"] == "done"
14
+ end
15
+ end
16
+
17
+ def next_job_uid(jobs)
18
+ "job-#{jobs.fetch("jobs", []).size + 1}"
19
+ end
20
+
21
+ def queue_job(jobs, request)
22
+ job_uid = next_job_uid(jobs)
23
+ jobs["jobs"] << AsyncGraph.stringify(
24
+ job_uid: job_uid,
25
+ kind: request.kind,
26
+ payload: request.payload,
27
+ status: :pending
28
+ )
29
+ job_uid
30
+ end
31
+
32
+ def spawn_tokens(graph_uid, token, state, destinations, next_tokens)
33
+ if destinations.size > 1
34
+ fork_uid = "fork-#{graph_uid}-#{token["token_uid"]}"
35
+
36
+ destinations.each do |edge|
37
+ next_tokens << AsyncGraph.stringify(
38
+ token_uid: "#{token["token_uid"]}.#{edge.branch}",
39
+ node: edge.to,
40
+ state: state,
41
+ fork_uid: fork_uid,
42
+ branch: edge.branch,
43
+ from_node: token["node"],
44
+ awaits: {}
45
+ )
46
+ end
47
+
48
+ return nil
49
+ end
50
+
51
+ edge = destinations.first
52
+ return state if edge.to == AsyncGraph::FINISH
53
+
54
+ next_tokens << AsyncGraph.stringify(
55
+ token_uid: token["token_uid"],
56
+ node: edge.to,
57
+ state: state,
58
+ fork_uid: token["fork_uid"],
59
+ branch: token["branch"],
60
+ from_node: token["node"],
61
+ awaits: {}
62
+ )
63
+
64
+ nil
65
+ end
66
+
67
+ def process_join(graph_state, token, joins, next_tokens)
68
+ expects = GRAPH.join_for(token["node"])
69
+ bucket_key = "#{token["fork_uid"]}:#{token["node"]}"
70
+ bucket = joins[bucket_key] || {"join_node" => token["node"], "states" => {}}
71
+ bucket["states"][token["from_node"]] = token["state"]
72
+
73
+ missing = expects.map(&:to_s) - bucket["states"].keys
74
+ if missing.empty?
75
+ joins.delete(bucket_key)
76
+ merged_state = bucket["states"].values.reduce({}) do |memo, state|
77
+ memo.merge(AsyncGraph.symbolize(state))
78
+ end
79
+
80
+ puts "#{graph_state["graph_uid"]}/#{token["token_uid"]} joined"
81
+ next_tokens << AsyncGraph.stringify(
82
+ token_uid: "#{token["fork_uid"]}.join",
83
+ node: token["node"],
84
+ state: merged_state,
85
+ fork_uid: nil,
86
+ branch: nil,
87
+ from_node: nil,
88
+ awaits: {}
89
+ )
90
+ return nil
91
+ end
92
+
93
+ joins[bucket_key] = bucket
94
+ puts "#{graph_state["graph_uid"]}/#{token["token_uid"]} parked"
95
+ nil
96
+ end
97
+
98
+ Dir.chdir(__dir__)
99
+
100
+ graph_states = JSON.parse(File.read("graph_states.json"))
101
+ jobs = JSON.parse(File.read("jobs.json"))
102
+
103
+ next_graph_states = graph_states.fetch("graphs", []).map do |graph_state|
104
+ if graph_state["status"] == "finished"
105
+ graph_state
106
+ else
107
+ next_tokens = []
108
+ joins = graph_state["joins"] || {}
109
+ final_state = graph_state["result"]
110
+
111
+ graph_state.fetch("tokens", []).each do |token|
112
+ if GRAPH.join?(token["node"]) && token["from_node"]
113
+ process_join(graph_state, token, joins, next_tokens)
114
+ next
115
+ end
116
+
117
+ waiting_jobs = token.fetch("awaits", {}).values
118
+ .filter_map { |job_uid| job_for(jobs, job_uid) }
119
+ .select { |job| job["status"] == "pending" }
120
+ unless waiting_jobs.empty?
121
+ puts "#{graph_state["graph_uid"]}/#{token["token_uid"]} waiting #{waiting_jobs.map { |job| job["job_uid"] }.join(",")}"
122
+ next_tokens << token
123
+ next
124
+ end
125
+
126
+ step = GRAPH.step(
127
+ state: AsyncGraph.symbolize(token["state"]),
128
+ node: token["node"],
129
+ resolved: resolved_for(token, jobs)
130
+ )
131
+
132
+ case step
133
+ when AsyncGraph::Suspended
134
+ awaits = token.fetch("awaits", {}).dup
135
+ job_uids = step.requests.map do |request|
136
+ awaits[request.key] ||= queue_job(jobs, request)
137
+ end
138
+ puts "#{graph_state["graph_uid"]}/#{token["token_uid"]} suspended #{job_uids.join(",")}"
139
+ next_tokens << AsyncGraph.stringify(
140
+ token_uid: token["token_uid"],
141
+ node: step.node,
142
+ state: step.state,
143
+ fork_uid: token["fork_uid"],
144
+ branch: token["branch"],
145
+ from_node: token["from_node"],
146
+ awaits: awaits
147
+ )
148
+ when AsyncGraph::Advanced
149
+ puts "#{graph_state["graph_uid"]}/#{token["token_uid"]} advanced"
150
+ advanced_state = spawn_tokens(
151
+ graph_state["graph_uid"],
152
+ token,
153
+ step.state,
154
+ step.destinations,
155
+ next_tokens
156
+ )
157
+ final_state = advanced_state if advanced_state
158
+ when AsyncGraph::Finished
159
+ final_state = step.state
160
+ end
161
+ end
162
+
163
+ status = final_state && next_tokens.empty? && joins.empty? ? :finished : :running
164
+ puts "#{graph_state["graph_uid"]} finished" if status == :finished
165
+
166
+ {
167
+ graph_uid: graph_state["graph_uid"],
168
+ status: status,
169
+ tokens: next_tokens,
170
+ joins: joins,
171
+ result: final_state
172
+ }
173
+ end
174
+ end
175
+
176
+ File.write(
177
+ "graph_states.json",
178
+ JSON.pretty_generate(AsyncGraph.stringify(graphs: next_graph_states)) + "\n"
179
+ )
180
+ File.write("jobs.json", JSON.pretty_generate(jobs) + "\n")
@@ -0,0 +1,42 @@
1
+ {
2
+ "graphs": [
3
+ {
4
+ "graph_uid": "graph-1",
5
+ "status": "finished",
6
+ "tokens": [],
7
+ "joins": {},
8
+ "result": {
9
+ "user_id": 7,
10
+ "left_ready": true,
11
+ "right_ready": true,
12
+ "left": {
13
+ "id": 7,
14
+ "name": "Ada-7"
15
+ },
16
+ "right": {
17
+ "score": 70
18
+ },
19
+ "message": "Ada-7 score=70"
20
+ }
21
+ },
22
+ {
23
+ "graph_uid": "graph-2",
24
+ "status": "finished",
25
+ "tokens": [],
26
+ "joins": {},
27
+ "result": {
28
+ "user_id": 8,
29
+ "left_ready": true,
30
+ "right_ready": true,
31
+ "left": {
32
+ "id": 8,
33
+ "name": "Ada-8"
34
+ },
35
+ "right": {
36
+ "score": 80
37
+ },
38
+ "message": "Ada-8 score=80"
39
+ }
40
+ }
41
+ ]
42
+ }
@@ -0,0 +1,50 @@
1
+ {
2
+ "jobs": [
3
+ {
4
+ "job_uid": "job-1",
5
+ "kind": "fetch_profile",
6
+ "payload": {
7
+ "user_id": 7
8
+ },
9
+ "status": "done",
10
+ "result": {
11
+ "id": 7,
12
+ "name": "Ada-7"
13
+ }
14
+ },
15
+ {
16
+ "job_uid": "job-2",
17
+ "kind": "fetch_score",
18
+ "payload": {
19
+ "user_id": 7
20
+ },
21
+ "status": "done",
22
+ "result": {
23
+ "score": 70
24
+ }
25
+ },
26
+ {
27
+ "job_uid": "job-3",
28
+ "kind": "fetch_profile",
29
+ "payload": {
30
+ "user_id": 8
31
+ },
32
+ "status": "done",
33
+ "result": {
34
+ "id": 8,
35
+ "name": "Ada-8"
36
+ }
37
+ },
38
+ {
39
+ "job_uid": "job-4",
40
+ "kind": "fetch_score",
41
+ "payload": {
42
+ "user_id": 8
43
+ },
44
+ "status": "done",
45
+ "result": {
46
+ "score": 80
47
+ }
48
+ }
49
+ ]
50
+ }
data/examples/reset.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "app_graph"
5
+
6
+ Dir.chdir(__dir__)
7
+
8
+ File.write(
9
+ "graph_states.json",
10
+ JSON.pretty_generate(
11
+ AsyncGraph.stringify(
12
+ graphs: [
13
+ {
14
+ graph_uid: "graph-1",
15
+ status: :running,
16
+ tokens: [{token_uid: "t1", node: GRAPH.entry, state: {user_id: 7}, fork_uid: nil, branch: nil, from_node: nil, awaits: {}}],
17
+ joins: {},
18
+ result: nil
19
+ },
20
+ {
21
+ graph_uid: "graph-2",
22
+ status: :running,
23
+ tokens: [{token_uid: "t1", node: GRAPH.entry, state: {user_id: 8}, fork_uid: nil, branch: nil, from_node: nil, awaits: {}}],
24
+ joins: {},
25
+ result: nil
26
+ }
27
+ ]
28
+ )
29
+ ) + "\n"
30
+ )
31
+ File.write("jobs.json", JSON.pretty_generate(jobs: []) + "\n")
32
+
33
+ puts "reset"
data/examples/run.sh ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ cd "$(dirname "$0")"
5
+
6
+ for file in app_graph.rb reset.rb graph_run.rb execute_jobs.rb; do
7
+ ruby -c "$file"
8
+ done
9
+
10
+ printf '\n== reset ==\n'
11
+ ruby reset.rb
12
+ cat graph_states.json
13
+ cat jobs.json
14
+
15
+ printf '\n== graph run 1 ==\n'
16
+ ruby graph_run.rb
17
+ cat graph_states.json
18
+ cat jobs.json
19
+
20
+ printf '\n== graph run 2 ==\n'
21
+ ruby graph_run.rb
22
+ cat graph_states.json
23
+ cat jobs.json
24
+
25
+ printf '\n== graph run 3 ==\n'
26
+ ruby graph_run.rb
27
+ cat graph_states.json
28
+ cat jobs.json
29
+
30
+ printf '\n== graph run 4 ==\n'
31
+ ruby graph_run.rb
32
+ cat graph_states.json
33
+ cat jobs.json
34
+
35
+ printf '\n== execute jobs ==\n'
36
+ ruby execute_jobs.rb
37
+ cat jobs.json
38
+
39
+ printf '\n== graph run 5 ==\n'
40
+ ruby graph_run.rb
41
+ cat graph_states.json
42
+ cat jobs.json
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsyncGraph
4
+ FINISH = :__finish__
5
+
6
+ Request = Struct.new(:key, :kind, :payload, keyword_init: true)
7
+ Edge = Struct.new(:to, :branch, keyword_init: true)
8
+ AwaitSignal = Struct.new(:requests, keyword_init: true)
9
+
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
25
+
26
+ class Command < Struct.new(:update, :goto, keyword_init: true)
27
+ def self.goto(node) = new(goto: node)
28
+ def self.update(delta) = new(update: delta)
29
+ def self.update_and_goto(delta, node) = new(update: delta, goto: node)
30
+ end
31
+
32
+ class Advanced < Struct.new(:state, :destinations, keyword_init: true)
33
+ def suspended? = false
34
+ def finished? = false
35
+ end
36
+
37
+ class Suspended < Struct.new(:state, :node, :requests, keyword_init: true)
38
+ def suspended? = true
39
+ def finished? = false
40
+ end
41
+
42
+ class Finished < Struct.new(:state, keyword_init: true)
43
+ def suspended? = false
44
+ def finished? = true
45
+ end
46
+
47
+ # Interesting options for issuing multiple external jobs from one logical step:
48
+ # 1. Model fan-out explicitly in the graph as a diamond.
49
+ # 2. Use await.all(...) inside one node to queue a batch and suspend once.
50
+ # 3. Use a two-phase API such as await.defer + await.resolve_all.
51
+ class Await
52
+ def initialize(resolved)
53
+ @resolved = resolved
54
+ end
55
+
56
+ def call(key, kind, payload = {})
57
+ key = key.to_s
58
+ return @resolved[key] if @resolved.key?(key)
59
+
60
+ throw :await, AwaitSignal.new(requests: [Request.new(key:, kind:, payload:)])
61
+ end
62
+
63
+ def all(definitions)
64
+ normalized = definitions.to_h do |key, (kind, payload)|
65
+ [key.to_s, [kind, payload || {}]]
66
+ end
67
+
68
+ missing = normalized.filter_map do |key, (kind, payload)|
69
+ next if @resolved.key?(key)
70
+
71
+ Request.new(key:, kind:, payload:)
72
+ end
73
+
74
+ throw :await, AwaitSignal.new(requests: missing) unless missing.empty?
75
+
76
+ normalized.keys.to_h { |key| [key.to_sym, @resolved[key]] }
77
+ end
78
+
79
+ def to_proc = method(:call).to_proc
80
+ end
81
+
82
+ class Graph
83
+ attr_reader :entry
84
+
85
+ def initialize(&block)
86
+ @nodes = {}
87
+ @edges = Hash.new { |hash, key| hash[key] = [] }
88
+ @join_expects = {}
89
+ instance_eval(&block) if block
90
+ end
91
+
92
+ def node(name, &block) = @nodes[name.to_sym] = block
93
+
94
+ def edge(from, to, branch: nil)
95
+ if from.is_a?(Array)
96
+ @join_expects[to.to_sym] = from.map(&:to_sym)
97
+ from.each { |item| edge(item, to, branch: item) }
98
+ else
99
+ @edges[from.to_sym] << Edge.new(to: to.to_sym, branch: branch&.to_sym)
100
+ end
101
+ end
102
+
103
+ def set_entry_point(name) = @entry = name.to_sym
104
+ def set_finish_point(name) = edge(name, FINISH)
105
+
106
+ def step(state:, node:, resolved: {})
107
+ current = node.to_sym
108
+ return Finished.new(state:) if current == FINISH
109
+
110
+ await = Await.new(resolved)
111
+ result = catch(:await) { [:ok, call_node(@nodes.fetch(current), state, await)] }
112
+ return Suspended.new(state:, node: current, requests: result.requests) if result.is_a?(AwaitSignal)
113
+
114
+ Advanced.new(**advance(current, state, result.last))
115
+ end
116
+
117
+ def edges_from(node)
118
+ @edges[node.to_sym].yield_self { |edges| edges.empty? ? [Edge.new(to: FINISH)] : edges }
119
+ end
120
+
121
+ def join?(node) = @join_expects.key?(node.to_sym)
122
+ def join_for(node) = @join_expects.fetch(node.to_sym)
123
+
124
+ private
125
+
126
+ def call_node(node, state, await)
127
+ case node.arity
128
+ when 0 then node.call
129
+ when 1 then node.call(state, &await.to_proc)
130
+ else node.call(state, await)
131
+ end
132
+ end
133
+
134
+ def advance(node, state, result)
135
+ case result
136
+ when Hash
137
+ { state: state.merge(result), destinations: edges_from(node) }
138
+ when Command
139
+ {
140
+ state: state.merge(result.update || {}),
141
+ destinations: result.goto ? [Edge.new(to: result.goto.to_sym)] : edges_from(node)
142
+ }
143
+ else
144
+ { state: state, destinations: edges_from(node) }
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsyncGraph
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "async_graph/version"
4
+ require_relative "async_graph/graph"
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe AsyncGraph::Graph do
4
+ it "suspends and resumes await.all in one node" do
5
+ graph = described_class.new do
6
+ node :merge do |state, await|
7
+ results = await.all(
8
+ profile: [:fetch_profile, {user_id: state[:user_id]}],
9
+ score: [:fetch_score, {user_id: state[:user_id]}]
10
+ )
11
+
12
+ {
13
+ profile: results[:profile],
14
+ score: results[:score]
15
+ }
16
+ end
17
+
18
+ set_entry_point :merge
19
+ set_finish_point :merge
20
+ end
21
+
22
+ step = graph.step(state: {user_id: 7}, node: graph.entry)
23
+
24
+ aggregate_failures do
25
+ expect(step).to be_a(AsyncGraph::Suspended)
26
+ expect(step.requests.map(&:key)).to eq(%w[profile score])
27
+ expect(step.requests.map(&:kind)).to eq(%i[fetch_profile fetch_score])
28
+ end
29
+
30
+ resumed = graph.step(
31
+ state: step.state,
32
+ node: step.node,
33
+ resolved: {
34
+ "profile" => {name: "Ada"},
35
+ "score" => {score: 70}
36
+ }
37
+ )
38
+
39
+ aggregate_failures do
40
+ expect(resumed).to be_a(AsyncGraph::Advanced)
41
+ expect(resumed.state).to eq(
42
+ user_id: 7,
43
+ profile: {name: "Ada"},
44
+ score: {score: 70}
45
+ )
46
+ expect(resumed.destinations.map(&:to)).to eq([AsyncGraph::FINISH])
47
+ end
48
+ end
49
+
50
+ it "builds a barrier edge from multiple sources" do
51
+ graph = described_class.new do
52
+ node :left do
53
+ end
54
+
55
+ node :right do
56
+ end
57
+
58
+ node :merge do
59
+ end
60
+
61
+ set_entry_point :left
62
+ edge %i[left right], :merge
63
+ end
64
+
65
+ aggregate_failures do
66
+ expect(graph.join?(:merge)).to eq(true)
67
+ expect(graph.join_for(:merge)).to eq(%i[left right])
68
+ expect(graph.edges_from(:left).map(&:to)).to eq([:merge])
69
+ expect(graph.edges_from(:right).map(&:to)).to eq([:merge])
70
+ end
71
+ end
72
+
73
+ it "applies command updates and explicit goto" do
74
+ graph = described_class.new do
75
+ node :start do
76
+ AsyncGraph::Command.update_and_goto({ok: true}, :done)
77
+ end
78
+
79
+ node :done do
80
+ end
81
+
82
+ set_entry_point :start
83
+ set_finish_point :done
84
+ end
85
+
86
+ step = graph.step(state: {}, node: graph.entry)
87
+
88
+ aggregate_failures do
89
+ expect(step).to be_a(AsyncGraph::Advanced)
90
+ expect(step.state).to eq(ok: true)
91
+ expect(step.destinations.map(&:to)).to eq([:done])
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async_graph"
metadata ADDED
@@ -0,0 +1,150 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: async-graph
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Author
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '13.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.10'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.10'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rspec_junit_formatter
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 0.5.1
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: 0.5.1
54
+ - !ruby/object:Gem::Dependency
55
+ name: rubocop
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.12'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.12'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rubocop-rake
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: 0.6.0
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: 0.6.0
82
+ - !ruby/object:Gem::Dependency
83
+ name: rubocop-rspec
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: 2.14.2
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: 2.14.2
96
+ description: A minimal Ruby graph runtime that suspends on external work and resumes
97
+ later.
98
+ email:
99
+ - author@email.address
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - ".github/workflows/ci.yml"
105
+ - ".github/workflows/release.yml"
106
+ - ".gitignore"
107
+ - ".rspec"
108
+ - ".rubocop.yml"
109
+ - ".ruby-version"
110
+ - Gemfile
111
+ - README.erb
112
+ - README.md
113
+ - Rakefile
114
+ - async-graph.gemspec
115
+ - bin/console
116
+ - examples/app_graph.rb
117
+ - examples/execute_jobs.rb
118
+ - examples/graph_run.rb
119
+ - examples/graph_states.json
120
+ - examples/jobs.json
121
+ - examples/reset.rb
122
+ - examples/run.sh
123
+ - lib/async_graph.rb
124
+ - lib/async_graph/graph.rb
125
+ - lib/async_graph/version.rb
126
+ - spec/async_graph_spec.rb
127
+ - spec/spec_helper.rb
128
+ homepage: https://rubygems.org/gems/async-graph
129
+ licenses:
130
+ - MIT
131
+ metadata:
132
+ source_code_uri: https://github.com/example/async-graph
133
+ rdoc_options: []
134
+ require_paths:
135
+ - lib
136
+ required_ruby_version: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ version: 3.4.4
141
+ required_rubygems_version: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ requirements: []
147
+ rubygems_version: 3.6.7
148
+ specification_version: 4
149
+ summary: Small async graph runtime with external job scheduling
150
+ test_files: []