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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +87 -0
  3. data/LICENSE +21 -0
  4. data/README.md +635 -0
  5. data/lib/generators/saga_forge/install/USAGE +28 -0
  6. data/lib/generators/saga_forge/install/install_generator.rb +60 -0
  7. data/lib/generators/saga_forge/migration_actions.rb +68 -0
  8. data/lib/generators/saga_forge/templates/initializer.rb +23 -0
  9. data/lib/generators/saga_forge/templates/install_saga_forge.rb +84 -0
  10. data/lib/generators/saga_forge/upgrade/USAGE +15 -0
  11. data/lib/generators/saga_forge/upgrade/upgrade_generator.rb +30 -0
  12. data/lib/saga_forge/application_record.rb +15 -0
  13. data/lib/saga_forge/base.rb +61 -0
  14. data/lib/saga_forge/compensation_job.rb +24 -0
  15. data/lib/saga_forge/compensation_runner.rb +142 -0
  16. data/lib/saga_forge/composite_retry_policy.rb +48 -0
  17. data/lib/saga_forge/configuration.rb +32 -0
  18. data/lib/saga_forge/dashboard/graph.rb +20 -0
  19. data/lib/saga_forge/definition.rb +268 -0
  20. data/lib/saga_forge/event.rb +16 -0
  21. data/lib/saga_forge/execution/compensation_facade.rb +21 -0
  22. data/lib/saga_forge/execution/facade.rb +45 -0
  23. data/lib/saga_forge/execution/post_commit.rb +53 -0
  24. data/lib/saga_forge/execution/runner.rb +240 -0
  25. data/lib/saga_forge/execution_job.rb +41 -0
  26. data/lib/saga_forge/publisher.rb +43 -0
  27. data/lib/saga_forge/railtie.rb +17 -0
  28. data/lib/saga_forge/retention_job.rb +28 -0
  29. data/lib/saga_forge/retry_policy.rb +107 -0
  30. data/lib/saga_forge/router.rb +70 -0
  31. data/lib/saga_forge/state.rb +113 -0
  32. data/lib/saga_forge/sweeper_job.rb +80 -0
  33. data/lib/saga_forge/timeout_job.rb +96 -0
  34. data/lib/saga_forge/version.rb +3 -0
  35. data/lib/saga_forge.rb +81 -0
  36. 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