lepus-saga 0.3.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 (84) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +28 -0
  3. data/Gemfile +5 -0
  4. data/Gemfile.lock +237 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +337 -0
  7. data/Rakefile +8 -0
  8. data/docs/brand/BRAND.md +67 -0
  9. data/docs/brand/apple-touch-icon.png +0 -0
  10. data/docs/brand/favicon-16.png +0 -0
  11. data/docs/brand/favicon-32.png +0 -0
  12. data/docs/brand/favicon.ico +0 -0
  13. data/docs/brand/favicon.svg +1 -0
  14. data/docs/brand/icon-512.png +0 -0
  15. data/docs/brand/lockup-lepus-mono.svg +1 -0
  16. data/docs/brand/lockup-lepus-on-dark.svg +1 -0
  17. data/docs/brand/lockup-lepus-on-light.svg +1 -0
  18. data/docs/brand/lockup-lepus-saga-mono.svg +1 -0
  19. data/docs/brand/lockup-lepus-saga-on-dark.svg +1 -0
  20. data/docs/brand/lockup-lepus-saga-on-light.svg +1 -0
  21. data/docs/brand/lockup-lepus-saga.svg +1 -0
  22. data/docs/brand/lockup-lepus.svg +1 -0
  23. data/docs/brand/mark-mono.svg +1 -0
  24. data/docs/brand/mark-on-dark.svg +1 -0
  25. data/docs/brand/mark-on-light.svg +1 -0
  26. data/docs/brand/mark-small-mono.svg +1 -0
  27. data/docs/brand/mark-small-on-dark.svg +1 -0
  28. data/docs/brand/mark-small-on-light.svg +1 -0
  29. data/docs/brand/mark-small.svg +1 -0
  30. data/docs/brand/mark.svg +1 -0
  31. data/lib/lepus/saga/configuration.rb +48 -0
  32. data/lib/lepus/saga/control.rb +27 -0
  33. data/lib/lepus/saga/definition.rb +123 -0
  34. data/lib/lepus/saga/execution_behaviour.rb +58 -0
  35. data/lib/lepus/saga/executor.rb +282 -0
  36. data/lib/lepus/saga/executor_consumer.rb +38 -0
  37. data/lib/lepus/saga/instrumentation.rb +41 -0
  38. data/lib/lepus/saga/log_subscriber.rb +42 -0
  39. data/lib/lepus/saga/queue_depth.rb +28 -0
  40. data/lib/lepus/saga/resume_stuck_job.rb +19 -0
  41. data/lib/lepus/saga/retry_policy.rb +19 -0
  42. data/lib/lepus/saga/rspec/engine_examples.rb +635 -0
  43. data/lib/lepus/saga/rspec/helpers.rb +22 -0
  44. data/lib/lepus/saga/rspec/query_examples.rb +378 -0
  45. data/lib/lepus/saga/rspec/store_examples.rb +514 -0
  46. data/lib/lepus/saga/rspec.rb +6 -0
  47. data/lib/lepus/saga/step.rb +9 -0
  48. data/lib/lepus/saga/step_attempt_behaviour.rb +35 -0
  49. data/lib/lepus/saga/step_producer.rb +37 -0
  50. data/lib/lepus/saga/stores/base.rb +305 -0
  51. data/lib/lepus/saga/stores/memory.rb +375 -0
  52. data/lib/lepus/saga/stores.rb +40 -0
  53. data/lib/lepus/saga/testing.rb +35 -0
  54. data/lib/lepus/saga/version.rb +7 -0
  55. data/lib/lepus/saga/web/api.rb +326 -0
  56. data/lib/lepus/saga/web/configuration.rb +49 -0
  57. data/lib/lepus/saga/web/guard.rb +38 -0
  58. data/lib/lepus/saga/web/serializer.rb +59 -0
  59. data/lib/lepus/saga/web/static.rb +108 -0
  60. data/lib/lepus/saga/web.rb +61 -0
  61. data/lib/lepus/saga.rb +212 -0
  62. data/lib/lepus-saga.rb +3 -0
  63. data/mise.toml +2 -0
  64. data/web/assets/app.css +913 -0
  65. data/web/assets/apple-touch-icon.png +0 -0
  66. data/web/assets/favicon-32.png +0 -0
  67. data/web/assets/favicon.svg +1 -0
  68. data/web/assets/fonts/overpass/OFL.txt +93 -0
  69. data/web/assets/fonts/overpass/overpass-latin-wght.woff2 +0 -0
  70. data/web/assets/js/api.js +50 -0
  71. data/web/assets/js/dom.js +84 -0
  72. data/web/assets/js/format.js +79 -0
  73. data/web/assets/js/live.js +74 -0
  74. data/web/assets/js/main.js +143 -0
  75. data/web/assets/js/map.js +639 -0
  76. data/web/assets/js/range.js +19 -0
  77. data/web/assets/js/sagas.js +18 -0
  78. data/web/assets/js/theme.js +8 -0
  79. data/web/assets/js/throughput.js +262 -0
  80. data/web/assets/js/views/execution.js +426 -0
  81. data/web/assets/js/views/executions.js +165 -0
  82. data/web/assets/js/views/overview.js +465 -0
  83. data/web/index.html +63 -0
  84. metadata +323 -0
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rack"
5
+ require_relative "../saga"
6
+
7
+ module Lepus
8
+ module Saga
9
+ # A Rack console for saga executions, mountable anywhere:
10
+ #
11
+ # mount Lepus::Saga::Web => "/sagas" # Rails routes
12
+ # run Lepus::Saga::Web # config.ru
13
+ #
14
+ # It reads through the store's query methods and acts through
15
+ # Lepus::Saga.retry and Lepus::Saga.compensate. Mount it behind the host's
16
+ # authentication; `config.authorize` adds per-action checks on top.
17
+ module Web
18
+ ROOT = File.expand_path("../../../web", __dir__)
19
+
20
+ autoload :Api, "lepus/saga/web/api"
21
+ autoload :Configuration, "lepus/saga/web/configuration"
22
+ autoload :Guard, "lepus/saga/web/guard"
23
+ autoload :Serializer, "lepus/saga/web/serializer"
24
+ autoload :Static, "lepus/saga/web/static"
25
+
26
+ class << self
27
+ def config
28
+ @config ||= Configuration.new
29
+ end
30
+
31
+ def configure
32
+ yield config
33
+ @app = nil
34
+ end
35
+
36
+ def call(env)
37
+ app.call(env)
38
+ end
39
+
40
+ def app
41
+ @app ||= build(config)
42
+ end
43
+
44
+ def build(config)
45
+ api = Api.new(config)
46
+ static = Static.new(config)
47
+ Rack::Builder.new do
48
+ use Guard, config
49
+ map("/api") { run api }
50
+ map("/") { run static }
51
+ end.to_app
52
+ end
53
+
54
+ def reset!
55
+ @config = nil
56
+ @app = nil
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
data/lib/lepus/saga.rb ADDED
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "lepus"
5
+ require_relative "saga/version"
6
+
7
+ module Lepus
8
+ # Temporal-inspired saga orchestration on top of Lepus/RabbitMQ.
9
+ #
10
+ # Sagas are declared as subclasses of Lepus::Saga::Definition (ordered steps
11
+ # with optional compensations and retry policies). State lives in the
12
+ # database (saga_executions + saga_step_attempts); messages on the saga
13
+ # exchange only point at the next unit of work, so redelivery and message
14
+ # loss are both recoverable from the database.
15
+ module Saga
16
+ class UnknownSagaError < StandardError; end
17
+ class UnknownStepError < StandardError; end
18
+ class NotRetryableError < StandardError; end
19
+ class ConfigurationError < StandardError; end
20
+ class DuplicateAttemptError < StandardError; end
21
+
22
+ autoload :Configuration, "lepus/saga/configuration"
23
+ autoload :Control, "lepus/saga/control"
24
+ autoload :Definition, "lepus/saga/definition"
25
+ autoload :ExecutionBehaviour, "lepus/saga/execution_behaviour"
26
+ autoload :Executor, "lepus/saga/executor"
27
+ autoload :ExecutorConsumer, "lepus/saga/executor_consumer"
28
+ autoload :Instrumentation, "lepus/saga/instrumentation"
29
+ autoload :LogSubscriber, "lepus/saga/log_subscriber"
30
+ autoload :QueueDepth, "lepus/saga/queue_depth"
31
+ autoload :ResumeStuckJob, "lepus/saga/resume_stuck_job"
32
+ autoload :RetryPolicy, "lepus/saga/retry_policy"
33
+ autoload :Step, "lepus/saga/step"
34
+ autoload :Stores, "lepus/saga/stores"
35
+ autoload :StepAttemptBehaviour, "lepus/saga/step_attempt_behaviour"
36
+ autoload :StepProducer, "lepus/saga/step_producer"
37
+ autoload :Testing, "lepus/saga/testing"
38
+
39
+ class << self
40
+ def config
41
+ @config ||= Configuration.new
42
+ end
43
+
44
+ def configure
45
+ yield config
46
+ end
47
+
48
+ def store
49
+ config.store || raise(ConfigurationError, "No saga store configured, set Lepus::Saga.config.store")
50
+ end
51
+
52
+ def exchange_name = config.exchange_name
53
+
54
+ def queue_name(partition)
55
+ (partition.to_s == "default") ? "#{exchange_name}.execute" : "#{exchange_name}.execute.#{partition}"
56
+ end
57
+
58
+ def routing_key(partition) = "saga.execute.#{partition}"
59
+
60
+ def registry
61
+ @registry ||= {}
62
+ end
63
+
64
+ def register(name, klass)
65
+ registry[name.to_s] = klass
66
+ end
67
+
68
+ # Every registered definition by saga name, after eager loading the
69
+ # application so none is missing.
70
+ def definitions
71
+ eager_load_definitions!
72
+ registry
73
+ end
74
+
75
+ def find_definition!(name)
76
+ registry.fetch(name.to_s) do
77
+ eager_load_definitions!
78
+ registry.fetch(name.to_s) { raise UnknownSagaError, "Unknown saga #{name.inspect}" }
79
+ end
80
+ end
81
+
82
+ # Re-enqueues a failed (or stuck) execution from its current step.
83
+ # Bumps the epoch so stale in-flight messages from before the retry are
84
+ # fenced out, and the attempt budget for the current step restarts.
85
+ def retry(execution)
86
+ ensure_retryable!(execution)
87
+
88
+ store.with_lock(execution) do
89
+ ensure_retryable!(execution)
90
+ # A hand-driven retry is a fresh start: the stall clock and the
91
+ # automatic resume budget both reset, or an abandoned execution would
92
+ # be abandoned again on the next sweep.
93
+ execution.record_progress
94
+ store.update_execution(execution,
95
+ epoch: execution.epoch + 1,
96
+ status: execution.compensate_direction? ? "compensating" : "running",
97
+ failure_reason: nil,
98
+ finished_at: nil)
99
+ end
100
+ Instrumentation.saga_event("saga.resumed", execution)
101
+ StepProducer.publish_step(execution)
102
+ execution
103
+ end
104
+
105
+ # Forces compensation of a running or failed execution. Bumps the epoch
106
+ # so in-flight execute messages are fenced out.
107
+ def compensate(execution, reason: "manually compensated")
108
+ ensure_not_terminal!(execution)
109
+
110
+ store.with_lock(execution) do
111
+ ensure_not_terminal!(execution)
112
+ execution.epoch += 1
113
+ Executor.begin_compensation(execution, reason: reason)
114
+ end
115
+ if execution.compensating?
116
+ Instrumentation.saga_event("saga.compensation_started", execution)
117
+ StepProducer.publish_step(execution)
118
+ else
119
+ Instrumentation.saga_event("saga.failed", execution)
120
+ end
121
+ execution
122
+ end
123
+
124
+ # Re-publishes the current step message for executions whose cursor has
125
+ # stopped moving (lost message, dead worker). Safe to over-fire: the
126
+ # executor acks anything already done.
127
+ #
128
+ # Staleness is measured from the last cursor move rather than from
129
+ # `updated_at`, because a re-publish, a lock or an attempt opened and
130
+ # abandoned all touch the row without the saga advancing — a wedged
131
+ # execution kept resetting its own clock and was never selected here.
132
+ #
133
+ # While a partition's queue is backlogged its executions may simply be
134
+ # waiting their turn: re-publishing them would only add duplicates and
135
+ # spend their resume budget until the backlog alone got them abandoned.
136
+ # So a backlogged execution is never abandoned, and is re-published only
137
+ # once, after config.backlog_patience. Oldest stalls are handled first.
138
+ #
139
+ # Returns the number of executions re-published; abandoned ones are not
140
+ # counted, they are failed.
141
+ def resume_stuck(older_than: 15 * 60, limit: 100, max_resumes: config.max_resumes)
142
+ resumed = handled = 0
143
+ backlogged = Hash.new { |memo, partition| memo[partition] = QueueDepth.call(partition) > config.backlog_threshold }
144
+
145
+ now = Time.now.utc
146
+ store.stalled_executions(before: now - older_than).each do |execution|
147
+ break if handled >= limit
148
+
149
+ if backlogged[execution.partition]
150
+ next unless execution.resume_count.zero? && execution.last_progress_at <= now - config.backlog_patience
151
+ elsif execution.resume_count >= max_resumes
152
+ handled += 1
153
+ abandon_stalled(execution, max_resumes)
154
+ next
155
+ end
156
+
157
+ handled += 1
158
+ resume(execution)
159
+ resumed += 1
160
+ end
161
+ resumed
162
+ end
163
+
164
+ private
165
+
166
+ def ensure_retryable!(execution)
167
+ return if %w[failed compensation_failed running compensating].include?(execution.status)
168
+
169
+ raise NotRetryableError, "Cannot retry execution in status #{execution.status}"
170
+ end
171
+
172
+ def ensure_not_terminal!(execution)
173
+ raise NotRetryableError, "Execution already terminal" if execution.terminal?
174
+ end
175
+
176
+ def resume(execution)
177
+ resume_count = store.increment_resume_count(execution)
178
+ Instrumentation.saga_event("saga.resumed", execution, resume_count: resume_count)
179
+ StepProducer.publish_step(execution)
180
+ end
181
+
182
+ # Gives up on an execution that has been re-published max_resumes times
183
+ # without the cursor moving: compensate what ran, otherwise fail it, so it
184
+ # lands in a terminal state something can actually alert on.
185
+ def abandon_stalled(execution, max_resumes)
186
+ reason = "no progress after #{max_resumes} resume attempts"
187
+ abandoned = false
188
+ store.with_lock(execution) do
189
+ next if execution.terminal?
190
+
191
+ Executor.begin_compensation(execution, reason: reason)
192
+ abandoned = true
193
+ end
194
+ return unless abandoned
195
+
196
+ if execution.compensating?
197
+ Instrumentation.saga_event("saga.compensation_started", execution, reason: reason)
198
+ StepProducer.publish_step(execution)
199
+ else
200
+ Instrumentation.saga_event("saga.failed", execution, reason: reason)
201
+ end
202
+ end
203
+
204
+ def eager_load_definitions!
205
+ return if @definitions_loaded
206
+
207
+ Rails.application.eager_load! if defined?(Rails.application) && Rails.application && !Rails.application.config.eager_load
208
+ @definitions_loaded = true
209
+ end
210
+ end
211
+ end
212
+ end
data/lib/lepus-saga.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lepus/saga"
data/mise.toml ADDED
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ ruby = "3.4.9"