lepus-saga 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +28 -0
  3. data/Gemfile +5 -0
  4. data/Gemfile.lock +237 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +337 -0
  7. data/Rakefile +8 -0
  8. data/docs/brand/BRAND.md +67 -0
  9. data/docs/brand/apple-touch-icon.png +0 -0
  10. data/docs/brand/favicon-16.png +0 -0
  11. data/docs/brand/favicon-32.png +0 -0
  12. data/docs/brand/favicon.ico +0 -0
  13. data/docs/brand/favicon.svg +1 -0
  14. data/docs/brand/icon-512.png +0 -0
  15. data/docs/brand/lockup-lepus-mono.svg +1 -0
  16. data/docs/brand/lockup-lepus-on-dark.svg +1 -0
  17. data/docs/brand/lockup-lepus-on-light.svg +1 -0
  18. data/docs/brand/lockup-lepus-saga-mono.svg +1 -0
  19. data/docs/brand/lockup-lepus-saga-on-dark.svg +1 -0
  20. data/docs/brand/lockup-lepus-saga-on-light.svg +1 -0
  21. data/docs/brand/lockup-lepus-saga.svg +1 -0
  22. data/docs/brand/lockup-lepus.svg +1 -0
  23. data/docs/brand/mark-mono.svg +1 -0
  24. data/docs/brand/mark-on-dark.svg +1 -0
  25. data/docs/brand/mark-on-light.svg +1 -0
  26. data/docs/brand/mark-small-mono.svg +1 -0
  27. data/docs/brand/mark-small-on-dark.svg +1 -0
  28. data/docs/brand/mark-small-on-light.svg +1 -0
  29. data/docs/brand/mark-small.svg +1 -0
  30. data/docs/brand/mark.svg +1 -0
  31. data/lib/lepus/saga/configuration.rb +48 -0
  32. data/lib/lepus/saga/control.rb +27 -0
  33. data/lib/lepus/saga/definition.rb +123 -0
  34. data/lib/lepus/saga/execution_behaviour.rb +58 -0
  35. data/lib/lepus/saga/executor.rb +282 -0
  36. data/lib/lepus/saga/executor_consumer.rb +38 -0
  37. data/lib/lepus/saga/instrumentation.rb +41 -0
  38. data/lib/lepus/saga/log_subscriber.rb +42 -0
  39. data/lib/lepus/saga/queue_depth.rb +28 -0
  40. data/lib/lepus/saga/resume_stuck_job.rb +19 -0
  41. data/lib/lepus/saga/retry_policy.rb +19 -0
  42. data/lib/lepus/saga/rspec/engine_examples.rb +635 -0
  43. data/lib/lepus/saga/rspec/helpers.rb +22 -0
  44. data/lib/lepus/saga/rspec/query_examples.rb +378 -0
  45. data/lib/lepus/saga/rspec/store_examples.rb +514 -0
  46. data/lib/lepus/saga/rspec.rb +6 -0
  47. data/lib/lepus/saga/step.rb +9 -0
  48. data/lib/lepus/saga/step_attempt_behaviour.rb +35 -0
  49. data/lib/lepus/saga/step_producer.rb +37 -0
  50. data/lib/lepus/saga/stores/base.rb +305 -0
  51. data/lib/lepus/saga/stores/memory.rb +375 -0
  52. data/lib/lepus/saga/stores.rb +40 -0
  53. data/lib/lepus/saga/testing.rb +35 -0
  54. data/lib/lepus/saga/version.rb +7 -0
  55. data/lib/lepus/saga/web/api.rb +326 -0
  56. data/lib/lepus/saga/web/configuration.rb +49 -0
  57. data/lib/lepus/saga/web/guard.rb +38 -0
  58. data/lib/lepus/saga/web/serializer.rb +59 -0
  59. data/lib/lepus/saga/web/static.rb +108 -0
  60. data/lib/lepus/saga/web.rb +61 -0
  61. data/lib/lepus/saga.rb +212 -0
  62. data/lib/lepus-saga.rb +3 -0
  63. data/mise.toml +2 -0
  64. data/web/assets/app.css +913 -0
  65. data/web/assets/apple-touch-icon.png +0 -0
  66. data/web/assets/favicon-32.png +0 -0
  67. data/web/assets/favicon.svg +1 -0
  68. data/web/assets/fonts/overpass/OFL.txt +93 -0
  69. data/web/assets/fonts/overpass/overpass-latin-wght.woff2 +0 -0
  70. data/web/assets/js/api.js +50 -0
  71. data/web/assets/js/dom.js +84 -0
  72. data/web/assets/js/format.js +79 -0
  73. data/web/assets/js/live.js +74 -0
  74. data/web/assets/js/main.js +143 -0
  75. data/web/assets/js/map.js +639 -0
  76. data/web/assets/js/range.js +19 -0
  77. data/web/assets/js/sagas.js +18 -0
  78. data/web/assets/js/theme.js +8 -0
  79. data/web/assets/js/throughput.js +262 -0
  80. data/web/assets/js/views/execution.js +426 -0
  81. data/web/assets/js/views/executions.js +165 -0
  82. data/web/assets/js/views/overview.js +465 -0
  83. data/web/index.html +63 -0
  84. metadata +323 -0
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
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).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec