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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7886960ade3be84a12c25f22f85047e113bdd2ca19055b6f065624c15d1bb3af
|
|
4
|
+
data.tar.gz: 4cd2d1fd471c40db739ccb41dcef0bf52196a1a240f95c53404fced9bb264566
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c1e827eb0eb4924c0951a5dd0c6aa08233fa141c7393e275ea0d19f1fbd5d038f2a675f7747762246814ec6dfca1f3ea6949fda0cc4d16ecc85c4ceda3891138
|
|
7
|
+
data.tar.gz: 3f9759919aa086d1afda979ee040010b549b9dd353944e4f5eb03a3373c6c2b9de4beaffb7a995cebf5ad30706aa19f43a880483bf3198ca65e274fb5a808d79
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## 0.3.0 - Unreleased
|
|
8
|
+
* Added: brand mark and README logo
|
|
9
|
+
* Added: saga engine: `Lepus::Saga::Definition`, `Executor`, `ExecutorConsumer`, `StepProducer`, `QueueDepth`, `Instrumentation`, `LogSubscriber` and `Testing.drain`
|
|
10
|
+
* Added: `Lepus::Saga.configure` with `store`, `exchange_name` (default `"lepus.saga"`), `max_resumes`, `backlog_threshold`, `backlog_patience` and `stale_running_attempt_after`
|
|
11
|
+
* Added: ORM-agnostic persistence through `Lepus::Saga::Stores::Base`; `config.store = :name` requires `lepus/saga/stores/<name>`. ActiveRecord lives in the lepus-saga-active_record gem
|
|
12
|
+
* Added: `Lepus::Saga::ExecutionBehaviour` and `StepAttemptBehaviour` for adapters' record classes
|
|
13
|
+
* Added: `Lepus::Saga::Stores::Memory`, a thread-safe in-process store
|
|
14
|
+
* Added: `require "lepus/saga/rspec"` with the "a lepus saga store" and "a lepus saga engine" shared examples
|
|
15
|
+
* Added: `Lepus::Saga::ResumeStuckJob` ActiveJob for scheduling `Lepus::Saga.resume_stuck`
|
|
16
|
+
* Changed: `resume_stuck` increments `resume_count` atomically instead of writing back the value it read
|
|
17
|
+
* Added: optional query methods on `Stores::Base` (`queries?`, `list_executions`, `count_executions`, `execution_series`, `step_latency`, `failure_summary`, `saga_names`), implemented by `Stores::Memory`
|
|
18
|
+
* Added: the "a lepus saga query store" shared examples in `lepus/saga/rspec`
|
|
19
|
+
* Added: `Lepus::Saga.definitions`, the registry after eager loading the application
|
|
20
|
+
* Added: `current_step` filter on `list_executions` and `count_executions`
|
|
21
|
+
* Changed: `Stores::Memory` indexes step attempts by execution, so attempt lookups no longer scan every attempt
|
|
22
|
+
* Added: `Lepus::Saga::Web`, a mountable Rack console (overview, executions, execution, insights) over the query methods, with `authorize`, `read_only`, `title`, `default_poll_interval` and `max_rows`; `rack >= 2.2` is a dependency
|
|
23
|
+
* Added: `bin/web-demo`, the console on a seeded in-memory store
|
|
24
|
+
* Added: `progress_from`/`progress_to` (last_progress_at) and `error_class`/`error_step`/`error_from`/`error_to` (a failed attempt) filters on `list_executions` and `count_executions`, and `execution_count` on `failure_summary` rows
|
|
25
|
+
* Changed: the web console is a live map of saga lines with moving tokens, a 1 h/24 h/7 d range, full-width throughput, "Needs attention" and "Waiting the longest", and a poll picker; insights and the Barlow/Atkinson fonts are gone in favour of Overpass
|
|
26
|
+
* Added: `Web` `stalled_after` setting; `/api/sagas` counts stalled executions per step, `/api/moving` lists the moving executions of the busiest steps in one request, and `/api/executions` takes `stalled`, `error`, `error_step`, `error_from` and `error_to`
|
|
27
|
+
* Added: `HEAVY=1 bin/web-demo` seeds thousands of failures, a crowded step and hundreds of stalled executions
|
|
28
|
+
* Changed: ActiveSupport is no longer a dependency. Durations are seconds (`ActiveSupport::Duration` still works), store names resolve through `Lepus::Primitive::String` and the shared examples run without ActiveSupport
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
PATH
|
|
2
|
+
remote: .
|
|
3
|
+
specs:
|
|
4
|
+
lepus-saga (0.3.0)
|
|
5
|
+
lepus (>= 0.2.1)
|
|
6
|
+
rack (>= 2.2)
|
|
7
|
+
|
|
8
|
+
GEM
|
|
9
|
+
remote: https://rubygems.org/
|
|
10
|
+
specs:
|
|
11
|
+
activejob (8.1.4.1)
|
|
12
|
+
activesupport (= 8.1.4.1)
|
|
13
|
+
globalid (>= 0.3.6)
|
|
14
|
+
activesupport (8.1.4.1)
|
|
15
|
+
base64
|
|
16
|
+
bigdecimal
|
|
17
|
+
concurrent-ruby (~> 1.0, >= 1.3.1)
|
|
18
|
+
connection_pool (>= 2.2.5)
|
|
19
|
+
drb
|
|
20
|
+
i18n (>= 1.6, < 2)
|
|
21
|
+
json
|
|
22
|
+
logger (>= 1.4.2)
|
|
23
|
+
minitest (>= 5.1)
|
|
24
|
+
securerandom (>= 0.3)
|
|
25
|
+
tzinfo (~> 2.0, >= 2.0.5)
|
|
26
|
+
uri (>= 0.13.1)
|
|
27
|
+
amq-protocol (2.10.0)
|
|
28
|
+
ast (2.4.3)
|
|
29
|
+
base64 (0.3.0)
|
|
30
|
+
bigdecimal (4.1.3)
|
|
31
|
+
bunny (3.5.0)
|
|
32
|
+
amq-protocol (~> 2.9)
|
|
33
|
+
logger (~> 1, >= 1.7)
|
|
34
|
+
openssl (>= 3.3)
|
|
35
|
+
sorted_set (~> 1, >= 1.0.2)
|
|
36
|
+
coderay (1.1.3)
|
|
37
|
+
concurrent-ruby (1.3.8)
|
|
38
|
+
connection_pool (3.0.2)
|
|
39
|
+
diff-lcs (1.6.2)
|
|
40
|
+
drb (2.2.3)
|
|
41
|
+
globalid (1.4.0)
|
|
42
|
+
activesupport (>= 6.1)
|
|
43
|
+
i18n (1.15.2)
|
|
44
|
+
concurrent-ruby (~> 1.0)
|
|
45
|
+
io-console (0.9.5)
|
|
46
|
+
json (3.1.0)
|
|
47
|
+
language_server-protocol (3.17.0.6)
|
|
48
|
+
lepus (0.2.2)
|
|
49
|
+
base64
|
|
50
|
+
bunny
|
|
51
|
+
concurrent-ruby
|
|
52
|
+
multi_json
|
|
53
|
+
thor
|
|
54
|
+
zeitwerk
|
|
55
|
+
lint_roller (1.1.0)
|
|
56
|
+
logger (1.7.0)
|
|
57
|
+
method_source (1.1.0)
|
|
58
|
+
minitest (6.0.6)
|
|
59
|
+
drb (~> 2.0)
|
|
60
|
+
prism (~> 1.5)
|
|
61
|
+
multi_json (1.21.2)
|
|
62
|
+
nio4r (2.7.5)
|
|
63
|
+
openssl (4.0.2)
|
|
64
|
+
parallel (2.3.0)
|
|
65
|
+
parser (3.3.13.0)
|
|
66
|
+
ast (~> 2.4.1)
|
|
67
|
+
racc
|
|
68
|
+
prism (1.9.0)
|
|
69
|
+
pry (0.16.1)
|
|
70
|
+
coderay (~> 1.1)
|
|
71
|
+
method_source (~> 1.0)
|
|
72
|
+
reline (>= 0.6.0)
|
|
73
|
+
puma (8.0.2)
|
|
74
|
+
nio4r (~> 2.0)
|
|
75
|
+
racc (1.8.1)
|
|
76
|
+
rack (3.2.8)
|
|
77
|
+
rack-test (2.3.0)
|
|
78
|
+
rack (>= 1.3)
|
|
79
|
+
rackup (2.3.1)
|
|
80
|
+
rack (>= 3)
|
|
81
|
+
rainbow (3.1.1)
|
|
82
|
+
rake (13.4.2)
|
|
83
|
+
rbtree (0.4.7)
|
|
84
|
+
regexp_parser (2.13.1)
|
|
85
|
+
reline (0.7.0)
|
|
86
|
+
io-console (~> 0.5)
|
|
87
|
+
rspec (3.13.2)
|
|
88
|
+
rspec-core (~> 3.13.0)
|
|
89
|
+
rspec-expectations (~> 3.13.0)
|
|
90
|
+
rspec-mocks (~> 3.13.0)
|
|
91
|
+
rspec-core (3.13.6)
|
|
92
|
+
rspec-support (~> 3.13.0)
|
|
93
|
+
rspec-expectations (3.13.5)
|
|
94
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
|
95
|
+
rspec-support (~> 3.13.0)
|
|
96
|
+
rspec-mocks (3.13.8)
|
|
97
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
|
98
|
+
rspec-support (~> 3.13.0)
|
|
99
|
+
rspec-support (3.13.7)
|
|
100
|
+
rubocop (1.91.0)
|
|
101
|
+
json (>= 2.3)
|
|
102
|
+
language_server-protocol (~> 3.17.0.2)
|
|
103
|
+
lint_roller (~> 1.1.0)
|
|
104
|
+
parallel (>= 1.10)
|
|
105
|
+
parser (>= 3.3.0.2)
|
|
106
|
+
rainbow (>= 2.2.2, < 4.0)
|
|
107
|
+
regexp_parser (>= 2.9.3, < 3.0)
|
|
108
|
+
rubocop-ast (>= 1.49.0, < 2.0)
|
|
109
|
+
ruby-progressbar (~> 1.7)
|
|
110
|
+
unicode-display_width (>= 2.4.0, < 4.0)
|
|
111
|
+
rubocop-ast (1.50.0)
|
|
112
|
+
parser (>= 3.3.7.2)
|
|
113
|
+
prism (~> 1.7)
|
|
114
|
+
rubocop-performance (1.26.1)
|
|
115
|
+
lint_roller (~> 1.1)
|
|
116
|
+
rubocop (>= 1.75.0, < 2.0)
|
|
117
|
+
rubocop-ast (>= 1.47.1, < 2.0)
|
|
118
|
+
rubocop-rspec (3.10.2)
|
|
119
|
+
lint_roller (~> 1.1)
|
|
120
|
+
regexp_parser (>= 2.0)
|
|
121
|
+
rubocop (~> 1.86, >= 1.86.2)
|
|
122
|
+
ruby-progressbar (1.13.0)
|
|
123
|
+
securerandom (0.4.1)
|
|
124
|
+
sorted_set (1.1.0)
|
|
125
|
+
rbtree
|
|
126
|
+
standard (1.57.0)
|
|
127
|
+
language_server-protocol (~> 3.17.0.2)
|
|
128
|
+
lint_roller (~> 1.0)
|
|
129
|
+
rubocop (~> 1.91.0)
|
|
130
|
+
standard-custom (~> 1.0.0)
|
|
131
|
+
standard-performance (~> 1.8)
|
|
132
|
+
standard-custom (1.0.2)
|
|
133
|
+
lint_roller (~> 1.0)
|
|
134
|
+
rubocop (~> 1.50)
|
|
135
|
+
standard-performance (1.9.0)
|
|
136
|
+
lint_roller (~> 1.1)
|
|
137
|
+
rubocop-performance (~> 1.26.0)
|
|
138
|
+
thor (1.5.0)
|
|
139
|
+
tzinfo (2.0.6)
|
|
140
|
+
concurrent-ruby (~> 1.0)
|
|
141
|
+
unicode-display_width (3.3.0)
|
|
142
|
+
unicode-emoji (~> 4.3)
|
|
143
|
+
unicode-emoji (4.3.0)
|
|
144
|
+
uri (1.1.2)
|
|
145
|
+
zeitwerk (2.8.3)
|
|
146
|
+
|
|
147
|
+
PLATFORMS
|
|
148
|
+
aarch64-linux-gnu
|
|
149
|
+
aarch64-linux-musl
|
|
150
|
+
arm-linux-gnu
|
|
151
|
+
arm-linux-musl
|
|
152
|
+
arm64-darwin
|
|
153
|
+
x86_64-darwin
|
|
154
|
+
x86_64-linux-gnu
|
|
155
|
+
x86_64-linux-musl
|
|
156
|
+
|
|
157
|
+
DEPENDENCIES
|
|
158
|
+
activejob
|
|
159
|
+
activesupport (>= 7.1)
|
|
160
|
+
lepus-saga!
|
|
161
|
+
pry
|
|
162
|
+
puma
|
|
163
|
+
rack-test
|
|
164
|
+
rackup
|
|
165
|
+
rake
|
|
166
|
+
rspec
|
|
167
|
+
rubocop
|
|
168
|
+
rubocop-performance
|
|
169
|
+
rubocop-rspec
|
|
170
|
+
standard
|
|
171
|
+
|
|
172
|
+
CHECKSUMS
|
|
173
|
+
activejob (8.1.4.1) sha256=82c4c9311ea1016d5f7f134a0c5f9b6527cad5a6237f5f3f60aa17984ef36229
|
|
174
|
+
activesupport (8.1.4.1) sha256=16ac4f341ca65c85a1fcdb8ac73f68891aae9e3c4a6f9c3a27daed8b00017ed7
|
|
175
|
+
amq-protocol (2.10.0) sha256=98b3fe83837401cd0a7ff008faf4a454a56dc0e8c569c12db65c9225b63fd60e
|
|
176
|
+
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
|
|
177
|
+
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
|
178
|
+
bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66
|
|
179
|
+
bundler (4.0.18) sha256=02d9a17429de1847b4e0c9f27a9ee4b20c0a74c0a641b4e77195d6019e3618ac
|
|
180
|
+
bunny (3.5.0) sha256=34e3322fdc556ec2ab44e64413f0c2d412eac029207995d57fc5b26adc92cd5c
|
|
181
|
+
coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b
|
|
182
|
+
concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1
|
|
183
|
+
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
|
|
184
|
+
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
|
|
185
|
+
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
|
|
186
|
+
globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427
|
|
187
|
+
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
|
|
188
|
+
io-console (0.9.5) sha256=83dd9bbf4c4680416629245b39cd4212eb5f4d9a4ea91804870c1f6b846414d5
|
|
189
|
+
json (3.1.0) sha256=3ce7ccf8fde58960177a3a756c31f292a4c1e1dedee884547096a5b0604e3bd8
|
|
190
|
+
language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
|
|
191
|
+
lepus (0.2.2) sha256=7de6bcfb13e92b03af09d4a018c765aee6381624d8ace650705d5a87bea07fb7
|
|
192
|
+
lepus-saga (0.3.0)
|
|
193
|
+
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
|
|
194
|
+
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
|
195
|
+
method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5
|
|
196
|
+
minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
|
|
197
|
+
multi_json (1.21.2) sha256=245531eaf54bdba57aba515ff08b4503505c5e09911daa89c5a561e2f85bea8e
|
|
198
|
+
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
|
|
199
|
+
openssl (4.0.2) sha256=1037ad2868ae58df9ad917891c0c0f9815a1172f6846d4bcdd508e4c2ee747c2
|
|
200
|
+
parallel (2.3.0) sha256=f75a3e904101ce6a1ccb6b8dc800cbafd42166d83330192438dcf29395167a6f
|
|
201
|
+
parser (3.3.13.0) sha256=e02f9b63c2fe05b0c68f692ba4df89b3cb113b663964f75d988fbebfd6c3deca
|
|
202
|
+
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
|
203
|
+
pry (0.16.1) sha256=d92d82c86e7f2dc34beec6d2892d5cf5ea517eea22eed3ceabb38ee62a98accc
|
|
204
|
+
puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb
|
|
205
|
+
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
|
|
206
|
+
rack (3.2.8) sha256=1fecc85e463ebc7a2a945fff829ffcd549babeb1556384796fad946c28dc4ec7
|
|
207
|
+
rack-test (2.3.0) sha256=b118d177cc002c572fc1b6fd8d19c9ed5881a36e8d6f689f9e618f9f861241e8
|
|
208
|
+
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
|
|
209
|
+
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
|
|
210
|
+
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
|
|
211
|
+
rbtree (0.4.7) sha256=1efabbcb3fd5f12249c9c8a610a765074868164eed0c50c9db2531c00ed161cb
|
|
212
|
+
regexp_parser (2.13.1) sha256=5aedb6b7c35688f51e86eef17a15374dfd287c83f4ca644b2c5f3ec50a33b44b
|
|
213
|
+
reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
|
|
214
|
+
rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587
|
|
215
|
+
rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
|
|
216
|
+
rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
|
|
217
|
+
rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47
|
|
218
|
+
rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c
|
|
219
|
+
rubocop (1.91.0) sha256=9c82b7bf391c5d7e3798c5b9996e22a1fe3bd7468e351dfdeb96140c058296d0
|
|
220
|
+
rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db
|
|
221
|
+
rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834
|
|
222
|
+
rubocop-rspec (3.10.2) sha256=0b3e2ecc592cd10ecbf0095bb58d1e357905276e069643523cc19eb7495f65e2
|
|
223
|
+
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
|
|
224
|
+
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
|
|
225
|
+
sorted_set (1.1.0) sha256=bac01eb16b6bcfcdb7d732a28e87422be8b5c9fdcfeadbe9072682407524ee83
|
|
226
|
+
standard (1.57.0) sha256=4a65ea92421fbaf77fb54da91e16093cf79b335b8f62c35aaa3dd714c8592858
|
|
227
|
+
standard-custom (1.0.2) sha256=424adc84179a074f1a2a309bb9cf7cd6bfdb2b6541f20c6bf9436c0ba22a652b
|
|
228
|
+
standard-performance (1.9.0) sha256=49483d31be448292951d80e5e67cdcb576c2502103c7b40aec6f1b6e9c88e3f2
|
|
229
|
+
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
|
|
230
|
+
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
|
|
231
|
+
unicode-display_width (3.3.0) sha256=4b7aa66a4b11db50f6f7e98411215cd0dd10eaecb665d36eec5bcacb3fa0b613
|
|
232
|
+
unicode-emoji (4.3.0) sha256=11c02fa73290378c066bb0562cd4c87d8e1b706fbbe0059ca12746a4244de8ce
|
|
233
|
+
uri (1.1.2) sha256=89292e98eabbef6248b0a0ed5e7e398e152f83f17560023a6d94a7f492b169fb
|
|
234
|
+
zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5
|
|
235
|
+
|
|
236
|
+
BUNDLED WITH
|
|
237
|
+
4.0.18
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marcos G. Zimmermann
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
<h1 align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/lockup-lepus-saga-on-dark.svg">
|
|
4
|
+
<img alt="Lepus saga" src="docs/brand/lockup-lepus-saga-on-light.svg" width="359">
|
|
5
|
+
</picture>
|
|
6
|
+
</h1>
|
|
7
|
+
|
|
8
|
+
Linear sagas on top of [Lepus](https://github.com/marcosgz/lepus).
|
|
9
|
+
|
|
10
|
+
A saga is an ordered list of steps with optional compensations and retry policies. State lives in a store: one execution per saga run and one step attempt per try of a step. Messages on the saga exchange only point at the next unit of work, so duplicate deliveries are fenced out, and lost messages are healed by re-publishing whatever the execution record says is current.
|
|
11
|
+
|
|
12
|
+
This gem is the engine and has no ORM dependency. Storage comes from an adapter:
|
|
13
|
+
|
|
14
|
+
| Store | Gem | `config.store =` |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| ActiveRecord (PostgreSQL, MySQL/MariaDB, SQLite) | [lepus-saga-active_record](https://github.com/marcosgz/lepus-saga-active_record) | `:active_record` |
|
|
17
|
+
| Sequel (PostgreSQL, MySQL/MariaDB, SQLite) | [lepus-saga-sequel](https://github.com/marcosgz/lepus-saga-sequel) | `:sequel` |
|
|
18
|
+
| In-process memory (tests, scripts) | this gem | `:memory` |
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
lepus-saga runs on plain Ruby and does not depend on ActiveSupport. Like Lepus, it fits into Rails when ActiveSupport is there: durations such as `15.minutes` are accepted wherever seconds are, engine events reach `ActiveSupport::Notifications`, and the optional `Lepus::Saga::ResumeStuckJob` runs on ActiveJob.
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem "lepus-saga"
|
|
26
|
+
gem "lepus-saga-active_record"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bundle install
|
|
31
|
+
bin/rails generate lepus:saga:install
|
|
32
|
+
bin/rails db:migrate
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
With Sequel, add `gem "lepus-saga-sequel"` instead, create the tables with `Lepus::Saga::Sequel.migrate(DB)` and set `config.store = :sequel` (see the [lepus-saga-sequel README](https://github.com/marcosgz/lepus-saga-sequel#readme)).
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# config/initializers/lepus_saga.rb
|
|
41
|
+
Lepus::Saga.configure do |config|
|
|
42
|
+
config.store = :active_record # a name, a store class or a store instance
|
|
43
|
+
config.exchange_name = "lepus.saga" # topic exchange; queues are "<exchange>.execute[.<partition>]"
|
|
44
|
+
config.max_resumes = 5 # re-publishes of a stalled execution before it is abandoned
|
|
45
|
+
config.backlog_threshold = 100 # ready messages above which a partition counts as backlogged
|
|
46
|
+
config.backlog_patience = 2 * 3600 # seconds stalled behind a backlog before one re-publish
|
|
47
|
+
config.stale_running_attempt_after = 5 * 60 # seconds after which a running attempt is closed as abandoned
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Durations are seconds. With ActiveSupport loaded, `2.hours` and other `ActiveSupport::Duration` values work as well.
|
|
52
|
+
|
|
53
|
+
Configure it before the consumer classes load, because `saga_partition` reads the exchange and queue names when the class body runs.
|
|
54
|
+
|
|
55
|
+
A store name is resolved the way Esse resolves plugins: `:active_record` requires `lepus/saga/stores/active_record` (shipped by the adapter gem) and instantiates `Lepus::Saga::Stores::ActiveRecord`. `Lepus::Saga.store` returns the configured instance and raises `Lepus::Saga::ConfigurationError` when there is none.
|
|
56
|
+
|
|
57
|
+
## Defining a saga
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
class Orders::CheckoutSaga < Lepus::Saga::Definition
|
|
61
|
+
saga_name "orders.checkout"
|
|
62
|
+
partition "default"
|
|
63
|
+
|
|
64
|
+
step :reserve_stock, compensate: :release_stock, retry: { attempts: 5 }
|
|
65
|
+
step :persist_local
|
|
66
|
+
|
|
67
|
+
def reserve_stock
|
|
68
|
+
context["reservation_id"] = Inventory.reserve(resource)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def release_stock
|
|
72
|
+
Inventory.release(context["reservation_id"])
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def persist_local
|
|
76
|
+
skip!("already persisted") if resource.persisted_remotely?
|
|
77
|
+
resource.update!(reservation_id: context["reservation_id"])
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
Orders::CheckoutSaga.start(resource: order, context: { "name" => order.name })
|
|
82
|
+
Orders::CheckoutSaga.start(resource: { type: "Order", id: "abc" }, partition: "priority")
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Step methods run with `execution`, `context`, `resource` and `step_attempt`. Changes to `context` are saved when the step succeeds. Delivery is at-least-once, so steps must be idempotent. `resource` is `resource_type.constantize.find(resource_id)`; override `find_resource` for anything else.
|
|
86
|
+
|
|
87
|
+
Inside a step:
|
|
88
|
+
|
|
89
|
+
- `skip!(reason = nil)` records the step as skipped and continues.
|
|
90
|
+
- `complete!(reason = nil)` finishes the execution successfully right away.
|
|
91
|
+
- `fail!(reason)` fails without retrying and compensates the succeeded steps.
|
|
92
|
+
- Any other exception is retried up to the step's `retry:` attempts (default 3), then compensated.
|
|
93
|
+
|
|
94
|
+
Call `start` after the transaction that changed the resource has committed.
|
|
95
|
+
|
|
96
|
+
## Consumers
|
|
97
|
+
|
|
98
|
+
Declare one executor consumer per partition:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
class SagaExecutorConsumer < Lepus::Saga::ExecutorConsumer
|
|
102
|
+
saga_partition "default", worker: { name: "saga", threads: 5 }, retry_delay: 10_000
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Failed attempts are rejected into a TTL retry queue (`retry_delay` in milliseconds), and the error queue is enabled.
|
|
107
|
+
|
|
108
|
+
## Operations
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
Lepus::Saga.retry(execution) # restart a failed/stuck execution from its current step
|
|
112
|
+
Lepus::Saga.compensate(execution, reason: "...") # force compensation of a non-terminal execution
|
|
113
|
+
Lepus::Saga.resume_stuck(older_than: 15 * 60, limit: 100, max_resumes: Lepus::Saga.config.max_resumes)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`retry` and `compensate` lock the execution, reload the record you pass in place and return it.
|
|
117
|
+
|
|
118
|
+
`resume_stuck` re-publishes executions whose cursor has not moved for `older_than` seconds. An execution re-published `max_resumes` times without progress is compensated, or failed when there is nothing to undo. While a partition's queue holds more than `backlog_threshold` ready messages, its executions are left alone and re-published once after `backlog_patience`.
|
|
119
|
+
|
|
120
|
+
Schedule it with the bundled ActiveJob (`require "lepus/saga/resume_stuck_job"`, needs `activejob`), e.g. in Solid Queue's `config/recurring.yml`:
|
|
121
|
+
|
|
122
|
+
```yaml
|
|
123
|
+
saga_resume_stuck:
|
|
124
|
+
class: Lepus::Saga::ResumeStuckJob
|
|
125
|
+
args: [15]
|
|
126
|
+
schedule: every 5 minutes
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Without ActiveJob, call it from any scheduler:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
class SagaResumeStuckWorker
|
|
133
|
+
include Sidekiq::Job
|
|
134
|
+
|
|
135
|
+
def perform = Lepus::Saga.resume_stuck(older_than: 15 * 60)
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Reads go through the store too, so they work with any adapter:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
store = Lepus::Saga.store
|
|
143
|
+
store.find_execution(id)
|
|
144
|
+
store.executions_for_resource("Order", order.id) # newest first
|
|
145
|
+
store.step_attempts(execution) # chronological
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Adapters usually offer richer queries on their own records (`Lepus::Saga::ActiveRecord::Execution.for_resource(...).completed`).
|
|
149
|
+
|
|
150
|
+
## Web console
|
|
151
|
+
|
|
152
|
+
`Lepus::Saga::Web` is a Rack app for the engineer on call. It has three screens:
|
|
153
|
+
|
|
154
|
+
- **Map**: every saga drawn as a line of its steps. Tokens are the executions moving right now, placed along the step by how long they have spent in it against the step's median, up to three lanes per step and a "+N running" pill for the rest (static when the viewer prefers reduced motion). Each step shows its stalled executions and its failures in the selected time range (1 h, 24 h or 7 d, kept in the URL). The headline reads as the key: how many executions are moving, compensating and stalled, and how many failed. Below the map, a full-width throughput chart (with a table view), **Needs attention** (failed attempts grouped by error, saga and step, each linking to exactly the executions that failed that way in the range) and **Waiting the longest** (the stalled executions with the oldest progress).
|
|
155
|
+
- **Executions**: a paged list filtered by status, saga, step, error, time range or stalled.
|
|
156
|
+
- **Execution**: a replay of the execution along its line, its attempt history with errors and backtraces, its context and other runs of the same resource. Retry and compensate are one click plus a confirmation.
|
|
157
|
+
|
|
158
|
+
It reads through the store's optional query methods, so the store must report `queries?` (`Stores::Memory`, `Stores::ActiveRecord` and `Stores::Sequel` do).
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
require "lepus/saga/web"
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
It has no authentication of its own: mount it behind yours. In Rails, with Devise:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
# config/routes.rb
|
|
168
|
+
authenticate :user, ->(user) { user.admin? } do
|
|
169
|
+
mount Lepus::Saga::Web => "/workflows"
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
or with a route constraint:
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
constraints(->(request) { request.env["warden"].user&.admin? }) do
|
|
177
|
+
mount Lepus::Saga::Web => "/workflows"
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
In a plain `config.ru`:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
require "lepus/saga/web"
|
|
185
|
+
|
|
186
|
+
use Rack::Auth::Basic, "SAGA" do |user, password|
|
|
187
|
+
Rack::Utils.secure_compare(user, ENV.fetch("LEPUS_SAGA_USER")) & Rack::Utils.secure_compare(password, ENV.fetch("LEPUS_SAGA_PASSWORD"))
|
|
188
|
+
end
|
|
189
|
+
run Lepus::Saga::Web
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The console works under any mount path: the page's `<base href>` is the mount point and every URL in it is relative.
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
Lepus::Saga::Web.configure do |config|
|
|
196
|
+
config.authorize = ->(env, action) { action == :read || env["warden"].user&.admin? } # :read, :retry or :compensate
|
|
197
|
+
config.read_only = false # true hides retry and compensate and refuses them
|
|
198
|
+
config.title = "SAGA"
|
|
199
|
+
config.default_poll_interval = 5 # seconds: 0, 2, 5, 15 or 60
|
|
200
|
+
config.max_rows = 50_000 # rows read for the throughput series and step latency
|
|
201
|
+
config.stalled_after = 15 * 60 # seconds without progress before an in-progress execution counts as stalled
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`authorize` runs on every request: a falsy answer for `:read` returns 403 for the whole console, and for `:retry` or `:compensate` it refuses that action and the page hides its button. Retry and compensate are POSTs that need a JSON body, an `X-Lepus-Saga` header and a same-origin `Origin` (or `Referer`), so a cookie-authenticated session cannot be driven from another site.
|
|
206
|
+
|
|
207
|
+
The page refreshes itself by polling the JSON API. The Live button in the top bar picks the interval: Paused, 2, 5, 15 or 60 seconds (`default_poll_interval` until the viewer picks one). Polling also pauses while the tab is hidden, and the map's tokens freeze while paused. The theme follows the system, or Light or Dark when picked; the interval, the theme and the time range are kept in the browser.
|
|
208
|
+
|
|
209
|
+
Content Security Policy: the console loads only its own files (no inline scripts or styles, no `eval`, no CDN) and sends `default-src 'self'` with its page. A host-wide policy works as long as it allows `'self'` for scripts, styles, fonts and fetches, and `data:` images. Static files are served with ETags; the page references them under a digest path, cached for a year.
|
|
210
|
+
|
|
211
|
+
To see the console without a database or RabbitMQ, run `bin/web-demo` from a checkout: it seeds an in-memory store with a week of executions generated by the real engine, keeps starting new ones, and serves the console on http://127.0.0.1:9292 (`PORT`, `HOST` and `DAYS` change it). `HEAVY=1` adds the load of a bad day: thousands of failed attempts, hundreds of executions crowding one step and hundreds of stalled ones. It is not part of the gem.
|
|
212
|
+
|
|
213
|
+
The font is Overpass, under the SIL Open Font License (`web/assets/fonts/overpass/OFL.txt`).
|
|
214
|
+
|
|
215
|
+
## Instrumentation and logging
|
|
216
|
+
|
|
217
|
+
Events go through `Lepus.instrument`, which publishes them to `ActiveSupport::Notifications` when it is loaded and does nothing otherwise. They are suffixed with `.lepus`: `saga.started`, `saga.step.started`, `saga.step.finished`, `saga.step.failed`, `saga.step.skipped`, `saga.completed`, `saga.compensation_started`, `saga.compensated`, `saga.failed`, `saga.compensation_failed`, `saga.resumed`. The payload carries the execution record under `:execution`.
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
ActiveSupport::Notifications.subscribe(/\Asaga\./) do |name, _started, _finished, _id, payload|
|
|
221
|
+
Lepus::Saga::LogSubscriber.call(name.delete_suffix(".lepus"), payload)
|
|
222
|
+
end
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`LogSubscriber` writes one `key=value` line per event to `Lepus.logger`, at debug for step progress, warn for step failures and error for failed sagas.
|
|
226
|
+
|
|
227
|
+
## Testing
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
require "lepus/saga/testing"
|
|
231
|
+
|
|
232
|
+
Lepus::Testing.enable!
|
|
233
|
+
Orders::CheckoutSaga.start(resource: order)
|
|
234
|
+
Lepus::Saga::Testing.drain(consumer: SagaExecutorConsumer)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
`drain` delivers the fake saga exchange's messages to the consumer until it is empty, redelivering rejected messages right away so retries run inline. It raises `Lepus::Saga::Testing::DrainStallError` after `max_iterations` (default 50).
|
|
238
|
+
|
|
239
|
+
Tests that should not touch a database can run on the memory store: `Lepus::Saga.config.store = :memory`, and `Lepus::Saga.store.clear!` between examples.
|
|
240
|
+
|
|
241
|
+
## Architecture
|
|
242
|
+
|
|
243
|
+
```
|
|
244
|
+
Definition.start ──> store.create_execution ──> StepProducer ──> saga exchange
|
|
245
|
+
│
|
|
246
|
+
ExecutorConsumer <─────────────────────────────────────────────────────┘
|
|
247
|
+
│
|
|
248
|
+
└─> Executor
|
|
249
|
+
prepare: store.with_locked_execution (fence epoch/step/direction, close
|
|
250
|
+
abandoned attempts, count them, store.create_step_attempt)
|
|
251
|
+
run step: no lock held (user code may be slow)
|
|
252
|
+
finish: store.with_locked_execution (finish the attempt, move the cursor)
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
- `Definition`, `Executor`, `ExecutorConsumer`, `StepProducer`, `QueueDepth`, `Instrumentation`, `LogSubscriber` and `Testing` only talk to `Lepus::Saga.store`.
|
|
256
|
+
- `Lepus::Saga::Stores::Base` is the persistence contract. Every method is documented in [`lib/lepus/saga/stores/base.rb`](lib/lepus/saga/stores/base.rb).
|
|
257
|
+
- `Lepus::Saga::ExecutionBehaviour` and `Lepus::Saga::StepAttemptBehaviour` hold what records mean: statuses and predicates (`running?`, `terminal?`, `compensate_direction?`), `record_progress`, `definition_class`, `partition`, `current_step_scope`, `duration` and `finish_attributes`. Adapters include them into their own record classes.
|
|
258
|
+
|
|
259
|
+
### The store contract
|
|
260
|
+
|
|
261
|
+
| Method | Semantics |
|
|
262
|
+
| --- | --- |
|
|
263
|
+
| `create_execution(attributes)` | Inserts an execution with a UUIDv7 id; defaults status `running`, direction `execute`, epoch and resume_count 0, context `{}` |
|
|
264
|
+
| `find_execution(id)` | The record or nil, never locked |
|
|
265
|
+
| `save_execution(execution)` | Persists the record's attributes, returns it |
|
|
266
|
+
| `with_lock(execution) { }` | Exclusive row lock for the block after reloading the record in place; writes kept on return (including `next`), discarded on raise; returns the block value |
|
|
267
|
+
| `with_locked_execution(id) { \|execution_or_nil\| }` | Same, from an id; Base implements it with `find_execution` + `with_lock`, adapters may do it in one query |
|
|
268
|
+
| `increment_resume_count(execution)` | One atomic `resume_count = resume_count + 1` that touches updated_at; sets and returns the stored value |
|
|
269
|
+
| `stalled_executions(before:)` | Running or compensating executions with `last_progress_at < before`, oldest first, as an Enumerable the caller may stop early |
|
|
270
|
+
| `executions_for_resource(type, id)` | A resource's executions, newest first |
|
|
271
|
+
| `create_step_attempt(execution, attributes)` | Inserts a `running` attempt; a second one with the same execution, step, direction, epoch and attempt raises `Lepus::Saga::DuplicateAttemptError` |
|
|
272
|
+
| `save_step_attempt(attempt)` | Persists the attempt, returns it |
|
|
273
|
+
| `step_attempts(execution)` | Attempts ordered by started_at, created_at |
|
|
274
|
+
| `count_step_attempts(execution, step_name:, direction:, epoch:)` | Attempts for that step, any status |
|
|
275
|
+
| `running_step_attempt?(execution, step_name:, direction:, epoch:, started_since:)` | A `running` attempt started at or after the cutoff exists |
|
|
276
|
+
| `stale_step_attempts(execution, step_name:, direction:, epoch:, started_before:)` | `running` attempts started before the cutoff |
|
|
277
|
+
| `step_succeeded?(execution, step_name)` | A `succeeded` attempt in the execute direction exists, any epoch |
|
|
278
|
+
|
|
279
|
+
Base builds `update_execution`, `update_step_attempt` and `finish_step_attempt` on top of these.
|
|
280
|
+
|
|
281
|
+
The query methods are optional. The engine never calls them; read-only tools such as dashboards do, after checking `queries?`. Base raises `NotImplementedError` from each and returns false from `queries?`.
|
|
282
|
+
|
|
283
|
+
| Method | Semantics |
|
|
284
|
+
| --- | --- |
|
|
285
|
+
| `queries?` | Whether the store implements the methods below |
|
|
286
|
+
| `list_executions(saga_name:, status:, current_step:, partition:, resource:, from:, to:, progress_from:, progress_to:, error_class:, error_step:, error_from:, error_to:, before_id:, limit: 50)` | Executions newest first by id, `limit` of them, the page after `before_id` when given. Every filter is optional: `saga_name`, `status` and `current_step` take a value or a list, `partition` matches the column or (when it is nil) the definition's partition, `resource` is a case-insensitive substring of resource_type or resource_id, `from`/`to` bound created_at and `progress_from`/`progress_to` bound last_progress_at (inclusive/exclusive), `error_class` and `error_step` (a value or a list) keep executions with a failed attempt of that error class at that step, started between `error_from` and `error_to` when given |
|
|
287
|
+
| `count_executions(group_by:, **filters)` | A Hash of counts keyed by `:saga_name`, `:status`, `:partition` or `:current_step`, or by arrays of values for an array such as `[:saga_name, :status]`; partition groups resolve nil to the definition's partition |
|
|
288
|
+
| `execution_series(from:, to:, bucket:, saga_name: nil, max_rows:)` | `{rows: [[bucket_start, {"started" => n, "completed" => n, "compensated" => n, "failed" => n, "compensation_failed" => n}], ...], truncated:}`: starts by created_at, terminal statuses by finished_at, every bucket of the window aligned to multiples of `bucket` seconds, at most `max_rows` timestamps of each kind (the newest) |
|
|
289
|
+
| `step_latency(from:, to:, saga_name: nil, max_rows:)` | `{rows: [{saga_name:, step_name:, direction:, count:, p50:, p95:, p99:, max:}, ...], truncated:}`: nearest-rank percentiles in milliseconds of succeeded and failed attempts started in the window, from the newest `max_rows` |
|
|
290
|
+
| `failure_summary(from:, to:, saga_name: nil, limit: 20)` | `[{error_class:, saga_name:, step_name:, count:, execution_count:, last_seen_at:, sample_message:, sample_execution_id:}, ...]`: failed attempts started in the window grouped by error class, saga and step, most frequent first, `execution_count` being the distinct executions and the sample the latest failure |
|
|
291
|
+
| `saga_names` | The distinct saga names in storage, sorted |
|
|
292
|
+
|
|
293
|
+
`max_rows` defaults to `Stores::Base::QUERY_MAX_ROWS` (50,000), and a series may hold at most 1,000 buckets. Stores compute buckets and percentiles in Ruby with the helpers Base provides, so they need nothing beyond filters, grouping and counting from their backend.
|
|
294
|
+
|
|
295
|
+
The engine changes an execution only inside `with_lock`/`with_locked_execution`. The two writes made without the lock are the atomic `increment_resume_count` and closing an attempt the worker opened itself. A store therefore needs a real exclusive lock (`SELECT ... FOR UPDATE` or an equivalent), atomic increments and a unique index on attempts. It does not need optimistic locking.
|
|
296
|
+
|
|
297
|
+
### Writing an adapter
|
|
298
|
+
|
|
299
|
+
1. Create a gem named `lepus-saga-<name>` with `lib/lepus/saga/stores/<name>.rb` defining `Lepus::Saga::Stores::<Name> < Lepus::Saga::Stores::Base`, so `config.store = :<name>` finds it.
|
|
300
|
+
2. Give it record classes that include `Lepus::Saga::ExecutionBehaviour` and `Lepus::Saga::StepAttemptBehaviour`. They need readers and writers for each behaviour's `ATTRIBUTES`, and `[](name)` returning the raw stored value. The behaviour's `partition` (the column, or the definition's partition when it is nil) must win over the column reader. ActiveRecord and Sequel define column readers in a module below the class, so including the behaviour in the class body is enough; check that your ORM does the same.
|
|
301
|
+
3. Implement every method of `Stores::Base` that raises `NotImplementedError`.
|
|
302
|
+
4. Run the shared examples:
|
|
303
|
+
|
|
304
|
+
```ruby
|
|
305
|
+
# spec/lepus/saga/stores/my_store_spec.rb
|
|
306
|
+
require "lepus/saga/rspec"
|
|
307
|
+
|
|
308
|
+
RSpec.describe Lepus::Saga::Stores::MyStore do
|
|
309
|
+
let(:store) { described_class.new }
|
|
310
|
+
|
|
311
|
+
after { DB[:saga_step_attempts].delete && DB[:saga_executions].delete }
|
|
312
|
+
|
|
313
|
+
it_behaves_like "a lepus saga store" # every store method, including locks across threads
|
|
314
|
+
it_behaves_like "a lepus saga engine" # the executor, retry, compensate and resume_stuck end to end
|
|
315
|
+
end
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Both accept `concurrency: false` to skip the threaded examples on a backend that cannot share data between threads.
|
|
319
|
+
|
|
320
|
+
5. Optionally implement the query methods, return true from `queries?` and add `it_behaves_like "a lepus saga query store"`. An adapter without them keeps working with the engine; tools that need them check `queries?` first.
|
|
321
|
+
|
|
322
|
+
[lepus-saga-active_record](https://github.com/marcosgz/lepus-saga-active_record) and [lepus-saga-sequel](https://github.com/marcosgz/lepus-saga-sequel) are complete adapters to start from: models, store, migrations and the shared examples run on PostgreSQL, MySQL/MariaDB and SQLite.
|
|
323
|
+
|
|
324
|
+
## Development
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
bin/setup
|
|
328
|
+
bundle exec rspec # memory store
|
|
329
|
+
BUNDLE_GEMFILE=ci/Gemfile.no_activesupport bundle install
|
|
330
|
+
BUNDLE_GEMFILE=ci/Gemfile.no_activesupport bundle exec rspec # without ActiveSupport or ActiveJob
|
|
331
|
+
bundle exec rubocop
|
|
332
|
+
bin/web-demo # the web console on a seeded memory store
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
## License
|
|
336
|
+
|
|
337
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|