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,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
data/mise.toml
ADDED