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,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ # Runs one unit of saga work for a step message. The store is the source
6
+ # of truth: messages only point at the execution, so duplicate or stale
7
+ # deliveries are fenced out and lost publishes are healed by re-publishing
8
+ # whatever the execution record says is current.
9
+ #
10
+ # Returns :ack (message done) or :retry (transient failure — the consumer
11
+ # rejects the message into the TTL retry queue).
12
+ class Executor
13
+ class AbandonedAttempt < StandardError
14
+ def initialize = super("attempt abandoned (worker died or lost the message)")
15
+ end
16
+
17
+ class << self
18
+ # Switches an execution onto the compensation path (or straight to
19
+ # failed when there is nothing to undo). Caller must hold the row lock
20
+ # and is responsible for instrumentation and publishing afterwards.
21
+ def begin_compensation(execution, reason:)
22
+ target = compensation_target(execution)
23
+
24
+ if target
25
+ # Switching onto the compensation path is a cursor move, so the
26
+ # compensation gets its own stall window and resume budget.
27
+ execution.record_progress
28
+ store.update_execution(execution,
29
+ status: "compensating", direction: "compensate",
30
+ current_step: target.name, failure_reason: reason)
31
+ else
32
+ store.update_execution(execution,
33
+ status: "failed", failure_reason: reason, finished_at: Time.now.utc)
34
+ end
35
+ execution
36
+ end
37
+
38
+ # The nearest step before the current one that succeeded and declares
39
+ # a compensation. Skipped or never-run steps have nothing to undo.
40
+ def compensation_target(execution)
41
+ index = execution.definition_class.step_index(execution.current_step) ||
42
+ execution.steps.size
43
+ execution.steps.first(index).reverse.find do |step|
44
+ step.compensatable? && store.step_succeeded?(execution, step.name)
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def store = Lepus::Saga.store
51
+ end
52
+
53
+ def initialize(payload)
54
+ @payload = payload.to_h.transform_keys(&:to_s)
55
+ end
56
+
57
+ def run
58
+ case prepare
59
+ when :proceed then run_step
60
+ when :exhausted then announce_failure
61
+ else :ack
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ attr_reader :payload, :execution, :attempt, :failure_outcome
68
+
69
+ def message_epoch = payload.fetch("epoch", 0).to_i
70
+
71
+ def message_step = payload["step"]
72
+
73
+ def message_direction = payload.fetch("direction", "execute")
74
+
75
+ def stale_running_attempt_after = Lepus::Saga.config.stale_running_attempt_after
76
+
77
+ def store = Lepus::Saga.store
78
+
79
+ # Fences the message against the execution record and opens a step
80
+ # attempt, all while holding the execution's lock.
81
+ def prepare
82
+ store.with_locked_execution(payload["execution_id"]) do |locked|
83
+ @execution = locked
84
+ next :ack unless execution
85
+ next :ack if execution.terminal?
86
+ next :ack if message_epoch < execution.epoch
87
+
88
+ unless message_step == execution.current_step && message_direction == execution.direction
89
+ StepProducer.publish_step(execution)
90
+ next :ack
91
+ end
92
+
93
+ next :ack if attempt_in_flight?
94
+
95
+ if attempts_so_far >= retry_policy.attempts
96
+ @failure_outcome = apply_failure(execution.failure_reason || "retries exhausted")
97
+ next :exhausted
98
+ end
99
+
100
+ open_attempt
101
+ :proceed
102
+ end
103
+ rescue DuplicateAttemptError
104
+ :ack
105
+ end
106
+
107
+ def attempts_so_far
108
+ @attempts_so_far ||= begin
109
+ close_abandoned_attempts
110
+ store.count_step_attempts(execution, **execution.current_step_scope)
111
+ end
112
+ end
113
+
114
+ def attempt_in_flight?
115
+ store.running_step_attempt?(execution, **execution.current_step_scope,
116
+ started_since: Time.now.utc - stale_running_attempt_after)
117
+ end
118
+
119
+ def close_abandoned_attempts
120
+ store.stale_step_attempts(execution, **execution.current_step_scope,
121
+ started_before: Time.now.utc - stale_running_attempt_after)
122
+ .each { |stale| store.finish_step_attempt(stale, "failed", error: AbandonedAttempt.new) }
123
+ end
124
+
125
+ def open_attempt
126
+ @attempt = store.create_step_attempt(execution,
127
+ step_name: execution.current_step,
128
+ direction: execution.direction,
129
+ epoch: execution.epoch,
130
+ attempt: attempts_so_far + 1,
131
+ started_at: Time.now.utc)
132
+ Instrumentation.saga_event("saga.step.started", execution,
133
+ step: attempt.step_name, attempt: attempt.attempt)
134
+ end
135
+
136
+ # Runs the step (or compensation) method lock-free — slow HTTP calls in
137
+ # user code must not hold the execution row.
138
+ def run_step
139
+ definition = execution.definition_class.new(execution, step_attempt: attempt)
140
+ step = execution.current_step_definition
141
+ definition.send(execution.compensate_direction? ? step.compensate : step.name)
142
+ finish_step("succeeded")
143
+ rescue Control::Skip => signal
144
+ finish_step("skipped", reason: signal.reason)
145
+ rescue Control::Complete => signal
146
+ finish_execution(signal.reason)
147
+ rescue Control::Fail => signal
148
+ store.finish_step_attempt(attempt, "failed", error: signal)
149
+ fail_permanently(signal.reason)
150
+ rescue => error
151
+ store.finish_step_attempt(attempt, "failed", error: error)
152
+ handle_step_error(error)
153
+ end
154
+
155
+ def finish_step(attempt_status, reason: nil)
156
+ new_context = execution.context
157
+ completed_event = nil
158
+ finished = with_fresh_lock do
159
+ store.finish_step_attempt(attempt, attempt_status, context_after: new_context)
160
+ execution.context = new_context
161
+ completed_event = advance_cursor
162
+ execution.record_progress
163
+ store.save_execution(execution)
164
+ end
165
+ return :ack unless finished
166
+
167
+ step_event = (attempt_status == "skipped") ? "saga.step.skipped" : "saga.step.finished"
168
+ Instrumentation.saga_event(step_event, execution,
169
+ step: attempt.step_name, attempt: attempt.attempt,
170
+ duration_ms: attempt.duration_ms, reason: reason)
171
+ if completed_event
172
+ Instrumentation.saga_event(completed_event, execution)
173
+ else
174
+ StepProducer.publish_step(execution)
175
+ end
176
+ :ack
177
+ end
178
+
179
+ def finish_execution(reason)
180
+ new_context = execution.context
181
+ new_context["completed_reason"] = reason if reason
182
+ finished = with_fresh_lock do
183
+ now = Time.now.utc
184
+ store.finish_step_attempt(attempt, "succeeded", context_after: new_context)
185
+ store.update_execution(execution, context: new_context, status: "completed", finished_at: now,
186
+ last_progress_at: now, resume_count: 0)
187
+ end
188
+ return :ack unless finished
189
+
190
+ Instrumentation.saga_event("saga.step.finished", execution,
191
+ step: attempt.step_name, attempt: attempt.attempt,
192
+ duration_ms: attempt.duration_ms)
193
+ Instrumentation.saga_event("saga.completed", execution, reason: reason)
194
+ :ack
195
+ end
196
+
197
+ # Re-acquires the lock on a fresh copy of the execution after user code
198
+ # ran lock-free. Yields with the lock held; returns false when a manual
199
+ # retry/compensate bumped the epoch in the meantime (this delivery's
200
+ # outcome must not move the cursor).
201
+ def with_fresh_lock
202
+ store.with_locked_execution(execution.id) do |fresh|
203
+ next false if fresh.nil? || fresh.epoch != message_epoch || fresh.terminal?
204
+
205
+ @execution = fresh
206
+ yield
207
+ true
208
+ end
209
+ end
210
+
211
+ def handle_step_error(error)
212
+ if attempt.attempt < retry_policy.attempts
213
+ Instrumentation.saga_event("saga.step.failed", execution,
214
+ step: attempt.step_name, attempt: attempt.attempt, error: error)
215
+ return :retry
216
+ end
217
+
218
+ Instrumentation.saga_event("saga.step.failed", execution,
219
+ step: attempt.step_name, attempt: attempt.attempt,
220
+ error: error, retries_exhausted: true)
221
+ fail_permanently(error.message)
222
+ end
223
+
224
+ def fail_permanently(reason)
225
+ with_fresh_lock do
226
+ @failure_outcome = apply_failure(reason)
227
+ end
228
+ announce_failure
229
+ end
230
+
231
+ def apply_failure(reason)
232
+ if execution.compensate_direction?
233
+ store.update_execution(execution, status: "compensation_failed", failure_reason: reason,
234
+ finished_at: Time.now.utc)
235
+ :compensation_failed
236
+ else
237
+ self.class.begin_compensation(execution, reason: reason)
238
+ execution.compensating? ? :compensating : :failed
239
+ end
240
+ end
241
+
242
+ def announce_failure
243
+ case failure_outcome
244
+ when :compensation_failed
245
+ Instrumentation.saga_event("saga.compensation_failed", execution)
246
+ when :compensating
247
+ Instrumentation.saga_event("saga.compensation_started", execution)
248
+ StepProducer.publish_step(execution)
249
+ when :failed
250
+ Instrumentation.saga_event("saga.failed", execution)
251
+ end
252
+ :ack
253
+ end
254
+
255
+ def advance_cursor
256
+ if execution.compensate_direction?
257
+ target = self.class.compensation_target(execution)
258
+ return complete_as("compensated") unless target
259
+
260
+ execution.current_step = target.name
261
+ else
262
+ index = execution.definition_class.step_index(execution.current_step)
263
+ next_step = execution.steps[index + 1]
264
+ return complete_as("completed") unless next_step
265
+
266
+ execution.current_step = next_step.name
267
+ end
268
+ nil
269
+ end
270
+
271
+ def complete_as(status)
272
+ execution.status = status
273
+ execution.finished_at = Time.now.utc
274
+ "saga.#{status}"
275
+ end
276
+
277
+ def retry_policy
278
+ execution.current_step_definition.retry_policy
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ # Abstract base for the generic step-executor consumer. The host app
6
+ # declares one concrete subclass per partition:
7
+ #
8
+ # class SagaExecutorConsumer < Lepus::Saga::ExecutorConsumer
9
+ # saga_partition "default", worker: { name: "saga", threads: 1 }
10
+ # end
11
+ #
12
+ # The exchange and queue names come from Lepus::Saga.config, so configure
13
+ # it before the consumer classes load.
14
+ class ExecutorConsumer < Lepus::Consumer
15
+ self.abstract_class = true
16
+
17
+ use(:json)
18
+
19
+ def self.saga_partition(name = "default", worker: {name: "default", threads: 1}, retry_delay: 10_000)
20
+ configure(
21
+ worker: worker,
22
+ queue: Lepus::Saga.queue_name(name),
23
+ exchange: {name: Lepus::Saga.exchange_name, type: :topic},
24
+ routing_key: Lepus::Saga.routing_key(name),
25
+ retry_queue: {delay: retry_delay},
26
+ error_queue: true
27
+ )
28
+ end
29
+
30
+ def perform(message)
31
+ case Executor.new(message.payload).run
32
+ when :retry then reject!
33
+ else ack!
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ # All engine events go through Lepus.instrument, which appends the
6
+ # ".lepus" suffix — subscribe with e.g. "saga.completed.lepus" or
7
+ # /^saga\./ via ActiveSupport::Notifications.
8
+ module Instrumentation
9
+ EVENTS = %w[
10
+ saga.started
11
+ saga.step.started
12
+ saga.step.finished
13
+ saga.step.failed
14
+ saga.step.skipped
15
+ saga.completed
16
+ saga.compensation_started
17
+ saga.compensated
18
+ saga.failed
19
+ saga.compensation_failed
20
+ saga.resumed
21
+ ].freeze
22
+
23
+ def self.saga_event(name, execution, step: nil, attempt: nil, error: nil, **extra)
24
+ Lepus.instrument(
25
+ name,
26
+ execution: execution,
27
+ saga_name: execution.saga_name,
28
+ resource_type: execution.resource_type,
29
+ resource_id: execution.resource_id,
30
+ status: execution.status,
31
+ step: step,
32
+ attempt: attempt,
33
+ direction: execution.direction,
34
+ error_class: error&.class&.name,
35
+ error_message: error&.message,
36
+ **extra
37
+ )
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ # Writes one line per engine event to Lepus.logger. Lepus logs each
6
+ # delivery, but not which saga, step or resource it carried, nor a step
7
+ # failure the executor handles itself.
8
+ class LogSubscriber
9
+ LEVELS = {
10
+ "saga.step.started" => :debug,
11
+ "saga.step.finished" => :debug,
12
+ "saga.step.skipped" => :debug,
13
+ "saga.step.failed" => :warn,
14
+ "saga.failed" => :error,
15
+ "saga.compensation_failed" => :error
16
+ }.freeze
17
+
18
+ ATTRIBUTES = %i[saga_name step attempt duration_ms status reason resume_count retries_exhausted].freeze
19
+
20
+ def self.call(event, payload)
21
+ Lepus.logger.public_send(LEVELS.fetch(event, :info)) { format(event, payload) }
22
+ end
23
+
24
+ def self.format(event, payload)
25
+ attributes = {
26
+ execution: payload[:execution]&.id,
27
+ resource: "#{payload[:resource_type]}/#{payload[:resource_id]}",
28
+ **payload.slice(*ATTRIBUTES),
29
+ error: error(payload)
30
+ }.compact
31
+
32
+ "#{event} #{attributes.map { |key, value| "#{key}=#{value}" }.join(" ")}"
33
+ end
34
+
35
+ def self.error(payload)
36
+ message = [payload[:error_class], payload[:error_message]].compact.join(": ")
37
+ message unless message.empty?
38
+ end
39
+ private_class_method :error
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ # Messages ready in a partition's execute queue. Under fake publishing the
6
+ # fake saga exchange stands in for the queue.
7
+ module QueueDepth
8
+ def self.call(partition)
9
+ return fake_depth(partition) if defined?(Lepus::Testing) && Lepus::Testing.fake_publisher_enabled?
10
+
11
+ Lepus.config.producer_config.with_connection do |connection|
12
+ channel = connection.create_channel
13
+ begin
14
+ channel.queue(Saga.queue_name(partition), passive: true).message_count
15
+ rescue Bunny::NotFound
16
+ 0
17
+ ensure
18
+ channel.close if channel.open?
19
+ end
20
+ end
21
+ end
22
+
23
+ def self.fake_depth(partition)
24
+ Lepus::Testing.exchange(Saga.exchange_name).find_messages(routing_key: Saga.routing_key(partition)).size
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module Lepus
6
+ module Saga
7
+ # Recurring job that heals stalled executions. Schedule it every few
8
+ # minutes, e.g. in Solid Queue's config/recurring.yml:
9
+ #
10
+ # saga_resume_stuck:
11
+ # class: Lepus::Saga::ResumeStuckJob
12
+ # schedule: every 5 minutes
13
+ class ResumeStuckJob < ActiveJob::Base
14
+ def perform(older_than_minutes = 15)
15
+ Lepus::Saga.resume_stuck(older_than: older_than_minutes * 60)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ RetryPolicy = Data.define(:attempts) do
6
+ self::DEFAULT_ATTEMPTS = 3
7
+
8
+ def self.build(options)
9
+ case options
10
+ when RetryPolicy then options
11
+ when Hash then new(attempts: options.fetch(:attempts, self::DEFAULT_ATTEMPTS))
12
+ when Integer then new(attempts: options)
13
+ when nil then new(attempts: self::DEFAULT_ATTEMPTS)
14
+ else raise ArgumentError, "Invalid retry policy: #{options.inspect}"
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end