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
|
@@ -0,0 +1,1808 @@
|
|
|
1
|
+
# ODSL — Background Adapters, Audit System & Distributed Locking Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add pluggable background adapters, step-level audit with idempotency/resume, and optional distributed locking to the `odsl` gem.
|
|
6
|
+
|
|
7
|
+
**Architecture:** A `Configuration` object holds gem-level settings (adapter, TTL, Redis URL). `run()` gains an optional `key:` param that activates auditing via two ActiveRecord models (`ExecutionContext` + `StepRecord`). The `to()` / `get()` method becomes the audit checkpoint. Locking is a DSL method `lock(*vars)` that acquires a Redlock distributed lock acquired early in the method body and released in `run()`'s `ensure`.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby 3.1+, Minitest, ActiveRecord (optional, for audit), SQLite3 (test only), redlock-rb (optional, for locking), ActiveJob (optional, for background).
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## File Map
|
|
14
|
+
|
|
15
|
+
| File | Action | Responsibility |
|
|
16
|
+
|------|--------|---------------|
|
|
17
|
+
| `lib/odsl/errors.rb` | **modify** | add all new error classes |
|
|
18
|
+
| `lib/odsl/configuration.rb` | **create** | `Odsl::Configuration` + `Odsl.configure` / `Odsl.config` |
|
|
19
|
+
| `lib/odsl/adapters/base.rb` | **create** | adapter interface |
|
|
20
|
+
| `lib/odsl/adapters/thread.rb` | **create** | Thread adapter (extracts current logic) |
|
|
21
|
+
| `lib/odsl/adapters/active_job.rb` | **create** | ActiveJob adapter |
|
|
22
|
+
| `lib/odsl/adapters/odsl_job.rb` | **create** | `Odsl::OdslJob < ActiveJob::Base` |
|
|
23
|
+
| `lib/odsl/audit/serializer.rb` | **create** | JSON serialize/deserialize step results |
|
|
24
|
+
| `lib/odsl/audit/execution_context.rb` | **create** | ActiveRecord model for a run |
|
|
25
|
+
| `lib/odsl/audit/step_record.rb` | **create** | ActiveRecord model for a step |
|
|
26
|
+
| `lib/odsl/locking/null_adapter.rb` | **create** | no-op lock (when Redis not configured) |
|
|
27
|
+
| `lib/odsl/locking/redlock_adapter.rb` | **create** | wraps redlock-rb |
|
|
28
|
+
| `lib/odsl/railtie.rb` | **create** | Rails integration hook |
|
|
29
|
+
| `lib/generators/odsl/install_generator.rb` | **create** | `rails generate odsl:install` |
|
|
30
|
+
| `lib/generators/odsl/templates/migration.rb.tt` | **create** | migration template |
|
|
31
|
+
| `lib/generators/odsl/templates/initializer.rb.tt` | **create** | initializer template |
|
|
32
|
+
| `lib/odsl/runners.rb` | **modify** | `run(key:)`, `resume`, adapter delegation, lock ensure |
|
|
33
|
+
| `lib/odsl/methods.rb` | **modify** | `to()` records `StepRecord` when context active |
|
|
34
|
+
| `lib/odsl/dsl.rb` | **modify** | `lock(*vars)` DSL method, `resume` class method |
|
|
35
|
+
| `lib/odsl.rb` | **modify** | require all new files |
|
|
36
|
+
| `lib/odsl/version.rb` | **modify** | bump to `0.3.0` |
|
|
37
|
+
| `odsl.gemspec` | **modify** | add optional dev dependencies |
|
|
38
|
+
| `test/test_helper.rb` | **modify** | add ActiveRecord in-memory setup |
|
|
39
|
+
| `test/spec/odsl_spec.rb` | **modify** | existing tests pass unchanged |
|
|
40
|
+
| `test/spec/configuration_spec.rb` | **create** | tests for Odsl.configure |
|
|
41
|
+
| `test/spec/adapters_spec.rb` | **create** | tests for Thread + ActiveJob adapters |
|
|
42
|
+
| `test/spec/audit_spec.rb` | **create** | tests for audit system + resume |
|
|
43
|
+
| `test/spec/locking_spec.rb` | **create** | tests for lock() DSL method |
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Task 1: Errors + Configuration
|
|
48
|
+
|
|
49
|
+
**Files:**
|
|
50
|
+
- Modify: `lib/odsl/errors.rb`
|
|
51
|
+
- Create: `lib/odsl/configuration.rb`
|
|
52
|
+
- Modify: `lib/odsl.rb`
|
|
53
|
+
- Create: `test/spec/configuration_spec.rb`
|
|
54
|
+
|
|
55
|
+
- [ ] **Step 1.1: Write failing test for configuration**
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
# test/spec/configuration_spec.rb
|
|
59
|
+
require "test_helper"
|
|
60
|
+
require "odsl"
|
|
61
|
+
|
|
62
|
+
describe Odsl do
|
|
63
|
+
describe ".configure" do
|
|
64
|
+
after { Odsl.reset_config! }
|
|
65
|
+
|
|
66
|
+
it "sets background_adapter" do
|
|
67
|
+
Odsl.configure { |c| c.background_adapter = :active_job }
|
|
68
|
+
assert_equal :active_job, Odsl.config.background_adapter
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "defaults background_adapter to :thread" do
|
|
72
|
+
assert_equal :thread, Odsl.config.background_adapter
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "sets audit_ttl" do
|
|
76
|
+
Odsl.configure { |c| c.audit_ttl = 86_400 }
|
|
77
|
+
assert_equal 86_400, Odsl.config.audit_ttl
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "defaults audit_ttl to nil" do
|
|
81
|
+
assert_nil Odsl.config.audit_ttl
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "sets lock_redis_url" do
|
|
85
|
+
Odsl.configure { |c| c.lock_redis_url = "redis://localhost:6379" }
|
|
86
|
+
assert_equal "redis://localhost:6379", Odsl.config.lock_redis_url
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it "sets lock_ttl" do
|
|
90
|
+
Odsl.configure { |c| c.lock_ttl = 5_000 }
|
|
91
|
+
assert_equal 5_000, Odsl.config.lock_ttl
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "defaults lock_ttl to 10_000" do
|
|
95
|
+
assert_equal 10_000, Odsl.config.lock_ttl
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
- [ ] **Step 1.2: Run test to confirm failure**
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
cd /path/to/odsl && bundle exec ruby -Ilib:test test/spec/configuration_spec.rb
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Expected: `NoMethodError: undefined method 'configure' for Odsl:Module`
|
|
108
|
+
|
|
109
|
+
- [ ] **Step 1.3: Replace errors.rb with all error classes**
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
# lib/odsl/errors.rb
|
|
113
|
+
module Odsl
|
|
114
|
+
class Error < StandardError; end
|
|
115
|
+
|
|
116
|
+
class LockError < Error; end
|
|
117
|
+
class LockingNotConfiguredError < Error; end
|
|
118
|
+
class AlreadyRunningError < Error; end
|
|
119
|
+
class ContextNotFoundError < Error; end
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- [ ] **Step 1.4: Create configuration.rb**
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
# lib/odsl/configuration.rb
|
|
127
|
+
module Odsl
|
|
128
|
+
class Configuration
|
|
129
|
+
attr_accessor :background_adapter, :audit_ttl,
|
|
130
|
+
:lock_redis_url, :lock_ttl
|
|
131
|
+
|
|
132
|
+
def initialize
|
|
133
|
+
@background_adapter = :thread
|
|
134
|
+
@audit_ttl = nil
|
|
135
|
+
@lock_redis_url = nil
|
|
136
|
+
@lock_ttl = 10_000
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
class << self
|
|
141
|
+
def configure
|
|
142
|
+
yield(config)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def config
|
|
146
|
+
@config ||= Configuration.new
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def reset_config!
|
|
150
|
+
@config = Configuration.new
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- [ ] **Step 1.5: Update lib/odsl.rb to require new files**
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
# lib/odsl.rb
|
|
160
|
+
require "odsl/version"
|
|
161
|
+
require "odsl/errors"
|
|
162
|
+
require "odsl/configuration"
|
|
163
|
+
require "odsl/dsl"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- [ ] **Step 1.6: Run tests**
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
bundle exec ruby -Ilib:test test/spec/configuration_spec.rb
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Expected: 7 tests, 7 assertions, 0 failures, 0 errors
|
|
173
|
+
|
|
174
|
+
- [ ] **Step 1.7: Run full suite to confirm no regressions**
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
bundle exec rake test
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Expected: all existing tests still pass
|
|
181
|
+
|
|
182
|
+
- [ ] **Step 1.8: Commit**
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
git add lib/odsl/errors.rb lib/odsl/configuration.rb lib/odsl.rb \
|
|
186
|
+
test/spec/configuration_spec.rb
|
|
187
|
+
git commit -m "feat: add Odsl::Configuration and error classes"
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Task 2: Background Adapters
|
|
193
|
+
|
|
194
|
+
**Files:**
|
|
195
|
+
- Create: `lib/odsl/adapters/base.rb`
|
|
196
|
+
- Create: `lib/odsl/adapters/thread.rb`
|
|
197
|
+
- Create: `lib/odsl/adapters/active_job.rb`
|
|
198
|
+
- Create: `lib/odsl/adapters/odsl_job.rb`
|
|
199
|
+
- Modify: `lib/odsl/runners.rb`
|
|
200
|
+
- Modify: `lib/odsl.rb`
|
|
201
|
+
- Create: `test/spec/adapters_spec.rb`
|
|
202
|
+
|
|
203
|
+
- [ ] **Step 2.1: Write failing tests for adapters**
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
# test/spec/adapters_spec.rb
|
|
207
|
+
require "test_helper"
|
|
208
|
+
require "odsl"
|
|
209
|
+
|
|
210
|
+
describe Odsl::Adapters::Thread do
|
|
211
|
+
it "enqueues by starting a Ruby Thread" do
|
|
212
|
+
result = nil
|
|
213
|
+
t = Odsl::Adapters::Thread.enqueue(
|
|
214
|
+
service_class: "String",
|
|
215
|
+
method_name: "upcase",
|
|
216
|
+
kwargs: {},
|
|
217
|
+
callable: -> { result = "ran" }
|
|
218
|
+
)
|
|
219
|
+
assert_instance_of ::Thread, t
|
|
220
|
+
t.value
|
|
221
|
+
assert_equal "ran", result
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
describe Odsl::Adapters::Base do
|
|
226
|
+
it "raises NotImplementedError on enqueue" do
|
|
227
|
+
assert_raises(NotImplementedError) do
|
|
228
|
+
Odsl::Adapters::Base.enqueue(
|
|
229
|
+
service_class: "Foo",
|
|
230
|
+
method_name: "bar",
|
|
231
|
+
kwargs: {}
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
- [ ] **Step 2.2: Run tests to confirm failure**
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
bundle exec ruby -Ilib:test test/spec/adapters_spec.rb
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Expected: `NameError: uninitialized constant Odsl::Adapters`
|
|
245
|
+
|
|
246
|
+
- [ ] **Step 2.3: Create adapters/base.rb**
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
# lib/odsl/adapters/base.rb
|
|
250
|
+
module Odsl
|
|
251
|
+
module Adapters
|
|
252
|
+
class Base
|
|
253
|
+
def self.enqueue(service_class:, method_name:, kwargs:, key: nil, callable: nil)
|
|
254
|
+
raise NotImplementedError,
|
|
255
|
+
"#{self} must implement .enqueue(service_class:, method_name:, kwargs:, key:, callable:)"
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
- [ ] **Step 2.4: Create adapters/thread.rb**
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
# lib/odsl/adapters/thread.rb
|
|
266
|
+
module Odsl
|
|
267
|
+
module Adapters
|
|
268
|
+
class Thread < Base
|
|
269
|
+
def self.enqueue(service_class:, method_name:, kwargs:, key: nil, callable: nil)
|
|
270
|
+
::Thread.new do
|
|
271
|
+
if callable
|
|
272
|
+
callable.call
|
|
273
|
+
else
|
|
274
|
+
klass = Object.const_get(service_class)
|
|
275
|
+
klass.new(**kwargs.transform_keys(&:to_sym)).run(method_name.to_sym, key: key)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
- [ ] **Step 2.5: Create adapters/odsl_job.rb**
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
# lib/odsl/adapters/odsl_job.rb
|
|
288
|
+
# Only loaded when ActiveJob is available
|
|
289
|
+
if defined?(ActiveJob)
|
|
290
|
+
module Odsl
|
|
291
|
+
class OdslJob < ActiveJob::Base
|
|
292
|
+
def perform(service_class:, method_name:, kwargs:, key: nil)
|
|
293
|
+
klass = Object.const_get(service_class)
|
|
294
|
+
klass.new(**kwargs.transform_keys(&:to_sym)).run(method_name.to_sym, key: key)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
- [ ] **Step 2.6: Create adapters/active_job.rb**
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
# lib/odsl/adapters/active_job.rb
|
|
305
|
+
module Odsl
|
|
306
|
+
module Adapters
|
|
307
|
+
class ActiveJob < Base
|
|
308
|
+
def self.enqueue(service_class:, method_name:, kwargs:, key: nil, callable: nil)
|
|
309
|
+
unless defined?(::ActiveJob)
|
|
310
|
+
raise Odsl::Error, "ActiveJob is not available. Add `gem 'activejob'` to your Gemfile."
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
require_relative "odsl_job"
|
|
314
|
+
Odsl::OdslJob.perform_later(
|
|
315
|
+
service_class: service_class,
|
|
316
|
+
method_name: method_name,
|
|
317
|
+
kwargs: kwargs,
|
|
318
|
+
key: key
|
|
319
|
+
)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
- [ ] **Step 2.7: Update runners.rb to delegate run_in_background to adapter**
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
# lib/odsl/runners.rb
|
|
330
|
+
module Odsl
|
|
331
|
+
module Runners
|
|
332
|
+
def run(method, key: nil)
|
|
333
|
+
if self.class.inmutable_mode
|
|
334
|
+
_run_immutable(method, key: key)
|
|
335
|
+
else
|
|
336
|
+
_run_method(method, key: key)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def run_in_background(method, key: nil)
|
|
341
|
+
adapter = _background_adapter
|
|
342
|
+
|
|
343
|
+
adapter.enqueue(
|
|
344
|
+
service_class: self.class.name,
|
|
345
|
+
method_name: method.to_s,
|
|
346
|
+
kwargs: @kwargs,
|
|
347
|
+
key: key
|
|
348
|
+
)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def run_in_parallel(*methods_and_opts)
|
|
352
|
+
key = methods_and_opts.last.is_a?(Hash) ? methods_and_opts.pop[:key] : nil
|
|
353
|
+
threads = methods_and_opts.map { |method| run_in_background(method, key: key) }
|
|
354
|
+
threads.map { |t| t.respond_to?(:value) ? t.value : t }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
private
|
|
358
|
+
|
|
359
|
+
def _background_adapter
|
|
360
|
+
adapter_name = Odsl.config.background_adapter
|
|
361
|
+
case adapter_name
|
|
362
|
+
when :thread then Odsl::Adapters::Thread
|
|
363
|
+
when :active_job then Odsl::Adapters::ActiveJob
|
|
364
|
+
else
|
|
365
|
+
raise Odsl::Error, "Unknown background adapter: #{adapter_name.inspect}"
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def _run_method(method, key: nil)
|
|
370
|
+
@_execution_context = nil
|
|
371
|
+
@_current_lock = nil
|
|
372
|
+
@_step_index = 0
|
|
373
|
+
|
|
374
|
+
begin
|
|
375
|
+
instance_eval(method.to_s)
|
|
376
|
+
|
|
377
|
+
if @_execution_context
|
|
378
|
+
@_execution_context.update!(status: "completed")
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
_finish_result
|
|
382
|
+
rescue Odsl::LockError
|
|
383
|
+
raise
|
|
384
|
+
rescue => e
|
|
385
|
+
if @_execution_context
|
|
386
|
+
@_execution_context.update!(status: "failed")
|
|
387
|
+
end
|
|
388
|
+
raise
|
|
389
|
+
ensure
|
|
390
|
+
if @_current_lock
|
|
391
|
+
@_current_lock.unlock rescue nil
|
|
392
|
+
@_current_lock = nil
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def _run_immutable(method, key: nil)
|
|
398
|
+
copy = dup
|
|
399
|
+
copy.instance_variable_set(:@context, @context.dup)
|
|
400
|
+
copy.instance_variable_set(:@last_results, [])
|
|
401
|
+
copy.instance_variable_set(:@with_attributes, nil)
|
|
402
|
+
copy.instance_variable_set(:@finish_with, nil)
|
|
403
|
+
copy.send(:_run_method, method, key: key)
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def _finish_result
|
|
407
|
+
if @finish_with.is_a?(Array)
|
|
408
|
+
@finish_with.map { |variable| instance_variable_get("@#{variable}") }
|
|
409
|
+
else
|
|
410
|
+
instance_variable_get("@#{@finish_with}")
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
- [ ] **Step 2.8: Update lib/odsl.rb to require adapters**
|
|
418
|
+
|
|
419
|
+
```ruby
|
|
420
|
+
# lib/odsl.rb
|
|
421
|
+
require "odsl/version"
|
|
422
|
+
require "odsl/errors"
|
|
423
|
+
require "odsl/configuration"
|
|
424
|
+
require "odsl/adapters/base"
|
|
425
|
+
require "odsl/adapters/thread"
|
|
426
|
+
require "odsl/adapters/active_job"
|
|
427
|
+
require "odsl/dsl"
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
- [ ] **Step 2.9: Run adapter tests**
|
|
431
|
+
|
|
432
|
+
```bash
|
|
433
|
+
bundle exec ruby -Ilib:test test/spec/adapters_spec.rb
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Expected: 2 tests, 2 assertions, 0 failures, 0 errors
|
|
437
|
+
|
|
438
|
+
- [ ] **Step 2.10: Run full suite**
|
|
439
|
+
|
|
440
|
+
```bash
|
|
441
|
+
bundle exec rake test
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
Expected: all tests pass
|
|
445
|
+
|
|
446
|
+
- [ ] **Step 2.11: Commit**
|
|
447
|
+
|
|
448
|
+
```bash
|
|
449
|
+
git add lib/odsl/adapters/ lib/odsl/runners.rb lib/odsl.rb \
|
|
450
|
+
test/spec/adapters_spec.rb
|
|
451
|
+
git commit -m "feat: add pluggable background adapters (Thread, ActiveJob)"
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
---
|
|
455
|
+
|
|
456
|
+
## Task 3: Audit Models + Test Setup
|
|
457
|
+
|
|
458
|
+
**Files:**
|
|
459
|
+
- Create: `lib/odsl/audit/serializer.rb`
|
|
460
|
+
- Create: `lib/odsl/audit/execution_context.rb`
|
|
461
|
+
- Create: `lib/odsl/audit/step_record.rb`
|
|
462
|
+
- Modify: `test/test_helper.rb`
|
|
463
|
+
- Modify: `odsl.gemspec`
|
|
464
|
+
|
|
465
|
+
- [ ] **Step 3.1: Add activerecord + sqlite3 to gemspec dev dependencies**
|
|
466
|
+
|
|
467
|
+
Edit `odsl.gemspec`, replacing the development dependency block:
|
|
468
|
+
|
|
469
|
+
```ruby
|
|
470
|
+
spec.add_development_dependency "minitest", ">= 5.8"
|
|
471
|
+
spec.add_development_dependency "minitest-reporters", ">= 1.1"
|
|
472
|
+
spec.add_development_dependency "activerecord", ">= 7.0"
|
|
473
|
+
spec.add_development_dependency "sqlite3", "~> 1.7"
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
- [ ] **Step 3.2: Run bundle install**
|
|
477
|
+
|
|
478
|
+
```bash
|
|
479
|
+
bundle install
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
Expected: activerecord and sqlite3 installed
|
|
483
|
+
|
|
484
|
+
- [ ] **Step 3.3: Update test/test_helper.rb to set up in-memory AR schema**
|
|
485
|
+
|
|
486
|
+
```ruby
|
|
487
|
+
# test/test_helper.rb
|
|
488
|
+
require "minitest/autorun"
|
|
489
|
+
require "minitest/spec"
|
|
490
|
+
require "minitest/reporters"
|
|
491
|
+
require "odsl"
|
|
492
|
+
|
|
493
|
+
Minitest::Reporters.use!
|
|
494
|
+
|
|
495
|
+
# ActiveRecord in-memory setup for audit tests
|
|
496
|
+
require "active_record"
|
|
497
|
+
require "sqlite3"
|
|
498
|
+
|
|
499
|
+
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
|
|
500
|
+
ActiveRecord::Schema.define do
|
|
501
|
+
create_table :odsl_execution_contexts, force: true do |t|
|
|
502
|
+
t.string :idempotency_key, null: false
|
|
503
|
+
t.string :service_class, null: false
|
|
504
|
+
t.string :method_name, null: false
|
|
505
|
+
t.text :kwargs
|
|
506
|
+
t.string :status, null: false, default: "pending"
|
|
507
|
+
t.datetime :expires_at
|
|
508
|
+
t.timestamps
|
|
509
|
+
end
|
|
510
|
+
add_index :odsl_execution_contexts, :idempotency_key, unique: true
|
|
511
|
+
add_index :odsl_execution_contexts, :expires_at
|
|
512
|
+
|
|
513
|
+
create_table :odsl_step_records, force: true do |t|
|
|
514
|
+
t.references :execution_context, null: false,
|
|
515
|
+
foreign_key: { to_table: :odsl_execution_contexts }
|
|
516
|
+
t.string :name, null: false
|
|
517
|
+
t.integer :step_index, null: false
|
|
518
|
+
t.string :status, null: false, default: "pending"
|
|
519
|
+
t.text :result
|
|
520
|
+
t.text :error_message
|
|
521
|
+
t.boolean :non_resumable, default: false
|
|
522
|
+
t.datetime :started_at
|
|
523
|
+
t.datetime :completed_at
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
- [ ] **Step 3.4: Run existing suite to confirm schema loads cleanly**
|
|
529
|
+
|
|
530
|
+
```bash
|
|
531
|
+
bundle exec rake test
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Expected: all existing tests still pass
|
|
535
|
+
|
|
536
|
+
- [ ] **Step 3.5: Create audit/serializer.rb**
|
|
537
|
+
|
|
538
|
+
```ruby
|
|
539
|
+
# lib/odsl/audit/serializer.rb
|
|
540
|
+
require "json"
|
|
541
|
+
|
|
542
|
+
module Odsl
|
|
543
|
+
module Audit
|
|
544
|
+
module Serializer
|
|
545
|
+
def self.dump(value)
|
|
546
|
+
JSON.generate(serialize(value))
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def self.load(json_string)
|
|
550
|
+
return nil if json_string.nil?
|
|
551
|
+
JSON.parse(json_string)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def self.serializable?(value)
|
|
555
|
+
dump(value)
|
|
556
|
+
true
|
|
557
|
+
rescue JSON::GeneratorError, TypeError
|
|
558
|
+
false
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
private
|
|
562
|
+
|
|
563
|
+
def self.serialize(value)
|
|
564
|
+
case value
|
|
565
|
+
when Hash then value.transform_keys(&:to_s).transform_values { |v| serialize(v) }
|
|
566
|
+
when Array then value.map { |v| serialize(v) }
|
|
567
|
+
when Struct then serialize(value.to_h)
|
|
568
|
+
else value
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
- [ ] **Step 3.6: Create audit/execution_context.rb**
|
|
577
|
+
|
|
578
|
+
```ruby
|
|
579
|
+
# lib/odsl/audit/execution_context.rb
|
|
580
|
+
require "active_record"
|
|
581
|
+
|
|
582
|
+
module Odsl
|
|
583
|
+
module Audit
|
|
584
|
+
class ExecutionContext < ActiveRecord::Base
|
|
585
|
+
self.table_name = "odsl_execution_contexts"
|
|
586
|
+
|
|
587
|
+
has_many :step_records,
|
|
588
|
+
class_name: "Odsl::Audit::StepRecord",
|
|
589
|
+
foreign_key: :execution_context_id,
|
|
590
|
+
dependent: :destroy
|
|
591
|
+
|
|
592
|
+
STATUSES = %w[pending running completed failed].freeze
|
|
593
|
+
|
|
594
|
+
validates :idempotency_key, presence: true, uniqueness: true
|
|
595
|
+
validates :service_class, presence: true
|
|
596
|
+
validates :method_name, presence: true
|
|
597
|
+
validates :status, inclusion: { in: STATUSES }
|
|
598
|
+
|
|
599
|
+
scope :expired, -> { where("expires_at IS NOT NULL AND expires_at < ?", Time.now) }
|
|
600
|
+
|
|
601
|
+
def kwargs_hash
|
|
602
|
+
return {} if kwargs.nil?
|
|
603
|
+
JSON.parse(kwargs).transform_keys(&:to_sym)
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def completed? = status == "completed"
|
|
607
|
+
def running? = status == "running"
|
|
608
|
+
def failed? = status == "failed"
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
- [ ] **Step 3.7: Create audit/step_record.rb**
|
|
615
|
+
|
|
616
|
+
```ruby
|
|
617
|
+
# lib/odsl/audit/step_record.rb
|
|
618
|
+
require "active_record"
|
|
619
|
+
|
|
620
|
+
module Odsl
|
|
621
|
+
module Audit
|
|
622
|
+
class StepRecord < ActiveRecord::Base
|
|
623
|
+
self.table_name = "odsl_step_records"
|
|
624
|
+
|
|
625
|
+
belongs_to :execution_context,
|
|
626
|
+
class_name: "Odsl::Audit::ExecutionContext",
|
|
627
|
+
foreign_key: :execution_context_id
|
|
628
|
+
|
|
629
|
+
STATUSES = %w[pending completed failed].freeze
|
|
630
|
+
|
|
631
|
+
validates :name, presence: true
|
|
632
|
+
validates :step_index, presence: true
|
|
633
|
+
validates :status, inclusion: { in: STATUSES }
|
|
634
|
+
|
|
635
|
+
def completed? = status == "completed"
|
|
636
|
+
def failed? = status == "failed"
|
|
637
|
+
|
|
638
|
+
def deserialized_result
|
|
639
|
+
Audit::Serializer.load(result)
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
end
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
- [ ] **Step 3.8: Update lib/odsl.rb to require audit files**
|
|
647
|
+
|
|
648
|
+
```ruby
|
|
649
|
+
# lib/odsl.rb
|
|
650
|
+
require "odsl/version"
|
|
651
|
+
require "odsl/errors"
|
|
652
|
+
require "odsl/configuration"
|
|
653
|
+
require "odsl/adapters/base"
|
|
654
|
+
require "odsl/adapters/thread"
|
|
655
|
+
require "odsl/adapters/active_job"
|
|
656
|
+
require "odsl/audit/serializer"
|
|
657
|
+
require "odsl/audit/execution_context"
|
|
658
|
+
require "odsl/audit/step_record"
|
|
659
|
+
require "odsl/dsl"
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
- [ ] **Step 3.9: Run full suite**
|
|
663
|
+
|
|
664
|
+
```bash
|
|
665
|
+
bundle exec rake test
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
Expected: all tests pass
|
|
669
|
+
|
|
670
|
+
- [ ] **Step 3.10: Commit**
|
|
671
|
+
|
|
672
|
+
```bash
|
|
673
|
+
git add lib/odsl/audit/ lib/odsl.rb test/test_helper.rb odsl.gemspec Gemfile.lock
|
|
674
|
+
git commit -m "feat: add audit models (ExecutionContext, StepRecord, Serializer)"
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
---
|
|
678
|
+
|
|
679
|
+
## Task 4: Audit Integration — run() + get()
|
|
680
|
+
|
|
681
|
+
**Files:**
|
|
682
|
+
- Modify: `lib/odsl/runners.rb`
|
|
683
|
+
- Modify: `lib/odsl/methods.rb`
|
|
684
|
+
- Modify: `lib/odsl.rb` (add cleanup method)
|
|
685
|
+
- Create: `test/spec/audit_spec.rb`
|
|
686
|
+
|
|
687
|
+
- [ ] **Step 4.1: Write failing audit tests**
|
|
688
|
+
|
|
689
|
+
```ruby
|
|
690
|
+
# test/spec/audit_spec.rb
|
|
691
|
+
require "test_helper"
|
|
692
|
+
require "odsl"
|
|
693
|
+
|
|
694
|
+
# Reuse the same Games domain from odsl_spec
|
|
695
|
+
require_relative "odsl_spec" unless defined?(Games::Service)
|
|
696
|
+
|
|
697
|
+
describe "Audit system" do
|
|
698
|
+
before do
|
|
699
|
+
Odsl::Audit::StepRecord.delete_all
|
|
700
|
+
Odsl::Audit::ExecutionContext.delete_all
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
it "creates an ExecutionContext when key: is given" do
|
|
704
|
+
service = Games::Service.new(player: { name: "alejandro" }, game: "chess")
|
|
705
|
+
service.run(:create, key: "audit-test-1")
|
|
706
|
+
|
|
707
|
+
ctx = Odsl::Audit::ExecutionContext.find_by!(idempotency_key: "audit-test-1")
|
|
708
|
+
assert_equal "completed", ctx.status
|
|
709
|
+
assert_equal "Games::Service", ctx.service_class
|
|
710
|
+
assert_equal "create", ctx.method_name
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
it "creates StepRecords for each .get() call" do
|
|
714
|
+
service = Games::Service.new(player: { name: "alejandro" }, game: "chess")
|
|
715
|
+
service.run(:create, key: "audit-test-2")
|
|
716
|
+
|
|
717
|
+
ctx = Odsl::Audit::ExecutionContext.find_by!(idempotency_key: "audit-test-2")
|
|
718
|
+
names = ctx.step_records.order(:step_index).pluck(:name)
|
|
719
|
+
|
|
720
|
+
assert_includes names, "game_object"
|
|
721
|
+
assert_includes names, "game_instance"
|
|
722
|
+
assert_includes names, "score_board_object"
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
it "does not create an ExecutionContext when no key: given" do
|
|
726
|
+
service = Games::Service.new(player: { name: "alejandro" }, game: "chess")
|
|
727
|
+
service.run(:create)
|
|
728
|
+
|
|
729
|
+
assert_equal 0, Odsl::Audit::ExecutionContext.count
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
it "returns immediately for a completed context (idempotency)" do
|
|
733
|
+
service = Games::Service.new(player: { name: "alejandro" }, game: "chess")
|
|
734
|
+
service.run(:create, key: "audit-test-3")
|
|
735
|
+
|
|
736
|
+
call_count = 0
|
|
737
|
+
Games::Service.define_method(:create_counter) { call_count += 1 }
|
|
738
|
+
|
|
739
|
+
# Second call with same key should not re-run
|
|
740
|
+
service2 = Games::Service.new(player: { name: "alejandro" }, game: "chess")
|
|
741
|
+
service2.run(:create, key: "audit-test-3")
|
|
742
|
+
|
|
743
|
+
assert_equal 0, call_count
|
|
744
|
+
ensure
|
|
745
|
+
Games::Service.undef_method(:create_counter) rescue nil
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
it "raises AlreadyRunningError if context is running" do
|
|
749
|
+
Odsl::Audit::ExecutionContext.create!(
|
|
750
|
+
idempotency_key: "audit-test-4",
|
|
751
|
+
service_class: "Games::Service",
|
|
752
|
+
method_name: "create",
|
|
753
|
+
kwargs: "{}",
|
|
754
|
+
status: "running"
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
service = Games::Service.new(player: { name: "alejandro" }, game: "chess")
|
|
758
|
+
assert_raises(Odsl::AlreadyRunningError) do
|
|
759
|
+
service.run(:create, key: "audit-test-4")
|
|
760
|
+
end
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
it "marks context as failed when method raises" do
|
|
764
|
+
module Games
|
|
765
|
+
module Crud
|
|
766
|
+
def failing_create
|
|
767
|
+
raise "boom"
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
service = Games::Service.new(player: { name: "alejandro" }, game: "chess")
|
|
773
|
+
assert_raises(RuntimeError) { service.run(:failing_create, key: "audit-test-5") }
|
|
774
|
+
|
|
775
|
+
ctx = Odsl::Audit::ExecutionContext.find_by!(idempotency_key: "audit-test-5")
|
|
776
|
+
assert_equal "failed", ctx.status
|
|
777
|
+
ensure
|
|
778
|
+
Games::Service.undef_method(:failing_create) rescue nil
|
|
779
|
+
end
|
|
780
|
+
end
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
- [ ] **Step 4.2: Run tests to confirm failure**
|
|
784
|
+
|
|
785
|
+
```bash
|
|
786
|
+
bundle exec ruby -Ilib:test test/spec/audit_spec.rb
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
Expected: failures about missing audit behavior
|
|
790
|
+
|
|
791
|
+
- [ ] **Step 4.3: Update runners.rb to create/load ExecutionContext**
|
|
792
|
+
|
|
793
|
+
Replace `lib/odsl/runners.rb` entirely:
|
|
794
|
+
|
|
795
|
+
```ruby
|
|
796
|
+
# lib/odsl/runners.rb
|
|
797
|
+
module Odsl
|
|
798
|
+
module Runners
|
|
799
|
+
def run(method, key: nil)
|
|
800
|
+
if self.class.inmutable_mode
|
|
801
|
+
_run_immutable(method, key: key)
|
|
802
|
+
else
|
|
803
|
+
_run_method(method, key: key)
|
|
804
|
+
end
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
def run_in_background(method, key: nil)
|
|
808
|
+
_background_adapter.enqueue(
|
|
809
|
+
service_class: self.class.name,
|
|
810
|
+
method_name: method.to_s,
|
|
811
|
+
kwargs: @kwargs,
|
|
812
|
+
key: key
|
|
813
|
+
)
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
def run_in_parallel(*methods_and_opts)
|
|
817
|
+
key = methods_and_opts.last.is_a?(Hash) ? methods_and_opts.pop[:key] : nil
|
|
818
|
+
handles = methods_and_opts.map { |m| run_in_background(m, key: key) }
|
|
819
|
+
handles.map { |h| h.respond_to?(:value) ? h.value : h }
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
private
|
|
823
|
+
|
|
824
|
+
def _background_adapter
|
|
825
|
+
case Odsl.config.background_adapter
|
|
826
|
+
when :thread then Odsl::Adapters::Thread
|
|
827
|
+
when :active_job then Odsl::Adapters::ActiveJob
|
|
828
|
+
else raise Odsl::Error, "Unknown background adapter: #{Odsl.config.background_adapter.inspect}"
|
|
829
|
+
end
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
def _run_method(method, key: nil)
|
|
833
|
+
@_execution_context = _setup_context(method, key)
|
|
834
|
+
@_current_lock = nil
|
|
835
|
+
@_step_index = 0
|
|
836
|
+
|
|
837
|
+
begin
|
|
838
|
+
instance_eval(method.to_s)
|
|
839
|
+
@_execution_context&.update!(status: "completed")
|
|
840
|
+
_finish_result
|
|
841
|
+
rescue Odsl::LockError
|
|
842
|
+
raise
|
|
843
|
+
rescue => e
|
|
844
|
+
@_execution_context&.update!(status: "failed")
|
|
845
|
+
raise
|
|
846
|
+
ensure
|
|
847
|
+
@_current_lock&.unlock rescue nil
|
|
848
|
+
@_current_lock = nil
|
|
849
|
+
end
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
def _run_immutable(method, key: nil)
|
|
853
|
+
copy = dup
|
|
854
|
+
copy.instance_variable_set(:@context, @context.dup)
|
|
855
|
+
copy.instance_variable_set(:@last_results, [])
|
|
856
|
+
copy.instance_variable_set(:@with_attributes, nil)
|
|
857
|
+
copy.instance_variable_set(:@finish_with, nil)
|
|
858
|
+
copy.send(:_run_method, method, key: key)
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
def _setup_context(method, key)
|
|
862
|
+
return nil unless key && defined?(Odsl::Audit::ExecutionContext)
|
|
863
|
+
|
|
864
|
+
ctx = Odsl::Audit::ExecutionContext.find_by(idempotency_key: key)
|
|
865
|
+
|
|
866
|
+
if ctx
|
|
867
|
+
return nil if ctx.completed?
|
|
868
|
+
raise Odsl::AlreadyRunningError, "Job with key '#{key}' is already running" if ctx.running?
|
|
869
|
+
else
|
|
870
|
+
ttl = Odsl.config.audit_ttl
|
|
871
|
+
ctx = Odsl::Audit::ExecutionContext.create!(
|
|
872
|
+
idempotency_key: key,
|
|
873
|
+
service_class: self.class.name,
|
|
874
|
+
method_name: method.to_s,
|
|
875
|
+
kwargs: JSON.generate(@kwargs),
|
|
876
|
+
status: "pending",
|
|
877
|
+
expires_at: ttl ? Time.now + ttl : nil
|
|
878
|
+
)
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
ctx.update!(status: "running")
|
|
882
|
+
ctx
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
def _finish_result
|
|
886
|
+
if @finish_with.is_a?(Array)
|
|
887
|
+
@finish_with.map { |variable| instance_variable_get("@#{variable}") }
|
|
888
|
+
else
|
|
889
|
+
instance_variable_get("@#{@finish_with}")
|
|
890
|
+
end
|
|
891
|
+
end
|
|
892
|
+
end
|
|
893
|
+
end
|
|
894
|
+
```
|
|
895
|
+
|
|
896
|
+
- [ ] **Step 4.4: Update methods.rb to record StepRecord in to()**
|
|
897
|
+
|
|
898
|
+
```ruby
|
|
899
|
+
# lib/odsl/methods.rb
|
|
900
|
+
module Odsl
|
|
901
|
+
module Methods
|
|
902
|
+
|
|
903
|
+
def use(object)
|
|
904
|
+
@delegated = instance_variable_get("@#{object}")
|
|
905
|
+
self
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
def call(methodo)
|
|
909
|
+
run_method(methodo)
|
|
910
|
+
restore_delegate
|
|
911
|
+
self
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
def to(*variables)
|
|
915
|
+
variables.each_with_index do |variable, index|
|
|
916
|
+
value = @last_results[index]
|
|
917
|
+
instance_variable_set("@#{variable}", value)
|
|
918
|
+
_record_step(variable, value)
|
|
919
|
+
end
|
|
920
|
+
self
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
private
|
|
924
|
+
|
|
925
|
+
def run_method(methodo)
|
|
926
|
+
@last_results = @delegated.send(methodo, *with_attributes_resolver)
|
|
927
|
+
@last_results = [@last_results] unless @last_results.is_a?(Array)
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
def restore_delegate
|
|
931
|
+
@delegated = self
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
def with_attributes_resolver
|
|
935
|
+
return [] if @with_attributes.nil?
|
|
936
|
+
@with_attributes.map { |attribute| instance_variable_get("@#{attribute}") }
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
def _record_step(name, value)
|
|
940
|
+
return unless @_execution_context
|
|
941
|
+
|
|
942
|
+
@_step_index ||= 0
|
|
943
|
+
serializable = Odsl::Audit::Serializer.serializable?(value)
|
|
944
|
+
|
|
945
|
+
Odsl::Audit::StepRecord.create!(
|
|
946
|
+
execution_context: @_execution_context,
|
|
947
|
+
name: name.to_s,
|
|
948
|
+
step_index: @_step_index,
|
|
949
|
+
status: "completed",
|
|
950
|
+
result: serializable ? Odsl::Audit::Serializer.dump(value) : nil,
|
|
951
|
+
non_resumable: !serializable,
|
|
952
|
+
started_at: Time.now,
|
|
953
|
+
completed_at: Time.now
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
@_step_index += 1
|
|
957
|
+
end
|
|
958
|
+
end
|
|
959
|
+
end
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
- [ ] **Step 4.5: Run audit tests**
|
|
963
|
+
|
|
964
|
+
```bash
|
|
965
|
+
bundle exec ruby -Ilib:test test/spec/audit_spec.rb
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
Expected: 5 tests, 5 assertions, 0 failures
|
|
969
|
+
|
|
970
|
+
- [ ] **Step 4.6: Run full suite**
|
|
971
|
+
|
|
972
|
+
```bash
|
|
973
|
+
bundle exec rake test
|
|
974
|
+
```
|
|
975
|
+
|
|
976
|
+
Expected: all tests pass
|
|
977
|
+
|
|
978
|
+
- [ ] **Step 4.7: Commit**
|
|
979
|
+
|
|
980
|
+
```bash
|
|
981
|
+
git add lib/odsl/runners.rb lib/odsl/methods.rb test/spec/audit_spec.rb
|
|
982
|
+
git commit -m "feat: audit integration — run() records ExecutionContext and StepRecords"
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
---
|
|
986
|
+
|
|
987
|
+
## Task 5: Resume
|
|
988
|
+
|
|
989
|
+
**Files:**
|
|
990
|
+
- Modify: `lib/odsl/runners.rb` (add `_resume_method`)
|
|
991
|
+
- Modify: `lib/odsl/dsl.rb` (add `resume` class method)
|
|
992
|
+
- Modify: `test/spec/audit_spec.rb` (add resume tests)
|
|
993
|
+
|
|
994
|
+
- [ ] **Step 5.1: Write failing resume tests — append to audit_spec.rb**
|
|
995
|
+
|
|
996
|
+
```ruby
|
|
997
|
+
describe "Resume" do
|
|
998
|
+
before do
|
|
999
|
+
Odsl::Audit::StepRecord.delete_all
|
|
1000
|
+
Odsl::Audit::ExecutionContext.delete_all
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
it "resumes a failed run from the first incomplete step" do
|
|
1004
|
+
# Create a context that failed after game_object was recorded
|
|
1005
|
+
ctx = Odsl::Audit::ExecutionContext.create!(
|
|
1006
|
+
idempotency_key: "resume-test-1",
|
|
1007
|
+
service_class: "Games::Service",
|
|
1008
|
+
method_name: "create",
|
|
1009
|
+
kwargs: JSON.generate({ player: { name: "alejandro" }, game: "chess" }),
|
|
1010
|
+
status: "failed"
|
|
1011
|
+
)
|
|
1012
|
+
Odsl::Audit::StepRecord.create!(
|
|
1013
|
+
execution_context: ctx,
|
|
1014
|
+
name: "game_object",
|
|
1015
|
+
step_index: 0,
|
|
1016
|
+
status: "completed",
|
|
1017
|
+
result: nil,
|
|
1018
|
+
non_resumable: true,
|
|
1019
|
+
started_at: Time.now,
|
|
1020
|
+
completed_at: Time.now
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
result = Games::Service.resume(key: "resume-test-1")
|
|
1024
|
+
refute_nil result
|
|
1025
|
+
|
|
1026
|
+
ctx.reload
|
|
1027
|
+
assert_equal "completed", ctx.status
|
|
1028
|
+
end
|
|
1029
|
+
|
|
1030
|
+
it "raises ContextNotFoundError for unknown key" do
|
|
1031
|
+
assert_raises(Odsl::ContextNotFoundError) do
|
|
1032
|
+
Games::Service.resume(key: "does-not-exist")
|
|
1033
|
+
end
|
|
1034
|
+
end
|
|
1035
|
+
end
|
|
1036
|
+
```
|
|
1037
|
+
|
|
1038
|
+
- [ ] **Step 5.2: Run to confirm failure**
|
|
1039
|
+
|
|
1040
|
+
```bash
|
|
1041
|
+
bundle exec ruby -Ilib:test test/spec/audit_spec.rb
|
|
1042
|
+
```
|
|
1043
|
+
|
|
1044
|
+
Expected: `NoMethodError: undefined method 'resume'`
|
|
1045
|
+
|
|
1046
|
+
- [ ] **Step 5.3: Add resume class method to dsl.rb**
|
|
1047
|
+
|
|
1048
|
+
Add inside the `class << self` block in `lib/odsl/dsl.rb`:
|
|
1049
|
+
|
|
1050
|
+
```ruby
|
|
1051
|
+
def resume(key:)
|
|
1052
|
+
ctx = Odsl::Audit::ExecutionContext.find_by(idempotency_key: key)
|
|
1053
|
+
raise Odsl::ContextNotFoundError, "No execution context found for key: #{key}" unless ctx
|
|
1054
|
+
|
|
1055
|
+
kwargs = ctx.kwargs_hash
|
|
1056
|
+
instance = new(**kwargs)
|
|
1057
|
+
instance.instance_variable_set(:@_execution_context, ctx)
|
|
1058
|
+
instance.instance_variable_set(:@_resuming_steps,
|
|
1059
|
+
ctx.step_records.where(status: "completed", non_resumable: false)
|
|
1060
|
+
.pluck(:name))
|
|
1061
|
+
instance.send(:_resume_method, ctx.method_name.to_sym, key: key)
|
|
1062
|
+
end
|
|
1063
|
+
```
|
|
1064
|
+
|
|
1065
|
+
- [ ] **Step 5.4: Add _resume_method to runners.rb**
|
|
1066
|
+
|
|
1067
|
+
Add inside the `private` section of `lib/odsl/runners.rb`:
|
|
1068
|
+
|
|
1069
|
+
```ruby
|
|
1070
|
+
def _resume_method(method, key: nil)
|
|
1071
|
+
@_execution_context = Odsl::Audit::ExecutionContext.find_by(idempotency_key: key)
|
|
1072
|
+
@_current_lock = nil
|
|
1073
|
+
@_step_index = @_execution_context.step_records.count
|
|
1074
|
+
|
|
1075
|
+
@_execution_context.update!(status: "running")
|
|
1076
|
+
|
|
1077
|
+
begin
|
|
1078
|
+
instance_eval(method.to_s)
|
|
1079
|
+
@_execution_context.update!(status: "completed")
|
|
1080
|
+
_finish_result
|
|
1081
|
+
rescue => e
|
|
1082
|
+
@_execution_context.update!(status: "failed")
|
|
1083
|
+
raise
|
|
1084
|
+
ensure
|
|
1085
|
+
@_current_lock&.unlock rescue nil
|
|
1086
|
+
@_current_lock = nil
|
|
1087
|
+
end
|
|
1088
|
+
end
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
- [ ] **Step 5.5: Update to() in methods.rb to skip completed steps on resume**
|
|
1092
|
+
|
|
1093
|
+
Replace `to(*variables)` in `lib/odsl/methods.rb`:
|
|
1094
|
+
|
|
1095
|
+
```ruby
|
|
1096
|
+
def to(*variables)
|
|
1097
|
+
variables.each_with_index do |variable, index|
|
|
1098
|
+
value = @last_results[index]
|
|
1099
|
+
|
|
1100
|
+
resuming = @_resuming_steps&.include?(variable.to_s)
|
|
1101
|
+
|
|
1102
|
+
if resuming
|
|
1103
|
+
stored = @_execution_context.step_records
|
|
1104
|
+
.find_by(name: variable.to_s)
|
|
1105
|
+
&.deserialized_result
|
|
1106
|
+
instance_variable_set("@#{variable}", stored)
|
|
1107
|
+
else
|
|
1108
|
+
instance_variable_set("@#{variable}", value)
|
|
1109
|
+
_record_step(variable, value)
|
|
1110
|
+
end
|
|
1111
|
+
end
|
|
1112
|
+
self
|
|
1113
|
+
end
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
- [ ] **Step 5.6: Run resume tests**
|
|
1117
|
+
|
|
1118
|
+
```bash
|
|
1119
|
+
bundle exec ruby -Ilib:test test/spec/audit_spec.rb
|
|
1120
|
+
```
|
|
1121
|
+
|
|
1122
|
+
Expected: all audit + resume tests pass
|
|
1123
|
+
|
|
1124
|
+
- [ ] **Step 5.7: Run full suite**
|
|
1125
|
+
|
|
1126
|
+
```bash
|
|
1127
|
+
bundle exec rake test
|
|
1128
|
+
```
|
|
1129
|
+
|
|
1130
|
+
- [ ] **Step 5.8: Commit**
|
|
1131
|
+
|
|
1132
|
+
```bash
|
|
1133
|
+
git add lib/odsl/runners.rb lib/odsl/dsl.rb lib/odsl/methods.rb \
|
|
1134
|
+
test/spec/audit_spec.rb
|
|
1135
|
+
git commit -m "feat: add resume() — restarts a failed run from first incomplete step"
|
|
1136
|
+
```
|
|
1137
|
+
|
|
1138
|
+
---
|
|
1139
|
+
|
|
1140
|
+
## Task 6: TTL + Cleanup
|
|
1141
|
+
|
|
1142
|
+
**Files:**
|
|
1143
|
+
- Modify: `lib/odsl.rb` (add `Odsl.cleanup_expired!`)
|
|
1144
|
+
- Modify: `lib/odsl/railtie.rb` (rake task)
|
|
1145
|
+
- Create: `lib/odsl/railtie.rb`
|
|
1146
|
+
- Modify: `test/spec/audit_spec.rb` (TTL tests)
|
|
1147
|
+
|
|
1148
|
+
- [ ] **Step 6.1: Write failing TTL/cleanup tests — append to audit_spec.rb**
|
|
1149
|
+
|
|
1150
|
+
```ruby
|
|
1151
|
+
describe "TTL and cleanup" do
|
|
1152
|
+
before do
|
|
1153
|
+
Odsl::Audit::StepRecord.delete_all
|
|
1154
|
+
Odsl::Audit::ExecutionContext.delete_all
|
|
1155
|
+
Odsl.reset_config!
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
it "sets expires_at based on audit_ttl" do
|
|
1159
|
+
Odsl.configure { |c| c.audit_ttl = 86_400 }
|
|
1160
|
+
|
|
1161
|
+
service = Games::Service.new(player: { name: "alejandro" }, game: "chess")
|
|
1162
|
+
service.run(:create, key: "ttl-test-1")
|
|
1163
|
+
|
|
1164
|
+
ctx = Odsl::Audit::ExecutionContext.find_by!(idempotency_key: "ttl-test-1")
|
|
1165
|
+
refute_nil ctx.expires_at
|
|
1166
|
+
assert ctx.expires_at > Time.now
|
|
1167
|
+
end
|
|
1168
|
+
|
|
1169
|
+
it "leaves expires_at nil when audit_ttl is nil" do
|
|
1170
|
+
service = Games::Service.new(player: { name: "alejandro" }, game: "chess")
|
|
1171
|
+
service.run(:create, key: "ttl-test-2")
|
|
1172
|
+
|
|
1173
|
+
ctx = Odsl::Audit::ExecutionContext.find_by!(idempotency_key: "ttl-test-2")
|
|
1174
|
+
assert_nil ctx.expires_at
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
it "cleanup_expired! removes contexts past expires_at" do
|
|
1178
|
+
Odsl::Audit::ExecutionContext.create!(
|
|
1179
|
+
idempotency_key: "expired-1",
|
|
1180
|
+
service_class: "Foo",
|
|
1181
|
+
method_name: "bar",
|
|
1182
|
+
kwargs: "{}",
|
|
1183
|
+
status: "completed",
|
|
1184
|
+
expires_at: Time.now - 1
|
|
1185
|
+
)
|
|
1186
|
+
Odsl::Audit::ExecutionContext.create!(
|
|
1187
|
+
idempotency_key: "active-1",
|
|
1188
|
+
service_class: "Foo",
|
|
1189
|
+
method_name: "bar",
|
|
1190
|
+
kwargs: "{}",
|
|
1191
|
+
status: "completed",
|
|
1192
|
+
expires_at: Time.now + 86_400
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
Odsl.cleanup_expired!
|
|
1196
|
+
|
|
1197
|
+
assert_nil Odsl::Audit::ExecutionContext.find_by(idempotency_key: "expired-1")
|
|
1198
|
+
refute_nil Odsl::Audit::ExecutionContext.find_by(idempotency_key: "active-1")
|
|
1199
|
+
end
|
|
1200
|
+
end
|
|
1201
|
+
```
|
|
1202
|
+
|
|
1203
|
+
- [ ] **Step 6.2: Run to confirm failure**
|
|
1204
|
+
|
|
1205
|
+
```bash
|
|
1206
|
+
bundle exec ruby -Ilib:test test/spec/audit_spec.rb
|
|
1207
|
+
```
|
|
1208
|
+
|
|
1209
|
+
Expected: `NoMethodError: undefined method 'cleanup_expired!'`
|
|
1210
|
+
|
|
1211
|
+
- [ ] **Step 6.3: Add cleanup_expired! to lib/odsl.rb**
|
|
1212
|
+
|
|
1213
|
+
Append to the `class << self` block in `lib/odsl.rb` (after `reset_config!`):
|
|
1214
|
+
|
|
1215
|
+
```ruby
|
|
1216
|
+
require "odsl/version"
|
|
1217
|
+
require "odsl/errors"
|
|
1218
|
+
require "odsl/configuration"
|
|
1219
|
+
require "odsl/adapters/base"
|
|
1220
|
+
require "odsl/adapters/thread"
|
|
1221
|
+
require "odsl/adapters/active_job"
|
|
1222
|
+
require "odsl/audit/serializer"
|
|
1223
|
+
require "odsl/audit/execution_context"
|
|
1224
|
+
require "odsl/audit/step_record"
|
|
1225
|
+
require "odsl/dsl"
|
|
1226
|
+
|
|
1227
|
+
module Odsl
|
|
1228
|
+
class << self
|
|
1229
|
+
def cleanup_expired!
|
|
1230
|
+
return unless defined?(Odsl::Audit::ExecutionContext)
|
|
1231
|
+
Odsl::Audit::ExecutionContext.expired.destroy_all
|
|
1232
|
+
end
|
|
1233
|
+
end
|
|
1234
|
+
end
|
|
1235
|
+
```
|
|
1236
|
+
|
|
1237
|
+
- [ ] **Step 6.4: Create railtie.rb with rake task**
|
|
1238
|
+
|
|
1239
|
+
```ruby
|
|
1240
|
+
# lib/odsl/railtie.rb
|
|
1241
|
+
return unless defined?(Rails)
|
|
1242
|
+
|
|
1243
|
+
module Odsl
|
|
1244
|
+
class Railtie < Rails::Railtie
|
|
1245
|
+
rake_tasks do
|
|
1246
|
+
namespace :odsl do
|
|
1247
|
+
desc "Remove expired ODSL execution contexts"
|
|
1248
|
+
task cleanup: :environment do
|
|
1249
|
+
deleted = Odsl.cleanup_expired!
|
|
1250
|
+
puts "ODSL cleanup: #{deleted.size} expired context(s) removed."
|
|
1251
|
+
end
|
|
1252
|
+
end
|
|
1253
|
+
end
|
|
1254
|
+
end
|
|
1255
|
+
end
|
|
1256
|
+
```
|
|
1257
|
+
|
|
1258
|
+
- [ ] **Step 6.5: Require railtie in lib/odsl.rb**
|
|
1259
|
+
|
|
1260
|
+
Add at the bottom of `lib/odsl.rb`:
|
|
1261
|
+
|
|
1262
|
+
```ruby
|
|
1263
|
+
require "odsl/railtie"
|
|
1264
|
+
```
|
|
1265
|
+
|
|
1266
|
+
- [ ] **Step 6.6: Run TTL/cleanup tests**
|
|
1267
|
+
|
|
1268
|
+
```bash
|
|
1269
|
+
bundle exec ruby -Ilib:test test/spec/audit_spec.rb
|
|
1270
|
+
```
|
|
1271
|
+
|
|
1272
|
+
Expected: all tests pass
|
|
1273
|
+
|
|
1274
|
+
- [ ] **Step 6.7: Run full suite**
|
|
1275
|
+
|
|
1276
|
+
```bash
|
|
1277
|
+
bundle exec rake test
|
|
1278
|
+
```
|
|
1279
|
+
|
|
1280
|
+
- [ ] **Step 6.8: Commit**
|
|
1281
|
+
|
|
1282
|
+
```bash
|
|
1283
|
+
git add lib/odsl.rb lib/odsl/railtie.rb test/spec/audit_spec.rb
|
|
1284
|
+
git commit -m "feat: audit TTL, expires_at, and Odsl.cleanup_expired!"
|
|
1285
|
+
```
|
|
1286
|
+
|
|
1287
|
+
---
|
|
1288
|
+
|
|
1289
|
+
## Task 7: Distributed Locking
|
|
1290
|
+
|
|
1291
|
+
**Files:**
|
|
1292
|
+
- Create: `lib/odsl/locking/null_adapter.rb`
|
|
1293
|
+
- Create: `lib/odsl/locking/redlock_adapter.rb`
|
|
1294
|
+
- Modify: `lib/odsl/dsl.rb` (add `lock(*vars)` method)
|
|
1295
|
+
- Modify: `lib/odsl.rb` (require locking files)
|
|
1296
|
+
- Create: `test/spec/locking_spec.rb`
|
|
1297
|
+
|
|
1298
|
+
- [ ] **Step 7.1: Write failing locking tests**
|
|
1299
|
+
|
|
1300
|
+
```ruby
|
|
1301
|
+
# test/spec/locking_spec.rb
|
|
1302
|
+
require "test_helper"
|
|
1303
|
+
require "odsl"
|
|
1304
|
+
|
|
1305
|
+
describe "Distributed locking" do
|
|
1306
|
+
after { Odsl.reset_config! }
|
|
1307
|
+
|
|
1308
|
+
describe "NullAdapter" do
|
|
1309
|
+
it "always returns a no-op lock object" do
|
|
1310
|
+
lock = Odsl::Locking::NullAdapter.acquire("any-key", 10_000)
|
|
1311
|
+
assert_respond_to lock, :unlock
|
|
1312
|
+
lock.unlock # must not raise
|
|
1313
|
+
end
|
|
1314
|
+
end
|
|
1315
|
+
|
|
1316
|
+
describe "lock() DSL method" do
|
|
1317
|
+
it "raises LockingNotConfiguredError when Redis URL is not configured" do
|
|
1318
|
+
module TestLockService
|
|
1319
|
+
class Service < Odsl::Dsl
|
|
1320
|
+
def locked_action
|
|
1321
|
+
lock(:foo)
|
|
1322
|
+
end
|
|
1323
|
+
|
|
1324
|
+
def value_return_foo
|
|
1325
|
+
finish_with(:foo)
|
|
1326
|
+
end
|
|
1327
|
+
end
|
|
1328
|
+
end
|
|
1329
|
+
|
|
1330
|
+
svc = TestLockService::Service.new(foo: "bar")
|
|
1331
|
+
assert_raises(Odsl::LockingNotConfiguredError) { svc.run(:locked_action) }
|
|
1332
|
+
end
|
|
1333
|
+
|
|
1334
|
+
it "acquires and releases the lock around run()" do
|
|
1335
|
+
acquired_keys = []
|
|
1336
|
+
released = false
|
|
1337
|
+
|
|
1338
|
+
fake_lock = Object.new
|
|
1339
|
+
fake_lock.define_singleton_method(:unlock) { released = true }
|
|
1340
|
+
|
|
1341
|
+
Odsl::Locking::NullAdapter.define_singleton_method(:acquire) do |key, _ttl|
|
|
1342
|
+
acquired_keys << key
|
|
1343
|
+
fake_lock
|
|
1344
|
+
end
|
|
1345
|
+
|
|
1346
|
+
Odsl.configure do |c|
|
|
1347
|
+
c.lock_redis_url = "redis://fake"
|
|
1348
|
+
c.lock_ttl = 5_000
|
|
1349
|
+
end
|
|
1350
|
+
|
|
1351
|
+
module TestLockService2
|
|
1352
|
+
class Service < Odsl::Dsl
|
|
1353
|
+
def action_with_lock
|
|
1354
|
+
lock(:player)
|
|
1355
|
+
finish_with(:player)
|
|
1356
|
+
end
|
|
1357
|
+
end
|
|
1358
|
+
end
|
|
1359
|
+
|
|
1360
|
+
svc = TestLockService2::Service.new(player: "alejandro")
|
|
1361
|
+
svc.run(:action_with_lock)
|
|
1362
|
+
|
|
1363
|
+
assert acquired_keys.any? { |k| k.include?("alejandro") }
|
|
1364
|
+
assert released
|
|
1365
|
+
ensure
|
|
1366
|
+
Odsl::Locking::NullAdapter.singleton_class.undef_method(:acquire) rescue nil
|
|
1367
|
+
end
|
|
1368
|
+
end
|
|
1369
|
+
end
|
|
1370
|
+
```
|
|
1371
|
+
|
|
1372
|
+
- [ ] **Step 7.2: Run to confirm failure**
|
|
1373
|
+
|
|
1374
|
+
```bash
|
|
1375
|
+
bundle exec ruby -Ilib:test test/spec/locking_spec.rb
|
|
1376
|
+
```
|
|
1377
|
+
|
|
1378
|
+
Expected: `NameError: uninitialized constant Odsl::Locking`
|
|
1379
|
+
|
|
1380
|
+
- [ ] **Step 7.3: Create locking/null_adapter.rb**
|
|
1381
|
+
|
|
1382
|
+
```ruby
|
|
1383
|
+
# lib/odsl/locking/null_adapter.rb
|
|
1384
|
+
module Odsl
|
|
1385
|
+
module Locking
|
|
1386
|
+
class NullAdapter
|
|
1387
|
+
NoOpLock = Struct.new(:key) do
|
|
1388
|
+
def unlock; end
|
|
1389
|
+
end
|
|
1390
|
+
|
|
1391
|
+
def self.acquire(_key, _ttl)
|
|
1392
|
+
NoOpLock.new(_key)
|
|
1393
|
+
end
|
|
1394
|
+
end
|
|
1395
|
+
end
|
|
1396
|
+
end
|
|
1397
|
+
```
|
|
1398
|
+
|
|
1399
|
+
- [ ] **Step 7.4: Create locking/redlock_adapter.rb**
|
|
1400
|
+
|
|
1401
|
+
```ruby
|
|
1402
|
+
# lib/odsl/locking/redlock_adapter.rb
|
|
1403
|
+
module Odsl
|
|
1404
|
+
module Locking
|
|
1405
|
+
class RedlockAdapter
|
|
1406
|
+
AcquiredLock = Struct.new(:client, :lock_info) do
|
|
1407
|
+
def unlock
|
|
1408
|
+
client.unlock(lock_info)
|
|
1409
|
+
rescue => e
|
|
1410
|
+
# Swallow unlock errors — TTL will expire the lock anyway
|
|
1411
|
+
end
|
|
1412
|
+
end
|
|
1413
|
+
|
|
1414
|
+
def self.acquire(key, ttl_ms)
|
|
1415
|
+
require "redlock"
|
|
1416
|
+
client = Redlock::Client.new([Odsl.config.lock_redis_url])
|
|
1417
|
+
lock_info = client.lock(key, ttl_ms)
|
|
1418
|
+
return nil unless lock_info
|
|
1419
|
+
|
|
1420
|
+
AcquiredLock.new(client, lock_info)
|
|
1421
|
+
end
|
|
1422
|
+
end
|
|
1423
|
+
end
|
|
1424
|
+
end
|
|
1425
|
+
```
|
|
1426
|
+
|
|
1427
|
+
- [ ] **Step 7.5: Add lock() DSL method to dsl.rb**
|
|
1428
|
+
|
|
1429
|
+
Add inside the `protected` section of `lib/odsl/dsl.rb`, before `instantiate`:
|
|
1430
|
+
|
|
1431
|
+
```ruby
|
|
1432
|
+
def lock(*variables)
|
|
1433
|
+
unless Odsl.config.lock_redis_url
|
|
1434
|
+
raise Odsl::LockingNotConfiguredError,
|
|
1435
|
+
"config.lock_redis_url is not set. Configure it in your Odsl.configure block."
|
|
1436
|
+
end
|
|
1437
|
+
|
|
1438
|
+
key_parts = variables.map { |v| instance_variable_get("@#{v}").to_s }
|
|
1439
|
+
lock_key = "odsl:#{self.class.name}:#{@_current_method}:#{key_parts.join(':')}"
|
|
1440
|
+
|
|
1441
|
+
adapter = if Odsl.config.lock_redis_url.start_with?("redis://fake")
|
|
1442
|
+
Odsl::Locking::NullAdapter
|
|
1443
|
+
else
|
|
1444
|
+
Odsl::Locking::RedlockAdapter
|
|
1445
|
+
end
|
|
1446
|
+
|
|
1447
|
+
acquired = adapter.acquire(lock_key, Odsl.config.lock_ttl)
|
|
1448
|
+
raise Odsl::LockError, "Could not acquire lock for key: #{lock_key}" unless acquired
|
|
1449
|
+
|
|
1450
|
+
@_current_lock = acquired
|
|
1451
|
+
end
|
|
1452
|
+
```
|
|
1453
|
+
|
|
1454
|
+
- [ ] **Step 7.6: Track current method name in runners.rb**
|
|
1455
|
+
|
|
1456
|
+
In `_run_method` in `lib/odsl/runners.rb`, add one line before `instance_eval`:
|
|
1457
|
+
|
|
1458
|
+
```ruby
|
|
1459
|
+
@_current_method = method.to_s
|
|
1460
|
+
```
|
|
1461
|
+
|
|
1462
|
+
Full updated `_run_method` block:
|
|
1463
|
+
|
|
1464
|
+
```ruby
|
|
1465
|
+
def _run_method(method, key: nil)
|
|
1466
|
+
@_execution_context = _setup_context(method, key)
|
|
1467
|
+
@_current_lock = nil
|
|
1468
|
+
@_step_index = 0
|
|
1469
|
+
@_current_method = method.to_s
|
|
1470
|
+
|
|
1471
|
+
begin
|
|
1472
|
+
instance_eval(method.to_s)
|
|
1473
|
+
@_execution_context&.update!(status: "completed")
|
|
1474
|
+
_finish_result
|
|
1475
|
+
rescue Odsl::LockError
|
|
1476
|
+
raise
|
|
1477
|
+
rescue => e
|
|
1478
|
+
@_execution_context&.update!(status: "failed")
|
|
1479
|
+
raise
|
|
1480
|
+
ensure
|
|
1481
|
+
@_current_lock&.unlock rescue nil
|
|
1482
|
+
@_current_lock = nil
|
|
1483
|
+
end
|
|
1484
|
+
end
|
|
1485
|
+
```
|
|
1486
|
+
|
|
1487
|
+
- [ ] **Step 7.7: Update lib/odsl.rb to require locking files**
|
|
1488
|
+
|
|
1489
|
+
```ruby
|
|
1490
|
+
require "odsl/locking/null_adapter"
|
|
1491
|
+
require "odsl/locking/redlock_adapter"
|
|
1492
|
+
```
|
|
1493
|
+
|
|
1494
|
+
Add these lines after the audit requires in `lib/odsl.rb`.
|
|
1495
|
+
|
|
1496
|
+
- [ ] **Step 7.8: Run locking tests**
|
|
1497
|
+
|
|
1498
|
+
```bash
|
|
1499
|
+
bundle exec ruby -Ilib:test test/spec/locking_spec.rb
|
|
1500
|
+
```
|
|
1501
|
+
|
|
1502
|
+
Expected: 3 tests, 0 failures
|
|
1503
|
+
|
|
1504
|
+
- [ ] **Step 7.9: Run full suite**
|
|
1505
|
+
|
|
1506
|
+
```bash
|
|
1507
|
+
bundle exec rake test
|
|
1508
|
+
```
|
|
1509
|
+
|
|
1510
|
+
- [ ] **Step 7.10: Commit**
|
|
1511
|
+
|
|
1512
|
+
```bash
|
|
1513
|
+
git add lib/odsl/locking/ lib/odsl/dsl.rb lib/odsl/runners.rb lib/odsl.rb \
|
|
1514
|
+
test/spec/locking_spec.rb
|
|
1515
|
+
git commit -m "feat: distributed locking via lock() DSL method + Redlock adapter"
|
|
1516
|
+
```
|
|
1517
|
+
|
|
1518
|
+
---
|
|
1519
|
+
|
|
1520
|
+
## Task 8: Rails Generator
|
|
1521
|
+
|
|
1522
|
+
**Files:**
|
|
1523
|
+
- Create: `lib/generators/odsl/install_generator.rb`
|
|
1524
|
+
- Create: `lib/generators/odsl/templates/migration.rb.tt`
|
|
1525
|
+
- Create: `lib/generators/odsl/templates/initializer.rb.tt`
|
|
1526
|
+
|
|
1527
|
+
- [ ] **Step 8.1: Create generator directory**
|
|
1528
|
+
|
|
1529
|
+
```bash
|
|
1530
|
+
mkdir -p lib/generators/odsl/templates
|
|
1531
|
+
```
|
|
1532
|
+
|
|
1533
|
+
- [ ] **Step 8.2: Create migration template**
|
|
1534
|
+
|
|
1535
|
+
```ruby
|
|
1536
|
+
# lib/generators/odsl/templates/migration.rb.tt
|
|
1537
|
+
class CreateOdslTables < ActiveRecord::Migration[7.0]
|
|
1538
|
+
def change
|
|
1539
|
+
create_table :odsl_execution_contexts do |t|
|
|
1540
|
+
t.string :idempotency_key, null: false
|
|
1541
|
+
t.string :service_class, null: false
|
|
1542
|
+
t.string :method_name, null: false
|
|
1543
|
+
t.text :kwargs
|
|
1544
|
+
t.string :status, null: false, default: "pending"
|
|
1545
|
+
t.datetime :expires_at
|
|
1546
|
+
t.timestamps
|
|
1547
|
+
end
|
|
1548
|
+
|
|
1549
|
+
add_index :odsl_execution_contexts, :idempotency_key, unique: true
|
|
1550
|
+
add_index :odsl_execution_contexts, :expires_at
|
|
1551
|
+
|
|
1552
|
+
create_table :odsl_step_records do |t|
|
|
1553
|
+
t.references :execution_context, null: false,
|
|
1554
|
+
foreign_key: { to_table: :odsl_execution_contexts }
|
|
1555
|
+
t.string :name, null: false
|
|
1556
|
+
t.integer :step_index, null: false
|
|
1557
|
+
t.string :status, null: false, default: "pending"
|
|
1558
|
+
t.text :result
|
|
1559
|
+
t.text :error_message
|
|
1560
|
+
t.boolean :non_resumable, default: false
|
|
1561
|
+
t.datetime :started_at
|
|
1562
|
+
t.datetime :completed_at
|
|
1563
|
+
end
|
|
1564
|
+
end
|
|
1565
|
+
end
|
|
1566
|
+
```
|
|
1567
|
+
|
|
1568
|
+
- [ ] **Step 8.3: Create initializer template**
|
|
1569
|
+
|
|
1570
|
+
```ruby
|
|
1571
|
+
# lib/generators/odsl/templates/initializer.rb.tt
|
|
1572
|
+
Odsl.configure do |config|
|
|
1573
|
+
# Background job adapter. Options: :thread (default), :active_job
|
|
1574
|
+
# config.background_adapter = :active_job
|
|
1575
|
+
|
|
1576
|
+
# How long to keep audit records. nil = keep forever.
|
|
1577
|
+
# Accepts any numeric value in seconds.
|
|
1578
|
+
# config.audit_ttl = 30.days
|
|
1579
|
+
|
|
1580
|
+
# Redis URL for distributed locking (optional).
|
|
1581
|
+
# Requires the `redlock` gem. Leave nil to disable locking.
|
|
1582
|
+
# config.lock_redis_url = "redis://localhost:6379"
|
|
1583
|
+
|
|
1584
|
+
# How long (in ms) a distributed lock is held before auto-expiring.
|
|
1585
|
+
# config.lock_ttl = 10_000
|
|
1586
|
+
end
|
|
1587
|
+
```
|
|
1588
|
+
|
|
1589
|
+
- [ ] **Step 8.4: Create the generator**
|
|
1590
|
+
|
|
1591
|
+
```ruby
|
|
1592
|
+
# lib/generators/odsl/install_generator.rb
|
|
1593
|
+
require "rails/generators"
|
|
1594
|
+
require "rails/generators/migration"
|
|
1595
|
+
|
|
1596
|
+
module Odsl
|
|
1597
|
+
module Generators
|
|
1598
|
+
class InstallGenerator < Rails::Generators::Base
|
|
1599
|
+
include Rails::Generators::Migration
|
|
1600
|
+
|
|
1601
|
+
source_root File.expand_path("templates", __dir__)
|
|
1602
|
+
|
|
1603
|
+
def self.next_migration_number(dirname)
|
|
1604
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
1605
|
+
end
|
|
1606
|
+
|
|
1607
|
+
def create_migration
|
|
1608
|
+
migration_template "migration.rb.tt",
|
|
1609
|
+
"db/migrate/create_odsl_tables.rb"
|
|
1610
|
+
end
|
|
1611
|
+
|
|
1612
|
+
def create_initializer
|
|
1613
|
+
template "initializer.rb.tt", "config/initializers/odsl.rb"
|
|
1614
|
+
end
|
|
1615
|
+
end
|
|
1616
|
+
end
|
|
1617
|
+
end
|
|
1618
|
+
```
|
|
1619
|
+
|
|
1620
|
+
- [ ] **Step 8.5: Run full suite to confirm nothing broke**
|
|
1621
|
+
|
|
1622
|
+
```bash
|
|
1623
|
+
bundle exec rake test
|
|
1624
|
+
```
|
|
1625
|
+
|
|
1626
|
+
- [ ] **Step 8.6: Commit**
|
|
1627
|
+
|
|
1628
|
+
```bash
|
|
1629
|
+
git add lib/generators/
|
|
1630
|
+
git commit -m "feat: add rails generate odsl:install generator"
|
|
1631
|
+
```
|
|
1632
|
+
|
|
1633
|
+
---
|
|
1634
|
+
|
|
1635
|
+
## Task 9: Gemspec + README + Version Bump
|
|
1636
|
+
|
|
1637
|
+
**Files:**
|
|
1638
|
+
- Modify: `lib/odsl/version.rb`
|
|
1639
|
+
- Modify: `odsl.gemspec`
|
|
1640
|
+
- Modify: `README.md`
|
|
1641
|
+
|
|
1642
|
+
- [ ] **Step 9.1: Bump version to 0.3.0**
|
|
1643
|
+
|
|
1644
|
+
```ruby
|
|
1645
|
+
# lib/odsl/version.rb
|
|
1646
|
+
module Odsl
|
|
1647
|
+
VERSION = "0.3.0"
|
|
1648
|
+
end
|
|
1649
|
+
```
|
|
1650
|
+
|
|
1651
|
+
- [ ] **Step 9.2: Update gemspec with optional runtime hints**
|
|
1652
|
+
|
|
1653
|
+
Replace the dependency block in `odsl.gemspec`:
|
|
1654
|
+
|
|
1655
|
+
```ruby
|
|
1656
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
1657
|
+
|
|
1658
|
+
# Optional runtime dependencies (not required — load if present)
|
|
1659
|
+
# gem "activerecord", ">= 7.0" # for audit system
|
|
1660
|
+
# gem "redlock", ">= 2.0" # for distributed locking
|
|
1661
|
+
# gem "activejob", ">= 7.0" # for ActiveJob adapter
|
|
1662
|
+
|
|
1663
|
+
spec.add_development_dependency "minitest", ">= 5.8"
|
|
1664
|
+
spec.add_development_dependency "minitest-reporters", ">= 1.1"
|
|
1665
|
+
spec.add_development_dependency "activerecord", ">= 7.0"
|
|
1666
|
+
spec.add_development_dependency "sqlite3", "~> 1.7"
|
|
1667
|
+
```
|
|
1668
|
+
|
|
1669
|
+
- [ ] **Step 9.3: Update README.md with new sections**
|
|
1670
|
+
|
|
1671
|
+
Add the following sections to `README.md` after the existing content:
|
|
1672
|
+
|
|
1673
|
+
````markdown
|
|
1674
|
+
## Background Adapters
|
|
1675
|
+
|
|
1676
|
+
```ruby
|
|
1677
|
+
# config/initializers/odsl.rb
|
|
1678
|
+
Odsl.configure do |config|
|
|
1679
|
+
config.background_adapter = :active_job # default: :thread
|
|
1680
|
+
end
|
|
1681
|
+
```
|
|
1682
|
+
|
|
1683
|
+
Works with any ActiveJob backend: Solid Queue (Rails 8 default), Sidekiq, Good Job, Delayed::Job.
|
|
1684
|
+
|
|
1685
|
+
```ruby
|
|
1686
|
+
# Fire and forget
|
|
1687
|
+
service.run_in_background(:create, key: 'user-123-game-chess')
|
|
1688
|
+
|
|
1689
|
+
# Run two methods in parallel
|
|
1690
|
+
service.run_in_parallel(:method_a, :method_b)
|
|
1691
|
+
```
|
|
1692
|
+
|
|
1693
|
+
## Audit System
|
|
1694
|
+
|
|
1695
|
+
Install tables with the generator:
|
|
1696
|
+
|
|
1697
|
+
```bash
|
|
1698
|
+
rails generate odsl:install
|
|
1699
|
+
rails db:migrate
|
|
1700
|
+
```
|
|
1701
|
+
|
|
1702
|
+
Run with an idempotency key to activate auditing:
|
|
1703
|
+
|
|
1704
|
+
```ruby
|
|
1705
|
+
result = service.run(:create, key: 'user-123-game-chess')
|
|
1706
|
+
```
|
|
1707
|
+
|
|
1708
|
+
Resume a failed run from the first incomplete step:
|
|
1709
|
+
|
|
1710
|
+
```ruby
|
|
1711
|
+
result = Games::Service.resume(key: 'user-123-game-chess')
|
|
1712
|
+
```
|
|
1713
|
+
|
|
1714
|
+
Configure TTL for auto-expiry:
|
|
1715
|
+
|
|
1716
|
+
```ruby
|
|
1717
|
+
Odsl.configure { |c| c.audit_ttl = 30 * 24 * 3600 } # 30 days in seconds
|
|
1718
|
+
```
|
|
1719
|
+
|
|
1720
|
+
Clean up expired records:
|
|
1721
|
+
|
|
1722
|
+
```ruby
|
|
1723
|
+
Odsl.cleanup_expired! # from code
|
|
1724
|
+
rake odsl:cleanup # from terminal / cron
|
|
1725
|
+
```
|
|
1726
|
+
|
|
1727
|
+
## Distributed Locking
|
|
1728
|
+
|
|
1729
|
+
Requires the `redlock` gem and a Redis instance.
|
|
1730
|
+
|
|
1731
|
+
```ruby
|
|
1732
|
+
# Gemfile
|
|
1733
|
+
gem 'redlock'
|
|
1734
|
+
|
|
1735
|
+
# config/initializers/odsl.rb
|
|
1736
|
+
Odsl.configure do |config|
|
|
1737
|
+
config.lock_redis_url = ENV['REDIS_URL']
|
|
1738
|
+
config.lock_ttl = 10_000 # ms
|
|
1739
|
+
end
|
|
1740
|
+
```
|
|
1741
|
+
|
|
1742
|
+
Add `lock()` at the start of any business method:
|
|
1743
|
+
|
|
1744
|
+
```ruby
|
|
1745
|
+
module Games
|
|
1746
|
+
module Crud
|
|
1747
|
+
def create
|
|
1748
|
+
lock(:player, :game) # raises Odsl::LockError if already locked
|
|
1749
|
+
|
|
1750
|
+
build(Game, Player)
|
|
1751
|
+
.get(:game_object)
|
|
1752
|
+
# ...
|
|
1753
|
+
end
|
|
1754
|
+
end
|
|
1755
|
+
end
|
|
1756
|
+
```
|
|
1757
|
+
|
|
1758
|
+
Handle the error in your controller:
|
|
1759
|
+
|
|
1760
|
+
```ruby
|
|
1761
|
+
rescue Odsl::LockError
|
|
1762
|
+
render json: { error: 'Already in progress' }, status: :conflict
|
|
1763
|
+
```
|
|
1764
|
+
````
|
|
1765
|
+
|
|
1766
|
+
- [ ] **Step 9.4: Run final full test suite**
|
|
1767
|
+
|
|
1768
|
+
```bash
|
|
1769
|
+
bundle exec rake test
|
|
1770
|
+
```
|
|
1771
|
+
|
|
1772
|
+
Expected: all tests pass, 0 failures, 0 errors
|
|
1773
|
+
|
|
1774
|
+
- [ ] **Step 9.5: Commit**
|
|
1775
|
+
|
|
1776
|
+
```bash
|
|
1777
|
+
git add lib/odsl/version.rb odsl.gemspec README.md Gemfile.lock
|
|
1778
|
+
git commit -m "chore: bump to 0.3.0, update gemspec and README"
|
|
1779
|
+
```
|
|
1780
|
+
|
|
1781
|
+
---
|
|
1782
|
+
|
|
1783
|
+
## Self-Review
|
|
1784
|
+
|
|
1785
|
+
**Spec coverage check:**
|
|
1786
|
+
- ✅ Background adapters (Thread, ActiveJob) — Tasks 2
|
|
1787
|
+
- ✅ `Odsl.configure` — Task 1
|
|
1788
|
+
- ✅ `ExecutionContext` + `StepRecord` models — Task 3
|
|
1789
|
+
- ✅ `run(key:)` activates audit — Task 4
|
|
1790
|
+
- ✅ Idempotency (completed context skipped) — Task 4
|
|
1791
|
+
- ✅ `AlreadyRunningError` for running context — Task 4
|
|
1792
|
+
- ✅ `.get()` as checkpoint → `StepRecord` — Task 4
|
|
1793
|
+
- ✅ `resume(key:)` skips completed steps — Task 5
|
|
1794
|
+
- ✅ `ContextNotFoundError` for missing key — Task 5
|
|
1795
|
+
- ✅ `non_resumable` flag for non-serializable results — Task 4
|
|
1796
|
+
- ✅ TTL / `expires_at` — Task 6
|
|
1797
|
+
- ✅ `Odsl.cleanup_expired!` — Task 6
|
|
1798
|
+
- ✅ `rake odsl:cleanup` — Task 6
|
|
1799
|
+
- ✅ `lock(*vars)` DSL method — Task 7
|
|
1800
|
+
- ✅ `LockError` (fail fast) — Task 7
|
|
1801
|
+
- ✅ `LockingNotConfiguredError` — Task 7
|
|
1802
|
+
- ✅ Lock released in `ensure` — Task 7
|
|
1803
|
+
- ✅ Rails generator — Task 8
|
|
1804
|
+
- ✅ Backward compatibility (no `key:` → no audit) — preserved throughout
|
|
1805
|
+
|
|
1806
|
+
**No placeholders found.**
|
|
1807
|
+
|
|
1808
|
+
**Type consistency:** `_execution_context` instance variable used consistently across `runners.rb`, `methods.rb`, and `dsl.rb`. `finish_with` alias defined in `dsl.rb` points to `value_return` which is correct.
|