saga_forge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +87 -0
- data/LICENSE +21 -0
- data/README.md +635 -0
- data/lib/generators/saga_forge/install/USAGE +28 -0
- data/lib/generators/saga_forge/install/install_generator.rb +60 -0
- data/lib/generators/saga_forge/migration_actions.rb +68 -0
- data/lib/generators/saga_forge/templates/initializer.rb +23 -0
- data/lib/generators/saga_forge/templates/install_saga_forge.rb +84 -0
- data/lib/generators/saga_forge/upgrade/USAGE +15 -0
- data/lib/generators/saga_forge/upgrade/upgrade_generator.rb +30 -0
- data/lib/saga_forge/application_record.rb +15 -0
- data/lib/saga_forge/base.rb +61 -0
- data/lib/saga_forge/compensation_job.rb +24 -0
- data/lib/saga_forge/compensation_runner.rb +142 -0
- data/lib/saga_forge/composite_retry_policy.rb +48 -0
- data/lib/saga_forge/configuration.rb +32 -0
- data/lib/saga_forge/dashboard/graph.rb +20 -0
- data/lib/saga_forge/definition.rb +268 -0
- data/lib/saga_forge/event.rb +16 -0
- data/lib/saga_forge/execution/compensation_facade.rb +21 -0
- data/lib/saga_forge/execution/facade.rb +45 -0
- data/lib/saga_forge/execution/post_commit.rb +53 -0
- data/lib/saga_forge/execution/runner.rb +240 -0
- data/lib/saga_forge/execution_job.rb +41 -0
- data/lib/saga_forge/publisher.rb +43 -0
- data/lib/saga_forge/railtie.rb +17 -0
- data/lib/saga_forge/retention_job.rb +28 -0
- data/lib/saga_forge/retry_policy.rb +107 -0
- data/lib/saga_forge/router.rb +70 -0
- data/lib/saga_forge/state.rb +113 -0
- data/lib/saga_forge/sweeper_job.rb +80 -0
- data/lib/saga_forge/timeout_job.rb +96 -0
- data/lib/saga_forge/version.rb +3 -0
- data/lib/saga_forge.rb +81 -0
- metadata +140 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/active_record/migration"
|
|
4
|
+
require_relative "../migration_actions"
|
|
5
|
+
|
|
6
|
+
module SagaForge
|
|
7
|
+
# Creates the SagaForge initializer and installs its migration into a new
|
|
8
|
+
# application. Idempotent: migrations that already exist are skipped, so
|
|
9
|
+
# re-running is safe. Multi-database aware: with --database (or an already
|
|
10
|
+
# configured config.database), the migration lands in db/NAME_migrate.
|
|
11
|
+
class InstallGenerator < Rails::Generators::Base
|
|
12
|
+
include ::ActiveRecord::Generators::Migration
|
|
13
|
+
include SagaForge::Generators::MigrationActions
|
|
14
|
+
|
|
15
|
+
source_root File.expand_path("../templates", __dir__)
|
|
16
|
+
|
|
17
|
+
desc "Creates the SagaForge initializer and installs its migrations. Pass " \
|
|
18
|
+
"--database=NAME to run SagaForge in its own database (multi-db)."
|
|
19
|
+
|
|
20
|
+
def copy_initializer
|
|
21
|
+
template "initializer.rb", "config/initializers/saga_forge.rb"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# With --database, record config.database in the initializer so later
|
|
25
|
+
# generator runs (e.g. saga_forge:upgrade after a gem update) still
|
|
26
|
+
# target the right directory without the flag. --database=primary means
|
|
27
|
+
# "stay on the default connection", so nothing is recorded.
|
|
28
|
+
def set_database_config
|
|
29
|
+
return unless (db = saga_forge_database)
|
|
30
|
+
|
|
31
|
+
gsub_file "config/initializers/saga_forge.rb", /^\s*#?\s*config\.database\s*=.*$/,
|
|
32
|
+
%( config.database = :#{db})
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def copy_migrations
|
|
36
|
+
copy_saga_forge_migrations
|
|
37
|
+
rescue => err
|
|
38
|
+
say "#{err.class}: #{err}\n#{err.backtrace.join("\n")}", :red
|
|
39
|
+
exit 1
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def print_next_steps
|
|
43
|
+
if (db = saga_forge_database)
|
|
44
|
+
say <<~MSG
|
|
45
|
+
|
|
46
|
+
Add the '#{db}' database to config/database.yml (per environment), e.g.:
|
|
47
|
+
|
|
48
|
+
#{db}:
|
|
49
|
+
<<: *default
|
|
50
|
+
database: myapp_#{db}
|
|
51
|
+
migrations_paths: db/#{db}_migrate
|
|
52
|
+
|
|
53
|
+
then run: bin/rails db:migrate:#{db}
|
|
54
|
+
MSG
|
|
55
|
+
else
|
|
56
|
+
say "\nNext: run bin/rails db:migrate"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SagaForge
|
|
4
|
+
module Generators
|
|
5
|
+
# Shared migration-copy logic for the install and upgrade generators.
|
|
6
|
+
#
|
|
7
|
+
# Copying is idempotent: a migration whose name already exists in the host
|
|
8
|
+
# application's db/migrate is skipped, so it is safe to re-run either
|
|
9
|
+
# generator. `install` copies the full set (a fresh app has none yet);
|
|
10
|
+
# `upgrade` copies only the migrations a previously-installed app is missing.
|
|
11
|
+
# Both share this method — the difference is purely which migrations already
|
|
12
|
+
# exist in the target app.
|
|
13
|
+
#
|
|
14
|
+
# MIGRATIONS is listed in application order; copying preserves that order
|
|
15
|
+
# because each migration_template assigns the next sequential version number.
|
|
16
|
+
module MigrationActions
|
|
17
|
+
MIGRATIONS = %w[
|
|
18
|
+
install_saga_forge
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
# Both generators take --database so a multi-db install/upgrade can be
|
|
22
|
+
# driven from the command line; without it they fall back to the
|
|
23
|
+
# configured database (config.database / connects_to writing role).
|
|
24
|
+
def self.included(base)
|
|
25
|
+
base.class_option :database, type: :string, aliases: "-d", default: nil, banner: "NAME",
|
|
26
|
+
desc: "Install migrations into db/NAME_migrate for this database " \
|
|
27
|
+
"(defaults to config.database / connects_to; 'primary' means " \
|
|
28
|
+
"the default connection and db/migrate)"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def copy_saga_forge_migrations
|
|
32
|
+
MIGRATIONS.each do |name|
|
|
33
|
+
if saga_forge_migration_exists?(name)
|
|
34
|
+
say_status :skip, "#{name} (migration already exists)", :yellow
|
|
35
|
+
else
|
|
36
|
+
migration_template "#{name}.rb", "#{saga_forge_migrations_dir}/#{name}.rb"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# db/migrate on the primary connection; db/<name>_migrate when
|
|
42
|
+
# SagaForge lives in its own database.
|
|
43
|
+
def saga_forge_migrations_dir
|
|
44
|
+
db = saga_forge_database
|
|
45
|
+
db.nil? ? "db/migrate" : "db/#{db}_migrate"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The database SagaForge should be installed into, nil when it stays on
|
|
49
|
+
# the primary connection. "primary" is normalized to nil here so every
|
|
50
|
+
# consumer (migration dir, initializer recording, next-steps message)
|
|
51
|
+
# agrees that it means the default.
|
|
52
|
+
def saga_forge_database
|
|
53
|
+
db = options[:database].presence || SagaForge.config.migrations_database
|
|
54
|
+
(db.to_s == "primary") ? nil : db
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Anchored to `<digits>_name.rb` exactly: a bare `*_name.rb` glob lets `*`
|
|
58
|
+
# swallow underscores, so any migration merely ENDING in `_name.rb`
|
|
59
|
+
# (a host's own, or a sibling whose name extends this one) would count as
|
|
60
|
+
# installed and silently suppress the copy.
|
|
61
|
+
def saga_forge_migration_exists?(name)
|
|
62
|
+
pattern = /\A\d+_#{Regexp.escape(name)}\.rb\z/
|
|
63
|
+
Dir.glob(File.join(destination_root, saga_forge_migrations_dir, "*.rb"))
|
|
64
|
+
.any? { |file| File.basename(file).match?(pattern) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
SagaForge.configure do |config|
|
|
2
|
+
# === Multi-database (optional) ===
|
|
3
|
+
# Put saga_forge's two tables on a named database from database.yml.
|
|
4
|
+
# Leaving this commented keeps them on the primary database.
|
|
5
|
+
# config.database = :saga_forge
|
|
6
|
+
#
|
|
7
|
+
# Escape hatch for custom roles/shards — a raw connects_to hash (wins over
|
|
8
|
+
# config.database):
|
|
9
|
+
# config.connects_to = {database: {writing: :saga_forge, reading: :saga_forge_replica}}
|
|
10
|
+
|
|
11
|
+
# === Engine tuning (defaults shown) ===
|
|
12
|
+
# config.stall_wait = 3.seconds # early-event queue-spin wait
|
|
13
|
+
# config.stall_budget = 3 # spins before an event parks as stalled
|
|
14
|
+
# config.sweep_interval = 30.seconds # SweeperJob cadence (schedule it yourself)
|
|
15
|
+
# config.retention = 90.days # processed-event pruning window (RetentionJob)
|
|
16
|
+
# config.job_queue = :default # hot path: execution, compensation, timeout.
|
|
17
|
+
# At scale, dedicate a queue (e.g. :sagas) with
|
|
18
|
+
# its own worker — but then you MUST run a worker
|
|
19
|
+
# for it, or sagas silently never process.
|
|
20
|
+
# config.maintenance_queue = :default # sweeper + retention; defaults to job_queue.
|
|
21
|
+
# Point elsewhere to isolate recovery/pruning.
|
|
22
|
+
# config.primary_key_type = :uuid # engine tables' PK type (default: host app convention)
|
|
23
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class InstallSagaForge < ActiveRecord::Migration[7.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :saga_forge_states, id: primary_key_type do |t|
|
|
6
|
+
t.string :saga_class, null: false
|
|
7
|
+
t.string :correlation_id, null: false
|
|
8
|
+
t.string :current_state, null: false
|
|
9
|
+
t.integer :version, null: false, default: 0
|
|
10
|
+
|
|
11
|
+
if t.respond_to?(:jsonb)
|
|
12
|
+
t.jsonb :context, null: false, default: {}
|
|
13
|
+
else
|
|
14
|
+
t.json :context, null: false, default: {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
t.datetime :finalized_at
|
|
18
|
+
t.datetime :last_active_at
|
|
19
|
+
|
|
20
|
+
t.timestamps
|
|
21
|
+
|
|
22
|
+
t.index %i[saga_class correlation_id], unique: true
|
|
23
|
+
t.index %i[saga_class current_state]
|
|
24
|
+
# Plain (non-partial, adapter-portable) index: the sweeper's stranded-
|
|
25
|
+
# compensating scan filters current_state cross-class, which neither
|
|
26
|
+
# of the above compound indexes serves.
|
|
27
|
+
t.index :current_state
|
|
28
|
+
# Retention/dashboard filter finalized vs active in SQL (finalized_at
|
|
29
|
+
# IS [NOT] NULL) without loading each saga class to ask terminal?.
|
|
30
|
+
t.index :finalized_at
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
create_table :saga_forge_events, id: primary_key_type do |t|
|
|
34
|
+
t.string :saga_class, null: false
|
|
35
|
+
t.string :correlation_id, null: false
|
|
36
|
+
# Lone-column index on saga_forge_state_id intentionally omitted:
|
|
37
|
+
# the [saga_forge_state_id, created_at] index below covers left-prefix lookups.
|
|
38
|
+
t.references :saga_forge_state, type: foreign_key_type,
|
|
39
|
+
foreign_key: {to_table: :saga_forge_states}, index: false
|
|
40
|
+
|
|
41
|
+
t.string :event_name, null: false
|
|
42
|
+
t.integer :status, null: false, default: 0
|
|
43
|
+
t.integer :stall_count, null: false, default: 0
|
|
44
|
+
t.integer :attempts, null: false, default: 0
|
|
45
|
+
t.datetime :last_processed_at
|
|
46
|
+
|
|
47
|
+
if t.respond_to?(:jsonb)
|
|
48
|
+
t.jsonb :payload, null: false, default: {}
|
|
49
|
+
t.jsonb :retry_budgets, null: false, default: {}
|
|
50
|
+
t.jsonb :error
|
|
51
|
+
else
|
|
52
|
+
t.json :payload, null: false, default: {}
|
|
53
|
+
t.json :retry_budgets, null: false, default: {}
|
|
54
|
+
t.json :error
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
t.timestamps
|
|
58
|
+
|
|
59
|
+
# Structural idempotency: a saga instance handles each event name at
|
|
60
|
+
# most once (forward-only, no stay), so this tuple IS the dedup key —
|
|
61
|
+
# webhook redeliveries and saga-to-saga fan-in no-op at the index.
|
|
62
|
+
t.index %i[saga_class correlation_id event_name], unique: true
|
|
63
|
+
t.index %i[saga_class correlation_id status]
|
|
64
|
+
t.index %i[status created_at]
|
|
65
|
+
t.index %i[saga_forge_state_id created_at]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Explicit config wins; otherwise the app's config.generators setting;
|
|
72
|
+
# otherwise Rails' create_table default (the :primary_key sentinel). See
|
|
73
|
+
# SagaForge.primary_key_type.
|
|
74
|
+
def primary_key_type
|
|
75
|
+
SagaForge.primary_key_type
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# t.references needs a concrete column type; :primary_key is only a valid
|
|
79
|
+
# value for create_table's `id:` option, so resolve that sentinel to :bigint
|
|
80
|
+
# here (mirrors what create_table would have picked for the referenced id).
|
|
81
|
+
def foreign_key_type
|
|
82
|
+
(primary_key_type == :primary_key) ? :bigint : primary_key_type
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Upgrades an existing SagaForge installation to the current schema.
|
|
3
|
+
|
|
4
|
+
New applications use `saga_forge:install`. Applications that installed an
|
|
5
|
+
earlier version run this to pick up any migrations they are missing.
|
|
6
|
+
Copying is idempotent: a migration that already exists in the target app
|
|
7
|
+
(db/migrate or db/NAME_migrate) is skipped, not duplicated.
|
|
8
|
+
|
|
9
|
+
Multi-database aware: with --database (or config.database recorded in
|
|
10
|
+
config/initializers/saga_forge.rb by a previous install), missing
|
|
11
|
+
migrations are copied into db/NAME_migrate instead of db/migrate.
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
bin/rails g saga_forge:upgrade
|
|
15
|
+
bin/rails db:migrate
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/active_record/migration"
|
|
4
|
+
require_relative "../migration_actions"
|
|
5
|
+
|
|
6
|
+
module SagaForge
|
|
7
|
+
# Brings an existing SagaForge installation up to the current schema by
|
|
8
|
+
# copying any migrations the application does not already have. Applications
|
|
9
|
+
# created with `saga_forge:install` on the current version already have
|
|
10
|
+
# everything; older installs pick up any additive migrations added since.
|
|
11
|
+
#
|
|
12
|
+
# rails generate saga_forge:upgrade
|
|
13
|
+
# rails db:migrate
|
|
14
|
+
#
|
|
15
|
+
# Multi-database aware: with --database (or config.database set in the
|
|
16
|
+
# initializer), missing migrations are copied into db/NAME_migrate.
|
|
17
|
+
class UpgradeGenerator < Rails::Generators::Base
|
|
18
|
+
include ::ActiveRecord::Generators::Migration
|
|
19
|
+
include SagaForge::Generators::MigrationActions
|
|
20
|
+
|
|
21
|
+
source_root File.expand_path("../templates", __dir__)
|
|
22
|
+
|
|
23
|
+
def start
|
|
24
|
+
copy_saga_forge_migrations
|
|
25
|
+
rescue => err
|
|
26
|
+
say "#{err.class}: #{err}\n#{err.backtrace.join("\n")}", :red
|
|
27
|
+
exit 1
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module SagaForge
|
|
2
|
+
# All engine models subclass this abstract class, so pointing it at a
|
|
3
|
+
# connection moves the whole engine. Config is read once at class load —
|
|
4
|
+
# safe because initializers run before models are first referenced.
|
|
5
|
+
# Nothing configured → models stay on the app's primary connection.
|
|
6
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
7
|
+
self.abstract_class = true
|
|
8
|
+
|
|
9
|
+
if SagaForge.config.connects_to
|
|
10
|
+
connects_to(**SagaForge.config.connects_to)
|
|
11
|
+
elsif (db = SagaForge.config.database)
|
|
12
|
+
connects_to database: {writing: db, reading: db}
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module SagaForge
|
|
2
|
+
# The saga DSL. Macros only record; Definition.compile (lazy, memoized)
|
|
3
|
+
# builds and validates the machine. The file IS the state machine.
|
|
4
|
+
class Base
|
|
5
|
+
class << self
|
|
6
|
+
attr_reader :correlator, :default_retry_policy
|
|
7
|
+
|
|
8
|
+
def inherited(subclass)
|
|
9
|
+
super
|
|
10
|
+
Router.register(subclass)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def correlate_by(key = nil, &block)
|
|
14
|
+
@correlator = block || ->(payload, _event = nil) { payload[key] }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def start_with(event, compensate: nil, timeout: nil, on_timeout: nil, retry_policy: nil, &block)
|
|
18
|
+
declarations << {kind: :start, event: event.to_sym, compensate:, timeout:, on_timeout:, retry_policy:, block:}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def during(state, on:, compensate: nil, timeout: nil, on_timeout: nil, retry_policy: nil, &block)
|
|
22
|
+
declarations << {kind: :during, state: state.to_sym, event: on.to_sym, compensate:, timeout:, on_timeout:, retry_policy:, block:}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def finish_with(state)
|
|
26
|
+
declarations << {kind: :finish, state: state.to_sym}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def compensation(name, &block)
|
|
30
|
+
declarations << {kind: :compensation, name: name.to_sym, block:}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def retry_policy(*policies, **kwargs)
|
|
34
|
+
if policies.any? && kwargs.any?
|
|
35
|
+
raise ArgumentError, "pass either policy objects or kwargs, not both"
|
|
36
|
+
end
|
|
37
|
+
@default_retry_policy =
|
|
38
|
+
if policies.any?
|
|
39
|
+
(policies.size == 1 && kwargs.empty?) ? policies.first : CompositeRetryPolicy.new(policies)
|
|
40
|
+
else
|
|
41
|
+
RetryPolicy.new(**kwargs)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def declarations = @declarations ||= []
|
|
46
|
+
|
|
47
|
+
def definition = @definition ||= Definition.compile(self)
|
|
48
|
+
|
|
49
|
+
# Introspection & recovery (class-level; instance ops land in Task 11).
|
|
50
|
+
def find_by_correlation(correlation_id) = State.for_saga(self).find_by(correlation_id: correlation_id.to_s)
|
|
51
|
+
|
|
52
|
+
def in_state(state) = State.for_saga(self).in_state(state)
|
|
53
|
+
|
|
54
|
+
def stalled = State.for_saga(self).stalled
|
|
55
|
+
|
|
56
|
+
def suspended = State.for_saga(self).suspended
|
|
57
|
+
|
|
58
|
+
def to_mermaid = definition.to_mermaid
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module SagaForge
|
|
2
|
+
class CompensationJob < ActiveJob::Base
|
|
3
|
+
queue_as { SagaForge.config.job_queue }
|
|
4
|
+
|
|
5
|
+
# See ExecutionJob::CONCURRENCY_KEY for why this is a constant.
|
|
6
|
+
CONCURRENCY_KEY = ->(state_id) {
|
|
7
|
+
state = State.find_by(id: state_id)
|
|
8
|
+
state ? "SagaLock:#{state.saga_class}:#{state.correlation_id}" : "SagaLock:none"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if defined?(SolidQueue)
|
|
12
|
+
limits_concurrency key: CONCURRENCY_KEY
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def perform(state_id)
|
|
16
|
+
state = State.find_by(id: state_id)
|
|
17
|
+
return unless state
|
|
18
|
+
return unless state.current_state == State::COMPENSATING.to_s
|
|
19
|
+
|
|
20
|
+
outcome, wait = CompensationRunner.new(state).call
|
|
21
|
+
retry_job(wait: wait) if outcome == :retry
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
module SagaForge
|
|
2
|
+
# Rollback is derived, not stored (§A.4): owed = processed events, mapped
|
|
3
|
+
# through the compensate: registry, deduped, run LIFO with a commit per
|
|
4
|
+
# compensation. Progress lives in context["__saga_forge"]. A saga stuck in
|
|
5
|
+
# :compensating after exhausted retries is recovered by operator
|
|
6
|
+
# compensate! (Task 11) or the sweeper (Task 10).
|
|
7
|
+
class CompensationRunner
|
|
8
|
+
COMP_ERROR_MESSAGE_LIMIT = 5_000
|
|
9
|
+
|
|
10
|
+
attr_reader :state
|
|
11
|
+
|
|
12
|
+
def initialize(state)
|
|
13
|
+
@state = state
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
definition = state.saga_definition
|
|
18
|
+
|
|
19
|
+
loop do
|
|
20
|
+
state.reload
|
|
21
|
+
name = next_owed(definition)
|
|
22
|
+
return finalize! if name.nil?
|
|
23
|
+
|
|
24
|
+
entry_version = state.version
|
|
25
|
+
begin
|
|
26
|
+
outcome = run_one(definition, name, entry_version)
|
|
27
|
+
rescue ConcurrencyConflict
|
|
28
|
+
# Lost the race to another CompensationJob for this same instance
|
|
29
|
+
# (defense-in-depth — limits_concurrency should make this rare in
|
|
30
|
+
# practice). Nothing was written on this path: the raise happens
|
|
31
|
+
# inside run_one's with_lock, before any update!, so no
|
|
32
|
+
# comp_attempts/comp_error bookkeeping to unwind. Re-loop: reload
|
|
33
|
+
# picks up the winner's committed progress, next_owed re-derives
|
|
34
|
+
# against it, and we snapshot fresh before trying again.
|
|
35
|
+
next
|
|
36
|
+
end
|
|
37
|
+
return outcome unless outcome == :continue
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def next_owed(definition)
|
|
44
|
+
done = (state.context.dig("__saga_forge", "compensated") || []).map(&:to_s)
|
|
45
|
+
owed = state.events.processed.ledger_order
|
|
46
|
+
.filter_map { |e| definition.handler_for(e.event_name)&.compensate }
|
|
47
|
+
.uniq
|
|
48
|
+
.reverse
|
|
49
|
+
owed.map(&:to_s).find { |n| !done.include?(n) }&.to_sym
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def run_one(definition, name, entry_version)
|
|
53
|
+
block = definition.compensations.fetch(name)
|
|
54
|
+
context = state.context.deep_dup.with_indifferent_access
|
|
55
|
+
facade = Execution::CompensationFacade.new(
|
|
56
|
+
correlation_id: state.correlation_id,
|
|
57
|
+
current_state: state.current_state,
|
|
58
|
+
context: context
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
SagaForge.guarding_execution { block.call(facade) }
|
|
63
|
+
rescue => error
|
|
64
|
+
return record_comp_error(name, error)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
inserted = nil
|
|
68
|
+
state.with_lock do
|
|
69
|
+
# facade.context was built from a pre-lock read (above); with_lock's
|
|
70
|
+
# reload just refreshed state to whatever's actually committed. If
|
|
71
|
+
# another CompensationJob for this same instance landed a compensation
|
|
72
|
+
# in between, state.version has moved past entry_version — writing
|
|
73
|
+
# `committed` (the whole context, built from our stale snapshot) now
|
|
74
|
+
# would silently clobber that instance's progress (lost context keys,
|
|
75
|
+
# a "completed" name no longer in `compensated`, so it'd look owed
|
|
76
|
+
# again). Mirrors Execution::Runner#commit!'s optimistic-concurrency
|
|
77
|
+
# check; on conflict we raise and let #call re-loop against fresh
|
|
78
|
+
# state rather than trying to merge two contexts here.
|
|
79
|
+
raise ConcurrencyConflict, "compensation version moved" if state.version != entry_version
|
|
80
|
+
|
|
81
|
+
committed = facade.context
|
|
82
|
+
meta = (committed["__saga_forge"] || {}).dup
|
|
83
|
+
meta["compensated"] = (meta["compensated"] || []) + [name.to_s]
|
|
84
|
+
committed["__saga_forge"] = meta
|
|
85
|
+
state.update!(context: committed, version: state.version + 1, last_active_at: Time.current)
|
|
86
|
+
# Same savepoint-per-row tolerance as Runner#commit!: a compensation
|
|
87
|
+
# publishing to a recipient that already has that event no-ops at the
|
|
88
|
+
# structural unique index instead of aborting this commit.
|
|
89
|
+
inserted = facade.staged_publishes.filter_map do |attrs|
|
|
90
|
+
ApplicationRecord.transaction(requires_new: true) { Event.create!(attrs) }
|
|
91
|
+
rescue ActiveRecord::RecordNotUnique
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
Array(inserted).each { |row| ExecutionJob.perform_later(row.id) }
|
|
96
|
+
:continue
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def record_comp_error(name, error)
|
|
100
|
+
backoff = nil
|
|
101
|
+
state.with_lock do
|
|
102
|
+
# deep_dup — not because in-place mutation would fool Rails' dirty
|
|
103
|
+
# tracking (JSON/JSONB attributes re-deserialize the stored raw value
|
|
104
|
+
# and diff by content on every check, so an in-place mutation IS
|
|
105
|
+
# detected correctly; that concern doesn't hold up). This is snapshot
|
|
106
|
+
# isolation, the same pre-lock-read discipline used everywhere else
|
|
107
|
+
# context is worked on (Execution::Runner#execute!, this class's own
|
|
108
|
+
# #run_one): treat state.context as a value read at a point in time,
|
|
109
|
+
# and hand back an independent copy rather than mutating the live
|
|
110
|
+
# attribute object in place.
|
|
111
|
+
context = state.context.deep_dup
|
|
112
|
+
meta = (context["__saga_forge"] || {}).dup
|
|
113
|
+
attempts = (meta["comp_attempts"] || {}).dup
|
|
114
|
+
attempts[name.to_s] = attempts.fetch(name.to_s, 0) + 1
|
|
115
|
+
meta["comp_attempts"] = attempts
|
|
116
|
+
|
|
117
|
+
backoff = RetryPolicy.compensation_default.retry_backoff(error, attempts: attempts[name.to_s])
|
|
118
|
+
unless backoff
|
|
119
|
+
meta["comp_error"] = {
|
|
120
|
+
"name" => name.to_s,
|
|
121
|
+
"class" => error.class.name,
|
|
122
|
+
"message" => SagaForge.safe_error_message(error.message, COMP_ERROR_MESSAGE_LIMIT)
|
|
123
|
+
}
|
|
124
|
+
Rails.logger.error { "[saga_forge] compensation #{name} exhausted for #{state.saga_class}##{state.correlation_id}: #{error.class}" }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
context["__saga_forge"] = meta
|
|
128
|
+
state.update!(context: context, last_active_at: Time.current)
|
|
129
|
+
end
|
|
130
|
+
backoff ? [:retry, backoff] : [:done]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def finalize!
|
|
134
|
+
state.with_lock do
|
|
135
|
+
target = state.context.dig("__saga_forge", "target") || "compensated"
|
|
136
|
+
now = Time.current
|
|
137
|
+
state.update!(current_state: target, version: state.version + 1, finalized_at: now, last_active_at: now)
|
|
138
|
+
end
|
|
139
|
+
[:done]
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SagaForge
|
|
4
|
+
# An ordered list of RetryPolicy objects, each scoped to an error type via
|
|
5
|
+
# its `retry_on`. On failure the first policy whose `retry_on` matches the
|
|
6
|
+
# raised error (by `is_a?`) is applied, giving each error type its own
|
|
7
|
+
# independent attempt budget and backoff curve. Put specific policies first
|
|
8
|
+
# and a catch-all (`retry_on: nil`) last; an unmatched error is not retried.
|
|
9
|
+
#
|
|
10
|
+
# Pure: it never reads storage. The per-error count is supplied by the
|
|
11
|
+
# caller through the block passed to #retry_backoff, keyed by the matched
|
|
12
|
+
# policy's budget_key (its declared errors) — the caller is expected to
|
|
13
|
+
# persist counts per budget_key, e.g. in Event#retry_budgets.
|
|
14
|
+
class CompositeRetryPolicy
|
|
15
|
+
attr_reader :policies
|
|
16
|
+
|
|
17
|
+
def initialize(policies)
|
|
18
|
+
@policies = Array(policies)
|
|
19
|
+
if @policies.empty?
|
|
20
|
+
raise ArgumentError, "composite retry policy needs at least one policy"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# First sub-policy whose retry_on matches the error, or nil.
|
|
25
|
+
def policy_for(error)
|
|
26
|
+
@policies.find { |p| p.matches?(error) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Routes on the live error and delegates the decision to the matched
|
|
30
|
+
# sub-policy. When a block is given it is called with the matched policy's
|
|
31
|
+
# budget_key and must return that policy's running attempt count (1-based,
|
|
32
|
+
# including the current failure); otherwise `attempts` is used.
|
|
33
|
+
def retry_backoff(error, attempts:)
|
|
34
|
+
sub = policy_for(error)
|
|
35
|
+
return nil if sub.nil?
|
|
36
|
+
|
|
37
|
+
count = block_given? ? yield(sub.budget_key) : attempts
|
|
38
|
+
sub.retryable?(error, count) ? sub.backoff_for(count) : nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Coarsest attempt bound across sub-policies, for a safety-net guard.
|
|
42
|
+
# nil (unbounded) if any sub-policy is unbounded.
|
|
43
|
+
def max_attempts
|
|
44
|
+
caps = @policies.map(&:max_attempts)
|
|
45
|
+
caps.include?(nil) ? nil : caps.max
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module SagaForge
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_accessor :stall_wait, :stall_budget, :sweep_interval, :retention,
|
|
4
|
+
:job_queue, :database, :connects_to, :primary_key_type
|
|
5
|
+
attr_writer :maintenance_queue
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@stall_wait = 3.seconds
|
|
9
|
+
@stall_budget = 3
|
|
10
|
+
@sweep_interval = 30.seconds
|
|
11
|
+
@retention = 90.days
|
|
12
|
+
@job_queue = :default
|
|
13
|
+
@maintenance_queue = nil
|
|
14
|
+
@database = nil
|
|
15
|
+
@connects_to = nil
|
|
16
|
+
@primary_key_type = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Housekeeping jobs (sweeper, retention) default to the hot-path queue so
|
|
20
|
+
# single-queue setups need no configuration; set explicitly to drain
|
|
21
|
+
# recovery/pruning work on a separate (typically lower-priority) queue.
|
|
22
|
+
def maintenance_queue
|
|
23
|
+
@maintenance_queue || @job_queue
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Which named database the generators should target when no --database
|
|
27
|
+
# flag is given: explicit database name, else the connects_to writing role.
|
|
28
|
+
def migrations_database
|
|
29
|
+
database || connects_to&.dig(:database, :writing)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module SagaForge
|
|
2
|
+
module Dashboard
|
|
3
|
+
# Structured, serializable graph derived from a compiled Definition. The
|
|
4
|
+
# core gem owns this shape so any consumer (the dashboard, a doc generator)
|
|
5
|
+
# gets the same typed representation instead of parsing the mermaid string.
|
|
6
|
+
Graph = Struct.new(:nodes, :edges) do
|
|
7
|
+
def to_h = {nodes: nodes.map(&:to_h), edges: edges.map(&:to_h)}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# kind: :start | :state | :terminal
|
|
11
|
+
Node = Struct.new(:id, :label, :kind) do
|
|
12
|
+
def to_h = {id: id, label: label, kind: kind}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# kind: :chain (complete) | :jump (best-effort)
|
|
16
|
+
Edge = Struct.new(:from, :to, :kind, :label) do
|
|
17
|
+
def to_h = {from: from, to: to, kind: kind, label: label}
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|