├── app ├── helpers │ └── .keep ├── jobs │ ├── .keep │ └── stepped │ │ ├── wait_job.rb │ │ ├── complete_action_job.rb │ │ ├── timeout_job.rb │ │ └── action_job.rb ├── mailers │ └── .keep ├── models │ ├── .keep │ ├── concerns │ │ ├── .keep │ │ └── stepped │ │ │ └── actionable.rb │ └── stepped │ │ ├── arguments.rb │ │ ├── achievement.rb │ │ ├── registry.rb │ │ ├── performance.rb │ │ ├── step.rb │ │ ├── definition.rb │ │ └── action.rb ├── views │ └── .keep ├── controllers │ ├── .keep │ └── concerns │ │ └── .keep └── assets │ ├── images │ └── stepped │ │ └── .keep │ └── stylesheets │ └── stepped │ └── .keep ├── test ├── helpers │ └── .keep ├── mailers │ └── .keep ├── models │ ├── .keep │ ├── stepped │ │ ├── definition_test.rb │ │ ├── achievement_test.rb │ │ ├── performance_test.rb │ │ ├── step_exceptions_test.rb │ │ ├── registry_test.rb │ │ ├── action_as_job_test.rb │ │ ├── action_prepend_step_test.rb │ │ ├── action_timeout_test.rb │ │ ├── step_test.rb │ │ ├── action_completes_outbound_test.rb │ │ ├── action_concurrency_key_test.rb │ │ ├── action_exceptions_test.rb │ │ ├── action_superseding_test.rb │ │ ├── action_checksums_test.rb │ │ └── action_test.rb │ ├── sample_test.rb │ └── stepped_car_test.rb ├── controllers │ └── .keep ├── dummy │ ├── log │ │ └── .keep │ ├── app │ │ ├── assets │ │ │ ├── images │ │ │ │ └── .keep │ │ │ └── stylesheets │ │ │ │ └── application.css │ │ ├── models │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ ├── sample.rb │ │ │ └── application_record.rb │ │ ├── controllers │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ └── application_controller.rb │ │ ├── views │ │ │ ├── layouts │ │ │ │ ├── mailer.text.erb │ │ │ │ ├── mailer.html.erb │ │ │ │ └── application.html.erb │ │ │ └── pwa │ │ │ │ ├── manifest.json.erb │ │ │ │ └── service-worker.js │ │ ├── helpers │ │ │ └── application_helper.rb │ │ ├── mailers │ │ │ └── application_mailer.rb │ │ └── jobs │ │ │ └── application_job.rb │ ├── bin │ │ ├── dev │ │ ├── rake │ │ ├── rails │ │ ├── ci │ │ └── setup │ ├── public │ │ ├── icon.png │ │ ├── icon.svg │ │ ├── 404.html │ │ ├── 400.html │ │ ├── 406-unsupported-browser.html │ │ ├── 500.html │ │ └── 422.html │ ├── config │ │ ├── environment.rb │ │ ├── cable.yml │ │ ├── boot.rb │ │ ├── initializers │ │ │ ├── assets.rb │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── inflections.rb │ │ │ └── content_security_policy.rb │ │ ├── ci.rb │ │ ├── routes.rb │ │ ├── storage.yml │ │ ├── locales │ │ │ └── en.yml │ │ ├── database.yml │ │ ├── application.rb │ │ ├── puma.rb │ │ └── environments │ │ │ ├── test.rb │ │ │ ├── development.rb │ │ │ └── production.rb │ ├── config.ru │ ├── Rakefile │ └── db │ │ └── schema.rb ├── integration │ ├── .keep │ └── navigation_test.rb ├── fixtures │ └── files │ │ └── .keep ├── stepped_test.rb └── test_helper.rb ├── .ruby-version ├── config └── routes.rb ├── lib ├── stepped │ ├── version.rb │ ├── engine.rb │ └── test_helper.rb ├── tasks │ └── stepped_tasks.rake └── stepped.rb ├── Rakefile ├── .gitignore ├── .rubocop.yml ├── bin ├── rubocop ├── rails └── release-gem ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── Gemfile ├── stepped.gemspec ├── MIT-LICENSE ├── db └── migrate │ └── 20251214104829_create_stepped_tables_if_missing.rb └── README.md /app/helpers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/jobs/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/views/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/helpers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.7 2 | -------------------------------------------------------------------------------- /app/controllers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/controllers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/integration/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/files/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/stepped/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/stylesheets/stepped/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /lib/stepped/version.rb: -------------------------------------------------------------------------------- 1 | module Stepped 2 | VERSION = "1.0.0" 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/models/sample.rb: -------------------------------------------------------------------------------- 1 | class Sample < ApplicationRecord 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | exec "./bin/rails", "server", *ARGV 3 | -------------------------------------------------------------------------------- /test/dummy/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/envirobly/stepped/HEAD/test/dummy/public/icon.png -------------------------------------------------------------------------------- /test/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /test/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /test/models/stepped/definition_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Stepped::DefinitionTest < Stepped::TestCase 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: "from@example.com" 3 | layout "mailer" 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/jobs/stepped/wait_job.rb: -------------------------------------------------------------------------------- 1 | class Stepped::WaitJob < ActiveJob::Base 2 | queue_as :default 3 | 4 | def perform(step) 5 | step.conclude_job 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) 4 | load "rails/tasks/engine.rake" 5 | 6 | require "bundler/gem_tasks" 7 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /test/integration/navigation_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class NavigationTest < Stepped::IntegrationTest 4 | # test "the truth" do 5 | # assert true 6 | # end 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /doc/ 3 | /log/*.log 4 | /pkg/ 5 | /tmp/ 6 | /test/dummy/db/*.sqlite3 7 | /test/dummy/db/*.sqlite3-* 8 | /test/dummy/log/*.log 9 | /test/dummy/storage/ 10 | /test/dummy/tmp/ 11 | *.gem 12 | -------------------------------------------------------------------------------- /test/dummy/bin/ci: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "active_support/continuous_integration" 4 | 5 | CI = ActiveSupport::ContinuousIntegration 6 | require_relative "../config/ci.rb" 7 | -------------------------------------------------------------------------------- /app/jobs/stepped/complete_action_job.rb: -------------------------------------------------------------------------------- 1 | class Stepped::CompleteActionJob < ActiveJob::Base 2 | queue_as :default 3 | 4 | def perform(actor, name, status = :succeeded) 5 | Stepped::Performance.outbound_complete(actor, name, status) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: dummy_production 11 | -------------------------------------------------------------------------------- /test/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. 3 | allow_browser versions: :modern 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) 3 | 4 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 5 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 6 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # Omakase Ruby styling for Rails 2 | inherit_gem: { rubocop-rails-omakase: rubocop.yml } 3 | 4 | # Overwrite or add rules to create your own house style 5 | # 6 | # # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` 7 | # Layout/SpaceInsideArrayLiteralBrackets: 8 | # Enabled: false 9 | -------------------------------------------------------------------------------- /test/stepped_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class SteppedTest < Stepped::TestCase 4 | test "it has a version number" do 5 | assert Stepped::VERSION 6 | end 7 | 8 | test "engine model is included" do 9 | assert_kind_of Class, Stepped::Action 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/jobs/stepped/timeout_job.rb: -------------------------------------------------------------------------------- 1 | class Stepped::TimeoutJob < ActiveJob::Base 2 | queue_as :default 3 | 4 | def perform(action) 5 | return unless action.performing? 6 | 7 | if action.started_at < action.timeout.ago 8 | action.complete!(:timed_out) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | # explicit rubocop config increases performance slightly while avoiding config confusion. 6 | ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) 7 | 8 | load Gem.bin_path("rubocop", "rubocop") 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: github-actions 9 | directory: "/" 10 | schedule: 11 | interval: weekly 12 | open-pull-requests-limit: 10 13 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/models/sample_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class SampleTest < Stepped::TestCase 4 | test "inherits from ApplicationRecord" do 5 | assert_equal ApplicationRecord, Sample.superclass 6 | end 7 | 8 | test "stepped_action class method has been included" do 9 | assert Sample.respond_to?(:stepped_action) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | -------------------------------------------------------------------------------- /app/jobs/stepped/action_job.rb: -------------------------------------------------------------------------------- 1 | class Stepped::ActionJob < ActiveJob::Base 2 | queue_as :default 3 | 4 | def perform(actor, name, *arguments, parent_step: nil) 5 | root = parent_step.nil? 6 | action = Stepped::Action.new(actor:, name:, arguments:, root:) 7 | action.parent_steps << parent_step if parent_step.present? 8 | action.obtain_lock_and_perform 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/models/stepped/arguments.rb: -------------------------------------------------------------------------------- 1 | require "active_job/arguments" 2 | 3 | class Stepped::Arguments 4 | class << self 5 | def load(serialized_arguments) 6 | return if serialized_arguments.nil? 7 | 8 | ActiveJob::Arguments.deserialize serialized_arguments 9 | end 10 | 11 | def dump(arguments) 12 | ActiveJob::Arguments.serialize arguments 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/tasks/stepped_tasks.rake: -------------------------------------------------------------------------------- 1 | namespace :stepped do 2 | desc "Install Stepped Actions" 3 | task install: "stepped:install:migrations" 4 | 5 | namespace :install do 6 | desc "Copy Stepped migrations to the host app" 7 | task migrations: :environment do 8 | ENV["FROM"] = Stepped::Engine.railtie_name 9 | Rake::Task["railties:install:migrations"].invoke 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in stepped.gemspec. 4 | gemspec 5 | 6 | gem "puma" 7 | 8 | gem "sqlite3" 9 | 10 | gem "propshaft" 11 | 12 | # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] 13 | gem "rubocop-rails-omakase", require: false 14 | 15 | # Start debugger with binding.b [https://github.com/ruby/debug] 16 | # gem "debug", ">= 1.0.0" 17 | -------------------------------------------------------------------------------- /lib/stepped/engine.rb: -------------------------------------------------------------------------------- 1 | module Stepped 2 | class Engine < ::Rails::Engine 3 | config.stepped_actions = ActiveSupport::OrderedOptions.new 4 | config.stepped_actions.handle_exceptions = Rails.env.test? ? [] : [ StandardError ] 5 | 6 | initializer "stepped.active_record.extensions" do 7 | ActiveSupport.on_load :active_record do 8 | include Stepped::Actionable 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/models/stepped/achievement_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Stepped::AchievementTest < Stepped::TestCase 4 | test "checksum_key must be unique" do 5 | assert_difference "Stepped::Achievement.count" => +1 do 6 | Stepped::Achievement.create!(checksum_key: "one", checksum: "1") 7 | end 8 | assert_raises ActiveRecord::RecordNotUnique do 9 | Stepped::Achievement.create!(checksum_key: "one", checksum: "2") 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/dummy/app/views/pwa/manifest.json.erb: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dummy", 3 | "icons": [ 4 | { 5 | "src": "/icon.png", 6 | "type": "image/png", 7 | "sizes": "512x512" 8 | }, 9 | { 10 | "src": "/icon.png", 11 | "type": "image/png", 12 | "sizes": "512x512", 13 | "purpose": "maskable" 14 | } 15 | ], 16 | "start_url": "/", 17 | "display": "standalone", 18 | "scope": "/", 19 | "description": "Dummy.", 20 | "theme_color": "red", 21 | "background_color": "red" 22 | } 23 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. 4 | # Use this to limit dissemination of sensitive information. 5 | # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc 8 | ] 9 | -------------------------------------------------------------------------------- /test/models/stepped_car_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class SteppedCarTest < Stepped::TestCase 4 | setup do 5 | Temping.create("Stepped::Car") do 6 | with_columns do |t| 7 | t.string :make 8 | t.string :model 9 | end 10 | end 11 | end 12 | 13 | test "persists a namespaced car created within the test" do 14 | car = Stepped::Car.create!(make: "Volvo", model: "XC40") 15 | 16 | assert_predicate car, :persisted? 17 | assert_equal %w[Volvo XC40], [ car.make, car.model ] 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails gems 3 | # installed from the root of your application. 4 | 5 | ENGINE_ROOT = File.expand_path("..", __dir__) 6 | ENGINE_PATH = File.expand_path("../lib/stepped/engine", __dir__) 7 | APP_PATH = File.expand_path("../test/dummy/config/application", __dir__) 8 | 9 | # Set up gems listed in the Gemfile. 10 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 11 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 12 | 13 | require "rails/all" 14 | require "rails/engine/commands" 15 | -------------------------------------------------------------------------------- /test/dummy/config/ci.rb: -------------------------------------------------------------------------------- 1 | # Run using bin/ci 2 | 3 | CI.run do 4 | step "Setup", "bin/setup --skip-server" 5 | 6 | 7 | step "Tests: Rails", "bin/rails test" 8 | step "Tests: System", "bin/rails test:system" 9 | step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant" 10 | 11 | # Optional: set a green GitHub commit status to unblock PR merge. 12 | # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. 13 | # if success? 14 | # step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" 15 | # else 16 | # failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again." 17 | # end 18 | end 19 | -------------------------------------------------------------------------------- /bin/release-gem: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | RELEASE_VERSION=$1 4 | 5 | if [ -z "$1" ]; then 6 | echo "Specify version to release as the first argument" 7 | exit 1 8 | fi 9 | 10 | echo "This will release gem v$RELEASE_VERSION" 11 | read -p "Are you sure you want to continue? [y/N]: " confirm 12 | if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then 13 | echo "Aborted." 14 | exit 1 15 | fi 16 | 17 | gem build stepped.gemspec 18 | gem install ./stepped-$RELEASE_VERSION.gem --no-document 19 | gem push stepped-$RELEASE_VERSION.gem 20 | 21 | # Tag and push the tag 22 | git tag v$RELEASE_VERSION 23 | git push origin tag v$RELEASE_VERSION 24 | 25 | echo "$RELEASE_VERSION released" 26 | -------------------------------------------------------------------------------- /lib/stepped.rb: -------------------------------------------------------------------------------- 1 | require "stepped/version" 2 | require "stepped/engine" 3 | 4 | module Stepped 5 | def self.table_name_prefix 6 | "stepped_" 7 | end 8 | 9 | def self.handled_exception_classes 10 | Array(Stepped::Engine.config.stepped_actions.handle_exceptions) 11 | end 12 | 13 | def self.handle_exception(context: {}) 14 | yield 15 | true 16 | rescue StandardError => e 17 | raise unless handled_exception_classes.any? { e.class <= _1 } 18 | Rails.error.report(e, handled: false, context:) 19 | false 20 | end 21 | 22 | def self.checksum(value) 23 | return if value.nil? 24 | Digest::SHA256.hexdigest JSON.dump(value) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/models/stepped/achievement.rb: -------------------------------------------------------------------------------- 1 | class Stepped::Achievement < ActiveRecord::Base 2 | class << self 3 | def exists_for?(action) 4 | return false if action.checksum.nil? 5 | 6 | exists? action.attributes.slice("checksum_key", "checksum") 7 | end 8 | 9 | def raise_if_exists_for?(action) 10 | if exists_for?(action) 11 | raise ExistsError 12 | end 13 | end 14 | 15 | def grand_to(action) 16 | create! action.attributes.slice("checksum_key", "checksum") 17 | end 18 | 19 | def erase_of(action) 20 | where(action.attributes.slice("checksum_key")).destroy_all 21 | end 22 | end 23 | 24 | class ExistsError < StandardError; end 25 | end 26 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html 3 | 4 | # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. 5 | # Can be used by load balancers and uptime monitors to verify that the app is live. 6 | get "up" => "rails/health#show", as: :rails_health_check 7 | 8 | # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) 9 | # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest 10 | # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker 11 | 12 | # Defines the root path route ("/") 13 | # root "posts#index" 14 | end 15 | -------------------------------------------------------------------------------- /test/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /stepped.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/stepped/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "stepped" 5 | spec.version = Stepped::VERSION 6 | spec.authors = [ "Robert Starsi" ] 7 | spec.email = [ "klevo@klevo.sk" ] 8 | spec.homepage = "https://github.com/envirobly/stepped" 9 | spec.summary = "Rails engine for orchestrating complex action trees." 10 | spec.license = "MIT" 11 | 12 | spec.metadata["homepage_uri"] = spec.homepage 13 | spec.metadata["source_code_uri"] = "https://github.com/envirobly/stepped" 14 | # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." 15 | 16 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 17 | Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] 18 | end 19 | 20 | spec.add_dependency "rails", ">= 8.1.1" 21 | spec.add_development_dependency "temping" 22 | end 23 | -------------------------------------------------------------------------------- /test/dummy/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket-<%= Rails.env %> 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket-<%= Rails.env %> 23 | 24 | # mirror: 25 | # service: Mirror 26 | # primary: local 27 | # mirrors: [ amazon, google, microsoft ] 28 | -------------------------------------------------------------------------------- /test/dummy/app/views/pwa/service-worker.js: -------------------------------------------------------------------------------- 1 | // Add a service worker for processing Web Push notifications: 2 | // 3 | // self.addEventListener("push", async (event) => { 4 | // const { title, options } = await event.data.json() 5 | // event.waitUntil(self.registration.showNotification(title, options)) 6 | // }) 7 | // 8 | // self.addEventListener("notificationclick", function(event) { 9 | // event.notification.close() 10 | // event.waitUntil( 11 | // clients.matchAll({ type: "window" }).then((clientList) => { 12 | // for (let i = 0; i < clientList.length; i++) { 13 | // let client = clientList[i] 14 | // let clientPath = (new URL(client.url)).pathname 15 | // 16 | // if (clientPath == event.notification.data.path && "focus" in client) { 17 | // return client.focus() 18 | // } 19 | // } 20 | // 21 | // if (clients.openWindow) { 22 | // return clients.openWindow(event.notification.data.path) 23 | // } 24 | // }) 25 | // ) 26 | // }) 27 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= content_for(:title) || "Dummy" %> 5 | 6 | 7 | 8 | 9 | <%= csrf_meta_tags %> 10 | <%= csp_meta_tag %> 11 | 12 | <%= yield :head %> 13 | 14 | <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> 15 | <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> 16 | 17 | 18 | 19 | 20 | 21 | <%# Includes all stylesheet files in app/assets/stylesheets %> 22 | <%= stylesheet_link_tag :app %> 23 | 24 | 25 | 26 | <%= yield %> 27 | 28 | 29 | -------------------------------------------------------------------------------- /test/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization and 2 | # are automatically loaded by Rails. If you want to use locales other than 3 | # English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t "hello" 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t("hello") %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more about the API, please read the Rails Internationalization guide 20 | # at https://guides.rubyonrails.org/i18n.html. 21 | # 22 | # Be aware that YAML interprets the following case-insensitive strings as 23 | # booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings 24 | # must be quoted to be interpreted as strings. For example: 25 | # 26 | # en: 27 | # "yes": yup 28 | # enabled: "ON" 29 | 30 | en: 31 | hello: "Hello world" 32 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Configure Rails Environment 2 | ENV["RAILS_ENV"] = "test" 3 | 4 | require_relative "../test/dummy/config/environment" 5 | ActiveRecord::Migrator.migrations_paths = [ File.expand_path("../test/dummy/db/migrate", __dir__) ] 6 | require "rails/test_help" 7 | require "temping" 8 | 9 | # Load fixtures from the engine 10 | if ActiveSupport::TestCase.respond_to?(:fixture_paths=) 11 | ActiveSupport::TestCase.fixture_paths = [ File.expand_path("fixtures", __dir__) ] 12 | ActionDispatch::IntegrationTest.fixture_paths = ActiveSupport::TestCase.fixture_paths 13 | ActiveSupport::TestCase.file_fixture_path = File.expand_path("fixtures", __dir__) + "/files" 14 | ActiveSupport::TestCase.fixtures :all 15 | end 16 | 17 | require "stepped/test_helper" 18 | 19 | module Stepped 20 | class TestCase < ActiveSupport::TestCase 21 | include ActiveJob::TestHelper 22 | include Stepped::TestHelper 23 | 24 | teardown { Temping.teardown } 25 | end 26 | 27 | class IntegrationTest < ActionDispatch::IntegrationTest 28 | teardown { Temping.teardown } 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Robert Starsi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem "sqlite3" 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: storage/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: storage/test.sqlite3 22 | 23 | 24 | # SQLite3 write its data on the local filesystem, as such it requires 25 | # persistent disks. If you are deploying to a managed service, you should 26 | # make sure it provides disk persistence, as many don't. 27 | # 28 | # Similarly, if you deploy your application as a Docker container, you must 29 | # ensure the database is located in a persisted volume. 30 | production: 31 | <<: *default 32 | # database: path/to/persistent/storage/production.sqlite3 33 | -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module Dummy 10 | class Application < Rails::Application 11 | config.load_defaults Rails::VERSION::STRING.to_f 12 | 13 | # For compatibility with applications that use this config 14 | config.action_controller.include_all_helpers = false 15 | 16 | # Please, add to the `ignore` list any other `lib` subdirectories that do 17 | # not contain `.rb` files, or that should not be reloaded or eager loaded. 18 | # Common ones are `templates`, `generators`, or `middleware`, for example. 19 | config.autoload_lib(ignore: %w[assets tasks]) 20 | 21 | # Configuration for the application, engines, and railties goes here. 22 | # 23 | # These settings can be overridden in specific environments using the files 24 | # in config/environments, which are processed later. 25 | # 26 | # config.time_zone = "Central Time (US & Canada)" 27 | # config.eager_load_paths << Rails.root.join("extras") 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | APP_ROOT = File.expand_path("..", __dir__) 5 | 6 | def system!(*args) 7 | system(*args, exception: true) 8 | end 9 | 10 | FileUtils.chdir APP_ROOT do 11 | # This script is a way to set up or update your development environment automatically. 12 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 13 | # Add necessary setup steps to this file. 14 | 15 | puts "== Installing dependencies ==" 16 | system("bundle check") || system!("bundle install") 17 | 18 | # puts "\n== Copying sample files ==" 19 | # unless File.exist?("config/database.yml") 20 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 21 | # end 22 | 23 | puts "\n== Preparing database ==" 24 | system! "bin/rails db:prepare" 25 | system! "bin/rails db:reset" if ARGV.include?("--reset") 26 | 27 | puts "\n== Removing old logs and tempfiles ==" 28 | system! "bin/rails log:clear tmp:clear" 29 | 30 | unless ARGV.include?("--skip-server") 31 | puts "\n== Starting development server ==" 32 | STDOUT.flush # flush the output before exec(2) so that it displays 33 | exec "bin/dev" 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /app/models/concerns/stepped/actionable.rb: -------------------------------------------------------------------------------- 1 | module Stepped::Actionable 2 | extend ActiveSupport::Concern 3 | 4 | class_methods do 5 | def stepped_action(name, outbound: false, timeout: nil, job: nil, &block) 6 | Stepped::Registry.add(self, name, outbound:, timeout:, job:, &block) 7 | 8 | define_method "#{name}_now" do |*arguments| 9 | Stepped::ActionJob.perform_now(self, name, *arguments) 10 | end 11 | 12 | define_method "#{name}_later" do |*arguments| 13 | Stepped::ActionJob.perform_later(self, name, *arguments) 14 | end 15 | end 16 | 17 | def prepend_stepped_action_step(name, &step_block) 18 | Stepped::Registry.prepend_step(self, name, &step_block) 19 | end 20 | 21 | def after_stepped_action(action_name, *statuses, &block) 22 | Stepped::Registry.append_after_callback(self, action_name, *statuses, &block) 23 | end 24 | end 25 | 26 | def stepped_action_tenancy_key(action_name) 27 | [ self.class.name, id, action_name ].join("/") 28 | end 29 | 30 | def complete_stepped_action_now(name, status = :succeeded) 31 | Stepped::CompleteActionJob.perform_now self, name, status 32 | end 33 | 34 | def complete_stepped_action_later(name, status = :succeeded) 35 | Stepped::CompleteActionJob.perform_later self, name, status 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/models/stepped/performance_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Stepped::PerformanceTest < Stepped::TestCase 4 | setup do 5 | Temping.create "account" do 6 | with_columns do |t| 7 | t.string :name 8 | end 9 | 10 | stepped_action :fly 11 | 12 | def fly; end 13 | end 14 | 15 | @account = Account.create!(name: "Acme Org") 16 | end 17 | 18 | test "only one action performance can exist at a time" do 19 | action = create_action 20 | assert_difference "Stepped::Performance.count" => +1 do 21 | Stepped::Performance.create!(action:) 22 | end 23 | assert_raises ActiveRecord::RecordNotUnique do 24 | Stepped::Performance.create!(action:) 25 | end 26 | end 27 | 28 | test "must have nil or unique concurrency key" do 29 | assert_difference "Stepped::Performance.count" => +3 do 30 | Stepped::Performance.create!(action: create_action, concurrency_key: nil) 31 | Stepped::Performance.create!(action: create_action, concurrency_key: nil) 32 | Stepped::Performance.create!(action: create_action, concurrency_key: "a") 33 | end 34 | assert_raises ActiveRecord::RecordNotUnique do 35 | Stepped::Performance.create!(action: create_action, concurrency_key: "a") 36 | end 37 | end 38 | 39 | def create_action 40 | actor = @account 41 | Stepped::Action.new(actor:, name: "fly").tap(&:apply_definition).tap(&:save!) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap, inline scripts, and inline styles. 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src style-src) 22 | # 23 | # # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag` 24 | # # if the corresponding directives are specified in `content_security_policy_nonce_directives`. 25 | # # config.content_security_policy_nonce_auto = true 26 | # 27 | # # Report violations without enforcing the policy. 28 | # # config.content_security_policy_report_only = true 29 | # end 30 | -------------------------------------------------------------------------------- /test/models/stepped/step_exceptions_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Stepped::StepExceptionsTest < Stepped::TestCase 4 | setup do 5 | Temping.create "car" do 6 | with_columns do |t| 7 | t.integer :honks, default: 0 8 | end 9 | 10 | def breakdown 11 | throw "oops" 12 | end 13 | 14 | def honk 15 | increment! :honks 16 | end 17 | end 18 | 19 | @car = Car.create! 20 | end 21 | 22 | test "exception in step body does not enqueue any actions part of that step" do 23 | Car.stepped_action :breakdown_wrapped do 24 | step do |step| 25 | step.do :honk 26 | breakdown 27 | step.do :honk 28 | honk 29 | end 30 | end 31 | 32 | handle_stepped_action_exceptions do 33 | assert_difference( 34 | "Stepped::Action.count" => +1, 35 | "Stepped::Step.count" => +1, 36 | "Stepped::Performance.count" => 0 37 | ) do 38 | assert_no_enqueued_jobs(only: Stepped::ActionJob) do 39 | assert Stepped::ActionJob.perform_now @car, :breakdown_wrapped 40 | end 41 | end 42 | 43 | step = Stepped::Step.last 44 | assert_predicate step, :failed? 45 | assert step.started_at 46 | assert step.completed_at 47 | assert_equal 0, step.pending_actions_count 48 | assert_equal 0, step.unsuccessful_actions_count 49 | 50 | parent_action = Stepped::Action.last 51 | assert_predicate parent_action, :failed? 52 | assert parent_action.started_at 53 | assert parent_action.completed_at 54 | 55 | assert_equal 0, @car.reload.honks 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [ main ] 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | env: 12 | RUBY_VERSION: ruby-3.4.7 13 | RUBOCOP_CACHE_ROOT: tmp/rubocop 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v5 17 | 18 | - name: Set up Ruby 19 | uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: ${{ env.RUBY_VERSION }} 22 | bundler-cache: true 23 | 24 | - name: Prepare RuboCop cache 25 | uses: actions/cache@v4 26 | env: 27 | DEPENDENCIES_HASH: ${{ hashFiles('**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }} 28 | with: 29 | path: ${{ env.RUBOCOP_CACHE_ROOT }} 30 | key: rubocop-${{ runner.os }}-${{ env.RUBY_VERSION }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }} 31 | restore-keys: | 32 | rubocop-${{ runner.os }}-${{ env.RUBY_VERSION }}-${{ env.DEPENDENCIES_HASH }}- 33 | 34 | - name: Lint code for consistent style 35 | run: bin/rubocop -f github 36 | 37 | test: 38 | runs-on: ubuntu-latest 39 | 40 | steps: 41 | - name: Checkout code 42 | uses: actions/checkout@v5 43 | 44 | - name: Set up Ruby 45 | uses: ruby/setup-ruby@v1 46 | with: 47 | ruby-version: ruby-3.4.7 48 | bundler-cache: true 49 | 50 | - name: Prepare test db 51 | env: 52 | RAILS_ENV: test 53 | run: bin/rails db:test:prepare 54 | 55 | - name: Run tests 56 | env: 57 | RAILS_ENV: test 58 | run: bin/rails test 59 | 60 | - name: Keep screenshots from failed system tests 61 | uses: actions/upload-artifact@v4 62 | if: failure() 63 | with: 64 | name: screenshots 65 | path: ${{ github.workspace }}/tmp/screenshots 66 | if-no-files-found: ignore 67 | -------------------------------------------------------------------------------- /test/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # This configuration file will be evaluated by Puma. The top-level methods that 2 | # are invoked here are part of Puma's configuration DSL. For more information 3 | # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. 4 | # 5 | # Puma starts a configurable number of processes (workers) and each process 6 | # serves each request in a thread from an internal thread pool. 7 | # 8 | # You can control the number of workers using ENV["WEB_CONCURRENCY"]. You 9 | # should only set this value when you want to run 2 or more workers. The 10 | # default is already 1. You can set it to `auto` to automatically start a worker 11 | # for each available processor. 12 | # 13 | # The ideal number of threads per worker depends both on how much time the 14 | # application spends waiting for IO operations and on how much you wish to 15 | # prioritize throughput over latency. 16 | # 17 | # As a rule of thumb, increasing the number of threads will increase how much 18 | # traffic a given process can handle (throughput), but due to CRuby's 19 | # Global VM Lock (GVL) it has diminishing returns and will degrade the 20 | # response time (latency) of the application. 21 | # 22 | # The default is set to 3 threads as it's deemed a decent compromise between 23 | # throughput and latency for the average Rails application. 24 | # 25 | # Any libraries that use a connection pool or another resource pool should 26 | # be configured to provide at least as many connections as the number of 27 | # threads. This includes Active Record's `pool` parameter in `database.yml`. 28 | threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) 29 | threads threads_count, threads_count 30 | 31 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 32 | port ENV.fetch("PORT", 3000) 33 | 34 | # Allow puma to be restarted by `bin/rails restart` command. 35 | plugin :tmp_restart 36 | 37 | # Specify the PID file. Defaults to tmp/pids/server.pid in development. 38 | # In other environments, only set the PID file if requested. 39 | pidfile ENV["PIDFILE"] if ENV["PIDFILE"] 40 | -------------------------------------------------------------------------------- /app/models/stepped/registry.rb: -------------------------------------------------------------------------------- 1 | class Stepped::Registry 2 | @job_classes = Concurrent::Array.new 3 | @definitions = Concurrent::Hash.new 4 | 5 | class << self 6 | attr_reader :job_classes, :definitions 7 | 8 | def key(klass) 9 | "#{klass.name}/#{klass.object_id}" 10 | end 11 | 12 | def add(actor_class, action_name, outbound: false, timeout: nil, job: nil, &block) 13 | if job && @job_classes.exclude?(job) 14 | @job_classes.push job 15 | end 16 | 17 | add_definition actor_class, action_name, Stepped::Definition.new( 18 | actor_class:, 19 | action_name:, 20 | outbound:, 21 | timeout:, 22 | job:, 23 | block: 24 | ) 25 | end 26 | 27 | def add_definition(actor_class, action_name, definition) 28 | class_key = key actor_class 29 | @definitions[class_key] ||= Concurrent::Hash.new 30 | @definitions[class_key][action_name.to_s] = definition 31 | end 32 | 33 | def prepend_step(actor_class, action_name, &step_block) 34 | definition = find_or_add actor_class, action_name 35 | 36 | unless definition.actor_class == actor_class 37 | definition = add_definition actor_class, action_name, definition.duplicate_as(actor_class) 38 | end 39 | 40 | definition.prepend_step(&step_block) 41 | end 42 | 43 | def append_after_callback(actor_class, action_name, *statuses, &block) 44 | definition = find_or_add actor_class, action_name 45 | 46 | unless definition.actor_class == actor_class 47 | definition = add_definition actor_class, action_name, definition.duplicate_as(actor_class) 48 | end 49 | 50 | definition.after(*statuses, &block) 51 | end 52 | 53 | def find(actor_class, action_name) 54 | actor_class.ancestors.each do |ancestor| 55 | definition = @definitions.dig key(ancestor), action_name.to_s 56 | return definition if definition 57 | end 58 | 59 | nil 60 | end 61 | 62 | def find_or_add(actor_class, action_name) 63 | find(actor_class, action_name) || add(actor_class, action_name) 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /app/models/stepped/performance.rb: -------------------------------------------------------------------------------- 1 | class Stepped::Performance < ActiveRecord::Base 2 | self.filter_attributes = [] 3 | 4 | belongs_to :action 5 | has_many :actions, -> { order(:id) }, dependent: :nullify 6 | 7 | scope :outbounds, -> { joins(:action).where(action: { outbound: true }) } 8 | 9 | before_save -> { self.outbound_complete_key = action.outbound_complete_key } 10 | 11 | class << self 12 | def obtain_for(action) 13 | transaction(requires_new: true) do 14 | lock. 15 | create_with(action:). 16 | find_or_create_by!(concurrency_key: action.concurrency_key). 17 | share_with(action) 18 | end 19 | end 20 | 21 | def outbound_complete(actor, name, status = :succeeded) 22 | outbound_complete_key = actor.stepped_action_tenancy_key name 23 | 24 | transaction(requires_new: true) do 25 | lock.find_by(outbound_complete_key:)&.forward(status:) 26 | end 27 | end 28 | 29 | def complete_action(action, status) 30 | transaction(requires_new: true) do 31 | lock.find_by(concurrency_key: action.concurrency_key)&.forward(action, status:) 32 | end 33 | end 34 | end 35 | 36 | def forward(completing_action = self.action, status: :succeeded) 37 | completing_action.finalize_complete status 38 | 39 | return unless completing_action == action 40 | 41 | if next_action = actions.incomplete.first 42 | update!(action: next_action) 43 | next_action.perform if next_action.pending? 44 | else 45 | destroy! 46 | end 47 | end 48 | 49 | def share_with(candidate) 50 | # Secondary check of this kind here within a performance lock 51 | # prevents race conditions between the first check and obtaining the lock, 52 | # while the first check in Action#obtain_lock_and_perform speeds things up. 53 | Stepped::Achievement.raise_if_exists_for?(candidate) 54 | 55 | if candidate.descendant_of?(action) 56 | return candidate.tap(&:deadlock!) 57 | end 58 | 59 | if candidate.checksum.present? 60 | actions.excluding(candidate).each do |action| 61 | if action.achieves?(candidate) 62 | candidate.copy_parent_steps_to action 63 | return action 64 | end 65 | end 66 | end 67 | 68 | other_pending_actions.each { _1.supersede_with(candidate) } 69 | candidate.tap { _1.update_performance(self) } 70 | end 71 | 72 | private 73 | def other_pending_actions 74 | actions.pending.excluding(action) 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # The test environment is used exclusively to run your application's 2 | # test suite. You never need to work with it otherwise. Remember that 3 | # your test database is "scratch space" for the test suite and is wiped 4 | # and recreated between test runs. Don't rely on the data there! 5 | 6 | Rails.application.configure do 7 | # Settings specified here will take precedence over those in config/application.rb. 8 | 9 | # While tests run files are not watched, reloading is not necessary. 10 | config.enable_reloading = false 11 | 12 | # Eager loading loads your entire application. When running a single test locally, 13 | # this is usually not necessary, and can slow down your test suite. However, it's 14 | # recommended that you enable it in continuous integration systems to ensure eager 15 | # loading is working properly before deploying your code. 16 | config.eager_load = ENV["CI"].present? 17 | 18 | # Configure public file server for tests with cache-control for performance. 19 | config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } 20 | 21 | # Show full error reports. 22 | config.consider_all_requests_local = true 23 | config.cache_store = :null_store 24 | 25 | # Render exception templates for rescuable exceptions and raise for other exceptions. 26 | config.action_dispatch.show_exceptions = :rescuable 27 | 28 | # Disable request forgery protection in test environment. 29 | config.action_controller.allow_forgery_protection = false 30 | 31 | # Store uploaded files on the local file system in a temporary directory. 32 | config.active_storage.service = :test 33 | 34 | # Tell Action Mailer not to deliver emails to the real world. 35 | # The :test delivery method accumulates sent emails in the 36 | # ActionMailer::Base.deliveries array. 37 | config.action_mailer.delivery_method = :test 38 | 39 | # Set host to be used by links generated in mailer templates. 40 | config.action_mailer.default_url_options = { host: "example.com" } 41 | 42 | # Print deprecation notices to the stderr. 43 | config.active_support.deprecation = :stderr 44 | 45 | # Raises error for missing translations. 46 | # config.i18n.raise_on_missing_translations = true 47 | 48 | # Annotate rendered view with file names. 49 | # config.action_view.annotate_rendered_view_with_filenames = true 50 | 51 | # Raise error when a before_action's only/except options reference missing actions. 52 | config.action_controller.raise_on_missing_callback_actions = true 53 | end 54 | -------------------------------------------------------------------------------- /app/models/stepped/step.rb: -------------------------------------------------------------------------------- 1 | class Stepped::Step < ActiveRecord::Base 2 | STATUSES = %w[ 3 | pending 4 | performing 5 | succeeded 6 | failed 7 | ].freeze 8 | 9 | enum :status, STATUSES.index_by(&:itself) 10 | 11 | belongs_to :action 12 | 13 | has_and_belongs_to_many :actions, -> { order(id: :asc) }, class_name: "Stepped::Action", 14 | join_table: :stepped_actions_steps, foreign_key: :action_id, association_foreign_key: :step_id, 15 | inverse_of: :parent_steps 16 | 17 | scope :incomplete, -> { where(status: %i[ pending performing ]) } 18 | 19 | def perform 20 | @jobs = [] 21 | if execute_block 22 | ActiveJob.perform_all_later @jobs 23 | else 24 | self.pending_actions_count = 0 25 | self.status = :failed 26 | end 27 | 28 | complete! if pending_actions_count.zero? 29 | end 30 | 31 | def do(action_name, *args) 32 | on action.actor, action_name, *args 33 | end 34 | 35 | def on(actors, action_name, *args) 36 | Array(actors).compact.each do |actor| 37 | increment :pending_actions_count 38 | @jobs << Stepped::ActionJob.new(actor, action_name, *args, parent_step: self) 39 | end 40 | 41 | save! 42 | end 43 | 44 | def wait(duration) 45 | increment! :pending_actions_count 46 | @jobs << Stepped::WaitJob.new(self).set(wait: duration) 47 | end 48 | 49 | def conclude_job(succeeded = true) 50 | with_lock do 51 | raise NoPendingActionsError unless pending_actions_count > 0 52 | 53 | decrement :pending_actions_count 54 | increment :unsuccessful_actions_count unless succeeded 55 | 56 | if pending_actions_count.zero? 57 | assign_attributes(completed_at: Time.zone.now, status: determine_status) 58 | end 59 | 60 | save! 61 | end 62 | 63 | action.accomplished(self) if pending_actions_count.zero? 64 | end 65 | 66 | def display_position 67 | definition_index + 1 68 | end 69 | 70 | private 71 | def complete!(status = determine_status) 72 | update!(completed_at: Time.zone.now, status:) 73 | action.accomplished self 74 | end 75 | 76 | def block 77 | action.definition.steps.fetch(definition_index) 78 | end 79 | 80 | def execute_block 81 | context = { 82 | step_id: id, 83 | parent_action_id: action_id, 84 | step_no: definition_index, 85 | block: 86 | } 87 | Stepped.handle_exception(context:) do 88 | action.actor.instance_exec self, *action.arguments, &block 89 | end 90 | end 91 | 92 | def determine_status 93 | return status unless performing? 94 | unsuccessful_actions_count > 0 ? :failed : :succeeded 95 | end 96 | 97 | class NoPendingActionsError < StandardError; end 98 | end 99 | -------------------------------------------------------------------------------- /lib/stepped/test_helper.rb: -------------------------------------------------------------------------------- 1 | module Stepped::TestHelper 2 | def perform_stepped_actions(only: stepped_job_classes) 3 | perform_enqueued_jobs_recursively(only:) 4 | complete_stepped_outbound_performances 5 | 6 | perform_stepped_actions(only:) if Stepped::Performance.any? || enqueued_jobs_with(only:).count > 0 7 | end 8 | 9 | def assert_stepped_action_job(name, actor = nil) 10 | jobs_before = enqueued_jobs + performed_jobs 11 | yield 12 | jobs = (enqueued_jobs + performed_jobs) - jobs_before 13 | found = false 14 | all_actions = [] 15 | jobs.each do |job| 16 | next unless job["job_class"] == "Stepped::ActionJob" 17 | 18 | job_actor, job_action_name = ActiveJob::Arguments.deserialize job["arguments"] 19 | all_actions << [ job_actor.to_global_id, job_action_name ].join("#") 20 | next if found 21 | 22 | found = job_action_name == name 23 | if found && actor.present? 24 | actor = eval(actor) if actor.is_a?(String) 25 | found = job_actor == actor 26 | end 27 | end 28 | assert found, <<~MESSAGE 29 | Stepped action job for '#{name}' was not enqueued or performed#{actor.respond_to?(:to_debug_id) ? " on #{actor.to_debug_id}" : nil}. 30 | Actions that were: #{all_actions.join(", ")} 31 | MESSAGE 32 | end 33 | 34 | def assert_no_stepped_actions 35 | jobs_before = enqueued_jobs + performed_jobs 36 | yield 37 | jobs = (enqueued_jobs + performed_jobs) - jobs_before 38 | found = false 39 | all_actions = [] 40 | jobs.each do |job| 41 | next unless job["job_class"] == "Stepped::ActionJob" 42 | found = true 43 | job_actor, job_action_name = ActiveJob::Arguments.deserialize job["arguments"] 44 | all_actions << [ job_actor.class.name, job_action_name ].join("#") 45 | end 46 | assert_not found, <<~MESSAGE 47 | Stepped action jobs enqueued or performed: #{all_actions.join(", ")} 48 | MESSAGE 49 | end 50 | 51 | def handle_stepped_action_exceptions(only: [ StandardError ]) 52 | was = Stepped::Engine.config.stepped_actions.handle_exceptions 53 | Stepped::Engine.config.stepped_actions.handle_exceptions = Array(only) 54 | yield 55 | ensure 56 | Stepped::Engine.config.stepped_actions.handle_exceptions = was 57 | end 58 | 59 | def perform_enqueued_jobs_recursively(only: nil) 60 | total = 0 61 | loop do 62 | batch = perform_enqueued_jobs(only:) 63 | break if batch == 0 64 | total += batch 65 | end 66 | total 67 | end 68 | 69 | def stepped_job_classes 70 | [ Stepped::ActionJob, Stepped::TimeoutJob, Stepped::WaitJob ] + Stepped::Registry.job_classes 71 | end 72 | 73 | def complete_stepped_outbound_performances 74 | # Consuming app can redefine 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /app/models/stepped/definition.rb: -------------------------------------------------------------------------------- 1 | class Stepped::Definition 2 | attr_reader :actor_class, :action_name, :block, 3 | :outbound, :timeout, :job, 4 | :concurrency_key_block, :before_block, 5 | :checksum_block, :checksum_key_block, 6 | :steps, :after_callbacks 7 | 8 | AFTER_CALLBACKS = %i[ 9 | cancelled 10 | timed_out 11 | succeeded 12 | failed 13 | ] 14 | 15 | def initialize(actor_class:, action_name:, outbound: false, timeout: nil, job: nil, block: nil) 16 | @actor_class = actor_class 17 | @action_name = action_name.to_s 18 | @outbound = outbound || job.present? 19 | @timeout = timeout 20 | @job = job 21 | @after_callbacks = [] 22 | @steps = [] 23 | @block = block 24 | 25 | instance_exec &block if block 26 | 27 | if @steps.empty? 28 | @steps.append generate_step 29 | end 30 | end 31 | 32 | def duplicate_as(actor_class) 33 | self.class.new(actor_class:, action_name:, outbound:, timeout:, job:, block:) 34 | end 35 | 36 | def before(&block) 37 | @before_block = block 38 | end 39 | 40 | def concurrency_key(method = nil, &block) 41 | @concurrency_key_block = procify method, &block 42 | end 43 | 44 | def checksum(method = nil, &block) 45 | @checksum_block = procify method, &block 46 | end 47 | 48 | def checksum_key(method = nil, &block) 49 | @checksum_key_block = procify method, &block 50 | end 51 | 52 | def step(&block) 53 | @steps.append block 54 | end 55 | 56 | def prepend_step(&block) 57 | @steps.prepend block 58 | end 59 | 60 | AFTER_CALLBACKS.each do |name| 61 | define_method name do |&block| 62 | after_callbacks << { name:, block: } 63 | end 64 | end 65 | 66 | def after(*statuses, &block) 67 | statuses = [ :all ] if statuses.empty? 68 | statuses.each do |status| 69 | status = status.to_sym 70 | unless status == :all || AFTER_CALLBACKS.include?(status) 71 | raise ArgumentError, "'#{status}' must be one of #{AFTER_CALLBACKS}" 72 | end 73 | after_callbacks << { name: status, block: } 74 | end 75 | end 76 | 77 | private 78 | def procify(method, &block) 79 | if method.is_a?(Symbol) 80 | proc do 81 | send method 82 | end 83 | elsif block_given? 84 | block 85 | else 86 | raise ArgumentError, "Symbol referring to a method to call or a block required" 87 | end 88 | end 89 | 90 | def method_call_step_block(method_name) 91 | proc do |step| 92 | send method_name, *step.action.arguments 93 | end 94 | end 95 | 96 | def job_step_block(job) 97 | proc do |step| 98 | job.perform_later step.action 99 | end 100 | end 101 | 102 | def generate_step 103 | if job 104 | job_step_block job 105 | else 106 | method_call_step_block action_name 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /test/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Make code changes take effect immediately without server restart. 7 | config.enable_reloading = true 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable server timing. 16 | config.server_timing = true 17 | 18 | # Enable/disable Action Controller caching. By default Action Controller caching is disabled. 19 | # Run rails dev:cache to toggle Action Controller caching. 20 | if Rails.root.join("tmp/caching-dev.txt").exist? 21 | config.action_controller.perform_caching = true 22 | config.action_controller.enable_fragment_cache_logging = true 23 | config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } 24 | else 25 | config.action_controller.perform_caching = false 26 | end 27 | 28 | # Change to :null_store to avoid any caching. 29 | config.cache_store = :memory_store 30 | 31 | # Store uploaded files on the local file system (see config/storage.yml for options). 32 | config.active_storage.service = :local 33 | 34 | # Don't care if the mailer can't send. 35 | config.action_mailer.raise_delivery_errors = false 36 | 37 | # Make template changes take effect immediately. 38 | config.action_mailer.perform_caching = false 39 | 40 | # Set localhost to be used by links generated in mailer templates. 41 | config.action_mailer.default_url_options = { host: "localhost", port: 3000 } 42 | 43 | # Print deprecation notices to the Rails logger. 44 | config.active_support.deprecation = :log 45 | 46 | # Raise an error on page load if there are pending migrations. 47 | config.active_record.migration_error = :page_load 48 | 49 | # Highlight code that triggered database queries in logs. 50 | config.active_record.verbose_query_logs = true 51 | 52 | # Append comments with runtime information tags to SQL queries in logs. 53 | config.active_record.query_log_tags_enabled = true 54 | 55 | # Highlight code that enqueued background job in logs. 56 | config.active_job.verbose_enqueue_logs = true 57 | 58 | # Highlight code that triggered redirect in logs. 59 | config.action_dispatch.verbose_redirect_logs = true 60 | 61 | # Suppress logger output for asset requests. 62 | config.assets.quiet = true 63 | 64 | # Raises error for missing translations. 65 | # config.i18n.raise_on_missing_translations = true 66 | 67 | # Annotate rendered view with file names. 68 | config.action_view.annotate_rendered_view_with_filenames = true 69 | 70 | # Uncomment if you wish to allow Action Cable access from any origin. 71 | # config.action_cable.disable_request_forgery_protection = true 72 | 73 | # Raise error when a before_action's only/except options reference missing actions. 74 | config.action_controller.raise_on_missing_callback_actions = true 75 | end 76 | -------------------------------------------------------------------------------- /test/models/stepped/registry_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Stepped::RegistryTest < Stepped::TestCase 4 | STEP_ONE = proc { } 5 | STEP_TWO = proc { } 6 | AFTER_CALLBACK = proc { } 7 | STEP = proc { } 8 | 9 | class Car 10 | include Stepped::Actionable 11 | 12 | stepped_action :drive do 13 | step &STEP_ONE 14 | step &STEP_TWO 15 | after &AFTER_CALLBACK 16 | end 17 | end 18 | 19 | class FlyJob; end 20 | class FlyingCar < Car 21 | stepped_action :fly, job: FlyJob 22 | end 23 | 24 | class DifferentlyDrivingCar < Car 25 | stepped_action :drive do 26 | step &STEP 27 | end 28 | end 29 | 30 | test "job_classes lists all job classes that have been used in definitions" do 31 | assert_includes Stepped::Registry.job_classes, FlyJob 32 | end 33 | 34 | test "find when action is not defined for a class" do 35 | assert_nil Stepped::Registry.find(Car, :fly) 36 | end 37 | 38 | [ :drive, "drive" ].each do |action_name| 39 | test "find when action is defined when action name is a #{action_name.class.name}" do 40 | definition = Stepped::Registry.find(Car, action_name) 41 | assert_kind_of Stepped::Definition, definition 42 | assert_equal "drive", definition.action_name 43 | assert_equal Car, definition.actor_class 44 | assert_equal 2, definition.steps.size 45 | assert_equal STEP_ONE, definition.steps.first 46 | assert_equal STEP_TWO, definition.steps.second 47 | definition.steps.each do |step_definition| 48 | assert_kind_of Proc, step_definition 49 | end 50 | assert_equal 1, definition.after_callbacks.size 51 | assert_equal AFTER_CALLBACK, definition.after_callbacks.first[:block] 52 | end 53 | end 54 | 55 | test "parent does not get childs definitions" do 56 | assert_nil Stepped::Registry.find(Car, :fly) 57 | end 58 | 59 | test "inheriting and action from parent" do 60 | definition = Stepped::Registry.find(FlyingCar, :drive) 61 | assert_equal Car, definition.actor_class 62 | assert_equal definition, Stepped::Registry.find(Car, :drive) 63 | end 64 | 65 | test "override of inherited definition" do 66 | definition = Stepped::Registry.find DifferentlyDrivingCar, :drive 67 | assert_equal 1, definition.steps.size 68 | assert_equal STEP, definition.steps.first 69 | assert_not_equal definition, Stepped::Registry.find(Car, :drive) 70 | end 71 | 72 | test "adding after callbacks to child doesn't modify parent action" do 73 | one = proc { } 74 | two = proc { } 75 | FlyingCar.after_stepped_action :drive, &one 76 | FlyingCar.after_stepped_action :drive, &two 77 | 78 | parent_definition = Stepped::Registry.find(Car, :drive) 79 | assert_equal 1, parent_definition.after_callbacks.size 80 | assert_equal AFTER_CALLBACK, parent_definition.after_callbacks.first[:block] 81 | 82 | definition = Stepped::Registry.find(FlyingCar, :drive) 83 | assert_not_equal definition, parent_definition 84 | assert_equal 3, definition.after_callbacks.size 85 | assert_equal AFTER_CALLBACK, definition.after_callbacks.first[:block] 86 | assert_equal one, definition.after_callbacks.second[:block] 87 | assert_equal two, definition.after_callbacks.third[:block] 88 | ensure 89 | # TODO: This is a weakness of the current registry global state in tests, 90 | # where definitions in one test, affect the rest of the suite. 91 | Stepped::Registry.definitions.delete "Stepped::RegistryTest::FlyingCar/#{FlyingCar.object_id}" 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/models/stepped/action_as_job_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Stepped::ActionAsJobTest < Stepped::TestCase 4 | class TowJob < ActiveJob::Base 5 | queue_as :default 6 | 7 | def perform(action) 8 | car = action.actor 9 | location = action.arguments.first 10 | 11 | return retry_job if location == "retry" 12 | 13 | car.update!(location:) 14 | 15 | action.complete! 16 | end 17 | end 18 | 19 | setup do 20 | Temping.create "car" do 21 | with_columns do |t| 22 | t.integer :honks, default: 0 23 | t.string :location 24 | end 25 | 26 | stepped_action :tow, job: TowJob 27 | 28 | def honk 29 | increment! :honks 30 | end 31 | end 32 | 33 | @car = Car.create! 34 | end 35 | 36 | test "perform action defined as Job" do 37 | action = nil 38 | assert_difference( 39 | "Stepped::Action.count" => +1, 40 | "Stepped::Step.count" => +1, 41 | "Stepped::Performance.count" => +1 42 | ) do 43 | assert_enqueued_with(job: TowJob) do 44 | action = Stepped::ActionJob.perform_now @car, :tow, "service" 45 | end 46 | end 47 | 48 | assert_predicate action, :performing? 49 | assert_predicate action.steps.first, :succeeded? 50 | 51 | assert_difference( 52 | "Stepped::Action.count" => 0, 53 | "Stepped::Step.count" => 0, 54 | "Stepped::Performance.count" => -1 55 | ) do 56 | perform_enqueued_jobs(only: TowJob) 57 | end 58 | 59 | assert_predicate action.reload, :succeeded? 60 | assert_predicate action, :outbound? 61 | assert_equal "service", @car.reload.location 62 | assert_equal 0, action.steps.first.pending_actions_count 63 | end 64 | 65 | test "job that retries itself" do 66 | action = nil 67 | assert_enqueued_with job: TowJob do 68 | action = Stepped::ActionJob.perform_now @car, :tow, "retry" 69 | end 70 | 71 | assert_enqueued_with job: TowJob do 72 | perform_enqueued_jobs(only: TowJob) 73 | end 74 | 75 | action.arguments = [ "Paris" ] 76 | action.save! 77 | 78 | assert_no_enqueued_jobs(only: TowJob) do 79 | perform_enqueued_jobs(only: TowJob) 80 | end 81 | 82 | assert_predicate action.reload, :succeeded? 83 | end 84 | 85 | test "prepending step to existing job action" do 86 | Car.prepend_stepped_action_step :tow do |step| 87 | honk 88 | end 89 | 90 | action = nil 91 | assert_difference( 92 | "Stepped::Action.count" => +1, 93 | "Stepped::Step.count" => +2, 94 | "Stepped::Performance.count" => +1 95 | ) do 96 | assert_enqueued_with(job: TowJob) do 97 | action = Stepped::ActionJob.perform_now @car, :tow, "service" 98 | end 99 | end 100 | 101 | assert_predicate action, :performing? 102 | assert_predicate action.steps.first, :succeeded? 103 | assert_equal 1, @car.honks 104 | 105 | assert_difference( 106 | "Stepped::Action.count" => 0, 107 | "Stepped::Step.count" => 0, 108 | "Stepped::Performance.count" => -1 109 | ) do 110 | perform_enqueued_jobs(only: TowJob) 111 | end 112 | 113 | assert_predicate action.reload, :succeeded? 114 | assert_equal 0, action.steps.first.pending_actions_count 115 | assert_equal 0, action.steps.second.pending_actions_count 116 | assert_equal 1, @car.reload.honks 117 | assert_equal "service", @car.location 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /test/models/stepped/action_prepend_step_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Stepped::ActionPrependStepTestTest < Stepped::TestCase 4 | setup do 5 | Temping.create "car" do 6 | with_columns do |t| 7 | t.integer :honks, default: 0 8 | t.integer :oinks, default: 0 9 | t.integer :boinks, default: 0 10 | t.integer :befores, default: 0 11 | end 12 | 13 | stepped_action :interact do 14 | before do 15 | increment! :befores 16 | end 17 | 18 | checksum { 1 } 19 | 20 | step do |step| 21 | step.do :honk 22 | end 23 | end 24 | 25 | prepend_stepped_action_step :interact do |step| 26 | step.do :oink 27 | end 28 | 29 | stepped_action :boink do 30 | step do |step| 31 | step.do :interact 32 | end 33 | 34 | step do 35 | increment! :boinks 36 | end 37 | end 38 | 39 | def honk 40 | increment! :honks 41 | end 42 | 43 | def oink 44 | increment! :oinks 45 | end 46 | end 47 | 48 | @car = Car.create! 49 | end 50 | 51 | test "step prepended after action definition" do 52 | action = 53 | assert_difference( 54 | "Stepped::Action.count" => +1, 55 | "Stepped::Step.count" => +1, 56 | "Stepped::Performance.count" => +1 57 | ) do 58 | @car.interact_now 59 | end 60 | assert_predicate action, :performing? 61 | assert_equal "interact", action.name 62 | assert_equal Stepped.checksum(1), action.checksum 63 | assert_equal "Car/#{@car.id}/interact", action.concurrency_key 64 | assert_equal "Car/#{@car.id}/interact", action.checksum_key 65 | assert_equal 0, @car.reload.oinks 66 | assert_equal 0, @car.honks 67 | assert_equal 1, @car.befores 68 | 69 | step = Stepped::Step.last 70 | assert_predicate step, :performing? 71 | 72 | assert_difference( 73 | "Stepped::Action.count" => +1, 74 | "Stepped::Step.count" => +2, 75 | "Stepped::Performance.count" => 0, 76 | "Stepped::Achievement.count" => 0 77 | ) do 78 | perform_enqueued_jobs(only: Stepped::ActionJob) 79 | end 80 | 81 | assert_predicate action.reload, :performing? 82 | assert_equal 1, @car.reload.oinks 83 | assert_equal 0, @car.honks 84 | assert_equal 1, @car.befores 85 | 86 | assert_difference( 87 | "Stepped::Action.count" => +1, 88 | "Stepped::Step.count" => +1, 89 | "Stepped::Performance.count" => -1, 90 | "Stepped::Achievement.count" => +1 91 | ) do 92 | perform_enqueued_jobs(only: Stepped::ActionJob) 93 | end 94 | 95 | assert_predicate action.reload, :succeeded? 96 | assert_equal Stepped.checksum(1), action.checksum 97 | assert_equal Stepped::Achievement.last.checksum, action.checksum 98 | 99 | assert_equal 1, @car.reload.oinks 100 | assert_equal 1, @car.honks 101 | assert_equal 1, @car.befores 102 | end 103 | 104 | test "exception in dependencies block fails the action" do 105 | Car.stepped_action :born_to_fail do 106 | step do 107 | throw "this should not be reached" 108 | end 109 | end 110 | Car.prepend_stepped_action_step :born_to_fail do 111 | raise StandardError 112 | end 113 | 114 | handle_stepped_action_exceptions do 115 | action = 116 | assert_difference "Stepped::Action.count" => +1 do 117 | @car.born_to_fail_now 118 | end 119 | assert_equal "born_to_fail", action.name 120 | assert_predicate action, :failed? 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /test/models/stepped/action_timeout_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Stepped::ActionTimeoutTest < Stepped::TestCase 4 | setup do 5 | Temping.create "car" do 6 | with_columns do |t| 7 | t.string :location 8 | end 9 | 10 | stepped_action :visit do 11 | step do |step, location| 12 | step.do :change_location, location 13 | end 14 | 15 | succeeded do 16 | throw "This should not be reached" 17 | end 18 | end 19 | 20 | stepped_action :change_location, outbound: true, timeout: 5.seconds do 21 | succeeded do 22 | throw "This should not be reached" 23 | end 24 | end 25 | 26 | def change_location(location) 27 | update!(location:) 28 | end 29 | end 30 | 31 | @car = Car.create! 32 | end 33 | 34 | test "action times out if not completed within the specified time" do 35 | start_at = Time.zone.local 2024, 12, 12 36 | assert_difference( 37 | "Stepped::Action.count" => +1, 38 | "Stepped::Performance.count" => +1 39 | ) do 40 | assert_enqueued_with(job: Stepped::TimeoutJob) do 41 | travel_to start_at do 42 | Stepped::ActionJob.perform_now @car, :change_location, "Copenhagen" 43 | end 44 | end 45 | end 46 | 47 | action = Stepped::Action.last 48 | assert_predicate action, :performing? 49 | assert_predicate action, :timeout? 50 | assert_equal 5.seconds, action.timeout 51 | assert_equal 5, action.timeout_seconds 52 | assert_equal start_at, action.started_at 53 | assert_equal "Copenhagen", @car.reload.location 54 | 55 | # TimeoutJob performs and action timed out 56 | timeout_at = start_at + 6.seconds 57 | assert_difference("Stepped::Performance.count" => -1) do 58 | travel_to timeout_at do 59 | assert_equal 1, perform_enqueued_jobs(only: Stepped::TimeoutJob) 60 | end 61 | end 62 | assert_predicate action.reload, :timed_out? 63 | assert_equal timeout_at, action.completed_at 64 | end 65 | 66 | test "timeout of nested action fails the parent step and parent action" do 67 | start_at = Time.zone.local 2024, 12, 12 68 | assert_difference( 69 | "Stepped::Action.count" => +2, 70 | "Stepped::Step.count" => +2, 71 | "Stepped::Performance.count" => +2 72 | ) do 73 | assert_enqueued_with(job: Stepped::TimeoutJob) do 74 | travel_to start_at do 75 | Stepped::ActionJob.perform_now @car, :visit, "Copenhagen" 76 | assert_equal 1, perform_enqueued_jobs(only: Stepped::ActionJob) 77 | end 78 | end 79 | end 80 | 81 | parent_action = Stepped::Action.last(2).first 82 | assert_predicate parent_action, :performing? 83 | assert_not_predicate parent_action, :timeout? 84 | assert_equal start_at, parent_action.started_at 85 | 86 | nested_action = Stepped::Action.last 87 | assert_predicate nested_action, :performing? 88 | assert_predicate nested_action, :timeout? 89 | assert_equal 5.seconds, nested_action.timeout 90 | assert_equal start_at, nested_action.started_at 91 | 92 | assert_equal "Copenhagen", @car.reload.location 93 | 94 | # TimeoutJob performs and action timed out 95 | timeout_at = start_at + 6.seconds 96 | assert_difference("Stepped::Performance.count" => -2) do 97 | travel_to timeout_at do 98 | assert_equal 1, perform_enqueued_jobs(only: Stepped::TimeoutJob) 99 | end 100 | end 101 | assert_predicate nested_action.reload, :timed_out? 102 | assert_equal timeout_at, nested_action.completed_at 103 | assert_predicate parent_action.reload, :failed? 104 | assert_equal timeout_at, parent_action.completed_at 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /test/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.enable_reloading = false 8 | 9 | # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). 10 | config.eager_load = true 11 | 12 | # Full error reports are disabled. 13 | config.consider_all_requests_local = false 14 | 15 | # Turn on fragment caching in view templates. 16 | config.action_controller.perform_caching = true 17 | 18 | # Cache assets for far-future expiry since they are all digest stamped. 19 | config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } 20 | 21 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 22 | # config.asset_host = "http://assets.example.com" 23 | 24 | # Store uploaded files on the local file system (see config/storage.yml for options). 25 | config.active_storage.service = :local 26 | 27 | # Assume all access to the app is happening through a SSL-terminating reverse proxy. 28 | config.assume_ssl = true 29 | 30 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 31 | config.force_ssl = true 32 | 33 | # Skip http-to-https redirect for the default health check endpoint. 34 | # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } 35 | 36 | # Log to STDOUT with the current request id as a default log tag. 37 | config.log_tags = [ :request_id ] 38 | config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) 39 | 40 | # Change to "debug" to log everything (including potentially personally-identifiable information!). 41 | config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") 42 | 43 | # Prevent health checks from clogging up the logs. 44 | config.silence_healthcheck_path = "/up" 45 | 46 | # Don't log any deprecations. 47 | config.active_support.report_deprecations = false 48 | 49 | # Replace the default in-process memory cache store with a durable alternative. 50 | # config.cache_store = :mem_cache_store 51 | 52 | # Replace the default in-process and non-durable queuing backend for Active Job. 53 | # config.active_job.queue_adapter = :resque 54 | 55 | # Ignore bad email addresses and do not raise email delivery errors. 56 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 57 | # config.action_mailer.raise_delivery_errors = false 58 | 59 | # Set host to be used by links generated in mailer templates. 60 | config.action_mailer.default_url_options = { host: "example.com" } 61 | 62 | # Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit. 63 | # config.action_mailer.smtp_settings = { 64 | # user_name: Rails.application.credentials.dig(:smtp, :user_name), 65 | # password: Rails.application.credentials.dig(:smtp, :password), 66 | # address: "smtp.example.com", 67 | # port: 587, 68 | # authentication: :plain 69 | # } 70 | 71 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 72 | # the I18n.default_locale when a translation cannot be found). 73 | config.i18n.fallbacks = true 74 | 75 | # Do not dump schema after migrations. 76 | config.active_record.dump_schema_after_migration = false 77 | 78 | # Only use :id for inspections in production. 79 | config.active_record.attributes_for_inspect = [ :id ] 80 | 81 | # Enable DNS rebinding protection and other `Host` header attacks. 82 | # config.hosts = [ 83 | # "example.com", # Allow requests from example.com 84 | # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` 85 | # ] 86 | # 87 | # Skip DNS rebinding protection for the default health check endpoint. 88 | # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } 89 | end 90 | -------------------------------------------------------------------------------- /db/migrate/20251214104829_create_stepped_tables_if_missing.rb: -------------------------------------------------------------------------------- 1 | class CreateSteppedTablesIfMissing < ActiveRecord::Migration[8.0] 2 | def change 3 | create_table :stepped_achievements, if_not_exists: true do |t| 4 | t.string :checksum, null: false 5 | t.string :checksum_key, null: false 6 | t.timestamps null: false 7 | end 8 | add_index :stepped_achievements, :checksum_key, unique: true, if_not_exists: true 9 | 10 | create_table :stepped_actions, if_not_exists: true do |t| 11 | t.bigint :actor_id, null: false 12 | t.string :actor_type, null: false 13 | t.integer :after_callbacks_failed_count 14 | t.integer :after_callbacks_succeeded_count 15 | t.json :arguments 16 | t.string :checksum 17 | t.string :checksum_key, null: false 18 | t.datetime :completed_at 19 | t.string :concurrency_key, null: false 20 | t.integer :current_step_index, null: false, default: 0 21 | t.string :job 22 | t.string :name, null: false 23 | t.boolean :outbound, null: false, default: false 24 | t.bigint :performance_id 25 | t.boolean :root, default: false 26 | t.datetime :started_at 27 | t.string :status, null: false, default: "pending" 28 | t.integer :timeout_seconds 29 | t.timestamps null: false 30 | end 31 | 32 | add_index :stepped_actions, %i[actor_type actor_id], name: "index_stepped_actions_on_actor", if_not_exists: true 33 | add_index :stepped_actions, %i[performance_id outbound], name: "index_stepped_actions_on_performance_id_and_outbound", if_not_exists: true 34 | add_index :stepped_actions, :performance_id, if_not_exists: true 35 | add_index :stepped_actions, :root, if_not_exists: true 36 | 37 | create_table :stepped_actions_steps, id: false, if_not_exists: true do |t| 38 | t.bigint :action_id, null: false 39 | t.bigint :step_id, null: false 40 | end 41 | 42 | add_index :stepped_actions_steps, %i[action_id step_id], unique: true, name: "index_stepped_actions_steps_on_action_id_and_step_id", if_not_exists: true 43 | add_index :stepped_actions_steps, %i[step_id action_id], name: "index_stepped_actions_steps_on_step_id_and_action_id", if_not_exists: true 44 | 45 | create_table :stepped_performances, if_not_exists: true do |t| 46 | t.bigint :action_id, null: false 47 | t.string :concurrency_key 48 | t.string :outbound_complete_key 49 | t.timestamps null: false 50 | end 51 | 52 | add_index :stepped_performances, :action_id, unique: true, name: "index_stepped_performances_on_action_id", if_not_exists: true 53 | add_index :stepped_performances, :concurrency_key, unique: true, name: "index_stepped_performances_on_concurrency_key", if_not_exists: true 54 | add_index :stepped_performances, :outbound_complete_key, 55 | name: "index_stepped_performances_on_outbound_complete_key", 56 | where: "(outbound_complete_key IS NOT NULL)", 57 | if_not_exists: true 58 | 59 | create_table :stepped_steps, if_not_exists: true do |t| 60 | t.bigint :action_id, null: false 61 | t.datetime :completed_at 62 | t.integer :definition_index, null: false 63 | t.integer :pending_actions_count, null: false, default: 0 64 | t.datetime :started_at 65 | t.string :status, null: false, default: "pending" 66 | t.integer :unsuccessful_actions_count, default: 0 67 | t.timestamps null: false 68 | end 69 | 70 | add_index :stepped_steps, %i[action_id definition_index], unique: true, name: "index_stepped_steps_on_action_id_and_definition_index", if_not_exists: true 71 | add_index :stepped_steps, :action_id, name: "index_stepped_steps_on_action_id", if_not_exists: true 72 | 73 | add_foreign_key :stepped_actions, :stepped_performances, column: :performance_id, if_not_exists: true 74 | add_foreign_key :stepped_performances, :stepped_actions, column: :action_id, if_not_exists: true 75 | add_foreign_key :stepped_steps, :stepped_actions, column: :action_id, if_not_exists: true 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/models/stepped/step_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Stepped::StepTest < Stepped::TestCase 4 | setup do 5 | Temping.create "account" do 6 | with_columns do |t| 7 | t.string :name 8 | end 9 | 10 | stepped_action :action1 11 | 12 | def action1; end 13 | end 14 | 15 | Temping.create "car" do 16 | with_columns do |t| 17 | t.integer :honks, default: 0 18 | end 19 | 20 | def honk 21 | increment! :honks 22 | end 23 | end 24 | 25 | @account = Account.create!(name: "Acme Org") 26 | @car = Car.create! 27 | end 28 | 29 | test "HABTM" do 30 | actor = Car.create! 31 | parent_action = Stepped::Action.create!(name: "test", actor:, checksum_key: "a", concurrency_key: "a") 32 | step = parent_action.steps.create!(definition_index: 0) 33 | action = Stepped::Action.new(name: "test2", actor: Car.create!, checksum_key: "b", concurrency_key: "b") 34 | action.parent_steps << step 35 | assert action.save! 36 | assert_equal 1, action.parent_steps.count 37 | assert_equal step, action.parent_steps.first 38 | 39 | # copy_parent_steps_to 40 | action2 = Stepped::Action.create!(name: "test3", actor: Car.create!, checksum_key: "c", concurrency_key: "c") 41 | 42 | Stepped::Action.transaction do 43 | action.copy_parent_steps_to(action2) 44 | action.copy_parent_steps_to(action2) 45 | assert_equal 1, action2.parent_steps.size 46 | action2.update! name: "test4" 47 | end 48 | 49 | assert_equal 1, action2.parent_steps.count 50 | assert_equal step, action2.parent_steps.first 51 | assert_equal "test4", action2.name 52 | end 53 | 54 | test "NoPendingActionsError" do 55 | action = Stepped::Action.new(actor: @account, name: "action1") 56 | action.apply_definition 57 | step = action.steps.build(pending_actions_count: 0, definition_index: 0) 58 | action.save! 59 | 60 | assert_raises Stepped::Step::NoPendingActionsError do 61 | step.conclude_job 62 | end 63 | end 64 | 65 | test "wait" do 66 | Car.stepped_action :stopover do 67 | step do |step| 68 | step.wait 5.seconds 69 | end 70 | 71 | step do 72 | honk 73 | end 74 | end 75 | start_at = Time.zone.local 2024, 12, 12 76 | 77 | assert_difference( 78 | "Stepped::Action.count" => +1, 79 | "Stepped::Step.count" => +1, 80 | "Stepped::Achievement.count" => 0, 81 | "Stepped::Performance.count" => +1 82 | ) do 83 | assert_enqueued_with(job: Stepped::WaitJob) do 84 | travel_to start_at do 85 | Stepped::ActionJob.perform_now @car, :stopover 86 | end 87 | end 88 | end 89 | 90 | action = Stepped::Action.last 91 | assert_predicate action, :performing? 92 | 93 | step = Stepped::Step.last 94 | assert_predicate step, :performing? 95 | assert_equal 1, step.pending_actions_count 96 | 97 | # Perform Stepped::WaitJob 98 | end_at = start_at + 6.seconds 99 | assert_difference("Stepped::Performance.count" => -1) do 100 | travel_to end_at do 101 | assert_equal 1, perform_enqueued_jobs(only: Stepped::WaitJob) 102 | end 103 | end 104 | 105 | assert_predicate action.reload, :succeeded? 106 | assert_predicate step.reload, :succeeded? 107 | assert_equal 0, step.pending_actions_count 108 | end 109 | 110 | test "failing deliberately within a step" do 111 | Car.stepped_action :soft_fail do 112 | step do 113 | honk 114 | end 115 | 116 | step do |step| 117 | step.status = :failed 118 | end 119 | 120 | step do 121 | honk 122 | end 123 | end 124 | 125 | assert_difference( 126 | "Stepped::Action.count" => +1, 127 | "Stepped::Step.count" => +2, 128 | "Stepped::Achievement.count" => 0, 129 | "Stepped::Performance.count" => 0 130 | ) do 131 | action = Stepped::ActionJob.perform_now @car, :soft_fail 132 | perform_enqueued_jobs_recursively(only: Stepped::ActionJob) 133 | assert_predicate action.reload, :failed? 134 | assert_predicate Stepped::Step.last, :failed? 135 | assert_equal 1, @car.honks 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /test/models/stepped/action_completes_outbound_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Stepped::ActionCompletesOutboundTest < Stepped::TestCase 4 | setup do 5 | Temping.create "car" do 6 | with_columns do |t| 7 | t.string :location 8 | t.integer :mileage, default: 0 9 | end 10 | 11 | stepped_action :visit do 12 | step do |step, distance, location| 13 | step.do :drive, distance 14 | end 15 | 16 | step do |step, distance, location| 17 | step.do :change_location, location 18 | end 19 | end 20 | 21 | stepped_action :drive, outbound: true do 22 | after :cancelled, :failed, :timed_out do 23 | drive 1 24 | end 25 | end 26 | 27 | def drive(mileage) 28 | self.mileage += mileage 29 | save! 30 | end 31 | 32 | def change_location(location) 33 | update!(location:) 34 | end 35 | end 36 | 37 | @car = Car.create! 38 | end 39 | 40 | test "performing direct action that completes successfully outbound" do 41 | action = 42 | assert_difference( 43 | "Stepped::Action.count" => +1, 44 | "Stepped::Step.count" => +1, 45 | "Stepped::Achievement.count" => 0, 46 | "Stepped::Performance.count" => +1 47 | ) do 48 | Stepped::ActionJob.perform_now @car, :drive, 11 49 | end 50 | 51 | assert_predicate action, :performing? 52 | assert_predicate action, :outbound? 53 | assert_equal @car, action.actor 54 | assert_equal "drive", action.name 55 | assert_equal [ 11 ], action.arguments 56 | assert action.started_at 57 | assert_nil action.completed_at 58 | assert_equal 0, action.current_step_index 59 | assert_equal 11, @car.reload.mileage 60 | 61 | assert_difference( 62 | "Stepped::Action.count" => 0, 63 | "Stepped::Step.count" => 0, 64 | "Stepped::Achievement.count" => 0, 65 | "Stepped::Performance.count" => -1 66 | ) do 67 | Stepped::Performance.outbound_complete @car, :drive 68 | end 69 | 70 | assert_predicate action.reload, :succeeded? 71 | assert action.completed_at 72 | assert action.started_at < action.completed_at 73 | end 74 | 75 | %w[ cancelled failed timed_out ].each do |status| 76 | test "completing outbound action as #{status} with an after callback" do 77 | action = Stepped::ActionJob.perform_now @car, :drive, 11 78 | assert_equal 11, @car.reload.mileage 79 | 80 | assert_difference( 81 | "Stepped::Action.count" => 0, 82 | "Stepped::Step.count" => 0, 83 | "Stepped::Achievement.count" => 0, 84 | "Stepped::Performance.count" => -1 85 | ) do 86 | Stepped::Performance.outbound_complete @car, :drive, status 87 | end 88 | 89 | assert_equal status, action.reload.status 90 | assert action.completed_at 91 | assert action.started_at < action.completed_at 92 | assert_equal 12, @car.reload.mileage 93 | end 94 | end 95 | 96 | test "performing action that completes outbound as nested action" do 97 | action = Stepped::ActionJob.perform_now @car, :visit, 100, "London" 98 | assert_equal 1, perform_enqueued_jobs_recursively(only: Stepped::ActionJob) 99 | assert_predicate action.reload, :performing? 100 | assert_not_predicate action, :outbound? 101 | action.steps.each do |step| 102 | assert_predicate step, :performing? 103 | end 104 | nested_action = Stepped::Action.last 105 | assert_predicate nested_action, :performing? 106 | assert_predicate nested_action, :outbound? 107 | assert_equal 100, @car.reload.mileage 108 | assert_nil @car.location 109 | 110 | assert_difference( 111 | "Stepped::Action.count" => 0, 112 | "Stepped::Step.count" => +1, 113 | "Stepped::Achievement.count" => 0, 114 | "Stepped::Performance.count" => -1 115 | ) do 116 | Stepped::Performance.outbound_complete @car, :drive 117 | end 118 | 119 | assert_predicate nested_action.reload, :succeeded? 120 | 121 | assert_equal 1, perform_enqueued_jobs_recursively(only: Stepped::ActionJob) 122 | assert_predicate action.reload, :succeeded? 123 | assert_equal "London", @car.reload.location 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /test/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema[8.1].define(version: 2025_12_14_104829) do 14 | create_table "stepped_achievements", force: :cascade do |t| 15 | t.string "checksum", null: false 16 | t.string "checksum_key", null: false 17 | t.datetime "created_at", null: false 18 | t.datetime "updated_at", null: false 19 | t.index [ "checksum_key" ], name: "index_stepped_achievements_on_checksum_key", unique: true 20 | end 21 | 22 | create_table "stepped_actions", force: :cascade do |t| 23 | t.bigint "actor_id", null: false 24 | t.string "actor_type", null: false 25 | t.integer "after_callbacks_failed_count" 26 | t.integer "after_callbacks_succeeded_count" 27 | t.json "arguments" 28 | t.string "checksum" 29 | t.string "checksum_key", null: false 30 | t.datetime "completed_at" 31 | t.string "concurrency_key", null: false 32 | t.datetime "created_at", null: false 33 | t.integer "current_step_index", default: 0, null: false 34 | t.string "job" 35 | t.string "name", null: false 36 | t.boolean "outbound", default: false, null: false 37 | t.bigint "performance_id" 38 | t.boolean "root", default: false 39 | t.datetime "started_at" 40 | t.string "status", default: "pending", null: false 41 | t.integer "timeout_seconds" 42 | t.datetime "updated_at", null: false 43 | t.index [ "actor_type", "actor_id" ], name: "index_stepped_actions_on_actor" 44 | t.index [ "performance_id", "outbound" ], name: "index_stepped_actions_on_performance_id_and_outbound" 45 | t.index [ "performance_id" ], name: "index_stepped_actions_on_performance_id" 46 | t.index [ "root" ], name: "index_stepped_actions_on_root" 47 | end 48 | 49 | create_table "stepped_actions_steps", id: false, force: :cascade do |t| 50 | t.bigint "action_id", null: false 51 | t.bigint "step_id", null: false 52 | t.index [ "action_id", "step_id" ], name: "index_stepped_actions_steps_on_action_id_and_step_id", unique: true 53 | t.index [ "step_id", "action_id" ], name: "index_stepped_actions_steps_on_step_id_and_action_id" 54 | end 55 | 56 | create_table "stepped_performances", force: :cascade do |t| 57 | t.bigint "action_id", null: false 58 | t.string "concurrency_key" 59 | t.datetime "created_at", null: false 60 | t.string "outbound_complete_key" 61 | t.datetime "updated_at", null: false 62 | t.index [ "action_id" ], name: "index_stepped_performances_on_action_id", unique: true 63 | t.index [ "concurrency_key" ], name: "index_stepped_performances_on_concurrency_key", unique: true 64 | t.index [ "outbound_complete_key" ], name: "index_stepped_performances_on_outbound_complete_key", where: "(outbound_complete_key IS NOT NULL)" 65 | end 66 | 67 | create_table "stepped_steps", force: :cascade do |t| 68 | t.bigint "action_id", null: false 69 | t.datetime "completed_at" 70 | t.datetime "created_at", null: false 71 | t.integer "definition_index", null: false 72 | t.integer "pending_actions_count", default: 0, null: false 73 | t.datetime "started_at" 74 | t.string "status", default: "pending", null: false 75 | t.integer "unsuccessful_actions_count", default: 0 76 | t.datetime "updated_at", null: false 77 | t.index [ "action_id", "definition_index" ], name: "index_stepped_steps_on_action_id_and_definition_index", unique: true 78 | t.index [ "action_id" ], name: "index_stepped_steps_on_action_id" 79 | end 80 | 81 | add_foreign_key "stepped_actions", "stepped_performances", column: "performance_id" 82 | add_foreign_key "stepped_performances", "stepped_actions", column: "action_id" 83 | add_foreign_key "stepped_steps", "stepped_actions", column: "action_id" 84 | end 85 | -------------------------------------------------------------------------------- /test/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The page you were looking for doesn't exist (404 Not found) 8 | 9 | 10 | 11 | 12 | 13 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 |
125 |
126 | 127 |
128 |
129 |

The page you were looking for doesn't exist. You may have mistyped the address or the page may have moved. If you're the application owner check the logs for more information.

130 |
131 |
132 | 133 | 134 | 135 | 136 | -------------------------------------------------------------------------------- /test/dummy/public/400.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The server cannot process the request due to a client error (400 Bad Request) 8 | 9 | 10 | 11 | 12 | 13 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 |
125 |
126 | 127 |
128 |
129 |

The server cannot process the request due to a client error. Please check the request and try again. If you're the application owner check the logs for more information.

130 |
131 |
132 | 133 | 134 | 135 | 136 | -------------------------------------------------------------------------------- /test/models/stepped/action_concurrency_key_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Stepped::ActionConcurrencyKeyTest < Stepped::TestCase 4 | setup do 5 | Temping.create "car" do 6 | with_columns do |t| 7 | t.integer :mileage, default: 0 8 | t.integer :honks, default: 0 9 | t.string :location 10 | end 11 | 12 | def drive(mileage) 13 | self.mileage += mileage 14 | save! 15 | end 16 | 17 | def honk 18 | increment! :honks 19 | end 20 | end 21 | 22 | @car = Car.create! 23 | end 24 | 25 | test "concurrency_key defined as string" do 26 | Car.stepped_action :drive_one_way_street do 27 | concurrency_key do 28 | "just-a-string" 29 | end 30 | end 31 | 32 | action = Stepped::Action.new(actor: @car, name: :drive_one_way_street) 33 | action.apply_definition 34 | assert_equal "just-a-string", action.concurrency_key 35 | end 36 | 37 | test "concurrency_key defined as blank means to use default (tenancy_key)" do 38 | [ nil, "", [] ].each do |value| 39 | Car.stepped_action :drive_one_way_street do 40 | concurrency_key { value } 41 | end 42 | 43 | action = Stepped::Action.new(actor: @car, name: :drive_one_way_street) 44 | action.apply_definition 45 | assert_equal action.send(:tenancy_key), action.concurrency_key 46 | end 47 | end 48 | 49 | test "actions on different actors with custom concurrency keys that matches ensures they queue up" do 50 | Car.stepped_action :drive_one_way_street do 51 | concurrency_key do 52 | [ "Car", "drive_one_way_street" ] 53 | end 54 | 55 | step do |step, mileage| 56 | step.do :drive, mileage 57 | end 58 | end 59 | 60 | first_car = @car 61 | second_car = Car.create! 62 | 63 | assert_difference( 64 | "Stepped::Action.count" => +1, 65 | "Stepped::Step.count" => +1, 66 | "Stepped::Performance.count" => +1 67 | ) do 68 | assert Stepped::ActionJob.perform_now first_car, :drive_one_way_street, 1 69 | end 70 | 71 | performance = Stepped::Performance.last 72 | first_car_action = Stepped::Action.last 73 | assert_equal "Car/drive_one_way_street", first_car_action.concurrency_key 74 | assert_equal performance, first_car_action.performance 75 | assert_predicate first_car_action, :performing? 76 | 77 | assert_difference( 78 | "Stepped::Action.count" => +1, 79 | "Stepped::Step.count" => 0, 80 | "Stepped::Performance.count" => 0 81 | ) do 82 | assert_predicate Stepped::ActionJob.perform_now(second_car, :drive_one_way_street, 2), :pending? 83 | end 84 | 85 | second_car_action = Stepped::Action.last 86 | assert_equal "Car/drive_one_way_street", second_car_action.concurrency_key 87 | assert_equal performance, second_car_action.performance 88 | assert_predicate second_car_action, :pending? 89 | 90 | # Finish first action 91 | assert_difference( 92 | "Stepped::Action.count" => +1, 93 | "Stepped::Step.count" => +2, 94 | "Stepped::Performance.count" => 0 95 | ) do 96 | assert_equal 1, perform_enqueued_jobs(only: Stepped::ActionJob) 97 | end 98 | 99 | assert_predicate first_car_action.reload, :succeeded? 100 | assert_equal 1, first_car.reload.mileage 101 | assert_predicate second_car_action.reload, :performing? 102 | 103 | # Finish second action 104 | assert_difference( 105 | "Stepped::Action.count" => +1, 106 | "Stepped::Step.count" => +1, 107 | "Stepped::Performance.count" => -1 108 | ) do 109 | assert_equal 1, perform_enqueued_jobs(only: Stepped::ActionJob) 110 | end 111 | assert_predicate second_car_action.reload, :succeeded? 112 | assert_equal 2, second_car.reload.mileage 113 | end 114 | 115 | test "cancelling queued action attached to performance while the first one is performing" do 116 | Car.stepped_action :drive, outbound: true 117 | 118 | assert_difference "Stepped::Performance.count" => +1 do 119 | assert_predicate Stepped::ActionJob.perform_now(@car, :drive, 1), :performing? 120 | assert_predicate Stepped::ActionJob.perform_now(@car, :drive, 2), :pending? 121 | end 122 | 123 | performance = Stepped::Performance.last 124 | assert_equal 2, performance.actions.size 125 | 126 | first_action = performance.actions.first 127 | assert_predicate first_action, :performing? 128 | 129 | second_action = performance.actions.second 130 | assert_predicate second_action, :pending? 131 | 132 | assert_no_difference "Stepped::Performance.count" do 133 | second_action.complete!(:cancelled) 134 | end 135 | 136 | assert_predicate second_action.reload, :cancelled? 137 | assert_nil second_action.performance 138 | assert_predicate first_action.reload, :performing? 139 | assert_equal performance, first_action.performance 140 | assert_equal 1, performance.actions.count 141 | 142 | assert_difference "Stepped::Performance.count" => -1 do 143 | Stepped::Performance.outbound_complete(@car, :drive) 144 | end 145 | 146 | assert_predicate first_action.reload, :succeeded? 147 | end 148 | 149 | test "same concurrency_key does not complete action that doesn't match actor or name" do 150 | Car.define_method :recycle do; end 151 | Car.define_method :paint do; end 152 | Car.stepped_action :recycle, outbound: true do 153 | concurrency_key { "Car/maintenance" } 154 | end 155 | Car.stepped_action :paint, outbound: true do 156 | concurrency_key { "Car/maintenance" } 157 | end 158 | 159 | recycle_action = Stepped::ActionJob.perform_now @car, :recycle 160 | assert_predicate recycle_action, :performing? 161 | assert_equal "recycle", recycle_action.name 162 | assert_predicate Stepped::ActionJob.perform_now(@car, :paint), :pending? 163 | paint_action = Stepped::Action.last 164 | assert_predicate paint_action, :pending? 165 | assert_equal "paint", paint_action.name 166 | 167 | # Attempt to complete the action that is not actually performing, but has the same concurrency_key 168 | assert_no_difference "Stepped::Performance.count" do 169 | assert_not Stepped::Performance.outbound_complete(@car, :paint) 170 | end 171 | assert_predicate recycle_action.reload, :performing? 172 | assert_predicate paint_action.reload, :pending? 173 | end 174 | 175 | test "descendent action can't have the same concurrency_key as parent" do 176 | Car.stepped_action :recycle do 177 | concurrency_key { "Car/maintenance" } 178 | 179 | step do |step| 180 | step.do :prepare_recycle 181 | end 182 | end 183 | Car.stepped_action :prepare_recycle do 184 | step do |step| 185 | step.do :drive, 1 186 | end 187 | end 188 | Car.stepped_action :drive do 189 | concurrency_key { "Car/maintenance" } 190 | end 191 | 192 | handle_stepped_action_exceptions(only: Stepped::Action::Deadlock) do 193 | recycle_action = nil 194 | assert_difference "Stepped::Action.count" => +2 do 195 | recycle_action = Stepped::ActionJob.perform_now @car, :recycle 196 | perform_enqueued_jobs_recursively(only: Stepped::ActionJob) 197 | end 198 | assert_predicate recycle_action.reload, :failed? 199 | end 200 | end 201 | 202 | test "concurrency_key can use action arguments" do 203 | Car.stepped_action :fun do 204 | concurrency_key do |arg1, arg2| 205 | "#{arg1}-#{arg2}" 206 | end 207 | 208 | step { } 209 | end 210 | 211 | action = Stepped::ActionJob.perform_now(@car, :fun, "foo", "bar") 212 | assert_equal "foo-bar", action.concurrency_key 213 | end 214 | end 215 | -------------------------------------------------------------------------------- /test/models/stepped/action_exceptions_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Stepped::ActionExceptionsTest < Stepped::TestCase 4 | setup do 5 | Temping.create "car" do 6 | with_columns do |t| 7 | t.integer :honks, default: 0 8 | end 9 | 10 | def breakdown 11 | raise StandardError 12 | end 13 | 14 | def honk 15 | increment! :honks 16 | end 17 | end 18 | 19 | @car = Car.create! 20 | end 21 | 22 | test "exception in direct method call with error raising disabled" do 23 | handle_stepped_action_exceptions do 24 | action = 25 | assert_difference( 26 | "Stepped::Action.count" => +1, 27 | "Stepped::Performance.count" => 0, 28 | "Stepped::Step.count" => +1 29 | ) do 30 | Stepped::ActionJob.perform_now @car, :breakdown 31 | end 32 | assert_predicate action, :failed? 33 | assert action.started_at 34 | assert action.completed_at 35 | assert_equal 0, @car.reload.honks 36 | end 37 | end 38 | 39 | test "exception in direct method call with definition with failed callback with error raising disabled" do 40 | Car.stepped_action :breakdown do 41 | failed do 42 | honk 43 | end 44 | end 45 | 46 | handle_stepped_action_exceptions do 47 | Stepped::ActionJob.perform_now @car, :breakdown 48 | action = Stepped::Action.last 49 | assert_predicate action, :failed? 50 | assert_equal 1, @car.reload.honks 51 | end 52 | end 53 | 54 | test "exception in a nested action with error raising disabled and failed callback" do 55 | Car.stepped_action :breakdown_wrapped do 56 | step do |step| 57 | step.do :breakdown 58 | end 59 | 60 | failed do 61 | honk 62 | end 63 | end 64 | 65 | handle_stepped_action_exceptions do 66 | assert_difference( 67 | "Stepped::Action.count" => +1, 68 | "Stepped::Performance.count" => +1, 69 | "Stepped::Step.count" => +1 70 | ) do 71 | assert_enqueued_with(job: Stepped::ActionJob) do 72 | Stepped::ActionJob.perform_now @car, :breakdown_wrapped 73 | end 74 | end 75 | 76 | parent_action = Stepped::Action.last 77 | 78 | assert_difference( 79 | "Stepped::Action.count" => +1, 80 | "Stepped::Performance.count" => -1, 81 | "Stepped::Step.count" => +1 82 | ) do 83 | assert_equal 1, perform_enqueued_jobs(only: Stepped::ActionJob) 84 | end 85 | 86 | action = Stepped::Action.last 87 | assert_predicate action, :failed? 88 | assert action.started_at 89 | assert action.completed_at 90 | 91 | step = Stepped::Step.last 92 | assert_predicate step, :failed? 93 | assert action.started_at 94 | assert action.completed_at 95 | 96 | assert_predicate parent_action.reload, :failed? 97 | assert parent_action.started_at 98 | assert parent_action.completed_at 99 | 100 | assert_equal 1, @car.reload.honks 101 | end 102 | end 103 | 104 | test "exception in failed callback" do 105 | Car.stepped_action :breakdown_wrapped do 106 | step do |step| 107 | step.do :breakdown 108 | end 109 | 110 | failed do 111 | honk 112 | raise StandardError 113 | end 114 | end 115 | 116 | handle_stepped_action_exceptions do 117 | Stepped::ActionJob.perform_now @car, :breakdown_wrapped 118 | parent_action = Stepped::Action.last 119 | assert_equal 1, perform_enqueued_jobs(only: Stepped::ActionJob) 120 | 121 | action = Stepped::Action.last 122 | assert_predicate action, :failed? 123 | step = Stepped::Step.last 124 | assert_predicate step, :failed? 125 | assert_predicate parent_action.reload, :failed? 126 | assert_equal 1, @car.reload.honks 127 | end 128 | end 129 | 130 | test "exception in succeeded callback doesn't change action status and doesn't generate Achievement" do 131 | Car.stepped_action :honk do 132 | checksum { "a" } 133 | 134 | succeeded do 135 | raise StandardError 136 | end 137 | end 138 | 139 | handle_stepped_action_exceptions do 140 | action = 141 | assert_no_difference "Stepped::Achievement.count" do 142 | Stepped::ActionJob.perform_now @car, :honk 143 | end 144 | assert_predicate action, :succeeded? 145 | assert_not_predicate action, :succeeded_including_callbacks? 146 | assert_equal 1, @car.reload.honks 147 | assert_nil action.after_callbacks_succeeded_count 148 | assert_equal 1, action.after_callbacks_failed_count 149 | end 150 | end 151 | 152 | test "exception in direct method call with error raising enabled" do 153 | assert_difference( 154 | "Stepped::Action.count" => +1, 155 | "Stepped::Performance.count" => +1, 156 | "Stepped::Step.count" => +1 157 | ) do 158 | assert_raises StandardError do 159 | Stepped::ActionJob.perform_now @car, :breakdown 160 | end 161 | end 162 | 163 | action = Stepped::Action.last 164 | assert_predicate action, :performing? 165 | assert action.started_at 166 | assert_nil action.completed_at 167 | end 168 | 169 | test "action that fails without checksum deletes its Achievement record" do 170 | handle_stepped_action_exceptions do 171 | achievement = Stepped::Achievement.create!( 172 | checksum_key: "Car/#{@car.id}/breakdown", 173 | checksum: Stepped.checksum("something") 174 | ) 175 | Car.stepped_action :breakdown do 176 | step do 177 | throw "fail" 178 | end 179 | end 180 | action = 181 | assert_difference( 182 | "Stepped::Action.count" => +1, 183 | "Stepped::Step.count" => +1, 184 | "Stepped::Achievement.count" => -1, 185 | "Stepped::Performance.count" => 0 186 | ) do 187 | Stepped::ActionJob.perform_now @car, :breakdown 188 | end 189 | assert_predicate action, :failed? 190 | assert_raises ActiveRecord::RecordNotFound do 191 | achievement.reload 192 | end 193 | end 194 | end 195 | 196 | test "before block exception is captured and halts action creation" do 197 | handle_stepped_action_exceptions do 198 | Car.stepped_action :breakdown do 199 | before do 200 | throw "fail" 201 | end 202 | end 203 | assert_difference( 204 | "Stepped::Action.count" => 0, 205 | "Stepped::Step.count" => 0, 206 | "Stepped::Achievement.count" => 0, 207 | "Stepped::Performance.count" => 0 208 | ) do 209 | assert_predicate Stepped::ActionJob.perform_now(@car, :breakdown), :failed? 210 | end 211 | end 212 | end 213 | 214 | test "before block exception in nested action fails the parent action" do 215 | handle_stepped_action_exceptions do 216 | Car.stepped_action :wrap_breakdown do 217 | step do |step| 218 | step.do :breakdown 219 | end 220 | end 221 | Car.stepped_action :breakdown do 222 | before do 223 | throw "fail" 224 | end 225 | end 226 | action = nil 227 | assert_difference( 228 | "Stepped::Action.count" => +1, 229 | "Stepped::Step.count" => +1, 230 | "Stepped::Achievement.count" => 0, 231 | "Stepped::Performance.count" => 0 232 | ) do 233 | action = Stepped::ActionJob.perform_now @car, :wrap_breakdown 234 | perform_enqueued_jobs(only: Stepped::ActionJob) 235 | end 236 | assert_predicate action.reload, :failed? 237 | assert_predicate action.steps.first, :failed? 238 | end 239 | end 240 | end 241 | -------------------------------------------------------------------------------- /test/dummy/public/406-unsupported-browser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Your browser is not supported (406 Not Acceptable) 8 | 9 | 10 | 11 | 12 | 13 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 |
125 |
126 | 127 |
128 |
129 |

Your browser is not supported.
Please upgrade your browser to continue.

130 |
131 |
132 | 133 | 134 | 135 | 136 | -------------------------------------------------------------------------------- /app/models/stepped/action.rb: -------------------------------------------------------------------------------- 1 | class Stepped::Action < ActiveRecord::Base 2 | self.filter_attributes = [] 3 | 4 | STATUSES = %w[ 5 | pending 6 | performing 7 | succeeded 8 | superseded 9 | cancelled 10 | failed 11 | timed_out 12 | deadlocked 13 | ].freeze 14 | 15 | enum :status, STATUSES.index_by(&:itself) 16 | 17 | serialize :arguments, coder: Stepped::Arguments 18 | 19 | belongs_to :actor, polymorphic: true 20 | belongs_to :performance, optional: true 21 | 22 | has_many :steps, -> { order(id: :asc) }, dependent: :destroy 23 | 24 | has_and_belongs_to_many :parent_steps, class_name: "Stepped::Step", 25 | join_table: :stepped_actions_steps, foreign_key: :step_id, association_foreign_key: :action_id, 26 | inverse_of: :actions 27 | 28 | scope :roots, -> { where(root: true) } 29 | scope :outbounds, -> { where(outbound: true) } 30 | scope :incomplete, -> { where(status: %i[ pending performing ]) } 31 | 32 | KEYS_JOINER = "/" 33 | 34 | def obtain_lock_and_perform 35 | apply_definition 36 | run_before_chain 37 | 38 | if completed? 39 | propagate_completion_to_parent_steps 40 | return self 41 | end 42 | 43 | set_checksum 44 | 45 | Stepped::Achievement.raise_if_exists_for?(self) 46 | Stepped::Performance.obtain_for(self) 47 | rescue Stepped::Achievement::ExistsError 48 | self.status = :succeeded 49 | propagate_completion_to_parent_steps 50 | self 51 | end 52 | 53 | def update_performance(performance) 54 | self.performance = performance 55 | perform if pending? && (performance.action == self) 56 | save! 57 | end 58 | 59 | def perform 60 | update! status: :performing, started_at: Time.zone.now 61 | 62 | Stepped::Achievement.erase_of self 63 | 64 | ActiveRecord.after_all_transactions_commit do 65 | perform_current_step 66 | Stepped::TimeoutJob.set(wait: timeout).perform_later(self) if timeout? 67 | end 68 | end 69 | 70 | def definition 71 | @definition = Stepped::Registry.find_or_add actor.class, name 72 | end 73 | 74 | def cancel 75 | self.status = :cancelled 76 | end 77 | 78 | def complete 79 | self.status = :succeeded 80 | end 81 | 82 | def supersede_with(action) 83 | update! completed_at: Time.zone.now, status: :superseded 84 | copy_parent_steps_to action 85 | end 86 | 87 | def achieves?(action) 88 | checksum_key == action.checksum_key && checksum == action.checksum 89 | end 90 | 91 | def copy_parent_steps_to(action) 92 | raise ArgumentError, "Can't copy_parent_steps_to self" if action == self 93 | 94 | parent_steps.each do |step| 95 | transaction(requires_new: true) do 96 | action.parent_steps << step 97 | end 98 | rescue ActiveRecord::RecordNotUnique 99 | action.reload 100 | end 101 | end 102 | 103 | def perform_current_step 104 | steps.create!( 105 | definition_index: current_step_index, 106 | started_at: Time.zone.now, 107 | status: :performing 108 | ).perform 109 | end 110 | 111 | def compute_concurrency_key 112 | run_definition_block :concurrency_key_block 113 | end 114 | 115 | def compute_checksum_key 116 | run_definition_block :checksum_key_block 117 | end 118 | 119 | def outbound_complete_key 120 | outbound? ? tenancy_key : nil 121 | end 122 | 123 | def accomplished(step) 124 | if step.failed? 125 | complete! :failed 126 | elsif more_steps_to_do? 127 | increment :current_step_index 128 | save! 129 | perform_current_step 130 | elsif !outbound? 131 | complete! 132 | end 133 | end 134 | 135 | def safe_actor 136 | actor 137 | rescue ActiveRecord::SubclassNotFound 138 | end 139 | 140 | def actor_becomes_base 141 | safe_actor&.becomes actor_type.constantize 142 | end 143 | 144 | def short_checksum 145 | checksum.to_s[0..7] 146 | end 147 | 148 | def timeout? 149 | timeout_seconds.present? 150 | end 151 | 152 | def compute_timeout 153 | if definition.timeout.is_a?(Symbol) 154 | actor.send(definition.timeout) 155 | else 156 | definition.timeout 157 | end 158 | end 159 | 160 | def cancellable? 161 | pending? || performing? 162 | end 163 | 164 | def complete!(status = :succeeded) 165 | Stepped::Performance.complete_action self, status 166 | end 167 | 168 | def finalize_complete(status) 169 | self.status = status 170 | execute_after_complete_callbacks 171 | update!(completed_at: Time.zone.now, performance: nil) 172 | Stepped::Achievement.grand_to(self) if succeeded_including_callbacks? && checksum.present? 173 | 174 | propagate_completion_to_parent_steps 175 | end 176 | 177 | def deadlock! 178 | e = Deadlock.new "#{name} on #{actor.class.name}/#{actor.id}" 179 | handled = Stepped.handled_exception_classes.any? { e.class <= _1 } 180 | raise e unless handled 181 | 182 | Rails.error.report(e, handled:) 183 | self.status = :deadlocked 184 | propagate_completion_to_parent_steps 185 | end 186 | 187 | def descendant_of?(action) 188 | parent_steps.any? do |step| 189 | return true if step.action_id == action.id 190 | step.action.descendant_of?(action) 191 | end 192 | end 193 | 194 | def completed? 195 | cancelled? || succeeded? || superseded? || failed? || timed_out? || deadlocked? 196 | end 197 | 198 | def propagated_touch 199 | touch 200 | parent_steps.each { _1.action.propagated_touch } 201 | end 202 | 203 | def apply_definition 204 | return if definition.nil? 205 | self.outbound = definition.outbound 206 | self.concurrency_key = compute_concurrency_key 207 | self.checksum_key = compute_checksum_key 208 | self.job = definition.job 209 | self.timeout_seconds = compute_timeout 210 | end 211 | 212 | def timeout 213 | timeout_seconds.seconds 214 | end 215 | 216 | def propagate_completion_to_parent_steps 217 | ActiveRecord.after_all_transactions_commit do 218 | parent_steps.each do |step| 219 | step.conclude_job(succeeded_including_callbacks?) 220 | end 221 | end 222 | end 223 | 224 | def succeeded_including_callbacks? 225 | succeeded? && after_callbacks_failed_count.nil? 226 | end 227 | 228 | class Deadlock < StandardError; end 229 | 230 | private 231 | def tenancy_key 232 | actor.stepped_action_tenancy_key name 233 | end 234 | 235 | def run_before_chain 236 | return if failed? 237 | 238 | if before_block = definition.before_block 239 | fail_on_exception do 240 | actor.instance_exec self, *arguments, &before_block 241 | end 242 | end 243 | end 244 | 245 | def fail_on_exception(&block) 246 | unless Stepped.handle_exception(context: { block: }, &block) 247 | self.status = :failed 248 | end 249 | end 250 | 251 | def run_definition_block(method) 252 | if block = definition.public_send(method) 253 | result = actor.instance_exec(*arguments, &block) 254 | 255 | return tenancy_key if result.blank? 256 | 257 | result.is_a?(Array) ? result.join(KEYS_JOINER) : result.to_s 258 | else 259 | tenancy_key 260 | end 261 | end 262 | 263 | def set_checksum 264 | if block = definition.checksum_block 265 | value = actor.instance_exec(*arguments, &block) 266 | self.checksum = Stepped.checksum value 267 | end 268 | end 269 | 270 | def execute_after_complete_callbacks 271 | return true if definition.nil? 272 | 273 | definition.after_callbacks.each do |callback| 274 | next true unless callback.fetch(:name).in?([ status.to_sym, :all ]) 275 | 276 | context = { action: to_global_id, callback: callback.inspect } 277 | 278 | succeeded = Stepped.handle_exception(context:) do 279 | actor.instance_exec self, *arguments, &callback.fetch(:block) 280 | end 281 | 282 | if succeeded 283 | increment :after_callbacks_succeeded_count 284 | else 285 | increment :after_callbacks_failed_count 286 | end 287 | end 288 | end 289 | 290 | def more_steps_to_do? 291 | definition.steps.size > (current_step_index + 1) 292 | end 293 | end 294 | -------------------------------------------------------------------------------- /test/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | We're sorry, but something went wrong (500 Internal Server Error) 8 | 9 | 10 | 11 | 12 | 13 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 |
125 |
126 | 127 |
128 |
129 |

We're sorry, but something went wrong.
If you're the application owner check the logs for more information.

130 |
131 |
132 | 133 | 134 | 135 | 136 | -------------------------------------------------------------------------------- /test/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The change you wanted was rejected (422 Unprocessable Entity) 8 | 9 | 10 | 11 | 12 | 13 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 |
125 |
126 | 127 |
128 |
129 |

The change you wanted was rejected. Maybe you tried to change something you didn't have access to. If you're the application owner check the logs for more information.

130 |
131 |
132 | 133 | 134 | 135 | 136 | -------------------------------------------------------------------------------- /test/models/stepped/action_superseding_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Stepped::ActionSupersedingTest < Stepped::TestCase 4 | setup do 5 | Temping.create "car" do 6 | with_columns do |t| 7 | t.string :location 8 | t.integer :mileage, default: 0 9 | end 10 | 11 | def change_location(location) 12 | update!(location:) 13 | end 14 | 15 | def drive(mileage) 16 | self.mileage += mileage 17 | save! 18 | end 19 | end 20 | 21 | @car = Car.create! 22 | end 23 | 24 | test "superseeding of root actions" do 25 | Car.stepped_action :visit do 26 | step do |step, location| 27 | step.do :change_location, location 28 | end 29 | end 30 | 31 | Stepped::ActionJob.perform_now @car, :visit, "Bratislava" 32 | performance = Stepped::Performance.last 33 | first_action = Stepped::Action.last 34 | assert_equal "visit", first_action.name 35 | assert_equal performance, first_action.performance 36 | 37 | assert_difference( 38 | "Stepped::Action.count" => +1, 39 | "Stepped::Step.count" => 0, 40 | "Stepped::Performance.count" => 0 41 | ) do 42 | assert_no_enqueued_jobs(only: Stepped::ActionJob) do 43 | Stepped::ActionJob.perform_now @car, :visit, "Paris" 44 | end 45 | end 46 | 47 | second_action = Stepped::Action.last 48 | assert_equal "visit", second_action.name 49 | assert_predicate second_action, :pending? 50 | assert_equal performance, second_action.performance 51 | 52 | assert_difference( 53 | "Stepped::Action.count" => +1, 54 | "Stepped::Step.count" => 0, 55 | "Stepped::Performance.count" => 0 56 | ) do 57 | assert_no_enqueued_jobs(only: Stepped::ActionJob) do 58 | Stepped::ActionJob.perform_now @car, :visit, "Berlin" 59 | end 60 | end 61 | 62 | third_action = Stepped::Action.last 63 | assert_equal "visit", third_action.name 64 | assert_predicate third_action, :pending? 65 | assert_predicate second_action.reload, :superseded? 66 | assert_equal performance, third_action.performance 67 | assert_equal 3, performance.actions.count 68 | 69 | # Perform the first nested action of the first action 70 | assert_difference( 71 | "Stepped::Action.count" => +1, 72 | "Stepped::Step.count" => +2, 73 | "Stepped::Performance.count" => 0 74 | ) do 75 | assert_enqueued_with(job: Stepped::ActionJob) do 76 | assert_equal 1, perform_enqueued_jobs(only: Stepped::ActionJob) 77 | end 78 | end 79 | assert_predicate first_action.reload, :succeeded? 80 | assert_equal "Bratislava", @car.reload.location 81 | assert_predicate third_action.reload, :performing? 82 | assert_equal 2, performance.actions.count 83 | assert_equal "visit", performance.actions.first.name 84 | 85 | # Complete the last actions by completing the pending nested action 86 | assert_difference( 87 | "Stepped::Action.count" => +1, 88 | "Stepped::Step.count" => +1, 89 | "Stepped::Performance.count" => -1 90 | ) do 91 | assert_no_enqueued_jobs(only: Stepped::ActionJob) do 92 | assert_equal 1, perform_enqueued_jobs(only: Stepped::ActionJob) 93 | end 94 | # puts Stepped::Action.last.attributes 95 | end 96 | assert_predicate third_action.reload, :succeeded? 97 | assert_equal "Berlin", @car.reload.location 98 | end 99 | 100 | test "superseeding of nested actions adds new action under the step where action was superseded" do 101 | Car.stepped_action :get_out_of_way do 102 | step do |step, mileage| 103 | step.do :drive, mileage 104 | end 105 | end 106 | 107 | Car.stepped_action :rush_hour_visit do 108 | step do |step, preceeding_car, location| 109 | step.on preceeding_car, :get_out_of_way, 1 110 | end 111 | 112 | step do |step, preceeding_car, location| 113 | step.do :change_location, location 114 | end 115 | end 116 | 117 | preceeding_car = Car.create! 118 | 119 | # Proceeding car starts performing get_out_of_way 120 | 121 | assert_difference( 122 | "Stepped::Action.count" => +1, 123 | "Stepped::Step.count" => +1, 124 | "Stepped::Performance.count" => +1 125 | ) do 126 | Stepped::ActionJob.perform_now preceeding_car, :get_out_of_way, 20 127 | end 128 | 129 | first_get_out_of_way_action = Stepped::Action.last 130 | assert_equal "get_out_of_way", first_get_out_of_way_action.name 131 | assert_predicate first_get_out_of_way_action, :performing? 132 | assert_equal 0, preceeding_car.reload.mileage 133 | 134 | first_get_out_of_way_performance = Stepped::Performance.last 135 | assert_equal 1, first_get_out_of_way_performance.actions.count 136 | assert_includes first_get_out_of_way_performance.actions, first_get_out_of_way_action 137 | 138 | # Now rush_hour_visit gets blocked by proceeding car's performing get_out_of_way 139 | 140 | assert_difference( 141 | "Stepped::Action.count" => +2, 142 | "Stepped::Step.count" => +1, 143 | "Stepped::Performance.count" => +1 144 | ) do 145 | perform_enqueued_jobs do 146 | Stepped::ActionJob.perform_now @car, :rush_hour_visit, preceeding_car, "Bratislava" 147 | end 148 | end 149 | 150 | rush_hour_visit_action = Stepped::Action.last(2).first 151 | assert_equal "rush_hour_visit", rush_hour_visit_action.name 152 | assert_predicate rush_hour_visit_action, :performing? 153 | assert_predicate first_get_out_of_way_action.reload, :performing? 154 | assert_equal 0, preceeding_car.reload.mileage 155 | second_get_out_of_way_action = Stepped::Action.last 156 | assert_equal "get_out_of_way", second_get_out_of_way_action.name 157 | assert_predicate second_get_out_of_way_action, :pending? 158 | assert_equal 2, first_get_out_of_way_performance.actions.count 159 | assert_equal first_get_out_of_way_action, first_get_out_of_way_performance.reload.action 160 | assert_includes first_get_out_of_way_performance.actions, second_get_out_of_way_action 161 | 162 | rush_hour_visit_first_step = Stepped::Step.last 163 | assert_predicate rush_hour_visit_first_step, :performing? 164 | 165 | # Proceeding car queues up another get_out_of_way action 166 | 167 | assert_difference( 168 | "Stepped::Action.count" => +1, 169 | "Stepped::Step.count" => 0, 170 | "Stepped::Performance.count" => 0 171 | ) do 172 | Stepped::ActionJob.perform_now preceeding_car, :get_out_of_way, 300 173 | end 174 | 175 | assert_equal 0, preceeding_car.reload.mileage 176 | third_get_out_of_way_action = Stepped::Action.last 177 | assert_predicate third_get_out_of_way_action, :pending? 178 | assert_predicate second_get_out_of_way_action.reload, :superseded? 179 | assert_predicate first_get_out_of_way_action.reload, :performing? 180 | assert_equal 3, first_get_out_of_way_performance.actions.count 181 | assert_equal 1, first_get_out_of_way_performance.actions.pending.count 182 | assert_includes first_get_out_of_way_performance.actions, third_get_out_of_way_action 183 | 184 | assert_equal 2, rush_hour_visit_first_step.actions.count 185 | assert_equal 1, rush_hour_visit_first_step.pending_actions_count 186 | assert_equal 1, rush_hour_visit_first_step.actions.superseded.count 187 | assert_equal second_get_out_of_way_action, rush_hour_visit_first_step.actions.superseded.sole 188 | assert_equal 1, rush_hour_visit_first_step.actions.pending.count 189 | assert_equal third_get_out_of_way_action, rush_hour_visit_first_step.actions.pending.sole 190 | 191 | # Finish everything 192 | 193 | assert_difference( 194 | "Stepped::Action.count" => +3, 195 | "Stepped::Step.count" => +5, 196 | "Stepped::Performance.count" => -2 197 | ) do 198 | assert_equal 3, perform_enqueued_jobs_recursively(only: Stepped::ActionJob) 199 | end 200 | 201 | assert_equal %w[ drive drive change_location ], Stepped::Action.last(3).pluck(:name) 202 | 203 | Stepped::Action.last(3).each do |action| 204 | assert_predicate action, :succeeded? 205 | assert_nil action.performance 206 | end 207 | 208 | assert_predicate rush_hour_visit_action.reload, :succeeded? 209 | assert_nil rush_hour_visit_action.performance 210 | assert_equal 320, preceeding_car.reload.mileage 211 | end 212 | 213 | test "performing actions with same checksum and concurrency_key but different checksum_key" do 214 | Car.stepped_action :tiny_drive do 215 | concurrency_key { "Car/drive" } 216 | checksum { 1 } 217 | checksum_key { "tiny_drive" } 218 | 219 | step do |step| 220 | step.do :drive, 1 221 | end 222 | end 223 | 224 | Car.stepped_action :short_drive do 225 | concurrency_key { "Car/drive" } 226 | checksum { 1 } 227 | checksum_key { "short_drive" } 228 | 229 | step do |step| 230 | step.do :drive, 10 231 | end 232 | end 233 | 234 | first_action = Stepped::ActionJob.perform_now @car, :tiny_drive 235 | assert_predicate first_action, :performing? 236 | 237 | performance = Stepped::Performance.last 238 | assert_equal "Car/drive", performance.concurrency_key 239 | 240 | # Queue up short_drive 241 | assert_difference( 242 | "Stepped::Action.count" => +1, 243 | "Stepped::Step.count" => 0, 244 | "Stepped::Performance.count" => 0 245 | ) do 246 | assert_predicate Stepped::ActionJob.perform_now(@car, :short_drive), :pending? 247 | end 248 | second_action = Stepped::Action.last 249 | assert_predicate second_action, :pending? 250 | assert_equal 2, performance.actions.count 251 | 252 | # Another tiny_drive drive is achieved by performing tiny_drive, so nothing gets created 253 | assert_difference( 254 | "Stepped::Action.count" => 0, 255 | "Stepped::Step.count" => 0, 256 | "Stepped::Performance.count" => 0 257 | ) do 258 | assert_equal first_action, Stepped::ActionJob.perform_now(@car, :tiny_drive) 259 | end 260 | 261 | # Complete everything 262 | assert_difference( 263 | "Stepped::Action.count" => +2, 264 | "Stepped::Step.count" => +3, 265 | "Stepped::Performance.count" => -1 266 | ) do 267 | perform_stepped_actions 268 | end 269 | assert_predicate second_action.reload, :succeeded? 270 | end 271 | end 272 | -------------------------------------------------------------------------------- /test/models/stepped/action_checksums_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Stepped::ActionChecksumsTest < Stepped::TestCase 4 | setup do 5 | Temping.create "car" do 6 | with_columns do |t| 7 | t.string :location 8 | t.integer :mileage, default: 0 9 | end 10 | 11 | stepped_action :visit do 12 | checksum do |location| 13 | location 14 | end 15 | 16 | step do |step, location| 17 | step.do :change_location, location 18 | end 19 | end 20 | 21 | def change_location(location) 22 | update!(location:) 23 | end 24 | 25 | def drive(mileage) 26 | self.mileage += mileage 27 | save! 28 | end 29 | end 30 | 31 | @car = Car.create! 32 | end 33 | 34 | test "module checksum" do 35 | assert_equal "b7a56873cd771f2c446d369b649430b65a756ba278ff97ec81bb6f55b2e73569", Stepped.checksum(25) 36 | assert_equal "0473ef2dc0d324ab659d3580c1134e9d812035905c4781fdd6d529b0c6860e13", Stepped.checksum([ "a", "b" ]) 37 | end 38 | 39 | test "module checksum with nil input is nil" do 40 | assert_nil Stepped.checksum(nil) 41 | end 42 | 43 | test "default checksum_key" do 44 | action = Stepped::Action.new(actor: @car, name: :visit) 45 | action.apply_definition 46 | assert_equal "Car/#{@car.id}/visit", action.checksum_key 47 | end 48 | 49 | test "custom checksum_key as string" do 50 | Car.stepped_action :visit do 51 | checksum_key do 52 | "custom" 53 | end 54 | end 55 | action = Stepped::Action.new(actor: @car, name: :visit) 56 | action.apply_definition 57 | assert_equal "custom", action.checksum_key 58 | end 59 | 60 | test "custom checksum_key as array" do 61 | Car.stepped_action :visit do 62 | checksum_key do 63 | [ "Car", "visit" ] 64 | end 65 | end 66 | action = Stepped::Action.new(actor: @car, name: :visit) 67 | action.apply_definition 68 | assert_equal "Car/visit", action.checksum_key 69 | end 70 | 71 | test "performing action is reused if checksum matches" do 72 | first_action = 73 | assert_difference( 74 | "Stepped::Action.count" => +1, 75 | "Stepped::Step.count" => +1, 76 | "Stepped::Achievement.count" => 0, 77 | "Stepped::Performance.count" => +1 78 | ) do 79 | Stepped::ActionJob.perform_now @car, :visit, "London" 80 | end 81 | 82 | assert_kind_of Stepped::Action, first_action 83 | assert_predicate first_action, :performing? 84 | assert_equal Stepped.checksum("London"), first_action.checksum 85 | assert_equal "Car/#{@car.id}/visit", first_action.checksum_key 86 | 87 | # No root action is created if performing the same checksum already 88 | 89 | assert_difference( 90 | "Stepped::Action.count" => 0, 91 | "Stepped::Step.count" => 0, 92 | "Stepped::Achievement.count" => 0, 93 | "Stepped::Performance.count" => 0 94 | ) do 95 | assert_equal first_action, Stepped::ActionJob.perform_now(@car, :visit, "London") 96 | end 97 | 98 | # Finish everything 99 | 100 | assert_difference( 101 | "Stepped::Action.count" => +1, 102 | "Stepped::Step.count" => +1, 103 | "Stepped::Achievement.count" => +1, 104 | "Stepped::Performance.count" => -1 105 | ) do 106 | assert_equal 1, perform_enqueued_jobs_recursively(only: Stepped::ActionJob) 107 | end 108 | 109 | assert_predicate first_action.reload, :succeeded? 110 | assert_equal "London", @car.reload.location 111 | 112 | achievement = Stepped::Achievement.last 113 | assert_equal "Car/#{@car.id}/visit", achievement.checksum_key 114 | assert_equal Stepped.checksum("London"), achievement.checksum 115 | 116 | # Try again with the same checksum, no action should be created as achievement is on that checksum 117 | 118 | assert_difference( 119 | "Stepped::Action.count" => 0, 120 | "Stepped::Step.count" => 0, 121 | "Stepped::Achievement.count" => 0, 122 | "Stepped::Performance.count" => 0 123 | ) do 124 | assert_predicate Stepped::ActionJob.perform_now(@car, :visit, "London"), :succeeded? 125 | end 126 | 127 | # Try again with different checksum, action is created... 128 | 129 | assert_difference( 130 | "Stepped::Action.count" => +2, 131 | "Stepped::Step.count" => +2, 132 | "Stepped::Achievement.count" => 0, 133 | "Stepped::Performance.count" => 0 134 | ) do 135 | assert Stepped::ActionJob.perform_now(@car, :visit, "Berlin") 136 | 137 | # After action starts current achievement is destroyed 138 | assert_raises ActiveRecord::RecordNotFound do 139 | achievement.reload 140 | end 141 | 142 | assert_equal 1, perform_enqueued_jobs_recursively(only: Stepped::ActionJob) 143 | end 144 | 145 | # ...and after finishing it, Achievement is updated to new checksum 146 | assert_equal "Berlin", @car.reload.location 147 | achievement = Stepped::Achievement.last 148 | assert_equal "Car/#{@car.id}/visit", achievement.checksum_key 149 | assert_equal Stepped.checksum("Berlin"), achievement.checksum 150 | end 151 | 152 | test "completing action with nil checksum deletes Achievement if it exists for this action and achievement" do 153 | achievement = Stepped::Achievement.create!( 154 | checksum_key: "Car/#{@car.id}/drive", 155 | checksum: Stepped.checksum("something") 156 | ) 157 | action = 158 | assert_difference( 159 | "Stepped::Action.count" => +1, 160 | "Stepped::Step.count" => +1, 161 | "Stepped::Achievement.count" => -1, 162 | "Stepped::Performance.count" => 0 163 | ) do 164 | Stepped::ActionJob.perform_now @car, :drive, 1 165 | end 166 | assert_nil action.checksum 167 | assert_equal 1, @car.reload.mileage 168 | assert_raises ActiveRecord::RecordNotFound do 169 | achievement.reload 170 | end 171 | end 172 | 173 | test "parent step of nested action matching and already performing checksum receives the matching action" do 174 | Car.stepped_action :trip do 175 | step do |step| 176 | step.do :visit, "Paris" 177 | end 178 | 179 | step do |step| 180 | step.do :visit, "Amsterdam" 181 | end 182 | end 183 | 184 | # Start the action that will later be reused within another action's step due to matching checksum 185 | Stepped::ActionJob.perform_now @car, :visit, "Paris" 186 | first_visit_action = Stepped::Action.last 187 | assert_predicate first_visit_action, :performing? 188 | 189 | trip_action = 190 | assert_difference( 191 | "Stepped::Action.count" => +1, 192 | "Stepped::Step.count" => +1, 193 | "Stepped::Achievement.count" => 0, 194 | "Stepped::Performance.count" => +1 195 | ) do 196 | perform_enqueued_jobs(only: Stepped::ActionJob) do 197 | Stepped::ActionJob.perform_now @car, :trip 198 | end 199 | end 200 | 201 | assert_predicate trip_action, :performing? 202 | assert_predicate first_visit_action.reload, :performing? 203 | 204 | step = Stepped::Step.last 205 | assert_predicate step, :performing? 206 | assert_equal 1, step.actions.count 207 | assert_includes step.actions, first_visit_action, "Already performing action was shared with this step" 208 | 209 | # Finish everything 210 | assert_difference( 211 | "Stepped::Action.count" => +3, 212 | "Stepped::Step.count" => +4, 213 | "Stepped::Achievement.count" => +1, 214 | "Stepped::Performance.count" => -2 215 | ) do 216 | assert_equal 3, perform_enqueued_jobs_recursively(only: Stepped::ActionJob) 217 | end 218 | 219 | assert_predicate trip_action.reload, :succeeded? 220 | assert_predicate first_visit_action.reload, :succeeded? 221 | 222 | achievement = Stepped::Achievement.last 223 | assert_equal "Car/#{@car.id}/visit", achievement.checksum_key 224 | assert_equal Stepped.checksum("Amsterdam"), achievement.checksum 225 | end 226 | 227 | test "reuse of completed action by checksum as nested action completes the step" do 228 | @car.update! location: "Paris" 229 | achievement = Stepped::Achievement.create!( 230 | checksum_key: "Car/#{@car.id}/visit", 231 | checksum: Stepped.checksum("Paris") 232 | ) 233 | 234 | Car.stepped_action :tour do 235 | step do |step| 236 | step.do :visit, "Paris" 237 | end 238 | end 239 | 240 | action = 241 | assert_difference( 242 | "Stepped::Action.count" => +1, 243 | "Stepped::Step.count" => +1, 244 | "Stepped::Achievement.count" => 0, 245 | "Stepped::Performance.count" => +1 246 | ) do 247 | Stepped::ActionJob.perform_now @car, :tour 248 | end 249 | 250 | assert_predicate action, :performing? 251 | step = Stepped::Step.last 252 | assert_predicate step, :performing? 253 | assert_equal 1, step.pending_actions_count 254 | assert_equal 0, step.unsuccessful_actions_count 255 | 256 | assert_difference( 257 | "Stepped::Action.count" => 0, 258 | "Stepped::Step.count" => 0, 259 | "Stepped::Achievement.count" => 0, 260 | "Stepped::Performance.count" => -1 261 | ) do 262 | assert_equal 1, perform_enqueued_jobs(only: Stepped::ActionJob) 263 | end 264 | 265 | assert_predicate action.reload, :succeeded? 266 | assert_predicate step.reload, :succeeded? 267 | assert_equal 0, step.pending_actions_count 268 | assert_equal 0, step.unsuccessful_actions_count 269 | 270 | assert_equal Stepped.checksum("Paris"), achievement.reload.checksum 271 | end 272 | 273 | test "action reuse based on custom checksum_key" do 274 | Car.stepped_action :drive do 275 | checksum_key do 276 | [ "Car", "drive" ] 277 | end 278 | 279 | checksum do |mileage| 280 | mileage 281 | end 282 | end 283 | 284 | assert_difference( 285 | "Stepped::Action.count" => +1, 286 | "Stepped::Step.count" => +1, 287 | "Stepped::Achievement.count" => +1, 288 | "Stepped::Performance.count" => 0 289 | ) do 290 | assert Stepped::ActionJob.perform_now(@car, :drive, 1) 291 | end 292 | 293 | achievement = Stepped::Achievement.last 294 | assert_equal "Car/drive", achievement.checksum_key 295 | assert_equal Stepped.checksum(1), achievement.checksum 296 | assert_equal 1, @car.reload.mileage 297 | 298 | # Reuse based on checksum and scope from different models 299 | 300 | assert_difference( 301 | "Stepped::Action.count" => 0, 302 | "Stepped::Step.count" => 0, 303 | "Stepped::Achievement.count" => 0, 304 | "Stepped::Performance.count" => 0 305 | ) do 306 | assert_predicate Stepped::ActionJob.perform_now(@car, :drive, 1), :succeeded? 307 | end 308 | 309 | assert_difference( 310 | "Stepped::Action.count" => 0, 311 | "Stepped::Step.count" => 0, 312 | "Stepped::Achievement.count" => 0, 313 | "Stepped::Performance.count" => 0 314 | ) do 315 | assert_predicate Stepped::ActionJob.perform_now(Car.create!, :drive, 1), :succeeded? 316 | end 317 | 318 | # Checksum changed 319 | 320 | assert_difference( 321 | "Stepped::Action.count" => +1, 322 | "Stepped::Step.count" => +1, 323 | "Stepped::Achievement.count" => 0, 324 | "Stepped::Performance.count" => 0 325 | ) do 326 | assert Stepped::ActionJob.perform_now(@car, :drive, 2) 327 | end 328 | 329 | assert_raises ActiveRecord::RecordNotFound do 330 | achievement.reload 331 | end 332 | 333 | achievement = Stepped::Achievement.last 334 | assert_equal "Car/drive", achievement.checksum_key 335 | assert_equal Stepped.checksum(2), achievement.checksum 336 | assert_equal 3, @car.reload.mileage 337 | end 338 | end 339 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stepped Actions 2 | 3 | Stepped is a Rails engine for orchestrating complex workflows as a tree of actions. Each action is persisted, runs through Active Job, and can fan out into more actions while keeping the parent action moving step-by-step as dependencies complete. 4 | 5 | stepped-actions 6 | 7 | Stepped was extracted out of [Envirobly](https://klevo.sk/projects/envirobly-efficient-application-hosting-platform/) where it powers tasks like application deployment, that involve complex, out-of-the-band tasks like DNS provisioning, retries, waiting for instances to boot, running health checks and all the fun of a highly distributed networked system. 8 | 9 | ## Concepts 10 | 11 | - **Action trees**: define a root action with multiple steps; each step can enqueue more actions and the step completes only once all the actions within it complete. 12 | - **Models are the Actors**: in Rails, your business logic usually centers around database-persisted models. Stepped takes advantage of this and allows you to define and run actions on all your models, out of the box. 13 | - **Concurrency lanes**: actions with the same `concurrency_key` share a `Stepped::Performance`, so only one runs at a time while others queue up (with automatic superseding of older queued work). 14 | - **Reuse**: optional `checksum` lets Stepped skip work that is already achieved, or share a currently-performing action with multiple parents. Imagine you need to launch multiple workflows with different outcomes, that all depend on the outcome of the same action, somewhere in the action tree. Stepped makes this easy and efficient. 15 | - **Outbound completion**: actions can be marked outbound (or implemented as a job) and completed later by an external event. 16 | 17 | ## Installation 18 | 19 | Add Stepped to your application (Rails `>= 8.1.1`): 20 | 21 | ```ruby 22 | gem "stepped" 23 | ``` 24 | 25 | Then install and run the migrations: 26 | 27 | ```bash 28 | bundle install 29 | bin/rails stepped:install 30 | bin/rails db:migrate 31 | ``` 32 | 33 | ## Quick start 34 | 35 | Stepped hooks into Active Record automatically, so any model can declare actions. 36 | 37 | If you define an action without steps, Stepped generates a single step that calls the instance method with the same name: 38 | 39 | ```ruby 40 | class Car < ApplicationRecord 41 | stepped_action :drive 42 | 43 | def drive(miles) 44 | update!(mileage: mileage + miles) 45 | end 46 | end 47 | 48 | car = Car.find(1) 49 | car.drive_later(5) # enqueues Stepped::ActionJob 50 | car.drive_now(5) # runs synchronously (still uses the Stepped state machine) 51 | ``` 52 | 53 | Calling `*_now`/`*_later` creates a `Stepped::Action` and a `Stepped::Step` record behind the scenes. If the action finishes immediately, the associated `Stepped::Performance` (the concurrency “lane”) is created and destroyed within the same run. If the action is short-circuited (for example, cancelled/completed in `before`, or skipped due to a matching achievement), Stepped returns an action instance but does not create any database rows. 54 | 55 | ## Concepts 56 | 57 | An action is represented by `Stepped::Action` (statuses include `pending`, `performing`, `succeeded`, `failed`, `cancelled`, `superseded`, `timed_out`, and `deadlocked`). Each action executes one step at a time; steps are stored in `Stepped::Step` and complete when all of their dependencies finish. 58 | 59 | Actions that share a `concurrency_key` are grouped under a `Stepped::Performance`. A performance behaves like a single-file queue: the current action performs, later actions wait as `pending`, and when the current action completes the performance advances to the next incomplete action. 60 | 61 | If you opt into reuse, successful actions write a `Stepped::Achievement` keyed by `checksum`. When an action is invoked again with the same `checksum`, Stepped can skip the work entirely. 62 | 63 | ## Defining actions 64 | 65 | Define an action on an Active Record model with `stepped_action`. The block is a small DSL that lets you specify steps, hooks, and keys: 66 | 67 | ```ruby 68 | class Car < ApplicationRecord 69 | stepped_action :visit do 70 | step do |step, location| 71 | step.do :change_location, location 72 | end 73 | 74 | succeeded do 75 | update!(last_visited_at: Time.current) 76 | end 77 | end 78 | 79 | def change_location(location) 80 | update!(location:) 81 | end 82 | end 83 | ``` 84 | 85 | ### Steps and action trees 86 | 87 | Each `step` block runs in the actor’s context (`self` is the model instance) and receives `(step, *arguments)`. Inside a step you typically enqueue more actions: 88 | 89 | ```ruby 90 | stepped_action :park do 91 | step do 92 | honk 93 | end 94 | 95 | step do |step, miles| 96 | step.do :honk 97 | step.on [self, nil], :drive, miles 98 | end 99 | end 100 | ``` 101 | 102 | `step.do` is shorthand for “run another action on the same actor”. `step.on` accepts a single actor or an array of actors; `nil` values are ignored. If a step enqueues work, the parent action will remain `performing` until those child actions finish and report back. 103 | 104 | To deliberately fail a step without raising, set `step.status = :failed` inside the step body. 105 | 106 | The code within the `step` block runs within the model instance context. Therefore you have flexibility to write any model code within this block, not just invoking actions. 107 | 108 | ### Waiting 109 | 110 | Steps can also enqueue a timed wait: 111 | 112 | ```ruby 113 | stepped_action :stopover do 114 | step { |step| step.wait 5.seconds } 115 | step { honk } 116 | end 117 | ``` 118 | 119 | ### Before hooks and argument mutation 120 | 121 | `before` runs once, before any steps are performed. It can mutate `action.arguments`, or cancel/complete the action early: 122 | 123 | ```ruby 124 | stepped_action :multiplied_drive do 125 | before do |action, distance| 126 | action.arguments = [distance * 2] 127 | end 128 | 129 | step do |step, distance| 130 | step.do :drive, distance 131 | end 132 | end 133 | ``` 134 | 135 | The checksum (if you define one) is computed after `before`, so it sees the updated arguments. 136 | 137 | ### After callbacks 138 | 139 | After callbacks run when the action is completed. You can attach them inline (`succeeded`, `failed`, `cancelled`, `timed_out`) or later from elsewhere with `after_stepped_action`: 140 | 141 | ```ruby 142 | Car.stepped_action :drive, outbound: true do 143 | after :cancelled, :failed, :timed_out do 144 | honk 145 | end 146 | end 147 | 148 | Car.after_stepped_action :drive, :succeeded do |action, miles| 149 | Rails.logger.info("Drove #{miles} miles") 150 | end 151 | ``` 152 | 153 | If an after callback raises and you’ve configured Stepped to handle that exception class, the action status is preserved but the callback is counted as failed and the action will not grant an achievement. 154 | 155 | ## Concurrency, queueing, and superseding 156 | 157 | Every action runs under a `concurrency_key`. Actions with the same key share a performance and therefore run one-at-a-time, in order. 158 | 159 | By default, the key is scoped to the actor and action name (for example `Car/123/visit`). You can override it with `concurrency_key` to coordinate across records or across different actions: 160 | 161 | ```ruby 162 | stepped_action :recycle, outbound: true do 163 | concurrency_key { "Car/maintenance" } 164 | end 165 | 166 | stepped_action :paint, outbound: true do 167 | concurrency_key { "Car/maintenance" } 168 | end 169 | ``` 170 | 171 | While one action is `performing`, later actions with the same key are queued as `pending`. If multiple pending actions build up, Stepped supersedes older pending actions in favor of the newest one, and transfers any parent-step dependencies to the newest action so waiting steps don’t get stuck. 172 | 173 | Stepped also protects you from deadlocks: if a descendant action tries to join the same `concurrency_key` as one of its ancestors, it is marked `deadlocked` and its parent step will fail. 174 | 175 | ## Checksums and reuse (Achievements) 176 | 177 | Reuse is opt-in per action via `checksum`. When a checksum is present, Stepped stores the last successful checksum in `Stepped::Achievement` under `checksum_key` (which defaults to the action’s tenancy key). 178 | 179 | ```ruby 180 | stepped_action :visit do 181 | checksum { |location| location } 182 | 183 | step do |step, location| 184 | step.do :change_location, location 185 | end 186 | end 187 | ``` 188 | 189 | With a checksum in place: 190 | 191 | 1. If you invoke an action while an identical checksum is already performing under the same concurrency lane, Stepped returns the existing performing action and attaches the new parent step to it. 192 | 2. If an identical checksum has already succeeded (an achievement exists), Stepped returns a `succeeded` action immediately without creating new records. 193 | 3. If the checksum changes, Stepped performs the action and updates the stored achievement to the new checksum. 194 | 195 | Use `checksum_key` to control the scope of reuse. Returning an array joins parts with `/`: 196 | 197 | ```ruby 198 | checksum_key { ["Car", "visit"] } # shared across all cars 199 | ``` 200 | 201 | ## Outbound actions and external completion 202 | 203 | An outbound action runs its steps but does not complete automatically when the final step finishes. It stays `performing` until you explicitly complete it (for example, from a webhook handler or another system): 204 | 205 | ```ruby 206 | stepped_action :charge_card, outbound: true do 207 | step do |step, amount_cents| 208 | # enqueue calls to external systems here 209 | end 210 | end 211 | 212 | user.charge_card_later(1500) 213 | user.complete_stepped_action_later(:charge_card, :succeeded) 214 | ``` 215 | 216 | Under the hood, completion forwards to the current outbound action for that actor+name and advances its performance queue. 217 | 218 | ## Job-backed actions 219 | 220 | This is especially useful if you'd like to have (delayed) retries on certain errors, that ActiveJob supports out of the box. 221 | 222 | You can declare it with `job:`. Job-backed actions are treated as outbound and are expected to call `action.complete!` when finished. The action instance is passed as the first and only argument. To work with the action arguments, use the familiar `action.arguments`: 223 | 224 | ```ruby 225 | class TowJob < ActiveJob::Base 226 | def perform(action) 227 | car = action.actor 228 | location = action.arguments.first 229 | car.update!(location:) 230 | 231 | action.complete! 232 | end 233 | end 234 | 235 | class Car < ApplicationRecord 236 | stepped_action :tow, job: TowJob 237 | end 238 | ``` 239 | 240 | You can extend existing actions (including job-backed ones) by prepending steps: 241 | 242 | ```ruby 243 | Car.prepend_stepped_action_step :tow do 244 | honk 245 | end 246 | ``` 247 | 248 | ## Timeouts 249 | 250 | Set `timeout:` to enqueue a `Stepped::TimeoutJob` when the action starts. If the action is still `performing` after the timeout elapses, it completes as `timed_out`: 251 | 252 | ```ruby 253 | stepped_action :change_location, outbound: true, timeout: 5.seconds 254 | ``` 255 | 256 | Timeouts propagate through the tree: a timed-out nested action fails its parent step, which fails the parent action. 257 | 258 | ## Exception handling 259 | 260 | Stepped can either raise exceptions (letting your job backend retry) or treat specific exception classes as handled and turn them into action failure. 261 | 262 | Configure the handled exception classes in your application: 263 | 264 | ```ruby 265 | # config/initializers/stepped.rb (or an environment file) 266 | Stepped::Engine.config.stepped_actions.handle_exceptions = [StandardError] 267 | ``` 268 | 269 | When an exception is handled, Stepped reports it via `Rails.error.report` and marks the action/step as `failed` instead of raising. 270 | 271 | ## Testing 272 | 273 | Stepped ships with `Stepped::TestHelper` (require `"stepped/test_helper"`) which builds on Active Job’s test helpers to make it easy to drain the full action tree. 274 | 275 | ```ruby 276 | # test/test_helper.rb 277 | require "stepped/test_helper" 278 | 279 | class ActiveSupport::TestCase 280 | include ActiveJob::TestHelper 281 | include Stepped::TestHelper 282 | 283 | # If your workflows include outbound actions, complete them here so 284 | # `perform_stepped_actions` can fully drain the tree. 285 | def complete_stepped_outbound_performances 286 | Stepped::Performance.outbounds.includes(:action).find_each do |performance| 287 | action = performance.action 288 | Stepped::Performance.outbound_complete(action.actor, action.name, :succeeded) 289 | end 290 | end 291 | end 292 | ``` 293 | 294 | In a test, you can perform Stepped jobs recursively: 295 | 296 | ```ruby 297 | car.visit_later("London") 298 | perform_stepped_actions 299 | ``` 300 | 301 | To test failure behavior without bubbling exceptions, you can temporarily mark exception classes as handled: 302 | 303 | ```ruby 304 | handle_stepped_action_exceptions(only: [StandardError]) do 305 | car.visit_now("London") 306 | end 307 | ``` 308 | 309 | ## Development 310 | 311 | Run the test suite: 312 | 313 | ```sh 314 | bin/rails db:test:prepare 315 | bin/rails test 316 | ``` 317 | 318 | ## License 319 | 320 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 321 | -------------------------------------------------------------------------------- /test/models/stepped/action_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Stepped::ActionTest < Stepped::TestCase 4 | setup do 5 | Temping.create "car" do 6 | with_columns do |t| 7 | t.integer :mileage, default: 0 8 | t.integer :honks, default: 0 9 | t.string :location 10 | end 11 | 12 | def drive(mileage) 13 | self.mileage += mileage 14 | save! 15 | end 16 | 17 | def honk 18 | increment! :honks 19 | end 20 | end 21 | 22 | @car = Car.create! 23 | end 24 | 25 | test "performing actor method without definition" do 26 | expected_time = Time.zone.local 2024, 12, 12 27 | assert_difference( 28 | "Stepped::Action.count" => +1, 29 | "Stepped::Step.count" => +1, 30 | "Stepped::Performance.count" => 0 31 | ) do 32 | travel_to expected_time do 33 | Stepped::ActionJob.perform_now @car, :drive, 4 34 | end 35 | end 36 | 37 | action = Stepped::Action.last 38 | assert_equal @car, action.actor 39 | assert_equal "drive", action.name 40 | assert_nil action.checksum 41 | assert_equal "Car/#{@car.id}/drive", action.checksum_key 42 | assert_equal "Car/#{@car.id}/drive", action.concurrency_key 43 | assert_predicate action, :succeeded? 44 | assert_equal 0, action.current_step_index 45 | assert_equal expected_time, action.started_at 46 | assert_equal expected_time, action.completed_at 47 | assert action.root? 48 | assert_equal 4, @car.mileage 49 | end 50 | 51 | test "perform with steps" do 52 | Car.stepped_action :park do 53 | step do 54 | honk 55 | end 56 | 57 | step do |step, mileage| 58 | step.do :honk 59 | step.on [ self, nil ], :drive, mileage 60 | end 61 | 62 | succeeded do 63 | update! location: "garage" 64 | end 65 | end 66 | 67 | assert_difference( 68 | "Stepped::Action.count" => +1, 69 | "Stepped::Step.count" => +2, 70 | "Stepped::Performance.count" => +1 71 | ) do 72 | assert_enqueued_with(job: Stepped::ActionJob) do 73 | Stepped::ActionJob.perform_now @car, :park, 5 74 | end 75 | end 76 | 77 | action_invoked_first = Stepped::Action.last 78 | assert_predicate action_invoked_first, :performing? 79 | assert_equal [ 5 ], action_invoked_first.arguments 80 | assert_equal 1, action_invoked_first.current_step_index 81 | assert_nil action_invoked_first.completed_at 82 | assert action_invoked_first.root? 83 | 84 | first_performance = Stepped::Performance.last 85 | assert_equal "Car/#{@car.id}/park", first_performance.concurrency_key 86 | assert_equal action_invoked_first, first_performance.action 87 | assert_equal 1, first_performance.actions.count 88 | assert_includes first_performance.actions, action_invoked_first 89 | assert_equal first_performance, action_invoked_first.performance 90 | 91 | assert_equal 1, @car.reload.honks 92 | assert_equal 2, action_invoked_first.steps.size 93 | 94 | step = action_invoked_first.steps.first 95 | assert_equal 0, step.pending_actions_count 96 | assert_equal 0, step.definition_index 97 | assert_predicate step, :succeeded? 98 | assert step.started_at 99 | assert step.completed_at 100 | 101 | step = action_invoked_first.steps.second 102 | assert_equal 1, step.definition_index 103 | assert_equal 2, step.pending_actions_count 104 | assert_predicate step, :performing? 105 | assert step.started_at 106 | assert_nil step.completed_at 107 | 108 | # Perform the same action again, with different arguments 109 | 110 | assert_difference( 111 | "Stepped::Action.count" => +1, 112 | "Stepped::Step.count" => 0, 113 | "Stepped::Performance.count" => 0 114 | ) do 115 | assert_no_enqueued_jobs(only: Stepped::ActionJob) do 116 | Stepped::ActionJob.perform_now @car, :park, 7 117 | end 118 | end 119 | 120 | action_invoked_second = Stepped::Action.last 121 | assert_equal [ 7 ], action_invoked_second.arguments 122 | assert_predicate action_invoked_second, :pending? 123 | assert_equal 2, first_performance.reload.actions.count 124 | assert_equal "Car/#{@car.id}/park", first_performance.concurrency_key 125 | assert_equal action_invoked_first, first_performance.action 126 | assert_includes first_performance.actions, action_invoked_second 127 | assert_equal first_performance, action_invoked_second.performance 128 | 129 | assert_equal 1, @car.reload.honks 130 | 131 | # Perform first action second step actions 132 | 133 | assert_difference( 134 | "Stepped::Action.count" => +2, 135 | "Stepped::Step.count" => +4, 136 | "Stepped::Performance.count" => 0 # 2 are created and then destroyed 137 | ) do 138 | assert_enqueued_with(job: Stepped::ActionJob) do 139 | assert_equal 2, perform_enqueued_jobs(only: Stepped::ActionJob) 140 | end 141 | end 142 | 143 | assert_equal 1, action_invoked_first.reload.current_step_index 144 | 145 | step = action_invoked_first.steps.second 146 | assert_equal 2, step.actions.size 147 | assert_predicate step, :succeeded? 148 | assert step.completed_at 149 | step.actions.each do |action| 150 | assert_predicate action, :succeeded? 151 | assert action.completed_at 152 | assert_not action.root? 153 | end 154 | 155 | assert_predicate action_invoked_first, :succeeded? 156 | assert action_invoked_first.completed_at 157 | assert_equal 5, @car.reload.mileage 158 | assert_equal "garage", @car.location 159 | 160 | # Second invocation should be performing now 161 | assert_predicate action_invoked_second.reload, :performing? 162 | assert_equal 3, @car.reload.honks 163 | 164 | last_performance = Stepped::Performance.last 165 | assert_equal first_performance, last_performance 166 | assert_equal "Car/#{@car.id}/park", last_performance.concurrency_key 167 | 168 | # Complete the second action 169 | 170 | assert_difference( 171 | "Stepped::Action.count" => +2, 172 | "Stepped::Step.count" => +2, 173 | "Stepped::Performance.count" => -1 174 | ) do 175 | assert_no_enqueued_jobs(only: Stepped::ActionJob) do 176 | assert_equal 2, perform_enqueued_jobs(only: Stepped::ActionJob) 177 | end 178 | end 179 | 180 | assert_predicate action_invoked_second.reload, :succeeded? 181 | assert_equal 5 + 7, @car.reload.mileage 182 | assert_raises ActiveRecord::RecordNotFound do 183 | last_performance.reload 184 | end 185 | end 186 | 187 | test "passing active record objects as arguments and using `on` with multiple different actions" do 188 | skip "TODO" 189 | end 190 | 191 | test "modifying arguments in before block and checksum uses arguments modified in before block" do 192 | Car.stepped_action :multiplied_drive do 193 | before do |action, distance| 194 | action.arguments = [ distance * 2 ] 195 | end 196 | 197 | checksum do |distance| 198 | distance 199 | end 200 | 201 | step do |step, distance| 202 | step.do :drive, distance 203 | end 204 | end 205 | 206 | action = Stepped::ActionJob.perform_now @car, :multiplied_drive, 5 207 | assert_equal 1, perform_enqueued_jobs_recursively(only: Stepped::ActionJob) 208 | assert_predicate action.reload, :succeeded? 209 | assert_equal [ 10 ], action.arguments 210 | assert_equal 10, @car.reload.mileage 211 | assert_equal Stepped.checksum(10), action.checksum 212 | end 213 | 214 | test "added arguments within a step are persisted" do 215 | Car.stepped_action :argument_add_in_step do 216 | step do |step| 217 | step.action.arguments.append 33 218 | end 219 | 220 | step do |step| 221 | step.do :drive, 2 222 | end 223 | 224 | step do |step, distance| 225 | drive distance 226 | end 227 | end 228 | 229 | action = Stepped::ActionJob.perform_now @car, :argument_add_in_step 230 | perform_enqueued_jobs_recursively(only: Stepped::ActionJob) 231 | assert_predicate action.reload, :succeeded? 232 | assert_equal [ 33 ], action.arguments 233 | assert_equal 35, @car.reload.mileage 234 | end 235 | 236 | test "cancelling action in before block" do 237 | Car.stepped_action :cancelled_drive do 238 | before do |action| 239 | action.cancel 240 | end 241 | 242 | step do |step| 243 | throw "This should not be reached" 244 | end 245 | end 246 | 247 | assert_difference( 248 | "Stepped::Action.count" => 0, 249 | "Stepped::Step.count" => 0, 250 | "Stepped::Achievement.count" => 0, 251 | "Stepped::Performance.count" => 0 252 | ) do 253 | assert_predicate Stepped::ActionJob.perform_now(@car, :cancelled_drive), :cancelled? 254 | end 255 | end 256 | 257 | test "cancelling nested action in before block completes parent step as failed" do 258 | Car.stepped_action :cancelled_drive do 259 | before do |action| 260 | action.cancel 261 | end 262 | 263 | step do |step| 264 | throw "This should not be reached" 265 | end 266 | end 267 | 268 | Car.stepped_action :failed_trip do 269 | step do |step| 270 | step.do :honk 271 | step.do :cancelled_drive 272 | end 273 | end 274 | 275 | parent_action = nil 276 | assert_difference( 277 | "Stepped::Action.count" => +2, 278 | "Stepped::Step.count" => +2, 279 | "Stepped::Achievement.count" => 0, 280 | "Stepped::Performance.count" => 0 281 | ) do 282 | parent_action = Stepped::ActionJob.perform_now(@car, :failed_trip) 283 | 284 | assert_equal "failed_trip", parent_action.name 285 | assert_equal 1, parent_action.steps.size 286 | 287 | perform_enqueued_jobs_recursively(only: Stepped::ActionJob) 288 | end 289 | 290 | assert_equal 1, @car.reload.honks 291 | assert_predicate parent_action.reload, :failed? 292 | assert_predicate parent_action.steps.last, :failed? 293 | end 294 | 295 | [ false, true ].each do |outbound| 296 | test "completing nested action (outbound: #{outbound}) in before block completes parent step as failed" do 297 | Car.stepped_action(:complete_early, outbound:) do 298 | before do |action| 299 | action.complete 300 | end 301 | 302 | step do |step| 303 | throw "This should not be reached" 304 | end 305 | end 306 | 307 | Car.stepped_action :trip do 308 | step do |step| 309 | step.do :honk 310 | step.do :complete_early 311 | end 312 | end 313 | 314 | assert_difference( 315 | "Stepped::Action.count" => +1, 316 | "Stepped::Step.count" => +1, 317 | "Stepped::Achievement.count" => 0, 318 | "Stepped::Performance.count" => +1 319 | ) do 320 | assert Stepped::ActionJob.perform_now(@car, :trip) 321 | end 322 | 323 | step = Stepped::Step.last 324 | assert_predicate step, :performing? 325 | assert_equal 2, step.pending_actions_count 326 | assert_equal 0, step.unsuccessful_actions_count 327 | 328 | assert_difference( 329 | "Stepped::Action.count" => +1, 330 | "Stepped::Step.count" => +1, 331 | "Stepped::Achievement.count" => 0, 332 | "Stepped::Performance.count" => -1 333 | ) do 334 | perform_enqueued_jobs_recursively(only: Stepped::ActionJob) 335 | end 336 | 337 | assert_equal 1, @car.reload.honks 338 | 339 | assert_predicate step.reload, :succeeded? 340 | assert_equal 0, step.pending_actions_count 341 | assert_equal 0, step.unsuccessful_actions_count 342 | 343 | Stepped::Action.last(2).each do |action| 344 | assert_predicate action, :succeeded? 345 | end 346 | end 347 | end 348 | 349 | test "method call action that completes outbound" do 350 | Car.stepped_action :drive, outbound: true do 351 | after { honk } 352 | end 353 | 354 | action = Stepped::ActionJob.perform_now @car, :drive, 1 355 | assert_predicate action, :performing? 356 | 357 | Stepped::Performance.outbound_complete(@car, :drive) 358 | assert_equal 1, @car.reload.honks 359 | 360 | assert_predicate action, :performing?, "Still performing without reload" 361 | 362 | assert_nil Stepped::Performance.outbound_complete(@car, :drive, :failed) 363 | assert_equal 1, @car.reload.honks 364 | assert_predicate action.reload, :succeeded? 365 | end 366 | 367 | test "action on blank actor completes parent step" do 368 | Car.stepped_action :foo do 369 | step do |step| 370 | step.on nil, :bar 371 | step.on [], :bar 372 | step.on [ nil ], :bar 373 | end 374 | end 375 | action = nil 376 | assert_difference( 377 | "Stepped::Action.count" => +1, 378 | "Stepped::Step.count" => +1, 379 | "Stepped::Achievement.count" => 0, 380 | "Stepped::Performance.count" => 0 381 | ) do 382 | action = Stepped::ActionJob.perform_now @car, :foo 383 | perform_enqueued_jobs(only: Stepped::ActionJob) 384 | end 385 | assert_predicate action.reload, :succeeded? 386 | assert_predicate action.steps.first, :succeeded? 387 | end 388 | 389 | test "adding after callbacks for an action that is not yet defined" do 390 | assert_nil Stepped::Registry.find(Car, :drive) 391 | 392 | Car.after_stepped_action :drive, :succeeded do |action, mileage| 393 | self.mileage += mileage 394 | save! 395 | end 396 | 397 | assert definition = Stepped::Registry.find(Car, :drive) 398 | 399 | Car.after_stepped_action :drive do |action, mileage| 400 | honk 401 | end 402 | 403 | assert_equal definition, Stepped::Registry.find(Car, :drive) 404 | 405 | action = Stepped::ActionJob.perform_now(@car, :drive, 30) 406 | assert_predicate action, :succeeded? 407 | assert_equal 60, @car.mileage 408 | assert_equal 1, @car.honks 409 | assert_equal 2, action.after_callbacks_succeeded_count 410 | assert_nil action.after_callbacks_failed_count 411 | end 412 | end 413 | --------------------------------------------------------------------------------