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,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lepus
|
|
4
|
+
module Saga
|
|
5
|
+
# Persistence adapters. Each one subclasses Stores::Base and lives at
|
|
6
|
+
# "lepus/saga/stores/<name>", which is how `config.store = :<name>` finds
|
|
7
|
+
# it, whether it ships with this gem (Memory) or in its own gem
|
|
8
|
+
# (lepus-saga-active_record ships Stores::ActiveRecord).
|
|
9
|
+
module Stores
|
|
10
|
+
autoload :Base, "lepus/saga/stores/base"
|
|
11
|
+
autoload :Memory, "lepus/saga/stores/memory"
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def build(value)
|
|
15
|
+
case value
|
|
16
|
+
when nil then nil
|
|
17
|
+
when Symbol, String then lookup(value).new
|
|
18
|
+
when Class then value.new
|
|
19
|
+
else value
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def lookup(name)
|
|
24
|
+
path = Lepus::Primitive::String.new(name.to_s).underscore
|
|
25
|
+
const_name = camelize(path)
|
|
26
|
+
require "lepus/saga/stores/#{path}" unless const_defined?(const_name, false)
|
|
27
|
+
const_get(const_name, false)
|
|
28
|
+
rescue LoadError, NameError => error
|
|
29
|
+
raise ConfigurationError, "Unknown saga store #{name.inspect} (#{error.message})"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def camelize(path)
|
|
35
|
+
path.split("/").map { |part| part.split("_").map(&:capitalize).join }.join("::")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lepus/testing"
|
|
4
|
+
|
|
5
|
+
module Lepus
|
|
6
|
+
module Saga
|
|
7
|
+
# Test-only helper to run saga step messages inline. Requires
|
|
8
|
+
# Lepus::Testing fake publishing to be enabled.
|
|
9
|
+
module Testing
|
|
10
|
+
class DrainStallError < StandardError; end
|
|
11
|
+
|
|
12
|
+
# Pops messages from the fake saga exchange and delivers them to the
|
|
13
|
+
# executor consumer until the exchange is empty. Messages rejected for
|
|
14
|
+
# retry are redelivered immediately (the TTL delay is irrelevant under
|
|
15
|
+
# fake publishing), so retry loops run to their conclusion inline.
|
|
16
|
+
def self.drain(consumer:, max_iterations: 50)
|
|
17
|
+
exchange = Lepus::Testing.exchange(Lepus::Saga.exchange_name)
|
|
18
|
+
iterations = 0
|
|
19
|
+
|
|
20
|
+
until exchange.empty?
|
|
21
|
+
if (iterations += 1) > max_iterations
|
|
22
|
+
raise DrainStallError, "saga drain did not settle after #{max_iterations} iterations"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
batch = exchange.messages.dup
|
|
26
|
+
exchange.clear_messages
|
|
27
|
+
batch.each do |message|
|
|
28
|
+
result = Lepus::Testing.consumer_perform(consumer, {payload: message[:payload]})
|
|
29
|
+
exchange.add_message(message) if result == :reject
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Lepus
|
|
6
|
+
module Saga
|
|
7
|
+
module Web
|
|
8
|
+
# The JSON API under /api. Reads need the store's query methods; the
|
|
9
|
+
# two actions are POSTs guarded against cross-site requests: they need a
|
|
10
|
+
# JSON body, the X-Lepus-Saga header and a same-origin Origin (or
|
|
11
|
+
# Referer when the browser sends no Origin).
|
|
12
|
+
class Api
|
|
13
|
+
class Error < StandardError
|
|
14
|
+
attr_reader :status
|
|
15
|
+
|
|
16
|
+
def initialize(status, message)
|
|
17
|
+
@status = status
|
|
18
|
+
super(message)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
ROUTES = [
|
|
23
|
+
["GET", %r{\A/me\z}, :me],
|
|
24
|
+
["GET", %r{\A/sagas\z}, :sagas],
|
|
25
|
+
["GET", %r{\A/executions\z}, :executions],
|
|
26
|
+
["GET", %r{\A/executions/(?<id>[^/]+)\z}, :execution],
|
|
27
|
+
["GET", %r{\A/moving\z}, :moving],
|
|
28
|
+
["GET", %r{\A/series\z}, :series],
|
|
29
|
+
["GET", %r{\A/insights/failures\z}, :failures],
|
|
30
|
+
["GET", %r{\A/insights/latency\z}, :latency],
|
|
31
|
+
["GET", %r{\A/stalled\z}, :stalled],
|
|
32
|
+
["POST", %r{\A/executions/(?<id>[^/]+)/retry\z}, :retry_execution],
|
|
33
|
+
["POST", %r{\A/executions/(?<id>[^/]+)/compensate\z}, :compensate_execution]
|
|
34
|
+
].freeze
|
|
35
|
+
ACTIONS = {retry_execution: :retry, compensate_execution: :compensate}.freeze
|
|
36
|
+
CSRF_HEADER = "HTTP_X_LEPUS_SAGA"
|
|
37
|
+
DEFAULT_WINDOW = 24 * 3600
|
|
38
|
+
SERIES_BUCKETS = [60, 300, 600, 900, 1800, 3600, 7200, 10_800, 21_600, 43_200, 86_400].freeze
|
|
39
|
+
SERIES_TARGET = 90
|
|
40
|
+
IN_PROGRESS = ExecutionBehaviour::IN_PROGRESS_STATUSES
|
|
41
|
+
STOPPED_STATUSES = %w[failed compensation_failed].freeze
|
|
42
|
+
RELATED_LIMIT = 20
|
|
43
|
+
|
|
44
|
+
def initialize(config)
|
|
45
|
+
@config = config
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def call(env)
|
|
49
|
+
request = Rack::Request.new(env)
|
|
50
|
+
method, _pattern, action, match = route(request)
|
|
51
|
+
raise Error.new(404, "Not found") unless action
|
|
52
|
+
raise Error.new(405, "Method not allowed") unless request.request_method == method
|
|
53
|
+
|
|
54
|
+
authorize_action!(request, ACTIONS[action]) if ACTIONS.key?(action)
|
|
55
|
+
json(200, send(action, request, **match.named_captures.transform_keys(&:to_sym)))
|
|
56
|
+
rescue Error => error
|
|
57
|
+
json(error.status, error: error.message)
|
|
58
|
+
rescue NotRetryableError, UnknownSagaError => error
|
|
59
|
+
json(409, error: error.message)
|
|
60
|
+
rescue NotImplementedError
|
|
61
|
+
json(501, error: "The saga store does not implement the query methods")
|
|
62
|
+
rescue ArgumentError => error
|
|
63
|
+
json(400, error: error.message)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
attr_reader :config
|
|
69
|
+
|
|
70
|
+
def store = config.store
|
|
71
|
+
|
|
72
|
+
def route(request)
|
|
73
|
+
path = request.path_info.empty? ? "/" : request.path_info
|
|
74
|
+
candidates = ROUTES.filter_map { |method, pattern, action| [method, pattern, action, pattern.match(path)] if pattern.match?(path) }
|
|
75
|
+
candidates.find { |method, *| method == request.request_method } || candidates.first
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def me(request)
|
|
79
|
+
{
|
|
80
|
+
title: config.title,
|
|
81
|
+
version: VERSION,
|
|
82
|
+
read_only: config.read_only ? true : false,
|
|
83
|
+
queries: store.queries?,
|
|
84
|
+
poll_interval: config.default_poll_interval,
|
|
85
|
+
poll_intervals: Configuration::POLL_INTERVALS,
|
|
86
|
+
stalled_after: config.stalled_after,
|
|
87
|
+
actions: ACTIONS.values.to_h { |action| [action, config.allowed?(request.env, action)] }
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def sagas(request)
|
|
92
|
+
from, to = window(request)
|
|
93
|
+
live = store.count_executions(group_by: %i[saga_name status current_step], status: IN_PROGRESS)
|
|
94
|
+
stalled = store.count_executions(group_by: %i[saga_name status current_step], status: IN_PROGRESS, progress_to: stalled_before)
|
|
95
|
+
stopped = store.count_executions(group_by: %i[saga_name status current_step], status: STOPPED_STATUSES, from: from, to: to)
|
|
96
|
+
totals = store.count_executions(group_by: %i[saga_name status], from: from, to: to)
|
|
97
|
+
failures = store.failure_summary(from: from, to: to, limit: 500)
|
|
98
|
+
definitions = Lepus::Saga.definitions
|
|
99
|
+
names = (definitions.keys + store.saga_names + totals.keys.map(&:first)).uniq.sort
|
|
100
|
+
|
|
101
|
+
sagas = names.map do |name|
|
|
102
|
+
Serializer.definition(name, definitions[name]).merge(
|
|
103
|
+
stations: stations(name, live.merge(stopped), stalled, failures),
|
|
104
|
+
totals: totals.filter_map { |(saga, status), count| [status, count] if saga == name }.to_h
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
{from: Serializer.time(from), to: Serializer.time(to), stalled_after: config.stalled_after, sagas: sagas}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def stations(name, counts, stalled, failures)
|
|
111
|
+
stations = Hash.new { |hash, step| hash[step] = {attempt_failures: 0, stalled: {}} }
|
|
112
|
+
counts.each do |(saga, status, step), count|
|
|
113
|
+
stations[step][status] = count if saga == name
|
|
114
|
+
end
|
|
115
|
+
stalled.each do |(saga, status, step), count|
|
|
116
|
+
stations[step][:stalled][status] = count if saga == name
|
|
117
|
+
end
|
|
118
|
+
failures.each do |failure|
|
|
119
|
+
stations[failure[:step_name]][:attempt_failures] += failure[:count] if failure[:saga_name] == name
|
|
120
|
+
end
|
|
121
|
+
stations
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def executions(request)
|
|
125
|
+
limit = integer(request, "limit", default: 50, range: 1..200)
|
|
126
|
+
rows = store.list_executions(**filters(request), before_id: param(request, "before"), limit: limit + 1)
|
|
127
|
+
{
|
|
128
|
+
executions: rows.first(limit).map { |execution| Serializer.execution(execution) },
|
|
129
|
+
next_before: (rows[limit - 1].id if rows.size > limit)
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Executions in progress and not stalled at the busiest steps, so a
|
|
134
|
+
# live view can draw every busy step from one request.
|
|
135
|
+
def moving(request)
|
|
136
|
+
per_station = integer(request, "per_station", default: 9, range: 1..50)
|
|
137
|
+
limit = integer(request, "stations", default: 40, range: 1..100)
|
|
138
|
+
since = stalled_before
|
|
139
|
+
counts = store.count_executions(group_by: %i[saga_name current_step], status: IN_PROGRESS, progress_from: since, saga_name: list(request, "saga"))
|
|
140
|
+
.reject { |(_saga, step), _count| step.nil? }
|
|
141
|
+
busiest = counts.sort_by { |(saga, step), count| [-count, saga, step] }.first(limit)
|
|
142
|
+
stations = busiest.map do |(saga, step), count|
|
|
143
|
+
rows = store.list_executions(saga_name: saga, current_step: step, status: IN_PROGRESS, progress_from: since, limit: per_station)
|
|
144
|
+
{saga_name: saga, step_name: step, count: count, executions: rows.map { |execution| Serializer.execution(execution) }}
|
|
145
|
+
end
|
|
146
|
+
{stalled_after: config.stalled_after, stations: stations, omitted: counts.size - busiest.size}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def execution(_request, id:)
|
|
150
|
+
execution = find!(id)
|
|
151
|
+
definition = Lepus::Saga.definitions[execution.saga_name]
|
|
152
|
+
related = store.executions_for_resource(execution.resource_type, execution.resource_id)
|
|
153
|
+
.reject { |other| other.id == execution.id }.first(RELATED_LIMIT)
|
|
154
|
+
{
|
|
155
|
+
execution: Serializer.execution(execution, context: true),
|
|
156
|
+
definition: Serializer.definition(execution.saga_name, definition),
|
|
157
|
+
attempts: store.step_attempts(execution).map { |attempt| Serializer.attempt(attempt) },
|
|
158
|
+
related: related.map { |other| Serializer.execution(other) }
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def series(request)
|
|
163
|
+
from, to = window(request)
|
|
164
|
+
bucket = integer(request, "bucket", default: series_bucket(to - from), range: 1..)
|
|
165
|
+
result = store.execution_series(from: from, to: to, bucket: bucket, saga_name: list(request, "saga"), max_rows: config.max_rows)
|
|
166
|
+
{
|
|
167
|
+
from: Serializer.time(from), to: Serializer.time(to), bucket: bucket, truncated: result[:truncated],
|
|
168
|
+
rows: result[:rows].map { |start, counts| {t: Serializer.time(start), **counts} }
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def failures(request)
|
|
173
|
+
from, to = window(request)
|
|
174
|
+
limit = integer(request, "limit", default: 20, range: 1..200)
|
|
175
|
+
rows = store.failure_summary(from: from, to: to, saga_name: list(request, "saga"), limit: limit)
|
|
176
|
+
{from: Serializer.time(from), to: Serializer.time(to), failures: rows.map { |row| row.merge(last_seen_at: Serializer.time(row[:last_seen_at])) }}
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def latency(request)
|
|
180
|
+
from, to = window(request)
|
|
181
|
+
result = store.step_latency(from: from, to: to, saga_name: list(request, "saga"), max_rows: config.max_rows)
|
|
182
|
+
rows = result[:rows].map { |row| row.merge(row.slice(:p50, :p95, :p99, :max).transform_values { |ms| ms&.round(1) }) }
|
|
183
|
+
{from: Serializer.time(from), to: Serializer.time(to), rows: rows, truncated: result[:truncated]}
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def stalled(request)
|
|
187
|
+
minutes = integer(request, "minutes", default: nil, range: 1..)
|
|
188
|
+
seconds = minutes ? minutes * 60 : config.stalled_after
|
|
189
|
+
limit = integer(request, "limit", default: 50, range: 1..200)
|
|
190
|
+
rows = store.stalled_executions(before: Time.now.utc - seconds).first(limit)
|
|
191
|
+
{stalled_after: seconds, executions: rows.map { |execution| Serializer.execution(execution) }}
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def retry_execution(_request, id:)
|
|
195
|
+
{execution: Serializer.execution(Lepus::Saga.retry(find_actionable!(id)))}
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def compensate_execution(request, id:)
|
|
199
|
+
reason = presence(body(request)["reason"].to_s.strip) || "manually compensated"
|
|
200
|
+
{execution: Serializer.execution(Lepus::Saga.compensate(find_actionable!(id), reason: reason))}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def authorize_action!(request, action)
|
|
204
|
+
raise Error.new(403, "The console is read-only") if config.read_only
|
|
205
|
+
raise Error.new(403, "Forbidden") unless config.allowed?(request.env, action)
|
|
206
|
+
raise Error.new(403, "Cross-site request rejected") unless same_origin?(request)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def same_origin?(request)
|
|
210
|
+
return false unless request.media_type == "application/json"
|
|
211
|
+
return false if request.get_header(CSRF_HEADER).to_s.empty?
|
|
212
|
+
|
|
213
|
+
origin = request.get_header("HTTP_ORIGIN") || referer_origin(request.referer)
|
|
214
|
+
!origin.nil? && origin == request.base_url
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def referer_origin(referer)
|
|
218
|
+
return if referer.nil?
|
|
219
|
+
|
|
220
|
+
uri = URI.parse(referer)
|
|
221
|
+
port = (uri.port == uri.default_port) ? "" : ":#{uri.port}"
|
|
222
|
+
"#{uri.scheme}://#{uri.host}#{port}" if uri.host
|
|
223
|
+
rescue URI::InvalidURIError
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def find!(id)
|
|
228
|
+
store.find_execution(id) || raise(Error.new(404, "Execution #{id} not found"))
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def find_actionable!(id)
|
|
232
|
+
execution = find!(id)
|
|
233
|
+
return execution if Lepus::Saga.definitions.key?(execution.saga_name)
|
|
234
|
+
|
|
235
|
+
raise Error.new(409, "Saga #{execution.saga_name} is not loaded in this process")
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def body(request)
|
|
239
|
+
content = request.body&.read.to_s
|
|
240
|
+
return {} if content.empty?
|
|
241
|
+
|
|
242
|
+
data = JSON.parse(content)
|
|
243
|
+
data.is_a?(Hash) ? data : {}
|
|
244
|
+
rescue JSON::ParserError
|
|
245
|
+
raise Error.new(400, "Invalid JSON body")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def filters(request)
|
|
249
|
+
filters = {
|
|
250
|
+
saga_name: list(request, "saga"),
|
|
251
|
+
status: list(request, "status"),
|
|
252
|
+
current_step: list(request, "step"),
|
|
253
|
+
partition: param(request, "partition"),
|
|
254
|
+
resource: param(request, "resource"),
|
|
255
|
+
from: time(request, "from"),
|
|
256
|
+
to: time(request, "to"),
|
|
257
|
+
error_class: list(request, "error"),
|
|
258
|
+
error_step: list(request, "error_step"),
|
|
259
|
+
error_from: time(request, "error_from"),
|
|
260
|
+
error_to: time(request, "error_to")
|
|
261
|
+
}
|
|
262
|
+
flag?(request, "stalled") ? stalled_filters(filters) : filters
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def stalled_filters(filters)
|
|
266
|
+
statuses = filters[:status] ? filters[:status] & IN_PROGRESS : IN_PROGRESS
|
|
267
|
+
raise ArgumentError, "Stalled executions are #{IN_PROGRESS.join(" or ")}" if statuses.empty?
|
|
268
|
+
|
|
269
|
+
filters.merge(status: statuses, progress_to: stalled_before)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def stalled_before = Time.now.utc - config.stalled_after
|
|
273
|
+
|
|
274
|
+
def window(request)
|
|
275
|
+
to = time(request, "to") || Time.now.utc
|
|
276
|
+
from = time(request, "from") || (to - DEFAULT_WINDOW)
|
|
277
|
+
raise ArgumentError, "from must be before to" unless from < to
|
|
278
|
+
|
|
279
|
+
[from, to]
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def series_bucket(span)
|
|
283
|
+
SERIES_BUCKETS.find { |seconds| span / seconds <= SERIES_TARGET } || SERIES_BUCKETS.last
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def param(request, name)
|
|
287
|
+
value = request.GET[name]
|
|
288
|
+
presence(value.strip) if value.is_a?(String)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def flag?(request, name)
|
|
292
|
+
%w[1 true].include?(param(request, name))
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def list(request, name)
|
|
296
|
+
presence(param(request, name)&.split(",")&.map(&:strip)&.reject(&:empty?))
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def time(request, name)
|
|
300
|
+
value = param(request, name)
|
|
301
|
+
value && Time.iso8601(value)
|
|
302
|
+
rescue ArgumentError
|
|
303
|
+
raise ArgumentError, "#{name} must be an ISO 8601 time"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def integer(request, name, default:, range:)
|
|
307
|
+
value = param(request, name)
|
|
308
|
+
return default if value.nil?
|
|
309
|
+
|
|
310
|
+
number = Integer(value, exception: false)
|
|
311
|
+
raise ArgumentError, "#{name} must be an integer in #{range}" unless number && range.cover?(number)
|
|
312
|
+
|
|
313
|
+
number
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def presence(value)
|
|
317
|
+
value unless value.nil? || value.empty?
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def json(status, data)
|
|
321
|
+
[status, {"content-type" => "application/json; charset=utf-8", "cache-control" => "no-store"}, [JSON.generate(data)]]
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lepus
|
|
4
|
+
module Saga
|
|
5
|
+
module Web
|
|
6
|
+
class Configuration
|
|
7
|
+
ACTIONS = %i[read retry compensate].freeze
|
|
8
|
+
POLL_INTERVALS = [0, 2, 5, 15, 60].freeze
|
|
9
|
+
|
|
10
|
+
# Called with the Rack env and one of ACTIONS; a falsy result answers
|
|
11
|
+
# 403. Allows everything by default.
|
|
12
|
+
attr_accessor :authorize
|
|
13
|
+
|
|
14
|
+
# Hides the retry and compensate actions and refuses their requests.
|
|
15
|
+
attr_accessor :read_only
|
|
16
|
+
|
|
17
|
+
attr_accessor :title
|
|
18
|
+
|
|
19
|
+
# Seconds between refreshes until the viewer picks another interval
|
|
20
|
+
# (one of POLL_INTERVALS; 0 turns polling off).
|
|
21
|
+
attr_accessor :default_poll_interval
|
|
22
|
+
|
|
23
|
+
# Cap on the rows read for the throughput series and step latency.
|
|
24
|
+
attr_accessor :max_rows
|
|
25
|
+
|
|
26
|
+
# Seconds without progress after which an in-progress execution
|
|
27
|
+
# counts as stalled.
|
|
28
|
+
attr_accessor :stalled_after
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@authorize = ->(_env, _action) { true }
|
|
32
|
+
@read_only = false
|
|
33
|
+
@title = "Sagas"
|
|
34
|
+
@default_poll_interval = 5
|
|
35
|
+
@max_rows = Stores::Base::QUERY_MAX_ROWS
|
|
36
|
+
@stalled_after = 15 * 60
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def allowed?(env, action)
|
|
40
|
+
return false if read_only && action != :read
|
|
41
|
+
|
|
42
|
+
authorize.call(env, action) ? true : false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def store = Lepus::Saga.store
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lepus
|
|
4
|
+
module Saga
|
|
5
|
+
module Web
|
|
6
|
+
# Refuses every request the `authorize` hook does not allow to read, and
|
|
7
|
+
# adds the headers every response carries.
|
|
8
|
+
class Guard
|
|
9
|
+
HEADERS = {
|
|
10
|
+
"x-content-type-options" => "nosniff",
|
|
11
|
+
"referrer-policy" => "same-origin",
|
|
12
|
+
"x-frame-options" => "SAMEORIGIN"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(app, config)
|
|
16
|
+
@app = app
|
|
17
|
+
@config = config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(env)
|
|
21
|
+
status, headers, body = @config.allowed?(env, :read) ? @app.call(env) : forbidden(env)
|
|
22
|
+
HEADERS.each { |name, value| headers[name] ||= value }
|
|
23
|
+
[status, headers, body]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def forbidden(env)
|
|
29
|
+
if env["PATH_INFO"].to_s.start_with?("/api/")
|
|
30
|
+
[403, {"content-type" => "application/json"}, [JSON.generate(error: "Forbidden")]]
|
|
31
|
+
else
|
|
32
|
+
[403, {"content-type" => "text/plain; charset=utf-8"}, ["Forbidden"]]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lepus
|
|
4
|
+
module Saga
|
|
5
|
+
module Web
|
|
6
|
+
module Serializer
|
|
7
|
+
EXECUTION_FIELDS = %i[
|
|
8
|
+
id saga_name resource_type resource_id status current_step direction
|
|
9
|
+
epoch correlation_id failure_reason resume_count
|
|
10
|
+
].freeze
|
|
11
|
+
EXECUTION_TIMES = %i[started_at finished_at last_progress_at created_at updated_at].freeze
|
|
12
|
+
ATTEMPT_FIELDS = %i[
|
|
13
|
+
id step_name direction epoch attempt status error_class error_message
|
|
14
|
+
error_backtrace duration_ms
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def execution(record, context: false)
|
|
20
|
+
data = EXECUTION_FIELDS.to_h { |name| [name, record.public_send(name)] }
|
|
21
|
+
EXECUTION_TIMES.each { |name| data[name] = time(record.public_send(name)) }
|
|
22
|
+
data[:partition] = partition(record)
|
|
23
|
+
data[:context] = record.context if context
|
|
24
|
+
data
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def attempt(record)
|
|
28
|
+
data = ATTEMPT_FIELDS.to_h { |name| [name, record.public_send(name)] }
|
|
29
|
+
data[:duration_ms] = data[:duration_ms]&.to_f&.round(1)
|
|
30
|
+
data[:context_after] = record.context_after
|
|
31
|
+
data[:started_at] = time(record.started_at)
|
|
32
|
+
data[:finished_at] = time(record.finished_at)
|
|
33
|
+
data
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def definition(name, definition)
|
|
37
|
+
{
|
|
38
|
+
name: name,
|
|
39
|
+
known: !definition.nil?,
|
|
40
|
+
partition: definition&.partition,
|
|
41
|
+
steps: Array(definition&.steps).map do |step|
|
|
42
|
+
{name: step.name, compensate: step.compensate, attempts: step.retry_policy.attempts}
|
|
43
|
+
end
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def time(value)
|
|
48
|
+
value&.utc&.iso8601(3)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def partition(record)
|
|
52
|
+
record.partition
|
|
53
|
+
rescue UnknownSagaError
|
|
54
|
+
record[:partition]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Lepus
|
|
6
|
+
module Saga
|
|
7
|
+
module Web
|
|
8
|
+
# Serves the console page and its assets from Web::ROOT. The page gets a
|
|
9
|
+
# <base href> of the mount path, so every relative URL works wherever
|
|
10
|
+
# the console is mounted, and points at "assets-<digest>/", a path that
|
|
11
|
+
# changes with the asset files and is therefore cached for a year.
|
|
12
|
+
# Plain "assets/" paths are served too, revalidated through their ETag.
|
|
13
|
+
class Static
|
|
14
|
+
CONTENT_SECURITY_POLICY = [
|
|
15
|
+
"default-src 'self'", "script-src 'self'", "style-src 'self'", "img-src 'self' data:",
|
|
16
|
+
"font-src 'self'", "connect-src 'self'", "object-src 'none'", "base-uri 'self'",
|
|
17
|
+
"form-action 'self'", "frame-ancestors 'self'"
|
|
18
|
+
].join("; ").freeze
|
|
19
|
+
IMMUTABLE = "public, max-age=31536000, immutable"
|
|
20
|
+
REVALIDATE = "no-cache"
|
|
21
|
+
INDEX_PATHS = ["", "/", "/index.html"].freeze
|
|
22
|
+
|
|
23
|
+
def initialize(config, root: ROOT)
|
|
24
|
+
@config = config
|
|
25
|
+
@root = root
|
|
26
|
+
@etags = {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(env)
|
|
30
|
+
return not_allowed unless %w[GET HEAD].include?(env["REQUEST_METHOD"])
|
|
31
|
+
|
|
32
|
+
path = env["PATH_INFO"].to_s
|
|
33
|
+
return index(env) if INDEX_PATHS.include?(path)
|
|
34
|
+
|
|
35
|
+
case path
|
|
36
|
+
when %r{\A/assets-(?<digest>\h+)/(?<file>.+)\z}
|
|
37
|
+
asset(env, $~[:file], ($~[:digest] == assets_digest) ? IMMUTABLE : REVALIDATE)
|
|
38
|
+
when %r{\A/assets/(?<file>.+)\z}
|
|
39
|
+
asset(env, $~[:file], REVALIDATE)
|
|
40
|
+
else
|
|
41
|
+
not_found
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
attr_reader :config, :root
|
|
48
|
+
|
|
49
|
+
def index(env)
|
|
50
|
+
base = "#{env["SCRIPT_NAME"].to_s.chomp("/")}/"
|
|
51
|
+
html = File.read(File.join(root, "index.html"))
|
|
52
|
+
.gsub("{{base}}", Rack::Utils.escape_html(base))
|
|
53
|
+
.gsub("{{assets}}", "assets-#{assets_digest}")
|
|
54
|
+
.gsub("{{title}}", Rack::Utils.escape_html(config.title))
|
|
55
|
+
headers = {
|
|
56
|
+
"content-type" => "text/html; charset=utf-8",
|
|
57
|
+
"cache-control" => REVALIDATE,
|
|
58
|
+
"content-security-policy" => CONTENT_SECURITY_POLICY
|
|
59
|
+
}
|
|
60
|
+
respond(env, html, %("#{Digest::SHA256.hexdigest(html)[0, 32]}"), headers)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def asset(env, file, cache_control)
|
|
64
|
+
path = resolve(file)
|
|
65
|
+
return not_found unless path
|
|
66
|
+
|
|
67
|
+
headers = {"content-type" => Rack::Mime.mime_type(File.extname(path), "application/octet-stream"), "cache-control" => cache_control}
|
|
68
|
+
respond(env, File.binread(path), etag(path), headers)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def respond(env, content, etag, headers)
|
|
72
|
+
headers = headers.merge("etag" => etag)
|
|
73
|
+
return [304, headers, []] if env["HTTP_IF_NONE_MATCH"].to_s.split(/\s*,\s*/).include?(etag)
|
|
74
|
+
|
|
75
|
+
body = (env["REQUEST_METHOD"] == "HEAD") ? [] : [content]
|
|
76
|
+
[200, headers.merge("content-length" => content.bytesize.to_s), body]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def resolve(file)
|
|
80
|
+
return if file.include?("\0")
|
|
81
|
+
|
|
82
|
+
assets = File.join(root, "assets")
|
|
83
|
+
path = File.expand_path(file, assets)
|
|
84
|
+
path if path.start_with?("#{assets}/") && File.file?(path)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def etag(path)
|
|
88
|
+
stat = File.stat(path)
|
|
89
|
+
@etags[[path, stat.mtime.to_r, stat.size]] ||= %("#{Digest::SHA256.file(path).hexdigest[0, 32]}")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def assets_digest
|
|
93
|
+
assets = File.join(root, "assets")
|
|
94
|
+
files = Dir.glob(File.join(assets, "**", "*")).select { |path| File.file?(path) }.sort
|
|
95
|
+
Digest::SHA256.hexdigest(files.map { |path| "#{path.delete_prefix(assets)}:#{etag(path)}" }.join("\n"))[0, 16]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def not_found
|
|
99
|
+
[404, {"content-type" => "text/plain; charset=utf-8"}, ["Not found"]]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def not_allowed
|
|
103
|
+
[405, {"content-type" => "text/plain; charset=utf-8", "allow" => "GET, HEAD"}, ["Method not allowed"]]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|