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.
@@ -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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Orchestr8
4
+ class Configuration
5
+ attr_accessor :base_controller_class
6
+
7
+ def initialize
8
+ @base_controller_class = "::ActionController::Base"
9
+ end
10
+ end
11
+ 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
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Orchestr8
4
+ module TestHelper
5
+ def self.included(base)
6
+ base.before { Orchestr8.test_mode! }
7
+ base.after { Orchestr8.reset_test_mode! }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Orchestr8
4
+ VERSION = "0.1.0"
5
+ 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: []