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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +28 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +237 -0
- data/LICENSE.txt +21 -0
- data/README.md +337 -0
- data/Rakefile +8 -0
- data/docs/brand/BRAND.md +67 -0
- data/docs/brand/apple-touch-icon.png +0 -0
- data/docs/brand/favicon-16.png +0 -0
- data/docs/brand/favicon-32.png +0 -0
- data/docs/brand/favicon.ico +0 -0
- data/docs/brand/favicon.svg +1 -0
- data/docs/brand/icon-512.png +0 -0
- data/docs/brand/lockup-lepus-mono.svg +1 -0
- data/docs/brand/lockup-lepus-on-dark.svg +1 -0
- data/docs/brand/lockup-lepus-on-light.svg +1 -0
- data/docs/brand/lockup-lepus-saga-mono.svg +1 -0
- data/docs/brand/lockup-lepus-saga-on-dark.svg +1 -0
- data/docs/brand/lockup-lepus-saga-on-light.svg +1 -0
- data/docs/brand/lockup-lepus-saga.svg +1 -0
- data/docs/brand/lockup-lepus.svg +1 -0
- data/docs/brand/mark-mono.svg +1 -0
- data/docs/brand/mark-on-dark.svg +1 -0
- data/docs/brand/mark-on-light.svg +1 -0
- data/docs/brand/mark-small-mono.svg +1 -0
- data/docs/brand/mark-small-on-dark.svg +1 -0
- data/docs/brand/mark-small-on-light.svg +1 -0
- data/docs/brand/mark-small.svg +1 -0
- data/docs/brand/mark.svg +1 -0
- data/lib/lepus/saga/configuration.rb +48 -0
- data/lib/lepus/saga/control.rb +27 -0
- data/lib/lepus/saga/definition.rb +123 -0
- data/lib/lepus/saga/execution_behaviour.rb +58 -0
- data/lib/lepus/saga/executor.rb +282 -0
- data/lib/lepus/saga/executor_consumer.rb +38 -0
- data/lib/lepus/saga/instrumentation.rb +41 -0
- data/lib/lepus/saga/log_subscriber.rb +42 -0
- data/lib/lepus/saga/queue_depth.rb +28 -0
- data/lib/lepus/saga/resume_stuck_job.rb +19 -0
- data/lib/lepus/saga/retry_policy.rb +19 -0
- data/lib/lepus/saga/rspec/engine_examples.rb +635 -0
- data/lib/lepus/saga/rspec/helpers.rb +22 -0
- data/lib/lepus/saga/rspec/query_examples.rb +378 -0
- data/lib/lepus/saga/rspec/store_examples.rb +514 -0
- data/lib/lepus/saga/rspec.rb +6 -0
- data/lib/lepus/saga/step.rb +9 -0
- data/lib/lepus/saga/step_attempt_behaviour.rb +35 -0
- data/lib/lepus/saga/step_producer.rb +37 -0
- data/lib/lepus/saga/stores/base.rb +305 -0
- data/lib/lepus/saga/stores/memory.rb +375 -0
- data/lib/lepus/saga/stores.rb +40 -0
- data/lib/lepus/saga/testing.rb +35 -0
- data/lib/lepus/saga/version.rb +7 -0
- data/lib/lepus/saga/web/api.rb +326 -0
- data/lib/lepus/saga/web/configuration.rb +49 -0
- data/lib/lepus/saga/web/guard.rb +38 -0
- data/lib/lepus/saga/web/serializer.rb +59 -0
- data/lib/lepus/saga/web/static.rb +108 -0
- data/lib/lepus/saga/web.rb +61 -0
- data/lib/lepus/saga.rb +212 -0
- data/lib/lepus-saga.rb +3 -0
- data/mise.toml +2 -0
- data/web/assets/app.css +913 -0
- data/web/assets/apple-touch-icon.png +0 -0
- data/web/assets/favicon-32.png +0 -0
- data/web/assets/favicon.svg +1 -0
- data/web/assets/fonts/overpass/OFL.txt +93 -0
- data/web/assets/fonts/overpass/overpass-latin-wght.woff2 +0 -0
- data/web/assets/js/api.js +50 -0
- data/web/assets/js/dom.js +84 -0
- data/web/assets/js/format.js +79 -0
- data/web/assets/js/live.js +74 -0
- data/web/assets/js/main.js +143 -0
- data/web/assets/js/map.js +639 -0
- data/web/assets/js/range.js +19 -0
- data/web/assets/js/sagas.js +18 -0
- data/web/assets/js/theme.js +8 -0
- data/web/assets/js/throughput.js +262 -0
- data/web/assets/js/views/execution.js +426 -0
- data/web/assets/js/views/executions.js +165 -0
- data/web/assets/js/views/overview.js +465 -0
- data/web/index.html +63 -0
- 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
|