odsl 0.1.1 → 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 +4 -4
- data/CHANGELOG.md +54 -2
- data/CONTRIBUTING.md +40 -0
- data/README.md +319 -66
- data/docs/superpowers/plans/2026-10-09-background-adapters-audit-locking.md +1808 -0
- data/docs/superpowers/specs/2026-10-09-background-adapters-audit-design.md +267 -0
- data/lib/generators/odsl/install_generator.rb +23 -0
- data/lib/generators/odsl/templates/create_odsl_tables.rb.erb +29 -0
- data/lib/generators/odsl/templates/odsl_initializer.rb.erb +13 -0
- data/lib/odsl/adapters/active_job.rb +21 -0
- data/lib/odsl/adapters/base.rb +11 -0
- data/lib/odsl/adapters/odsl_job.rb +12 -0
- data/lib/odsl/adapters/thread.rb +17 -0
- data/lib/odsl/audit/execution_context.rb +34 -0
- data/lib/odsl/audit/serializer.rb +35 -0
- data/lib/odsl/audit/step_record.rb +27 -0
- data/lib/odsl/configuration.rb +36 -0
- data/lib/odsl/dsl.rb +67 -17
- data/lib/odsl/errors.rb +8 -0
- data/lib/odsl/locking/lock_handle.rb +9 -0
- data/lib/odsl/methods.rb +37 -8
- data/lib/odsl/railtie.rb +15 -0
- data/lib/odsl/runners.rb +117 -6
- data/lib/odsl/version.rb +1 -1
- data/lib/odsl.rb +12 -2
- data/odsl.gemspec +9 -4
- metadata +78 -5
- data/lib/odsl/error.rb +0 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a5247ca88b029a8621f55ca52a4cfc376c8c4f170059a2e62edd714d2ced6a1d
|
|
4
|
+
data.tar.gz: a68b985769287f222931e2c2ca290e875275cefddfce53070b8c71ba148a88b0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a9c50af3890a595878927066ef6bbceb44e20428b29e94b21624c0b57e1bd829ad31b5f3e5376f72b862fd20d3d4af27699341771113ef20207b10f9f9316ab5
|
|
7
|
+
data.tar.gz: 9d0d31d06e99681966c96099d970ae5d53181af59fe6606118b8645365e2e0146b578d14aa4d29110b05b412fbadd1f96cae201c9338579c8f84ca1d68ef9ad6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
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/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
1
8
|
## [Unreleased]
|
|
2
9
|
|
|
3
|
-
## [0.
|
|
10
|
+
## [0.3.0] - 2026-10-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Pluggable background adapters: `:thread` (default) and `:active_job`
|
|
14
|
+
- `run_in_background(:method)` and `run_in_parallel(*methods)` on service instances
|
|
15
|
+
- `Odsl::Configuration` with `background_adapter`, `audit_ttl`, `lock_redis_url`, `lock_ttl`
|
|
16
|
+
- `Odsl.configure {}` and `Odsl.config` with thread-safe access
|
|
17
|
+
- Step-level audit system: `Odsl::Audit::ExecutionContext` and `Odsl::Audit::StepRecord`
|
|
18
|
+
- Idempotent runs via `run(:method, key: "...")` — no-op if already completed
|
|
19
|
+
- `resume(key:)` class method — restarts a failed run from the first incomplete step
|
|
20
|
+
- TTL support for audit records via `audit_ttl` config option
|
|
21
|
+
- `Odsl.cleanup_expired!` and `rake odsl:cleanup` for pruning expired records
|
|
22
|
+
- Distributed locking via `lock(*vars)` DSL method (backed by [Redlock](https://github.com/leandromoreira/redlock-rb))
|
|
23
|
+
- `Odsl::LockError`, `Odsl::AlreadyRunningError`, `Odsl::ContextNotFoundError`
|
|
24
|
+
- Rails generator: `rails generate odsl:install` (migration + initializer)
|
|
25
|
+
- `Odsl::Railtie` for automatic Rake task registration in Rails apps
|
|
26
|
+
- `Odsl::Audit::Serializer` for JSON-safe step result persistence
|
|
27
|
+
- GitHub Actions CI across Ruby 3.1, 3.2, 3.3
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- Thread-safe configuration access via `CONFIG_MUTEX`
|
|
31
|
+
- `lib/odsl/error.rb` replaced with `lib/odsl/errors.rb` (syntax fix, plural naming)
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
- Minimum Ruby version remains `>= 3.1.0`
|
|
35
|
+
- Version bumped to `0.3.0`
|
|
36
|
+
|
|
37
|
+
## [0.2.0] - 2024-01-01
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
- `inmutable` mode
|
|
41
|
+
- `run_in_background` / `run_in_parallel`
|
|
42
|
+
- `subscribe` / `dispatch` event bus
|
|
43
|
+
- `finish_with` / `value_return` aliases
|
|
44
|
+
- `set` / `link` aliases for `make` / `call`
|
|
45
|
+
|
|
46
|
+
## [0.1.0] - 2023-01-01
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
- Initial DSL: `build`, `with`, `make`, `call`, `get`, `use`, `instantiate`, `finish_with`
|
|
50
|
+
- `Odsl::Dsl` base class
|
|
51
|
+
- `inmutable` class-level flag
|
|
52
|
+
- Minitest test suite
|
|
4
53
|
|
|
5
|
-
|
|
54
|
+
[Unreleased]: https://github.com/aleguro/ODSL/compare/v0.3.0...HEAD
|
|
55
|
+
[0.3.0]: https://github.com/aleguro/ODSL/compare/v0.2.0...v0.3.0
|
|
56
|
+
[0.2.0]: https://github.com/aleguro/ODSL/compare/v0.1.0...v0.2.0
|
|
57
|
+
[0.1.0]: https://github.com/aleguro/ODSL/releases/tag/v0.1.0
|
data/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Contributing to Odsl
|
|
2
|
+
|
|
3
|
+
Thanks for taking the time to contribute!
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
git clone https://github.com/aleguro/ODSL.git
|
|
9
|
+
cd ODSL
|
|
10
|
+
bundle install
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Running Tests
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle exec rake test
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The test suite uses an in-memory SQLite database — no external services required.
|
|
20
|
+
|
|
21
|
+
## Adding a Feature
|
|
22
|
+
|
|
23
|
+
1. Fork the repo and create a branch: `git checkout -b feat/your-feature`
|
|
24
|
+
2. Write a failing test first
|
|
25
|
+
3. Implement the feature
|
|
26
|
+
4. Run `bundle exec rake test` — all tests must pass
|
|
27
|
+
5. Update `README.md` if the public API changed
|
|
28
|
+
6. Add an entry to `CHANGELOG.md` under `[Unreleased]`
|
|
29
|
+
7. Open a PR against `master`
|
|
30
|
+
|
|
31
|
+
## Code Style
|
|
32
|
+
|
|
33
|
+
- No unnecessary comments — let well-named identifiers speak
|
|
34
|
+
- No added error handling for scenarios that can't happen
|
|
35
|
+
- Prefer editing existing files over creating new ones
|
|
36
|
+
- Keep files focused; one clear responsibility per file
|
|
37
|
+
|
|
38
|
+
## Reporting a Bug
|
|
39
|
+
|
|
40
|
+
Use the [bug report template](.github/ISSUE_TEMPLATE/bug_report.md) and include a minimal reproducible example.
|
data/README.md
CHANGED
|
@@ -1,109 +1,362 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Odsl
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://badge.fury.io/rb/odsl)
|
|
4
|
+
[](https://github.com/aleguro/ODSL/actions/workflows/ci.yml)
|
|
5
|
+
[](https://www.ruby-lang.org)
|
|
6
|
+
[](LICENSE.txt)
|
|
7
|
+
[](https://github.com/aleguro/ODSL)
|
|
8
|
+
[](https://guides.rubygems.org/mfa-requirement-opt-in/)
|
|
4
9
|
|
|
5
|
-
|
|
6
|
-
- Inmutability
|
|
7
|
-
- Dry off
|
|
8
|
-
- Decouple aspects from code ( background, paralelization )
|
|
10
|
+
> **O**bject **D**SL — a fluent, chainable framework for writing service objects in Ruby that are easy to read, test, and extend.
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
Odsl lets you express complex business workflows as a sequence of named steps — composing objects, calling methods, and storing intermediate results — without the sprawling `def call` anti-pattern. Every service is immutable by default, background-capable, audit-ready, and optionally protected by distributed locking.
|
|
11
13
|
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Why Odsl?
|
|
17
|
+
|
|
18
|
+
Most Ruby service objects end up as a `call` method with a hundred lines of code, private helpers tangled with side effects, and zero insight into which step failed when something goes wrong.
|
|
19
|
+
|
|
20
|
+
Odsl solves this by making each step **named**, **recorded**, and **resumable**:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
def create_order
|
|
24
|
+
build(Inventory, Pricing) # compose a scoped object
|
|
25
|
+
.get(:catalog) # store it as @catalog
|
|
26
|
+
|
|
27
|
+
with(:catalog, :items, :user)
|
|
28
|
+
.make(:calculate_total) # call your private method
|
|
29
|
+
.get(:order_total) # store the result
|
|
30
|
+
|
|
31
|
+
with(:user, :order_total)
|
|
32
|
+
.instantiate(Order) # build an AR model
|
|
33
|
+
.get(:order)
|
|
34
|
+
|
|
35
|
+
with(:order)
|
|
36
|
+
.make(:charge_customer)
|
|
37
|
+
.get(:payment)
|
|
38
|
+
|
|
39
|
+
finish_with(:order, :payment) # declare what to return
|
|
40
|
+
end
|
|
12
41
|
```
|
|
13
|
-
class ScoreBoard
|
|
14
42
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
43
|
+
Each `.get(:var)` is a named checkpoint — recorded to the audit log, replayable on resume, and crystal clear in stack traces.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
18
48
|
|
|
19
|
-
|
|
20
|
-
|
|
49
|
+
```ruby
|
|
50
|
+
# Gemfile
|
|
51
|
+
gem "odsl"
|
|
52
|
+
|
|
53
|
+
# Optional: background jobs
|
|
54
|
+
gem "activejob"
|
|
55
|
+
|
|
56
|
+
# Optional: distributed locking
|
|
57
|
+
gem "redlock"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
For audit tables (Rails):
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
rails generate odsl:install
|
|
64
|
+
rails db:migrate
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Quick Start
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
# Define your modules (plain Ruby — no inheritance required)
|
|
73
|
+
module Player
|
|
74
|
+
attr_reader :player
|
|
75
|
+
|
|
76
|
+
def with_player(player)
|
|
77
|
+
@player = Struct.new(:name).new(player)
|
|
78
|
+
self
|
|
21
79
|
end
|
|
22
80
|
end
|
|
23
81
|
|
|
24
82
|
module Game
|
|
83
|
+
attr_reader :game
|
|
84
|
+
|
|
25
85
|
def start(game_type)
|
|
26
|
-
@game = Struct.new(
|
|
86
|
+
@game = Struct.new(:game_type, :player).new(game_type, @player.name)
|
|
27
87
|
self
|
|
28
88
|
end
|
|
29
89
|
end
|
|
30
90
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
91
|
+
# Define your service
|
|
92
|
+
class GameService < Odsl::Dsl
|
|
93
|
+
include GameCrud
|
|
94
|
+
inmutable true
|
|
95
|
+
|
|
96
|
+
subscribe 'start_game', to: :create
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
module GameCrud
|
|
100
|
+
def create
|
|
101
|
+
build(Game, Player)
|
|
102
|
+
.get(:game_object)
|
|
103
|
+
|
|
104
|
+
with(:player, :game, :game_object)
|
|
105
|
+
.make(:new_game)
|
|
106
|
+
.get(:game_instance)
|
|
107
|
+
|
|
108
|
+
with(:game_instance)
|
|
109
|
+
.instantiate(ScoreBoard)
|
|
110
|
+
.get(:score_board)
|
|
111
|
+
|
|
112
|
+
finish_with(:game_instance, :score_board)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def new_game(player, game, game_object)
|
|
118
|
+
game_object
|
|
119
|
+
.with_player(player[:name])
|
|
120
|
+
.start(game)
|
|
35
121
|
end
|
|
36
122
|
end
|
|
37
123
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
.get(:game_object)
|
|
124
|
+
# Run it
|
|
125
|
+
game, board = GameService.new(player: { name: "alejandro" }, game: "chess").run(:create)
|
|
126
|
+
game.game.game_type # => "chess"
|
|
127
|
+
game.game.player # => "alejandro"
|
|
43
128
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
129
|
+
# Or via event dispatch
|
|
130
|
+
game, board = GameService.dispatch('start_game', player: { name: "alejandro" }, game: "chess")
|
|
131
|
+
```
|
|
47
132
|
|
|
48
|
-
|
|
49
|
-
.instanciate(ScoreBoard)
|
|
50
|
-
.get(:score_board_object)
|
|
133
|
+
---
|
|
51
134
|
|
|
52
|
-
|
|
53
|
-
.call(:publish)
|
|
135
|
+
## DSL Reference
|
|
54
136
|
|
|
55
|
-
|
|
56
|
-
end
|
|
137
|
+
### Chaining Methods
|
|
57
138
|
|
|
58
|
-
|
|
59
|
-
|
|
139
|
+
| Method | Alias(es) | Description |
|
|
140
|
+
|--------|-----------|-------------|
|
|
141
|
+
| `build(*modules)` | — | Composes a new object by mixing modules into an anonymous class |
|
|
142
|
+
| `with(*attrs)` | — | Sets the arguments passed to the next `make`/`call`/`instantiate` |
|
|
143
|
+
| `make(:method)` | `set`, `link` | Calls a private method with the current `with` attrs |
|
|
144
|
+
| `call(:method)` | — | Calls a method on the currently delegated object |
|
|
145
|
+
| `get(*vars)` | `to` | Stores last result(s) into named instance variables |
|
|
146
|
+
| `use(:object)` | — | Points delegation to the object in `@object` |
|
|
147
|
+
| `instantiate(Class)` | — | Instantiates a class using the current `with` attrs |
|
|
148
|
+
| `finish_with(*vars)` | `value_return` | Declares the return value(s) of `run` |
|
|
60
149
|
|
|
61
|
-
|
|
150
|
+
### Running
|
|
62
151
|
|
|
63
|
-
|
|
152
|
+
| Method | Description |
|
|
153
|
+
|--------|-------------|
|
|
154
|
+
| `instance.run(:method)` | Executes a method, returns `finish_with` values |
|
|
155
|
+
| `instance.execute(:method)` | Alias for `run` |
|
|
156
|
+
| `MyService.resume(key:)` | Resumes a failed run from the first incomplete step |
|
|
64
157
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
158
|
+
### Class-Level DSL
|
|
159
|
+
|
|
160
|
+
| Method | Description |
|
|
161
|
+
|--------|-------------|
|
|
162
|
+
| `inmutable true` | Each `run` operates on a fresh dup — no shared state |
|
|
163
|
+
| `subscribe 'event', to: :method` | Maps a string event name to a method |
|
|
164
|
+
| `dispatch('event', **kwargs)` | Finds the subscription and runs it |
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Features
|
|
169
|
+
|
|
170
|
+
### Immutability
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
class ReportService < Odsl::Dsl
|
|
174
|
+
inmutable true
|
|
70
175
|
end
|
|
71
|
-
|
|
72
|
-
module Game
|
|
73
|
-
class Service < Odsl:Dsl
|
|
74
|
-
include Crud
|
|
75
176
|
|
|
76
|
-
|
|
77
|
-
|
|
177
|
+
service = ReportService.new(year: 2024)
|
|
178
|
+
result1 = service.run(:generate)
|
|
179
|
+
result2 = service.run(:generate) # identical — original service is untouched
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
When `inmutable true` is set, every `run` works on a `dup` of the instance — your kwargs and context are never mutated.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
### Background Jobs
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
# Fire and forget (returns a Thread by default)
|
|
190
|
+
thread = service.run_in_background(:process)
|
|
191
|
+
result = thread.value # wait for it
|
|
192
|
+
|
|
193
|
+
# Run multiple steps in parallel
|
|
194
|
+
game1, game2 = service.run_in_parallel(:create, :create)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Switch adapters as your stack grows:
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
Odsl.configure do |config|
|
|
201
|
+
config.background_adapter = :active_job # delegates to your configured queue backend
|
|
78
202
|
end
|
|
79
203
|
```
|
|
80
204
|
|
|
81
|
-
|
|
205
|
+
With `:active_job`, Odsl enqueues an `Odsl::OdslJob` that reconstructs the service from its kwargs and calls the method. Works with Sidekiq, Solid Queue, Good Job, and any other ActiveJob backend.
|
|
82
206
|
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
### Audit & Idempotency
|
|
210
|
+
|
|
211
|
+
Every `run` can be made idempotent by passing a `key:`. Odsl records an `ExecutionContext` and a `StepRecord` per `.get(:var)` checkpoint.
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
# Won't re-run if already completed with this key
|
|
215
|
+
service.run(:process_order, key: "order-#{order.id}")
|
|
216
|
+
|
|
217
|
+
# A second call with the same key returns nil immediately
|
|
218
|
+
service.run(:process_order, key: "order-#{order.id}") # => nil
|
|
83
219
|
```
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
220
|
+
|
|
221
|
+
**Step-level audit** means you can see exactly which steps completed and what their results were:
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
ctx = Odsl::Audit::ExecutionContext.find_by(idempotency_key: "order-42")
|
|
225
|
+
ctx.status # => "completed"
|
|
226
|
+
ctx.step_records # => [#<StepRecord name="catalog">, #<StepRecord name="order_total">, ...]
|
|
87
227
|
```
|
|
88
228
|
|
|
89
|
-
|
|
229
|
+
**Resume a failed run** from the first step that wasn't recorded:
|
|
90
230
|
|
|
91
|
-
|
|
231
|
+
```ruby
|
|
232
|
+
# Process died after step 2 of 5 — resume picks up from step 3
|
|
233
|
+
result = OrderService.resume(key: "order-42")
|
|
234
|
+
```
|
|
92
235
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
- Run sevreral methods in paralell
|
|
236
|
+
Completed steps are loaded from the database (no side effects re-run). Non-serializable results (complex objects) are re-executed.
|
|
96
237
|
|
|
97
|
-
|
|
238
|
+
**TTL & cleanup:**
|
|
98
239
|
|
|
99
|
-
|
|
240
|
+
```ruby
|
|
241
|
+
Odsl.configure do |config|
|
|
242
|
+
config.audit_ttl = 7 * 24 * 3600 # expire records after 1 week
|
|
243
|
+
end
|
|
100
244
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
include Crud
|
|
245
|
+
# Remove expired records
|
|
246
|
+
Odsl.cleanup_expired!
|
|
104
247
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
248
|
+
# Or with Rake
|
|
249
|
+
rake odsl:cleanup
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
**Rails setup:**
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
rails generate odsl:install
|
|
256
|
+
rails db:migrate
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Generates `config/initializers/odsl.rb` and a migration for `odsl_execution_contexts` + `odsl_step_records`.
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
### Distributed Locking
|
|
264
|
+
|
|
265
|
+
Prevent double-execution when users submit the same action from two browser tabs simultaneously.
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
# Gemfile
|
|
269
|
+
gem "redlock"
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
Odsl.configure do |config|
|
|
274
|
+
config.lock_redis_url = ENV["REDIS_URL"]
|
|
275
|
+
config.lock_ttl = 10_000 # ms — how long to hold the lock
|
|
276
|
+
end
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Call `lock` at the top of any business method:
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
def process_order
|
|
283
|
+
lock(:order_id) # key = "OrderService:42"
|
|
284
|
+
|
|
285
|
+
with(:order_id)
|
|
286
|
+
.make(:fetch_order)
|
|
287
|
+
.get(:order)
|
|
288
|
+
|
|
289
|
+
# ...
|
|
290
|
+
finish_with(:order)
|
|
108
291
|
end
|
|
109
|
-
```
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
`lock` builds the key from the service class name + the string values of the given instance variables. If the lock is already held, `Odsl::LockError` is raised immediately (fail-fast — no waiting). The lock is released automatically in the `ensure` block.
|
|
295
|
+
|
|
296
|
+
`lock` is a **no-op** when `lock_redis_url` is not configured, so the same code works in development without Redis.
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
### Event Bus
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
class GameService < Odsl::Dsl
|
|
304
|
+
include Crud
|
|
305
|
+
|
|
306
|
+
subscribe 'game.start', to: :create
|
|
307
|
+
subscribe 'game.finish', to: :end_game
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Dispatch by event name
|
|
311
|
+
result = GameService.dispatch('game.start', player: { name: 'alejandro' }, game: 'chess')
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Useful for decoupling event producers from the service implementation — the event string is the contract, not the method name.
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Configuration Reference
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
Odsl.configure do |config|
|
|
322
|
+
# :thread (default) or :active_job
|
|
323
|
+
config.background_adapter = :thread
|
|
324
|
+
|
|
325
|
+
# Seconds to keep audit records. nil = never expire.
|
|
326
|
+
config.audit_ttl = nil
|
|
327
|
+
|
|
328
|
+
# Redis URL for Redlock. nil = locking disabled (no-op).
|
|
329
|
+
config.lock_redis_url = nil
|
|
330
|
+
|
|
331
|
+
# Distributed lock TTL in milliseconds.
|
|
332
|
+
config.lock_ttl = 10_000
|
|
333
|
+
end
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## Error Reference
|
|
339
|
+
|
|
340
|
+
| Error | When raised |
|
|
341
|
+
|-------|-------------|
|
|
342
|
+
| `Odsl::LockError` | Distributed lock couldn't be acquired |
|
|
343
|
+
| `Odsl::AlreadyRunningError` | Idempotency key is currently in a `running` state |
|
|
344
|
+
| `Odsl::ContextNotFoundError` | `resume(key:)` called for an unknown key |
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## Contributing
|
|
349
|
+
|
|
350
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, code style, and the PR checklist.
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
## Changelog
|
|
355
|
+
|
|
356
|
+
See [CHANGELOG.md](CHANGELOG.md) for a full history of releases.
|
|
357
|
+
|
|
358
|
+
---
|
|
359
|
+
|
|
360
|
+
## License
|
|
361
|
+
|
|
362
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|