async-graph 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d3e3bf481043bd72452272103c97734117490058de259428a68eef31fd264d02
4
- data.tar.gz: fe632f6b44ae151a7a84de797f87dcbabfea0fa657f1f2fc89e44346ae2f0a04
3
+ metadata.gz: 9e191c8f8d75367ded4fb71dc739d1c337bc41b158e40577aaf06a054a0321f4
4
+ data.tar.gz: b82e743e0b5119862364200eb8ff558312cb7d0963dd7257e87b38f8895e5924
5
5
  SHA512:
6
- metadata.gz: be4936d2a7d36d3935d179d74b7bf5715c1065f30e825b5e1a2e0ab04ee1f6cbbf528e736967c8c099df55d0b59bb72f39d4d906d964a84e67c9666215637edb
7
- data.tar.gz: cde66865264df5b7d647c2d6704d8fed789c54e25ab7b3c27fe57c2d52e8ca63c01218bcf38d6db0b95d45a825e2641dd90a34db88d388e2426d5cd0861dfba4
6
+ metadata.gz: a351f8bdbb37c7b7da3d09ce4c63d965d9981d6ebeb97bd7473837909085d186c31e2d1efe2a28e46c9d3bd417b20f1cd851a7a4d44ef817360b026d249f7729
7
+ data.tar.gz: 1ad91c064b0dcd6bafd8bc8f62e987d6b909fb77f713eedadbe2581b6025e47a03d2ee1b36f2f04752718df9efb2fbbd743743ee837012053957634214ed2d7e
@@ -4,15 +4,44 @@ on:
4
4
  push:
5
5
  pull_request:
6
6
 
7
+ env:
8
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
9
+
7
10
  jobs:
8
11
  test:
9
12
  runs-on: ubuntu-latest
10
13
  steps:
11
- - uses: actions/checkout@v4
14
+ - uses: actions/checkout@v5
12
15
  - uses: ruby/setup-ruby@v1
13
16
  with:
14
17
  ruby-version: "3.4.4"
15
18
  bundler-cache: true
16
19
  - run: bundle exec rake
17
- - run: bundle exec rubocop
20
+ - name: Run RuboCop
21
+ run: |
22
+ mkdir -p results
23
+ set -o pipefail
24
+ git ls-files -z '*.rb' '*.gemspec' Gemfile Rakefile 'bin/*' \
25
+ | xargs -0 bundle exec rubocop --display-cop-names --extra-details --cache false --force-exclusion 2>&1 \
26
+ | tee results/rubocop.log
27
+ - name: Upload RuboCop log
28
+ if: failure()
29
+ uses: actions/upload-artifact@v6
30
+ with:
31
+ name: rubocop-log
32
+ path: results/rubocop.log
18
33
  - run: bash examples/run.sh
34
+ docs:
35
+ runs-on: ubuntu-latest
36
+ defaults:
37
+ run:
38
+ working-directory: docs-site
39
+ steps:
40
+ - uses: actions/checkout@v5
41
+ - uses: actions/setup-node@v6
42
+ with:
43
+ node-version: 24
44
+ cache: npm
45
+ cache-dependency-path: docs-site/package-lock.json
46
+ - run: npm ci
47
+ - run: npm run build
@@ -0,0 +1,36 @@
1
+ name: docs
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+ pages: write
11
+ id-token: write
12
+
13
+ env:
14
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
15
+
16
+ jobs:
17
+ build:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v5
21
+ - uses: withastro/action@v5
22
+ with:
23
+ path: docs-site
24
+ node-version: 24
25
+ package-manager: npm@11.6.2
26
+
27
+ deploy:
28
+ needs: build
29
+ runs-on: ubuntu-latest
30
+ environment:
31
+ name: github-pages
32
+ url: ${{ steps.deployment.outputs.page_url }}
33
+ steps:
34
+ - name: Deploy to GitHub Pages
35
+ id: deployment
36
+ uses: actions/deploy-pages@v4
@@ -4,18 +4,35 @@ on:
4
4
  push:
5
5
  branches: [main]
6
6
 
7
+ env:
8
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
9
+
7
10
  jobs:
8
11
  build_and_publish:
9
12
  runs-on: ubuntu-latest
10
13
  steps:
11
- - uses: actions/checkout@v4
14
+ - uses: actions/checkout@v5
12
15
  - uses: ruby/setup-ruby@v1
13
16
  with:
14
17
  ruby-version: "3.4.4"
15
- - run: |
18
+ - run: bundle install
19
+ - id: gem_version
20
+ name: Check published gem version
21
+ run: |
22
+ current="$(ruby -e 'require_relative "lib/async-graph/version"; print AsyncGraph::VERSION')"
23
+ latest="$(ruby -rjson -ropen-uri -e 'begin; print JSON.parse(URI.open("https://rubygems.org/api/v1/versions/async-graph/latest.json", &:read))["version"]; rescue StandardError; end')"
24
+ echo "current=$current" >> "$GITHUB_OUTPUT"
25
+ echo "latest=$latest" >> "$GITHUB_OUTPUT"
26
+ - name: Publish gem
27
+ if: steps.gem_version.outputs.current != steps.gem_version.outputs.latest
28
+ run: |
16
29
  mkdir -p ~/.gem && touch ~/.gem/credentials && chmod 0600 ~/.gem/credentials
17
30
  printf -- "---\n:rubygems_api_key: ${API_KEY}\n" > ~/.gem/credentials
18
- bundle install
19
31
  bundle exec rake push
20
32
  env:
21
33
  API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
34
+ - name: Skip published version
35
+ if: steps.gem_version.outputs.current == steps.gem_version.outputs.latest
36
+ run: echo "async-graph ${CURRENT_VERSION} is already published; skipping release."
37
+ env:
38
+ CURRENT_VERSION: ${{ steps.gem_version.outputs.current }}
data/.gitignore CHANGED
@@ -1,8 +1,23 @@
1
1
  /.bundle/
2
+ .idea/
2
3
  /pkg/
3
4
  /results/*
4
5
  !/results/.gitkeep
5
6
  /.idea/workspace.xml
7
+ /docs-site/.astro
8
+ /docs-site/.vscode
6
9
  /examples/*.json
7
10
  /*.gem
8
11
  /*.iml
12
+ /docs-site/dist/
13
+ /docs-site/node_modules/
14
+ /docs-site/npm-debug.log*
15
+ /docs-site/yarn-debug.log*
16
+ /docs-site/yarn-error.log*
17
+ /docs-site/pnpm-debug.log*
18
+ /docs-site/.env
19
+ /docs-site/.env.production
20
+ /docs-site/.DS_Store
21
+ /docs-site/src/assets/houston.webp
22
+ AGENTS.md
23
+ **/.knowledge/
data/README.erb CHANGED
@@ -1,12 +1,16 @@
1
1
  # AsyncGraph
2
+ Published docs: <https://artyomb.github.io/async-graph/>
2
3
 
3
4
  AsyncGraph is a Ruby runtime for graph-style workflows that suspend on external work,
4
5
  store jobs outside the graph, and resume on later passes. It supports:
5
6
 
6
7
  - single-step graph execution
8
+ - runner helpers for opaque persisted run state and fan-out/join bookkeeping
7
9
  - barrier joins such as `edge %i[left right], :merge`
10
+ - library-owned join processing for persisted branch tokens
8
11
  - `await.call(...)` for one external job
9
12
  - `await.all(...)` for multiple parallel jobs in one node
13
+ - graph validation before execution
10
14
 
11
15
  ## Installation
12
16
 
@@ -42,10 +46,31 @@ resumed.state
42
46
  # => { user_id: 7, user: { id: 7, name: "Ada" } }
43
47
  ```
44
48
 
49
+ For persisted multi-pass execution, `AsyncGraph::Runner` can create and advance a run
50
+ snapshot while your application still owns persistence and external jobs.
51
+
45
52
  ## Demo
46
53
 
47
- The repository includes a runnable example in `examples/`:
54
+ The repository includes two runnable examples in `examples/`:
55
+
56
+ - persisted multi-pass flow with external job persistence:
48
57
 
49
58
  ```bash
50
59
  bash examples/run.sh
51
60
  ```
61
+
62
+ - self-contained runner loop with inline `:add` / `:subtract` request handling:
63
+
64
+ ```bash
65
+ ruby examples/all_in_one_runner.rb
66
+ ```
67
+
68
+ ## Documentation
69
+
70
+ The repository also includes the Starlight source site in `docs-site/`.
71
+
72
+ ```bash
73
+ cd docs-site
74
+ npm install
75
+ npm run dev
76
+ ```
data/README.md CHANGED
@@ -1,12 +1,16 @@
1
1
  # AsyncGraph
2
+ Published docs: <https://artyomb.github.io/async-graph/>
2
3
 
3
4
  AsyncGraph is a Ruby runtime for graph-style workflows that suspend on external work,
4
5
  store jobs outside the graph, and resume on later passes. It supports:
5
6
 
6
7
  - single-step graph execution
8
+ - runner helpers for opaque persisted run state and fan-out/join bookkeeping
7
9
  - barrier joins such as `edge %i[left right], :merge`
10
+ - library-owned join processing for persisted branch tokens
8
11
  - `await.call(...)` for one external job
9
12
  - `await.all(...)` for multiple parallel jobs in one node
13
+ - graph validation before execution
10
14
 
11
15
  ## Installation
12
16
 
@@ -42,10 +46,31 @@ resumed.state
42
46
  # => { user_id: 7, user: { id: 7, name: "Ada" } }
43
47
  ```
44
48
 
49
+ For persisted multi-pass execution, `AsyncGraph::Runner` can create and advance a run
50
+ snapshot while your application still owns persistence and external jobs.
51
+
45
52
  ## Demo
46
53
 
47
- The repository includes a runnable example in `examples/`:
54
+ The repository includes two runnable examples in `examples/`:
55
+
56
+ - persisted multi-pass flow with external job persistence:
48
57
 
49
58
  ```bash
50
59
  bash examples/run.sh
51
60
  ```
61
+
62
+ - self-contained runner loop with inline `:add` / `:subtract` request handling:
63
+
64
+ ```bash
65
+ ruby examples/all_in_one_runner.rb
66
+ ```
67
+
68
+ ## Documentation
69
+
70
+ The repository also includes the Starlight source site in `docs-site/`.
71
+
72
+ ```bash
73
+ cd docs-site
74
+ npm install
75
+ npm run dev
76
+ ```
@@ -0,0 +1,48 @@
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
+ results = {}
27
+ run = runner.start_run state: {left: 7, right: 5, total: 20, discount: 3}
28
+
29
+ until run.finished?
30
+ run = runner.advance_run(
31
+ run: run,
32
+ resolved_for: lambda do |token|
33
+ token[:awaits].each_with_object({}) do |(key, request_id), memo|
34
+ memo[key.to_s] = results[request_id] if results.key?(request_id)
35
+ end
36
+ end
37
+ ) do |request|
38
+ results[request.key] =
39
+ case request.kind
40
+ when :add then request.payload[:left] + request.payload[:right]
41
+ when :subtract then request.payload[:left] - request.payload[:right]
42
+ end
43
+
44
+ request.key
45
+ end
46
+ end
47
+
48
+ puts JSON.pretty_generate(run.result)
@@ -33,3 +33,5 @@ GRAPH = AsyncGraph::Graph.new do
33
33
  edge %i[left right], :merge
34
34
  set_finish_point :merge
35
35
  end
36
+
37
+ RUNNER = AsyncGraph::Runner.new(GRAPH)
@@ -4,25 +4,25 @@ require "json"
4
4
 
5
5
  Dir.chdir(__dir__)
6
6
 
7
- jobs = JSON.parse(File.read("jobs.json"))
7
+ jobs = JSON.parse(File.read("jobs.json"), symbolize_names: true)
8
8
 
9
- jobs.fetch("jobs", []).each do |job|
10
- next unless job["status"] == "pending"
9
+ jobs.fetch(:jobs, []).each do |job|
10
+ next unless job[:status] == "pending"
11
11
 
12
- job["result"] =
13
- case job["kind"]
12
+ job[:result] =
13
+ case job[:kind]
14
14
  when "fetch_profile"
15
- user_id = job.dig("payload", "user_id")
16
- {"id" => user_id, "name" => "Ada-#{user_id}"}
15
+ user_id = job.dig(:payload, :user_id)
16
+ {id: user_id, name: "Ada-#{user_id}"}
17
17
  when "fetch_score"
18
- user_id = job.dig("payload", "user_id")
19
- {"score" => user_id * 10}
18
+ user_id = job.dig(:payload, :user_id)
19
+ {score: user_id * 10}
20
20
  end
21
21
 
22
- next unless job["result"]
22
+ next unless job[:result]
23
23
 
24
- job["status"] = "done"
25
- puts "done #{job["job_uid"]} #{job["kind"]}"
24
+ job[:status] = "done"
25
+ puts "done #{job[:job_uid]} #{job[:kind]}"
26
26
  end
27
27
 
28
28
  File.write("jobs.json", JSON.pretty_generate(jobs) + "\n")
@@ -3,178 +3,42 @@
3
3
  require "json"
4
4
  require_relative "app_graph"
5
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))
6
+ def advance_graph_state!(graph_state, job_list, jobs_by_uid)
7
+ graph_uid = graph_state.fetch(:graph_uid)
8
+ next_run = RUNNER.advance_run(
9
+ run: graph_state.fetch(:run),
10
+ resolved_for: lambda do |token|
11
+ token.fetch(:awaits, {}).each_with_object({}) do |(key, job_uid), memo|
12
+ job = jobs_by_uid[job_uid]
13
+ memo[key.to_s] = job[:result] if job&.[](:status) == "done"
14
+ end
78
15
  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
16
+ ) do |request|
17
+ job_uid = "job-#{job_list.size + 1}"
18
+ job = jobs_by_uid[job_uid] = {
19
+ job_uid: job_uid,
20
+ kind: request.kind.to_s,
21
+ payload: request.payload,
22
+ status: "pending"
23
+ }
24
+ job_list << job
25
+ job_uid
91
26
  end
92
27
 
93
- joins[bucket_key] = bucket
94
- puts "#{graph_state["graph_uid"]}/#{token["token_uid"]} parked"
95
- nil
28
+ puts "#{graph_uid} finished" if next_run.finished?
29
+ graph_state[:run] = next_run.to_h
96
30
  end
97
31
 
98
32
  Dir.chdir(__dir__)
99
33
 
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
34
+ graph_states = JSON.parse(File.read("graph_states.json"), symbolize_names: true)
35
+ jobs = JSON.parse(File.read("jobs.json"), symbolize_names: true)
36
+ job_list = jobs.fetch(:jobs)
37
+ jobs_by_uid = job_list.to_h { |job| [job[:job_uid], job] }
125
38
 
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
39
+ graph_states.fetch(:graphs, [])
40
+ .reject { it.dig(:run, :status) == "finished" }
41
+ .each { advance_graph_state!(it, job_list, jobs_by_uid) }
175
42
 
176
- File.write(
177
- "graph_states.json",
178
- JSON.pretty_generate(AsyncGraph.stringify(graphs: next_graph_states)) + "\n"
179
- )
43
+ File.write("graph_states.json", JSON.pretty_generate(graph_states) + "\n")
180
44
  File.write("jobs.json", JSON.pretty_generate(jobs) + "\n")
data/examples/reset.rb CHANGED
@@ -8,24 +8,16 @@ Dir.chdir(__dir__)
8
8
  File.write(
9
9
  "graph_states.json",
10
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
- )
11
+ graphs: [
12
+ {
13
+ graph_uid: "graph-1",
14
+ run: RUNNER.start_run(state: {user_id: 7}).to_h
15
+ },
16
+ {
17
+ graph_uid: "graph-2",
18
+ run: RUNNER.start_run(state: {user_id: 8}).to_h
19
+ }
20
+ ]
29
21
  ) + "\n"
30
22
  )
31
23
  File.write("jobs.json", JSON.pretty_generate(jobs: []) + "\n")
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; do
6
+ for file in app_graph.rb reset.rb graph_run.rb execute_jobs.rb all_in_one_runner.rb; do
7
7
  ruby -c "$file"
8
8
  done
9
9
 
@@ -40,3 +40,6 @@ printf '\n== graph run 5 ==\n'
40
40
  ruby graph_run.rb
41
41
  cat graph_states.json
42
42
  cat jobs.json
43
+
44
+ printf '\n== all-in-one runner ==\n'
45
+ ruby all_in_one_runner.rb