orchestr8 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/LICENSE +21 -0
- data/README.md +186 -0
- data/Rakefile +11 -0
- data/app/assets/stylesheets/orchestr8/application.css +436 -0
- data/app/controllers/orchestr8/application_controller.rb +7 -0
- data/app/controllers/orchestr8/steps_controller.rb +12 -0
- data/app/controllers/orchestr8/workflows_controller.rb +46 -0
- data/app/models/orchestr8/step.rb +70 -0
- data/app/models/orchestr8/step_dependency.rb +12 -0
- data/app/models/orchestr8/workflow.rb +76 -0
- data/app/views/layouts/orchestr8/application.html.erb +206 -0
- data/app/views/orchestr8/workflows/_step_node.html.erb +35 -0
- data/app/views/orchestr8/workflows/index.html.erb +71 -0
- data/app/views/orchestr8/workflows/show.html.erb +56 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20260407000000_create_orchestr8_tables.rb +45 -0
- data/lib/generators/orchestr8/install/install_generator.rb +13 -0
- data/lib/orchestr8/callback_handler.rb +36 -0
- data/lib/orchestr8/configuration.rb +11 -0
- data/lib/orchestr8/dsl.rb +74 -0
- data/lib/orchestr8/engine.rb +17 -0
- data/lib/orchestr8/scheduler.rb +80 -0
- data/lib/orchestr8/stepable.rb +42 -0
- data/lib/orchestr8/test_helper.rb +10 -0
- data/lib/orchestr8/version.rb +5 -0
- data/lib/orchestr8.rb +39 -0
- metadata +99 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Orchestr8
|
|
4
|
+
class CallbackHandler
|
|
5
|
+
def self.on_perform(active_job_id)
|
|
6
|
+
step = find_step(active_job_id)
|
|
7
|
+
return unless step
|
|
8
|
+
|
|
9
|
+
step.update!(status: "running", started_at: Time.current)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.on_success(active_job_id)
|
|
13
|
+
step = find_step(active_job_id)
|
|
14
|
+
return unless step
|
|
15
|
+
|
|
16
|
+
step.update!(status: "completed", finished_at: Time.current)
|
|
17
|
+
Scheduler.call(step.workflow)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.on_failure(active_job_id, error)
|
|
21
|
+
step = find_step(active_job_id)
|
|
22
|
+
return unless step
|
|
23
|
+
|
|
24
|
+
error_message = "#{error.class}: #{error.message}"
|
|
25
|
+
error_message += "\n#{error.backtrace&.first(20)&.join("\n")}" if error.backtrace
|
|
26
|
+
step.update!(status: "failed", error: error_message, finished_at: Time.current)
|
|
27
|
+
Scheduler.call(step.workflow)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.find_step(active_job_id)
|
|
31
|
+
Step.find_by(active_job_id: active_job_id)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private_class_method :find_step
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Orchestr8
|
|
4
|
+
class DuplicateStepError < StandardError; end
|
|
5
|
+
class UnknownStepError < StandardError; end
|
|
6
|
+
class CircularDependencyError < StandardError; end
|
|
7
|
+
|
|
8
|
+
module Dsl
|
|
9
|
+
def self.included(base)
|
|
10
|
+
base.extend(ClassMethods)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module ClassMethods
|
|
14
|
+
def step_definitions
|
|
15
|
+
@step_definitions ||= {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def inherited(subclass)
|
|
19
|
+
super
|
|
20
|
+
subclass.instance_variable_set(:@step_definitions, step_definitions.dup)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def step(name, job:, after: nil, on_failure: :halt, queue: nil)
|
|
24
|
+
name = name.to_sym
|
|
25
|
+
raise DuplicateStepError, "Step :#{name} is already defined" if step_definitions.key?(name)
|
|
26
|
+
|
|
27
|
+
after_deps = normalize_dependencies(after)
|
|
28
|
+
validate_dependencies!(after_deps)
|
|
29
|
+
step_definitions[name] = { job: job, after: after_deps, on_failure: on_failure, queue: queue }
|
|
30
|
+
detect_cycles!
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def normalize_dependencies(after)
|
|
36
|
+
return [] if after.nil?
|
|
37
|
+
|
|
38
|
+
Array(after).map(&:to_sym)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def validate_dependencies!(deps)
|
|
42
|
+
deps.each do |dep_name|
|
|
43
|
+
unless step_definitions.key?(dep_name)
|
|
44
|
+
raise UnknownStepError,
|
|
45
|
+
"Step :#{dep_name} is not defined. Dependencies must reference previously defined steps."
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def detect_cycles!
|
|
51
|
+
visited = {}
|
|
52
|
+
in_stack = {}
|
|
53
|
+
step_definitions.each_key do |name|
|
|
54
|
+
if detect_cycle_from(name, visited, in_stack)
|
|
55
|
+
raise CircularDependencyError, "Circular dependency detected in workflow definition"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def detect_cycle_from(name, visited, in_stack)
|
|
61
|
+
return false if visited[name]
|
|
62
|
+
return true if in_stack[name]
|
|
63
|
+
|
|
64
|
+
in_stack[name] = true
|
|
65
|
+
step_definitions[name][:after].each do |dep_name|
|
|
66
|
+
return true if detect_cycle_from(dep_name, visited, in_stack)
|
|
67
|
+
end
|
|
68
|
+
in_stack.delete(name)
|
|
69
|
+
visited[name] = true
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Orchestr8
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace Orchestr8
|
|
6
|
+
|
|
7
|
+
config.generators do |g|
|
|
8
|
+
g.test_framework :rspec
|
|
9
|
+
g.fixture_replacement :factory_bot
|
|
10
|
+
g.factory_bot dir: "spec/factories"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
initializer "orchestr8.assets" do |app|
|
|
14
|
+
app.config.assets.precompile += %w[orchestr8/application.css] if app.config.respond_to?(:assets)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Orchestr8
|
|
4
|
+
class Scheduler
|
|
5
|
+
def self.call(workflow)
|
|
6
|
+
new(workflow).call
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(workflow)
|
|
10
|
+
@workflow = workflow
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
workflow.with_lock do
|
|
15
|
+
loop do
|
|
16
|
+
ready_steps = find_ready_steps
|
|
17
|
+
break if ready_steps.empty?
|
|
18
|
+
|
|
19
|
+
enqueue_steps(ready_steps)
|
|
20
|
+
break unless Orchestr8.synchronous?
|
|
21
|
+
|
|
22
|
+
workflow.steps.reload
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
update_workflow_status
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :workflow
|
|
32
|
+
|
|
33
|
+
def find_ready_steps
|
|
34
|
+
workflow.steps.includes(:dependencies).select(&:ready?)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def enqueue_steps(steps)
|
|
38
|
+
steps.each do |step|
|
|
39
|
+
job_class = step.job_class.constantize
|
|
40
|
+
job = job_class.new(workflow.arguments)
|
|
41
|
+
job.queue_name = step.queue if step.queue.present?
|
|
42
|
+
step.update!(status: "enqueued", active_job_id: job.job_id)
|
|
43
|
+
|
|
44
|
+
if Orchestr8.synchronous?
|
|
45
|
+
step.update!(status: "running", started_at: Time.current)
|
|
46
|
+
begin
|
|
47
|
+
job.perform_now
|
|
48
|
+
step.update!(status: "completed", finished_at: Time.current)
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
error_msg = "#{e.class}: #{e.message}"
|
|
51
|
+
step.update!(status: "failed", error: error_msg, finished_at: Time.current)
|
|
52
|
+
end
|
|
53
|
+
else
|
|
54
|
+
job.enqueue
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def update_workflow_status
|
|
60
|
+
steps = workflow.steps.reload
|
|
61
|
+
all_statuses = steps.map(&:status)
|
|
62
|
+
|
|
63
|
+
if all_statuses.all? { |s| s == "completed" }
|
|
64
|
+
workflow.update!(status: "completed", finished_at: Time.current)
|
|
65
|
+
elsif nothing_can_progress?(steps)
|
|
66
|
+
workflow.update!(status: "failed", finished_at: Time.current)
|
|
67
|
+
elsif workflow.pending? && all_statuses.any? { |s| s == "enqueued" }
|
|
68
|
+
workflow.update!(status: "running", started_at: Time.current)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def nothing_can_progress?(steps)
|
|
73
|
+
has_failure = steps.any?(&:failed?)
|
|
74
|
+
no_active = steps.none? { |s| s.pending? || s.enqueued? || s.running? }
|
|
75
|
+
no_ready = steps.none?(&:ready?)
|
|
76
|
+
|
|
77
|
+
has_failure && no_active && no_ready
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Orchestr8
|
|
4
|
+
module Stepable
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
attr_accessor :orchestr8_step
|
|
9
|
+
|
|
10
|
+
before_perform :orchestr8_on_perform
|
|
11
|
+
after_perform :orchestr8_on_success
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def output(data)
|
|
15
|
+
return unless orchestr8_step
|
|
16
|
+
|
|
17
|
+
orchestr8_step.update!(output: data)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def payloads
|
|
21
|
+
return {} unless orchestr8_step
|
|
22
|
+
|
|
23
|
+
orchestr8_step.dependencies.each_with_object({}) do |dep, hash|
|
|
24
|
+
hash[dep.name.to_sym] = dep.output
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def orchestr8_on_perform
|
|
31
|
+
return if Orchestr8.synchronous?
|
|
32
|
+
|
|
33
|
+
CallbackHandler.on_perform(job_id)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def orchestr8_on_success
|
|
37
|
+
return if Orchestr8.synchronous?
|
|
38
|
+
|
|
39
|
+
CallbackHandler.on_success(job_id)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/orchestr8.rb
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "orchestr8/version"
|
|
4
|
+
require "orchestr8/configuration"
|
|
5
|
+
require "orchestr8/dsl"
|
|
6
|
+
require "orchestr8/stepable"
|
|
7
|
+
require "orchestr8/scheduler"
|
|
8
|
+
require "orchestr8/callback_handler"
|
|
9
|
+
require "orchestr8/engine"
|
|
10
|
+
|
|
11
|
+
module Orchestr8
|
|
12
|
+
class << self
|
|
13
|
+
attr_accessor :synchronous_mode
|
|
14
|
+
|
|
15
|
+
def configuration
|
|
16
|
+
@configuration ||= Configuration.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def configure
|
|
20
|
+
yield(configuration)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reset_configuration!
|
|
24
|
+
@configuration = Configuration.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def test_mode!
|
|
28
|
+
self.synchronous_mode = true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reset_test_mode!
|
|
32
|
+
self.synchronous_mode = false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def synchronous?
|
|
36
|
+
synchronous_mode == true
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: orchestr8
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Romulo Storel
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-04-09 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: turbo-rails
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.5'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.5'
|
|
40
|
+
description: Define multi-step job workflows as directed acyclic graphs. Parallel
|
|
41
|
+
execution, failure handling, data passing between steps, and a built-in DAG visualizer.
|
|
42
|
+
No Redis — all state in your database.
|
|
43
|
+
email:
|
|
44
|
+
- romulostorel.dev@gmail.com
|
|
45
|
+
executables: []
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- LICENSE
|
|
50
|
+
- README.md
|
|
51
|
+
- Rakefile
|
|
52
|
+
- app/assets/stylesheets/orchestr8/application.css
|
|
53
|
+
- app/controllers/orchestr8/application_controller.rb
|
|
54
|
+
- app/controllers/orchestr8/steps_controller.rb
|
|
55
|
+
- app/controllers/orchestr8/workflows_controller.rb
|
|
56
|
+
- app/models/orchestr8/step.rb
|
|
57
|
+
- app/models/orchestr8/step_dependency.rb
|
|
58
|
+
- app/models/orchestr8/workflow.rb
|
|
59
|
+
- app/views/layouts/orchestr8/application.html.erb
|
|
60
|
+
- app/views/orchestr8/workflows/_step_node.html.erb
|
|
61
|
+
- app/views/orchestr8/workflows/index.html.erb
|
|
62
|
+
- app/views/orchestr8/workflows/show.html.erb
|
|
63
|
+
- config/routes.rb
|
|
64
|
+
- db/migrate/20260407000000_create_orchestr8_tables.rb
|
|
65
|
+
- lib/generators/orchestr8/install/install_generator.rb
|
|
66
|
+
- lib/orchestr8.rb
|
|
67
|
+
- lib/orchestr8/callback_handler.rb
|
|
68
|
+
- lib/orchestr8/configuration.rb
|
|
69
|
+
- lib/orchestr8/dsl.rb
|
|
70
|
+
- lib/orchestr8/engine.rb
|
|
71
|
+
- lib/orchestr8/scheduler.rb
|
|
72
|
+
- lib/orchestr8/stepable.rb
|
|
73
|
+
- lib/orchestr8/test_helper.rb
|
|
74
|
+
- lib/orchestr8/version.rb
|
|
75
|
+
homepage: https://github.com/romulostorel/orchestr8
|
|
76
|
+
licenses:
|
|
77
|
+
- MIT
|
|
78
|
+
metadata:
|
|
79
|
+
homepage_uri: https://github.com/romulostorel/orchestr8
|
|
80
|
+
source_code_uri: https://github.com/romulostorel/orchestr8
|
|
81
|
+
changelog_uri: https://github.com/romulostorel/orchestr8/blob/main/CHANGELOG.md
|
|
82
|
+
rdoc_options: []
|
|
83
|
+
require_paths:
|
|
84
|
+
- lib
|
|
85
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: 3.1.0
|
|
90
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '0'
|
|
95
|
+
requirements: []
|
|
96
|
+
rubygems_version: 3.6.2
|
|
97
|
+
specification_version: 4
|
|
98
|
+
summary: DAG-based job workflow orchestration for Rails
|
|
99
|
+
test_files: []
|