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.
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.
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 |
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 |
--------------------------------------------------------------------------------