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 +7 -0
- data/.github/workflows/ci.yml +18 -0
- data/.github/workflows/release.yml +21 -0
- data/.gitignore +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +98 -0
- data/.ruby-version +1 -0
- data/Gemfile +3 -0
- data/README.erb +51 -0
- data/README.md +51 -0
- data/Rakefile +44 -0
- data/async-graph.gemspec +37 -0
- data/bin/console +8 -0
- data/examples/app_graph.rb +35 -0
- data/examples/execute_jobs.rb +28 -0
- data/examples/graph_run.rb +180 -0
- data/examples/graph_states.json +42 -0
- data/examples/jobs.json +50 -0
- data/examples/reset.rb +33 -0
- data/examples/run.sh +42 -0
- data/lib/async_graph/graph.rb +148 -0
- data/lib/async_graph/version.rb +5 -0
- data/lib/async_graph.rb +4 -0
- data/spec/async_graph_spec.rb +94 -0
- data/spec/spec_helper.rb +3 -0
- metadata +150 -0
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
data/.rspec
ADDED
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
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
|
data/async-graph.gemspec
ADDED
|
@@ -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,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
|
+
}
|
data/examples/jobs.json
ADDED
|
@@ -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
|
data/lib/async_graph.rb
ADDED
|
@@ -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
|
data/spec/spec_helper.rb
ADDED
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: []
|