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.
@@ -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.